Skip to content

Commit adfd471

Browse files
Preston SeayPreston Seay
authored andcommitted
Merge remote-tracking branch 'origin/main' into preston/more-connections
# Conflicts: # README.md # skills/generic/latchkey/SKILL.md # skills/openclaw/latchkey/SKILL.md # src/serviceRegistry.ts # src/services/index.ts # tests/serviceRegistry.test.ts
2 parents 865101c + 02a504f commit adfd471

21 files changed

Lines changed: 894 additions & 52 deletions

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,5 +440,4 @@ override `config.json` values.
440440
Latchkey currently offers varying levels of support for the
441441
following services: AWS, Calendly, Discord, Dropbox, Figma, GitHub, GitLab,
442442
Gmail, Google Analytics, Google Calendar, Google Docs, Google Drive, Google Sheets,
443-
Linear, Mailchimp, Notion, Ramp, Sentry, Slack, Stripe,
444-
Telegram, Yelp, Zoom, and more.
443+
Linear, Mailchimp, Notion, Ramp, Sentry, Slack, Stripe, Telegram, Todoist, Yelp, Zoom, and more.

package-lock.json

Lines changed: 19 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
"node": ">=20"
5959
},
6060
"dependencies": {
61-
"@imbue-ai/detent": "^1.6.0",
61+
"@imbue-ai/detent": "^1.7.0",
6262
"@napi-rs/keyring": "^1.2.0",
6363
"commander": "^12.0.0",
6464
"playwright": "~1.60.0",

skills/generic/latchkey/SKILL.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,7 @@ used to see how to provide credentials for a specific service.
106106
Latchkey currently offers varying levels of support for the
107107
following services: AWS, Calendly, Coolify, Discord, Dropbox, Figma, GitHub, GitLab,
108108
Gmail, Google Analytics, Google Calendar, Google Docs, Google Drive, Google Sheets,
109-
Linear, Mailchimp, Notion, Ramp, Sentry, Slack, Stripe,
110-
Telegram, Umami, Yelp, Zoom, and more.
109+
Linear, Mailchimp, Notion, Ramp, Sentry, Slack, Stripe, Telegram, Todoist, Umami, Yelp, Zoom, and more.
111110

112111
### User-registered services
113112

skills/openclaw/latchkey/SKILL.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,7 @@ used to see how to provide credentials for a specific service.
9898
Latchkey currently offers varying levels of support for the
9999
following services: AWS, Calendly, Coolify, Discord, Dropbox, Figma, GitHub, GitLab,
100100
Gmail, Google Analytics, Google Calendar, Google Docs, Google Drive, Google Sheets,
101-
Linear, Mailchimp, Notion, Ramp, Sentry, Slack, Stripe,
102-
Telegram, Umami, Yelp, Zoom, and more.
101+
Linear, Mailchimp, Notion, Ramp, Sentry, Slack, Stripe, Telegram, Todoist, Umami, Yelp, Zoom, and more.
103102

104103
### User-registered services
105104

src/cliCommands.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { existsSync, statSync, unlinkSync } from 'node:fs';
77
import { basename, join } from 'node:path';
88
import { createInterface } from 'node:readline';
99
import { ApiCredentialStore, ApiCredentialStoreError } from './apiCredentials/store.js';
10-
import { ApiCredentials, RawCurlCredentials } from './apiCredentials/base.js';
10+
import {
11+
ApiCredentials,
12+
ApiCredentialsUsageError,
13+
RawCurlCredentials,
14+
} from './apiCredentials/base.js';
1115
import {
1216
CredentialsExpiredError,
1317
NoCredentialsForServiceError,
@@ -46,6 +50,8 @@ import {
4650
LoginCancelledError,
4751
LoginFailedError,
4852
NoCurlCredentialsNotSupportedError,
53+
PrepareInputInvalidError,
54+
PrepareNotSupportedError,
4955
Service,
5056
} from './services/index.js';
5157
import {
@@ -77,6 +83,7 @@ import {
7783
authList,
7884
authBrowser,
7985
authBrowserPrepare,
86+
prepareService,
8087
UnknownServiceError,
8188
BrowserNotConfiguredError,
8289
PreparationRequiredError,
@@ -734,6 +741,50 @@ export function registerCommands(program: Command, deps: CliDependencies): void
734741
}
735742
});
736743

744+
authCommand
745+
.command('prepare')
746+
.description(
747+
"Register a service's client details (e.g. an OAuth client id/secret) from a JSON payload, for use during login."
748+
)
749+
.argument('<service_name>', 'Name of the service to prepare')
750+
.argument(
751+
'<json>',
752+
'Service-specific registration JSON, e.g. \'{"clientId":"...","clientSecret":"..."}\''
753+
)
754+
.addHelpText(
755+
'after',
756+
`\nExample:\n $ latchkey auth prepare google-gmail '{"clientId":"<id>","clientSecret":"<secret>"}'`
757+
)
758+
.action(async (serviceName: string, json: string) => {
759+
if (deps.config.gatewayUrl !== null) {
760+
await forwardToGateway(deps, {
761+
command: 'auth prepare',
762+
params: { serviceName, json },
763+
});
764+
deps.log(`Done`);
765+
return;
766+
}
767+
try {
768+
const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
769+
const apiCredentialStore = new ApiCredentialStore(
770+
deps.config.credentialStorePath,
771+
encryptedStorage
772+
);
773+
prepareService(deps.registry, apiCredentialStore, serviceName, json);
774+
deps.log(`Done`);
775+
} catch (error) {
776+
if (
777+
error instanceof UnknownServiceError ||
778+
error instanceof PrepareNotSupportedError ||
779+
error instanceof PrepareInputInvalidError
780+
) {
781+
deps.errorLog(`Error: ${error.message}`);
782+
deps.exit(1);
783+
}
784+
throw error;
785+
}
786+
});
787+
737788
program
738789
.command('curl')
739790
.description('Run curl with API credential injection.')
@@ -810,7 +861,8 @@ export function registerCommands(program: Command, deps: CliDependencies): void
810861
error instanceof UrlExtractionFailedError ||
811862
error instanceof NoServiceForUrlError ||
812863
error instanceof NoCredentialsForServiceError ||
813-
error instanceof CredentialsExpiredError
864+
error instanceof CredentialsExpiredError ||
865+
error instanceof ApiCredentialsUsageError
814866
) {
815867
deps.errorLog(error.message);
816868
deps.exit(1);

src/gateway/latchkeyEndpoint.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
authList,
1818
authBrowser,
1919
authBrowserPrepare,
20+
prepareService,
2021
UnknownServiceError,
2122
BrowserNotConfiguredError,
2223
PreparationRequiredError,
@@ -26,7 +27,12 @@ import {
2627
BrowserFlowsNotSupportedError,
2728
GraphicalEnvironmentNotFoundError,
2829
} from '../playwrightUtils.js';
29-
import { LoginCancelledError, LoginFailedError } from '../services/index.js';
30+
import {
31+
LoginCancelledError,
32+
LoginFailedError,
33+
PrepareInputInvalidError,
34+
PrepareNotSupportedError,
35+
} from '../services/index.js';
3036

3137
const serviceNameParamsMessage = "missing required argument 'service_name'";
3238
const serviceNameParams = z.object(
@@ -68,12 +74,20 @@ const AuthBrowserPrepareRequestSchema = z.object({
6874
params: serviceNameParams,
6975
});
7076

77+
const PrepareRequestSchema = z.object({
78+
command: z.literal('auth prepare'),
79+
params: serviceNameParams.extend({
80+
json: z.string(),
81+
}),
82+
});
83+
7184
export const LatchkeyRequestSchema = z.discriminatedUnion('command', [
7285
ServicesListRequestSchema,
7386
ServicesInfoRequestSchema,
7487
AuthListRequestSchema,
7588
AuthBrowserRequestSchema,
7689
AuthBrowserPrepareRequestSchema,
90+
PrepareRequestSchema,
7791
]);
7892

7993
export type LatchkeyRequest = z.infer<typeof LatchkeyRequestSchema>;
@@ -87,6 +101,8 @@ const KNOWN_ERROR_CLASSES: readonly (abstract new (...args: never[]) => Error)[]
87101
PreparationRequiredError,
88102
LoginCancelledError,
89103
LoginFailedError,
104+
PrepareNotSupportedError,
105+
PrepareInputInvalidError,
90106
];
91107

92108
function isKnownError(error: unknown): error is Error {
@@ -158,6 +174,14 @@ async function dispatch(
158174
deps.config,
159175
parsed.params.serviceName
160176
);
177+
178+
case 'auth prepare':
179+
return prepareService(
180+
deps.registry,
181+
apiCredentialStore,
182+
parsed.params.serviceName,
183+
parsed.params.json
184+
);
161185
}
162186
}
163187

src/serviceRegistry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
COOLIFY,
3535
UMAMI,
3636
RAMP,
37+
TODOIST,
3738
} from './services/index.js';
3839

3940
export class DuplicateServiceNameError extends Error {
@@ -162,4 +163,5 @@ export const SERVICE_REGISTRY = new ServiceRegistry([
162163
COOLIFY,
163164
UMAMI,
164165
RAMP,
166+
TODOIST,
165167
]);

src/services/core/base.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import type { Browser, BrowserContext, Page, Response } from 'playwright';
6+
import type { z, ZodTypeAny } from 'zod';
67
import {
78
ApiCredentialStatus,
89
ApiCredentials,
@@ -38,6 +39,58 @@ export class LoginFailedError extends Error {
3839
}
3940
}
4041

42+
/**
43+
* Thrown when `latchkey auth prepare` is run for a service that does not declare a
44+
* prepare schema (the base default — services opt in by setting one).
45+
*/
46+
export class PrepareNotSupportedError extends Error {
47+
constructor(serviceName: string) {
48+
super(
49+
`Service '${serviceName}' does not support 'latchkey auth prepare'. ` +
50+
`Use 'latchkey services info ${serviceName}' to see how to authenticate.`
51+
);
52+
this.name = 'PrepareNotSupportedError';
53+
}
54+
}
55+
56+
/**
57+
* Thrown when the JSON passed to `latchkey auth prepare` is malformed or does not
58+
* match the service's prepare schema. The whole command is rejected and
59+
* nothing is stored.
60+
*/
61+
export class PrepareInputInvalidError extends Error {
62+
constructor(serviceName: string, detail: string) {
63+
super(`Invalid prepare input for '${serviceName}': ${detail}`);
64+
this.name = 'PrepareInputInvalidError';
65+
}
66+
}
67+
68+
/**
69+
* Validate a parsed JSON value against a service's prepare schema and build the
70+
* resulting credentials. Centralizes validation so each service's
71+
* `prepareFromJson` only expresses its schema and build step. Throws
72+
* `PrepareInputInvalidError` (with the failing fields) on any schema mismatch;
73+
* nothing is built unless the input fully validates.
74+
*/
75+
export function buildPreparedCredentials<Schema extends ZodTypeAny>(
76+
serviceName: string,
77+
schema: Schema,
78+
parsedJson: unknown,
79+
build: (validatedInput: z.infer<Schema>) => ApiCredentials
80+
): ApiCredentials {
81+
const result = schema.safeParse(parsedJson);
82+
if (!result.success) {
83+
const detail = result.error.issues
84+
.map((issue) => {
85+
const path = issue.path.join('.');
86+
return path ? `${path}: ${issue.message}` : issue.message;
87+
})
88+
.join('; ');
89+
throw new PrepareInputInvalidError(serviceName, detail);
90+
}
91+
return build(result.data as z.infer<Schema>);
92+
}
93+
4194
export function isBrowserClosedError(error: Error): boolean {
4295
const message = error.message.toLowerCase();
4396
return (
@@ -50,6 +103,22 @@ export function isBrowserClosedError(error: Error): boolean {
50103
);
51104
}
52105

106+
/**
107+
* Detects the Playwright/CDP error raised when a response body can no longer be
108+
* retrieved (`Network.getResponseBody` reports "No resource with given
109+
* identifier found"). This happens for responses that retain no readable body —
110+
* redirects, evicted or cached resources, or bodies fetched after the page has
111+
* navigated onward. Callers that read response bodies opportunistically should
112+
* treat this as inconclusive rather than fatal.
113+
*/
114+
export function isResponseBodyUnavailableError(error: Error): boolean {
115+
const message = error.message.toLowerCase();
116+
return (
117+
message.includes('no resource with given identifier') ||
118+
message.includes('network.getresponsebody')
119+
);
120+
}
121+
53122
export function isTimeoutError(error: Error): boolean {
54123
return error.name === 'TimeoutError';
55124
}
@@ -126,6 +195,18 @@ export abstract class Service {
126195
throw new NoCurlCredentialsNotSupportedError(this.name);
127196
}
128197

198+
/**
199+
* Build credentials from a parsed JSON payload for `latchkey auth prepare`.
200+
*
201+
* Optional, like `getSession`/`refreshCredentials`: services opt in by
202+
* implementing it (typically via `buildPreparedCredentials` with a Zod
203+
* schema). When a service does not implement it, prepare is "not supported"
204+
* — the default that lets every service stay closed until it declares a
205+
* schema. Implementations validate `parsedJson` and throw
206+
* `PrepareInputInvalidError` on mismatch.
207+
*/
208+
prepareFromJson?(parsedJson: unknown): ApiCredentials;
209+
129210
/**
130211
* Get a new session for the login flow.
131212
* Services that don't support browser login should not implement this method.

0 commit comments

Comments
 (0)