latchkey 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +63 -7
  2. package/dist/scripts/recordBrowserSession.js +3 -3
  3. package/dist/scripts/recordBrowserSession.js.map +1 -1
  4. package/dist/src/{apiCredentials.d.ts → apiCredentials/base.d.ts} +6 -6
  5. package/dist/src/apiCredentials/base.d.ts.map +1 -0
  6. package/dist/src/{apiCredentials.js → apiCredentials/base.js} +5 -5
  7. package/dist/src/apiCredentials/base.js.map +1 -0
  8. package/dist/src/{apiCredentialsSerialization.d.ts → apiCredentials/serialization.d.ts} +5 -5
  9. package/dist/src/apiCredentials/serialization.d.ts.map +1 -0
  10. package/dist/src/{apiCredentialsSerialization.js → apiCredentials/serialization.js} +9 -9
  11. package/dist/src/apiCredentials/serialization.js.map +1 -0
  12. package/dist/src/{apiCredentialStore.d.ts → apiCredentials/store.d.ts} +3 -3
  13. package/dist/src/apiCredentials/store.d.ts.map +1 -0
  14. package/dist/src/{apiCredentialStore.js → apiCredentials/store.js} +2 -2
  15. package/dist/src/apiCredentials/store.js.map +1 -0
  16. package/dist/src/apiCredentials/utils.d.ts +13 -0
  17. package/dist/src/apiCredentials/utils.d.ts.map +1 -0
  18. package/dist/src/apiCredentials/utils.js +27 -0
  19. package/dist/src/apiCredentials/utils.js.map +1 -0
  20. package/dist/src/cli.js +42 -39
  21. package/dist/src/cli.js.map +1 -1
  22. package/dist/src/cliCommands.d.ts +6 -3
  23. package/dist/src/cliCommands.d.ts.map +1 -1
  24. package/dist/src/cliCommands.js +243 -190
  25. package/dist/src/cliCommands.js.map +1 -1
  26. package/dist/src/config.d.ts +36 -2
  27. package/dist/src/config.d.ts.map +1 -1
  28. package/dist/src/config.js +112 -17
  29. package/dist/src/config.js.map +1 -1
  30. package/dist/src/configDataStore.d.ts +44 -0
  31. package/dist/src/configDataStore.d.ts.map +1 -1
  32. package/dist/src/configDataStore.js +27 -0
  33. package/dist/src/configDataStore.js.map +1 -1
  34. package/dist/src/curl.d.ts +41 -8
  35. package/dist/src/curl.d.ts.map +1 -1
  36. package/dist/src/curl.js +80 -75
  37. package/dist/src/curl.js.map +1 -1
  38. package/dist/src/curlInjection.d.ts +46 -0
  39. package/dist/src/curlInjection.d.ts.map +1 -0
  40. package/dist/src/curlInjection.js +99 -0
  41. package/dist/src/curlInjection.js.map +1 -0
  42. package/dist/src/errorMessages.d.ts +14 -0
  43. package/dist/src/errorMessages.d.ts.map +1 -0
  44. package/dist/src/errorMessages.js +22 -0
  45. package/dist/src/errorMessages.js.map +1 -0
  46. package/dist/src/gateway/client.d.ts +32 -0
  47. package/dist/src/gateway/client.d.ts.map +1 -0
  48. package/dist/src/gateway/client.js +89 -0
  49. package/dist/src/gateway/client.js.map +1 -0
  50. package/dist/src/gateway/gatewayEndpoint.d.ts +43 -0
  51. package/dist/src/gateway/gatewayEndpoint.d.ts.map +1 -0
  52. package/dist/src/gateway/gatewayEndpoint.js +297 -0
  53. package/dist/src/gateway/gatewayEndpoint.js.map +1 -0
  54. package/dist/src/gateway/latchkeyEndpoint.d.ts +105 -0
  55. package/dist/src/gateway/latchkeyEndpoint.d.ts.map +1 -0
  56. package/dist/src/gateway/latchkeyEndpoint.js +144 -0
  57. package/dist/src/gateway/latchkeyEndpoint.js.map +1 -0
  58. package/dist/src/gateway/server.d.ts +20 -0
  59. package/dist/src/gateway/server.d.ts.map +1 -0
  60. package/dist/src/gateway/server.js +90 -0
  61. package/dist/src/gateway/server.js.map +1 -0
  62. package/dist/src/index.d.ts +4 -4
  63. package/dist/src/index.d.ts.map +1 -1
  64. package/dist/src/index.js +5 -5
  65. package/dist/src/index.js.map +1 -1
  66. package/dist/src/permissions.d.ts +2 -1
  67. package/dist/src/permissions.d.ts.map +1 -1
  68. package/dist/src/permissions.js +8 -4
  69. package/dist/src/permissions.js.map +1 -1
  70. package/dist/src/{registry.d.ts → serviceRegistry.d.ts} +4 -4
  71. package/dist/src/serviceRegistry.d.ts.map +1 -0
  72. package/dist/src/{registry.js → serviceRegistry.js} +4 -4
  73. package/dist/src/serviceRegistry.js.map +1 -0
  74. package/dist/src/services/aws.d.ts +2 -2
  75. package/dist/src/services/aws.d.ts.map +1 -1
  76. package/dist/src/services/aws.js +17 -10
  77. package/dist/src/services/aws.js.map +1 -1
  78. package/dist/src/services/core/base.d.ts +2 -2
  79. package/dist/src/services/core/base.d.ts.map +1 -1
  80. package/dist/src/services/core/base.js +3 -3
  81. package/dist/src/services/core/base.js.map +1 -1
  82. package/dist/src/services/core/registered.d.ts +2 -2
  83. package/dist/src/services/core/registered.d.ts.map +1 -1
  84. package/dist/src/services/core/registered.js +2 -2
  85. package/dist/src/services/core/registered.js.map +1 -1
  86. package/dist/src/services/discord.d.ts +1 -1
  87. package/dist/src/services/discord.d.ts.map +1 -1
  88. package/dist/src/services/discord.js +1 -1
  89. package/dist/src/services/discord.js.map +1 -1
  90. package/dist/src/services/dropbox.d.ts +1 -1
  91. package/dist/src/services/dropbox.d.ts.map +1 -1
  92. package/dist/src/services/dropbox.js +1 -1
  93. package/dist/src/services/dropbox.js.map +1 -1
  94. package/dist/src/services/github.d.ts +1 -1
  95. package/dist/src/services/github.d.ts.map +1 -1
  96. package/dist/src/services/github.js +1 -1
  97. package/dist/src/services/github.js.map +1 -1
  98. package/dist/src/services/google/base.d.ts +2 -2
  99. package/dist/src/services/google/base.d.ts.map +1 -1
  100. package/dist/src/services/google/base.js +3 -3
  101. package/dist/src/services/google/base.js.map +1 -1
  102. package/dist/src/services/google/directions.d.ts +1 -1
  103. package/dist/src/services/google/directions.d.ts.map +1 -1
  104. package/dist/src/services/linear.d.ts +1 -1
  105. package/dist/src/services/linear.d.ts.map +1 -1
  106. package/dist/src/services/linear.js +1 -1
  107. package/dist/src/services/linear.js.map +1 -1
  108. package/dist/src/services/notion.d.ts +1 -1
  109. package/dist/src/services/notion.d.ts.map +1 -1
  110. package/dist/src/services/notion.js +1 -1
  111. package/dist/src/services/notion.js.map +1 -1
  112. package/dist/src/services/sentry.d.ts +2 -2
  113. package/dist/src/services/sentry.d.ts.map +1 -1
  114. package/dist/src/services/sentry.js +6 -3
  115. package/dist/src/services/sentry.js.map +1 -1
  116. package/dist/src/services/slack.d.ts +3 -3
  117. package/dist/src/services/slack.d.ts.map +1 -1
  118. package/dist/src/services/slack.js +5 -5
  119. package/dist/src/services/slack.js.map +1 -1
  120. package/dist/src/services/telegram.d.ts +2 -2
  121. package/dist/src/services/telegram.d.ts.map +1 -1
  122. package/dist/src/services/telegram.js +2 -2
  123. package/dist/src/services/telegram.js.map +1 -1
  124. package/dist/src/sharedOperations.d.ts +44 -0
  125. package/dist/src/sharedOperations.d.ts.map +1 -0
  126. package/dist/src/sharedOperations.js +131 -0
  127. package/dist/src/sharedOperations.js.map +1 -0
  128. package/dist/src/version.d.ts +2 -0
  129. package/dist/src/version.d.ts.map +1 -0
  130. package/dist/src/version.js +4 -0
  131. package/dist/src/version.js.map +1 -0
  132. package/dist/tests/apiCredentialStore.test.js +2 -2
  133. package/dist/tests/apiCredentialStore.test.js.map +1 -1
  134. package/dist/tests/apiCredentials.test.js +37 -36
  135. package/dist/tests/apiCredentials.test.js.map +1 -1
  136. package/dist/tests/cli.test.js +241 -55
  137. package/dist/tests/cli.test.js.map +1 -1
  138. package/dist/tests/config.test.d.ts +2 -0
  139. package/dist/tests/config.test.d.ts.map +1 -0
  140. package/dist/tests/config.test.js +150 -0
  141. package/dist/tests/config.test.js.map +1 -0
  142. package/dist/tests/gateway.test.d.ts +2 -0
  143. package/dist/tests/gateway.test.d.ts.map +1 -0
  144. package/dist/tests/gateway.test.js +566 -0
  145. package/dist/tests/gateway.test.js.map +1 -0
  146. package/dist/tests/gatewayClient.test.d.ts +2 -0
  147. package/dist/tests/gatewayClient.test.d.ts.map +1 -0
  148. package/dist/tests/gatewayClient.test.js +85 -0
  149. package/dist/tests/gatewayClient.test.js.map +1 -0
  150. package/dist/tests/latchkeyEndpoint.test.d.ts +2 -0
  151. package/dist/tests/latchkeyEndpoint.test.d.ts.map +1 -0
  152. package/dist/tests/latchkeyEndpoint.test.js +385 -0
  153. package/dist/tests/latchkeyEndpoint.test.js.map +1 -0
  154. package/dist/tests/permissions.test.js +18 -3
  155. package/dist/tests/permissions.test.js.map +1 -1
  156. package/dist/tests/serviceRegistry.test.d.ts +2 -0
  157. package/dist/tests/serviceRegistry.test.d.ts.map +1 -0
  158. package/dist/tests/{registry.test.js → serviceRegistry.test.js} +17 -17
  159. package/dist/tests/serviceRegistry.test.js.map +1 -0
  160. package/dist/tests/servicesAgainstRecordings.test.js +3 -3
  161. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
  162. package/dist/tests/sharedOperations.test.d.ts +2 -0
  163. package/dist/tests/sharedOperations.test.d.ts.map +1 -0
  164. package/dist/tests/sharedOperations.test.js +264 -0
  165. package/dist/tests/sharedOperations.test.js.map +1 -0
  166. package/package.json +8 -2
  167. package/dist/src/apiCredentialStore.d.ts.map +0 -1
  168. package/dist/src/apiCredentialStore.js.map +0 -1
  169. package/dist/src/apiCredentials.d.ts.map +0 -1
  170. package/dist/src/apiCredentials.js.map +0 -1
  171. package/dist/src/apiCredentialsSerialization.d.ts.map +0 -1
  172. package/dist/src/apiCredentialsSerialization.js.map +0 -1
  173. package/dist/src/registry.d.ts.map +0 -1
  174. package/dist/src/registry.js.map +0 -1
  175. package/dist/tests/registry.test.d.ts +0 -2
  176. package/dist/tests/registry.test.d.ts.map +0 -1
  177. package/dist/tests/registry.test.js.map +0 -1
@@ -3,55 +3,40 @@
3
3
  */
4
4
  import { existsSync, unlinkSync } from 'node:fs';
5
5
  import { createInterface } from 'node:readline';
6
- import { ApiCredentialStore } from './apiCredentialStore.js';
7
- import { ApiCredentialStatus, RawCurlCredentials } from './apiCredentials.js';
6
+ import { ApiCredentialStore } from './apiCredentials/store.js';
7
+ import { RawCurlCredentials } from './apiCredentials/base.js';
8
+ import { CredentialsExpiredError, NoCredentialsForServiceError, NoServiceForUrlError, prepareCurlInvocation, RequestNotPermittedError, UrlExtractionFailedError, } from './curlInjection.js';
8
9
  import { BROWSER_SOURCES, BrowserNotFoundError, DEFAULT_BROWSER_SOURCES, ensureBrowser, } from './browserConfig.js';
9
10
  import { CONFIG } from './config.js';
10
- import { deleteRegisteredService, loadBrowserConfig, saveRegisteredService, } from './configDataStore.js';
11
- import { BrowserDisabledError, GraphicalEnvironmentNotFoundError, hasGraphicalEnvironment, } from './playwrightUtils.js';
11
+ import { deleteRegisteredService, saveRegisteredService } from './configDataStore.js';
12
+ import { BrowserDisabledError, BrowserFlowsNotSupportedError, GraphicalEnvironmentNotFoundError, } from './playwrightUtils.js';
12
13
  import { EncryptedStorage } from './encryptedStorage.js';
13
- import { DuplicateServiceNameError, InvalidServiceNameError, REGISTRY, canonicalizeServiceName, } from './registry.js';
14
+ import { DuplicateServiceNameError, InvalidServiceNameError, SERVICE_REGISTRY, canonicalizeServiceName, } from './serviceRegistry.js';
14
15
  import { RegisteredService } from './services/core/registered.js';
15
16
  import { LoginCancelledError, LoginFailedError, NoCurlCredentialsNotSupportedError, } from './services/index.js';
16
- import { extractUrlFromCurlArguments, run as curlRun } from './curl.js';
17
+ import { CurlParseError, extractUrlFromCurlArguments, run as curlRun, runAsync as curlRunAsync, } from './curl.js';
17
18
  import { checkPermission, PermissionCheckError } from './permissions.js';
19
+ import { ErrorMessages } from './errorMessages.js';
18
20
  import { getSkillMdContent } from './skillMd.js';
21
+ import { startGateway } from './gateway/server.js';
22
+ import { callLatchkeyEndpoint, GatewayCommandNotSupportedError, GatewayCurlRewriteError, GatewayRequestError, rewriteCurlArgumentsForGateway, } from './gateway/client.js';
23
+ import { servicesList, servicesInfo, authList, authBrowser, authBrowserPrepare, UnknownServiceError, BrowserNotConfiguredError, PreparationRequiredError, } from './sharedOperations.js';
24
+ import { VERSION } from './version.js';
19
25
  /**
20
26
  * Exit code used when a request is rejected by permission rules.
21
27
  * Uses the Unix convention for "command not permitted" (126).
22
28
  * Curl itself does not use this exit code.
23
29
  */
24
30
  export const PERMISSION_DENIED_EXIT_CODE = 126;
25
- /**
26
- * Try to refresh expired credentials if the service supports it.
27
- * Returns refreshed credentials if successful, otherwise returns the original credentials.
28
- */
29
- async function maybeRefreshCredentials(service, apiCredentials, apiCredentialStore) {
30
- if (apiCredentials.isExpired() !== true || !service.refreshCredentials) {
31
- return apiCredentials;
32
- }
33
- const refreshedCredentials = await service.refreshCredentials(apiCredentials);
34
- if (refreshedCredentials !== null) {
35
- apiCredentialStore.save(service.name, refreshedCredentials);
36
- return refreshedCredentials;
37
- }
38
- return apiCredentials;
39
- }
40
- async function getCredentialStatus(service, credentials, apiCredentialStore) {
41
- if (credentials === null) {
42
- return ApiCredentialStatus.Missing;
43
- }
44
- const refreshed = await maybeRefreshCredentials(service, credentials, apiCredentialStore);
45
- return service.checkApiCredentials(refreshed);
46
- }
47
31
  /**
48
32
  * Default implementation of CLI dependencies.
49
33
  */
50
34
  export function createDefaultDependencies() {
51
35
  return {
52
- registry: REGISTRY,
36
+ registry: SERVICE_REGISTRY,
53
37
  config: CONFIG,
54
38
  runCurl: curlRun,
39
+ runCurlAsync: curlRunAsync,
55
40
  checkPermission: checkPermission,
56
41
  confirm: defaultConfirm,
57
42
  exit: (code) => process.exit(code),
@@ -61,8 +46,40 @@ export function createDefaultDependencies() {
61
46
  errorLog: (message) => {
62
47
  console.error(message);
63
48
  },
49
+ version: VERSION,
64
50
  };
65
51
  }
52
+ /**
53
+ * Forward a request to the gateway's `/latchkey/` endpoint. On transport or
54
+ * protocol errors the CLI exits with status 1 after logging the error message.
55
+ */
56
+ async function forwardToGateway(deps, request) {
57
+ const gatewayUrl = deps.config.gatewayUrl;
58
+ if (gatewayUrl === null) {
59
+ throw new GatewayCommandNotSupportedError(request.command);
60
+ }
61
+ try {
62
+ return await callLatchkeyEndpoint(gatewayUrl, request);
63
+ }
64
+ catch (error) {
65
+ if (error instanceof GatewayRequestError) {
66
+ deps.errorLog(`Error: ${error.message}`);
67
+ deps.exit(1);
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+ /**
73
+ * If the CLI is running in gateway mode, log an error and exit. Used for
74
+ * commands that manage local state and cannot be meaningfully delegated.
75
+ */
76
+ function refuseInGatewayMode(deps, commandName) {
77
+ if (deps.config.gatewayUrl !== null) {
78
+ const error = new GatewayCommandNotSupportedError(commandName);
79
+ deps.errorLog(`Error: ${error.message}`);
80
+ deps.exit(1);
81
+ }
82
+ }
66
83
  async function defaultConfirm(message) {
67
84
  const readline = createInterface({
68
85
  input: process.stdin,
@@ -136,44 +153,6 @@ async function clearService(deps, serviceName) {
136
153
  deps.log(`No API credentials found for ${serviceName}.`);
137
154
  }
138
155
  }
139
- /**
140
- * Check if browser login is disabled via environment variable.
141
- * Exits with error if LATCHKEY_DISABLE_BROWSER is set.
142
- */
143
- function checkBrowserNotDisabledOrExit(deps) {
144
- if (deps.config.browserDisabled) {
145
- deps.errorLog(new BrowserDisabledError().message);
146
- deps.exit(1);
147
- }
148
- }
149
- /**
150
- * Check if a graphical environment is available.
151
- * Exits with error if no display server (X11 or Wayland) is detected on Linux.
152
- */
153
- function checkGraphicalEnvironmentOrExit(deps) {
154
- if (!hasGraphicalEnvironment()) {
155
- deps.errorLog(new GraphicalEnvironmentNotFoundError().message);
156
- deps.exit(1);
157
- }
158
- }
159
- /**
160
- * Get the browser launch options from configuration, handling errors with CLI output.
161
- * Exits with error if no valid browser config exists, if browser is disabled,
162
- * or if no graphical environment is available.
163
- */
164
- function getBrowserLaunchOptionsOrExit(deps) {
165
- checkBrowserNotDisabledOrExit(deps);
166
- checkGraphicalEnvironmentOrExit(deps);
167
- const browserConfig = loadBrowserConfig(deps.config.configPath);
168
- if (!browserConfig) {
169
- deps.errorLog("Error: No browser configured. Run 'latchkey ensure-browser' first.");
170
- deps.exit(1);
171
- }
172
- return {
173
- browserStatePath: deps.config.browserStatePath,
174
- executablePath: browserConfig.executablePath,
175
- };
176
- }
177
156
  /**
178
157
  * Register all CLI commands on the given program.
179
158
  */
@@ -187,56 +166,45 @@ export function registerCommands(program, deps) {
187
166
  .option('--builtin', 'Only list built-in services (exclude registered services)')
188
167
  .option('--viable', 'Only list services that either have stored credentials or can be authenticated via a browser.')
189
168
  .action(async (options) => {
190
- let services = deps.registry.services;
191
- if (options.builtin === true) {
192
- services = services.filter((service) => !(service instanceof RegisteredService));
193
- }
194
- if (options.viable === true) {
195
- const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
196
- const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
197
- const allCredentials = apiCredentialStore.getAll();
198
- services = services.filter((service) => {
199
- if (allCredentials.has(service.name)) {
200
- return true;
201
- }
202
- const supportsBrowser = service.getSession !== undefined &&
203
- !deps.config.browserDisabled &&
204
- hasGraphicalEnvironment();
205
- return supportsBrowser;
169
+ if (deps.config.gatewayUrl !== null) {
170
+ const result = await forwardToGateway(deps, {
171
+ command: 'services list',
172
+ params: options,
206
173
  });
174
+ deps.log(JSON.stringify(result, null, 2));
175
+ return;
207
176
  }
208
- const serviceNames = services.map((service) => service.name).sort();
209
- deps.log(JSON.stringify(serviceNames, null, 2));
177
+ const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
178
+ const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
179
+ const result = servicesList(deps.registry, apiCredentialStore, deps.config, options);
180
+ deps.log(JSON.stringify(result, null, 2));
210
181
  });
211
182
  servicesCommand
212
183
  .command('info')
213
184
  .description('Show information about a service.')
214
185
  .argument('<service_name>', 'Name of the service to get info for')
215
186
  .action(async (serviceName) => {
216
- const service = deps.registry.getByName(serviceName);
217
- if (service === null) {
218
- deps.errorLog(`Error: Unknown service: ${serviceName}`);
219
- deps.errorLog("Use 'latchkey services list' to see available services.");
220
- deps.exit(1);
187
+ if (deps.config.gatewayUrl !== null) {
188
+ const info = await forwardToGateway(deps, {
189
+ command: 'services info',
190
+ params: { serviceName },
191
+ });
192
+ deps.log(JSON.stringify(info, null, 2));
193
+ return;
194
+ }
195
+ try {
196
+ const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
197
+ const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
198
+ const info = await servicesInfo(deps.registry, apiCredentialStore, deps.config, serviceName);
199
+ deps.log(JSON.stringify(info, null, 2));
200
+ }
201
+ catch (error) {
202
+ if (error instanceof UnknownServiceError) {
203
+ deps.errorLog(`Error: ${error.message}`);
204
+ deps.exit(1);
205
+ }
206
+ throw error;
221
207
  }
222
- // Login options
223
- const supportsBrowser = service.getSession !== undefined && !deps.config.browserDisabled;
224
- const authOptions = supportsBrowser ? ['browser', 'set'] : ['set'];
225
- // Credentials status
226
- const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
227
- const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
228
- const apiCredentials = apiCredentialStore.get(serviceName);
229
- const credentialStatus = await getCredentialStatus(service, apiCredentials, apiCredentialStore);
230
- const serviceType = service instanceof RegisteredService ? 'user-registered' : 'built-in';
231
- const info = {
232
- type: serviceType,
233
- baseApiUrls: service.baseApiUrls,
234
- authOptions,
235
- credentialStatus,
236
- setCredentialsExample: service.setCredentialsExample(serviceName),
237
- developerNotes: service.info,
238
- };
239
- deps.log(JSON.stringify(info, null, 2));
240
208
  });
241
209
  servicesCommand
242
210
  .command('register')
@@ -246,6 +214,7 @@ export function registerCommands(program, deps) {
246
214
  .option('--service-family <name>', 'Name of the built-in service to use as a template, if any (e.g. gitlab)')
247
215
  .option('--login-url <url>', 'Login URL for browser-based authentication, if applicable')
248
216
  .action((rawServiceName, options) => {
217
+ refuseInGatewayMode(deps, 'services register');
249
218
  let serviceName;
250
219
  try {
251
220
  serviceName = canonicalizeServiceName(rawServiceName);
@@ -303,6 +272,7 @@ export function registerCommands(program, deps) {
303
272
  .description('Deregister a previously registered service instance.')
304
273
  .argument('<service_name>', 'Name of the registered service to remove')
305
274
  .action(async (serviceName) => {
275
+ refuseInGatewayMode(deps, 'services deregister');
306
276
  const service = deps.registry.getByName(serviceName);
307
277
  if (service === null) {
308
278
  deps.errorLog(`Error: Unknown service: ${serviceName}`);
@@ -330,6 +300,7 @@ export function registerCommands(program, deps) {
330
300
  .argument('[service_name]', 'Name of the service to clear API credentials for')
331
301
  .option('-y, --yes', 'Skip confirmation prompt when clearing all data')
332
302
  .action(async (serviceName, options) => {
303
+ refuseInGatewayMode(deps, 'auth clear');
333
304
  if (serviceName === undefined) {
334
305
  await clearAll(deps, options.yes ?? false);
335
306
  }
@@ -341,17 +312,14 @@ export function registerCommands(program, deps) {
341
312
  .command('list')
342
313
  .description('List all stored credentials and their status.')
343
314
  .action(async () => {
315
+ if (deps.config.gatewayUrl !== null) {
316
+ const entries = await forwardToGateway(deps, { command: 'auth list' });
317
+ deps.log(JSON.stringify(entries, null, 2));
318
+ return;
319
+ }
344
320
  const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
345
321
  const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
346
- const allCredentials = apiCredentialStore.getAll();
347
- const statusChecks = Array.from(allCredentials, async ([serviceName, credentials]) => {
348
- const service = deps.registry.getByName(serviceName);
349
- const credentialStatus = service !== null
350
- ? await getCredentialStatus(service, credentials, apiCredentialStore)
351
- : ApiCredentialStatus.Valid;
352
- return [serviceName, { credentialType: credentials.objectType, credentialStatus }];
353
- });
354
- const entries = Object.fromEntries(await Promise.all(statusChecks));
322
+ const entries = await authList(deps.registry, apiCredentialStore);
355
323
  deps.log(JSON.stringify(entries, null, 2));
356
324
  });
357
325
  authCommand
@@ -362,6 +330,7 @@ export function registerCommands(program, deps) {
362
330
  .allowUnknownOption()
363
331
  .allowExcessArguments()
364
332
  .action(async (_serviceName, _options, command) => {
333
+ refuseInGatewayMode(deps, 'auth set');
365
334
  const [serviceName, ...curlArguments] = command.args;
366
335
  if (serviceName === undefined) {
367
336
  deps.errorLog('Error: Service name is required.');
@@ -394,6 +363,7 @@ export function registerCommands(program, deps) {
394
363
  .addHelpText('after', `\nExample:\n $ latchkey auth set-nocurl aws <access-key-id> <secret-access-key>`)
395
364
  .allowExcessArguments()
396
365
  .action(async (_serviceName, _options, command) => {
366
+ refuseInGatewayMode(deps, 'auth set-nocurl');
397
367
  const [serviceName, ...noCurlArguments] = command.args;
398
368
  if (serviceName === undefined) {
399
369
  deps.errorLog('Error: Service name is required.');
@@ -426,33 +396,45 @@ export function registerCommands(program, deps) {
426
396
  .description('Login to a service via the browser and store the API credentials.')
427
397
  .argument('<service_name>', 'Name of the service to login to')
428
398
  .action(async (serviceName) => {
429
- const service = deps.registry.getByName(serviceName);
430
- if (service === null) {
431
- deps.errorLog(`Error: Unknown service: ${serviceName}`);
432
- deps.errorLog("Use 'latchkey services list' to see available services.");
433
- deps.exit(1);
434
- }
435
- const session = service.getSession?.();
436
- if (!session) {
437
- deps.errorLog(`Service '${serviceName}' does not support browser flows. ` +
438
- `Use '${service.setCredentialsExample(serviceName)}' to set credentials manually.`);
439
- deps.exit(1);
440
- }
441
- const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
442
- const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
443
- const oldCredentials = apiCredentialStore.get(service.name);
444
- if (session.prepare && oldCredentials === null) {
445
- deps.errorLog(`Error: Service ${serviceName} requires preparation first.`);
446
- deps.errorLog(`Run 'latchkey auth browser-prepare ${serviceName}' before logging in.`);
447
- deps.exit(1);
399
+ if (deps.config.gatewayUrl !== null) {
400
+ await forwardToGateway(deps, {
401
+ command: 'auth browser',
402
+ params: { serviceName },
403
+ });
404
+ deps.log('Done');
405
+ return;
448
406
  }
449
- const launchOptions = getBrowserLaunchOptionsOrExit(deps);
450
407
  try {
451
- const apiCredentials = await session.login(encryptedStorage, launchOptions, oldCredentials ?? undefined);
452
- apiCredentialStore.save(service.name, apiCredentials);
408
+ const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
409
+ const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
410
+ await authBrowser(deps.registry, apiCredentialStore, encryptedStorage, deps.config, serviceName);
453
411
  deps.log('Done');
454
412
  }
455
413
  catch (error) {
414
+ if (error instanceof UnknownServiceError) {
415
+ deps.errorLog(`Error: ${error.message}`);
416
+ deps.exit(1);
417
+ }
418
+ if (error instanceof BrowserFlowsNotSupportedError) {
419
+ deps.errorLog(error.message);
420
+ deps.exit(1);
421
+ }
422
+ if (error instanceof PreparationRequiredError) {
423
+ deps.errorLog(`Error: ${error.message}`);
424
+ deps.exit(1);
425
+ }
426
+ if (error instanceof BrowserDisabledError) {
427
+ deps.errorLog(error.message);
428
+ deps.exit(1);
429
+ }
430
+ if (error instanceof GraphicalEnvironmentNotFoundError) {
431
+ deps.errorLog(error.message);
432
+ deps.exit(1);
433
+ }
434
+ if (error instanceof BrowserNotConfiguredError) {
435
+ deps.errorLog(`Error: ${error.message}`);
436
+ deps.exit(1);
437
+ }
456
438
  if (error instanceof LoginCancelledError) {
457
439
  deps.errorLog('Login cancelled.');
458
440
  deps.exit(1);
@@ -469,32 +451,47 @@ export function registerCommands(program, deps) {
469
451
  .description('Prepare a service to be used with the browser command.')
470
452
  .argument('<service_name>', 'Name of the service to prepare')
471
453
  .action(async (serviceName) => {
472
- const service = deps.registry.getByName(serviceName);
473
- if (service === null) {
474
- deps.errorLog(`Error: Unknown service: ${serviceName}`);
475
- deps.errorLog("Use 'latchkey services list' to see available services.");
476
- deps.exit(1);
477
- }
478
- const session = service.getSession?.();
479
- if (!session?.prepare) {
480
- deps.log('This service does not require a preparation step.');
481
- return;
482
- }
483
- const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
484
- const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
485
- // Check if already prepared (credentials exist)
486
- const existingCredentials = apiCredentialStore.get(service.name);
487
- if (existingCredentials !== null) {
488
- deps.log('Already prepared.');
454
+ if (deps.config.gatewayUrl !== null) {
455
+ const result = (await forwardToGateway(deps, {
456
+ command: 'auth browser-prepare',
457
+ params: { serviceName },
458
+ }));
459
+ if (result !== null && result.alreadyPrepared === true) {
460
+ deps.log('Already prepared.');
461
+ }
462
+ else {
463
+ deps.log('Done');
464
+ }
489
465
  return;
490
466
  }
491
- const launchOptions = getBrowserLaunchOptionsOrExit(deps);
492
467
  try {
493
- const apiCredentials = await session.prepare(encryptedStorage, launchOptions);
494
- apiCredentialStore.save(service.name, apiCredentials);
495
- deps.log('Done');
468
+ const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
469
+ const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
470
+ const result = await authBrowserPrepare(deps.registry, apiCredentialStore, encryptedStorage, deps.config, serviceName);
471
+ if (result.alreadyPrepared) {
472
+ deps.log('Already prepared.');
473
+ }
474
+ else {
475
+ deps.log('Done');
476
+ }
496
477
  }
497
478
  catch (error) {
479
+ if (error instanceof UnknownServiceError) {
480
+ deps.errorLog(`Error: ${error.message}`);
481
+ deps.exit(1);
482
+ }
483
+ if (error instanceof BrowserDisabledError) {
484
+ deps.errorLog(error.message);
485
+ deps.exit(1);
486
+ }
487
+ if (error instanceof GraphicalEnvironmentNotFoundError) {
488
+ deps.errorLog(error.message);
489
+ deps.exit(1);
490
+ }
491
+ if (error instanceof BrowserNotConfiguredError) {
492
+ deps.errorLog(`Error: ${error.message}`);
493
+ deps.exit(1);
494
+ }
498
495
  if (error instanceof LoginCancelledError) {
499
496
  deps.errorLog('Preparation cancelled.');
500
497
  deps.exit(1);
@@ -513,51 +510,106 @@ export function registerCommands(program, deps) {
513
510
  .allowExcessArguments()
514
511
  .action(async (_options, command) => {
515
512
  const curlArguments = command.args;
516
- try {
517
- const allowed = await deps.checkPermission(curlArguments, deps.config.permissionsConfigPath);
518
- if (!allowed) {
519
- deps.errorLog('Error: Request not permitted by the user.');
520
- deps.exit(PERMISSION_DENIED_EXIT_CODE);
513
+ if (deps.config.gatewayUrl !== null) {
514
+ let targetUrl;
515
+ try {
516
+ targetUrl = extractUrlFromCurlArguments(curlArguments);
517
+ }
518
+ catch (error) {
519
+ if (error instanceof CurlParseError) {
520
+ if (error.message) {
521
+ deps.errorLog(`${ErrorMessages.couldNotExtractUrlBrief} ${error.message}`);
522
+ }
523
+ else {
524
+ deps.errorLog(`${ErrorMessages.couldNotExtractUrl} ${error.message}`);
525
+ }
526
+ deps.exit(1);
527
+ }
528
+ throw error;
529
+ }
530
+ if (targetUrl === null) {
531
+ deps.errorLog(ErrorMessages.couldNotExtractUrl);
532
+ deps.exit(1);
533
+ }
534
+ let rewritten;
535
+ try {
536
+ rewritten = rewriteCurlArgumentsForGateway(curlArguments, targetUrl, deps.config.gatewayUrl);
537
+ }
538
+ catch (error) {
539
+ if (error instanceof GatewayCurlRewriteError) {
540
+ deps.errorLog(`Error: ${error.message}`);
541
+ deps.exit(1);
542
+ }
543
+ throw error;
521
544
  }
545
+ const result = deps.runCurl(rewritten);
546
+ deps.exit(result.returncode);
547
+ }
548
+ const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
549
+ const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
550
+ let finalArguments;
551
+ try {
552
+ finalArguments = await prepareCurlInvocation(curlArguments, apiCredentialStore, {
553
+ registry: deps.registry,
554
+ checkPermission: deps.checkPermission,
555
+ permissionsConfigPath: deps.config.permissionsConfigPath,
556
+ permissionsDoNotUseBuiltinSchemas: deps.config.permissionsDoNotUseBuiltinSchemas,
557
+ passthroughUnknown: deps.config.passthroughUnknown,
558
+ });
522
559
  }
523
560
  catch (error) {
561
+ if (error instanceof RequestNotPermittedError) {
562
+ deps.errorLog(error.message);
563
+ deps.exit(PERMISSION_DENIED_EXIT_CODE);
564
+ }
524
565
  if (error instanceof PermissionCheckError) {
525
566
  deps.errorLog(`Error: ${error.message}`);
526
567
  deps.exit(PERMISSION_DENIED_EXIT_CODE);
527
568
  }
569
+ if (error instanceof UrlExtractionFailedError ||
570
+ error instanceof NoServiceForUrlError ||
571
+ error instanceof NoCredentialsForServiceError ||
572
+ error instanceof CredentialsExpiredError) {
573
+ deps.errorLog(error.message);
574
+ deps.exit(1);
575
+ }
528
576
  throw error;
529
577
  }
530
- const url = extractUrlFromCurlArguments(curlArguments);
531
- if (url === null) {
532
- deps.errorLog('Error: Could not extract URL from curl arguments.');
578
+ const result = deps.runCurl(finalArguments);
579
+ deps.exit(result.returncode);
580
+ });
581
+ program
582
+ .command('gateway')
583
+ .description('Start a local HTTP gateway that proxies requests with credential injection.')
584
+ .option('--port <number>', `Port to listen on (default: ${deps.config.gatewayListenPort.toString()}, configurable via config.json key 'gatewayListenPort')`)
585
+ .option('--host <address>', `Address to bind to (default: ${deps.config.gatewayListenHost}, configurable via config.json key 'gatewayListenHost')`)
586
+ .option('--max-body-size <bytes>', 'Maximum request body size in bytes', String(10 * 1024 * 1024))
587
+ .action(async (options) => {
588
+ refuseInGatewayMode(deps, 'gateway');
589
+ const portString = options.port ?? deps.config.gatewayListenPort.toString();
590
+ const port = parseInt(portString, 10);
591
+ if (isNaN(port) || port < 0 || port > 65535) {
592
+ deps.errorLog(`Error: Invalid port number: ${portString}`);
533
593
  deps.exit(1);
534
594
  }
535
- const service = deps.registry.getByUrl(url);
536
- if (service === null) {
537
- deps.errorLog(`Error: No service matches URL: ${url}`);
595
+ const maxBodySize = parseInt(options.maxBodySize, 10);
596
+ if (isNaN(maxBodySize) || maxBodySize <= 0) {
597
+ deps.errorLog(`Error: Invalid max body size: ${options.maxBodySize}`);
538
598
  deps.exit(1);
539
599
  }
540
600
  const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
541
601
  const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
542
- let apiCredentials = apiCredentialStore.get(service.name);
543
- // Check if credentials exist but are expired
544
- const isExpired = apiCredentials?.isExpired() === true;
545
- if (apiCredentials === null) {
546
- deps.errorLog(`Error: No credentials found for ${service.name}.`);
547
- deps.errorLog(`Run 'latchkey auth browser ${service.name}' or 'latchkey auth set ${service.name}' first.`);
548
- deps.exit(1);
549
- }
550
- if (isExpired) {
551
- apiCredentials = await maybeRefreshCredentials(service, apiCredentials, apiCredentialStore);
552
- if (apiCredentials.isExpired() === true) {
553
- deps.errorLog(`Error: Credentials for ${service.name} are expired.`);
554
- deps.errorLog(`Run 'latchkey auth browser ${service.name}' or 'latchkey auth set ${service.name}' to refresh them.`);
555
- deps.exit(1);
556
- }
557
- }
558
- const allArguments = apiCredentials.injectIntoCurlCall(curlArguments);
559
- const result = deps.runCurl(allArguments);
560
- deps.exit(result.returncode);
602
+ const gateway = await startGateway(deps, apiCredentialStore, encryptedStorage, {
603
+ port,
604
+ host: options.host ?? deps.config.gatewayListenHost,
605
+ maxBodySize,
606
+ });
607
+ const shutdown = async () => {
608
+ await gateway.close();
609
+ deps.exit(0);
610
+ };
611
+ process.on('SIGINT', () => void shutdown());
612
+ process.on('SIGTERM', () => void shutdown());
561
613
  });
562
614
  program
563
615
  .command('skill-md')
@@ -570,6 +622,7 @@ export function registerCommands(program, deps) {
570
622
  .description('Ensure a Chrome/Chromium browser is available for Latchkey to use.')
571
623
  .option('--source <sources>', `Comma-separated list of sources to try in order: ${BROWSER_SOURCES.join(', ')}`, DEFAULT_BROWSER_SOURCES.join(','))
572
624
  .action(async (options) => {
625
+ refuseInGatewayMode(deps, 'ensure-browser');
573
626
  const configPath = deps.config.configPath;
574
627
  // Parse and validate sources
575
628
  const sourceList = options.source.split(',').map((s) => s.trim());