latchkey 2.6.1 → 2.7.1

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 (193) hide show
  1. package/README.md +50 -1
  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/browserConfig.d.ts +5 -1
  21. package/dist/src/browserConfig.d.ts.map +1 -1
  22. package/dist/src/browserConfig.js +17 -3
  23. package/dist/src/browserConfig.js.map +1 -1
  24. package/dist/src/cli.js +42 -39
  25. package/dist/src/cli.js.map +1 -1
  26. package/dist/src/cliCommands.d.ts +5 -2
  27. package/dist/src/cliCommands.d.ts.map +1 -1
  28. package/dist/src/cliCommands.js +270 -190
  29. package/dist/src/cliCommands.js.map +1 -1
  30. package/dist/src/config.d.ts +32 -2
  31. package/dist/src/config.d.ts.map +1 -1
  32. package/dist/src/config.js +107 -20
  33. package/dist/src/config.js.map +1 -1
  34. package/dist/src/configDataStore.d.ts +44 -0
  35. package/dist/src/configDataStore.d.ts.map +1 -1
  36. package/dist/src/configDataStore.js +27 -0
  37. package/dist/src/configDataStore.js.map +1 -1
  38. package/dist/src/curl.d.ts +41 -8
  39. package/dist/src/curl.d.ts.map +1 -1
  40. package/dist/src/curl.js +80 -75
  41. package/dist/src/curl.js.map +1 -1
  42. package/dist/src/curlInjection.d.ts +46 -0
  43. package/dist/src/curlInjection.d.ts.map +1 -0
  44. package/dist/src/curlInjection.js +99 -0
  45. package/dist/src/curlInjection.js.map +1 -0
  46. package/dist/src/errorMessages.d.ts +14 -0
  47. package/dist/src/errorMessages.d.ts.map +1 -0
  48. package/dist/src/errorMessages.js +22 -0
  49. package/dist/src/errorMessages.js.map +1 -0
  50. package/dist/src/gateway/client.d.ts +32 -0
  51. package/dist/src/gateway/client.d.ts.map +1 -0
  52. package/dist/src/gateway/client.js +89 -0
  53. package/dist/src/gateway/client.js.map +1 -0
  54. package/dist/src/gateway/gatewayEndpoint.d.ts +43 -0
  55. package/dist/src/gateway/gatewayEndpoint.d.ts.map +1 -0
  56. package/dist/src/gateway/gatewayEndpoint.js +297 -0
  57. package/dist/src/gateway/gatewayEndpoint.js.map +1 -0
  58. package/dist/src/gateway/latchkeyEndpoint.d.ts +105 -0
  59. package/dist/src/gateway/latchkeyEndpoint.d.ts.map +1 -0
  60. package/dist/src/gateway/latchkeyEndpoint.js +144 -0
  61. package/dist/src/gateway/latchkeyEndpoint.js.map +1 -0
  62. package/dist/src/gateway/server.d.ts +20 -0
  63. package/dist/src/gateway/server.d.ts.map +1 -0
  64. package/dist/src/gateway/server.js +90 -0
  65. package/dist/src/gateway/server.js.map +1 -0
  66. package/dist/src/index.d.ts +4 -4
  67. package/dist/src/index.d.ts.map +1 -1
  68. package/dist/src/index.js +5 -5
  69. package/dist/src/index.js.map +1 -1
  70. package/dist/src/permissions.d.ts.map +1 -1
  71. package/dist/src/permissions.js +4 -2
  72. package/dist/src/permissions.js.map +1 -1
  73. package/dist/src/playwrightDownload.d.ts +1 -1
  74. package/dist/src/playwrightDownload.d.ts.map +1 -1
  75. package/dist/src/playwrightDownload.js +5 -4
  76. package/dist/src/playwrightDownload.js.map +1 -1
  77. package/dist/src/playwrightLoader.d.ts +17 -0
  78. package/dist/src/playwrightLoader.d.ts.map +1 -0
  79. package/dist/src/playwrightLoader.js +47 -0
  80. package/dist/src/playwrightLoader.js.map +1 -0
  81. package/dist/src/playwrightUtils.d.ts.map +1 -1
  82. package/dist/src/playwrightUtils.js +2 -1
  83. package/dist/src/playwrightUtils.js.map +1 -1
  84. package/dist/src/{registry.d.ts → serviceRegistry.d.ts} +4 -4
  85. package/dist/src/serviceRegistry.d.ts.map +1 -0
  86. package/dist/src/{registry.js → serviceRegistry.js} +4 -4
  87. package/dist/src/serviceRegistry.js.map +1 -0
  88. package/dist/src/services/aws.d.ts +2 -2
  89. package/dist/src/services/aws.d.ts.map +1 -1
  90. package/dist/src/services/aws.js +17 -10
  91. package/dist/src/services/aws.js.map +1 -1
  92. package/dist/src/services/core/base.d.ts +2 -2
  93. package/dist/src/services/core/base.d.ts.map +1 -1
  94. package/dist/src/services/core/base.js +3 -3
  95. package/dist/src/services/core/base.js.map +1 -1
  96. package/dist/src/services/core/registered.d.ts +2 -2
  97. package/dist/src/services/core/registered.d.ts.map +1 -1
  98. package/dist/src/services/core/registered.js +2 -2
  99. package/dist/src/services/core/registered.js.map +1 -1
  100. package/dist/src/services/discord.d.ts +1 -1
  101. package/dist/src/services/discord.d.ts.map +1 -1
  102. package/dist/src/services/discord.js +1 -1
  103. package/dist/src/services/discord.js.map +1 -1
  104. package/dist/src/services/dropbox.d.ts +1 -1
  105. package/dist/src/services/dropbox.d.ts.map +1 -1
  106. package/dist/src/services/dropbox.js +1 -1
  107. package/dist/src/services/dropbox.js.map +1 -1
  108. package/dist/src/services/github.d.ts +2 -2
  109. package/dist/src/services/github.d.ts.map +1 -1
  110. package/dist/src/services/github.js +2 -2
  111. package/dist/src/services/github.js.map +1 -1
  112. package/dist/src/services/google/base.d.ts +2 -2
  113. package/dist/src/services/google/base.d.ts.map +1 -1
  114. package/dist/src/services/google/base.js +3 -3
  115. package/dist/src/services/google/base.js.map +1 -1
  116. package/dist/src/services/google/directions.d.ts +1 -1
  117. package/dist/src/services/google/directions.d.ts.map +1 -1
  118. package/dist/src/services/linear.d.ts +1 -1
  119. package/dist/src/services/linear.d.ts.map +1 -1
  120. package/dist/src/services/linear.js +1 -1
  121. package/dist/src/services/linear.js.map +1 -1
  122. package/dist/src/services/notion.d.ts +1 -1
  123. package/dist/src/services/notion.d.ts.map +1 -1
  124. package/dist/src/services/notion.js +1 -1
  125. package/dist/src/services/notion.js.map +1 -1
  126. package/dist/src/services/sentry.d.ts +2 -2
  127. package/dist/src/services/sentry.d.ts.map +1 -1
  128. package/dist/src/services/sentry.js +6 -3
  129. package/dist/src/services/sentry.js.map +1 -1
  130. package/dist/src/services/slack.d.ts +3 -3
  131. package/dist/src/services/slack.d.ts.map +1 -1
  132. package/dist/src/services/slack.js +5 -5
  133. package/dist/src/services/slack.js.map +1 -1
  134. package/dist/src/services/telegram.d.ts +2 -2
  135. package/dist/src/services/telegram.d.ts.map +1 -1
  136. package/dist/src/services/telegram.js +2 -2
  137. package/dist/src/services/telegram.js.map +1 -1
  138. package/dist/src/sharedOperations.d.ts +44 -0
  139. package/dist/src/sharedOperations.d.ts.map +1 -0
  140. package/dist/src/sharedOperations.js +131 -0
  141. package/dist/src/sharedOperations.js.map +1 -0
  142. package/dist/src/version.d.ts +2 -0
  143. package/dist/src/version.d.ts.map +1 -0
  144. package/dist/src/version.js +4 -0
  145. package/dist/src/version.js.map +1 -0
  146. package/dist/tests/apiCredentialStore.test.js +2 -2
  147. package/dist/tests/apiCredentialStore.test.js.map +1 -1
  148. package/dist/tests/apiCredentials.test.js +37 -36
  149. package/dist/tests/apiCredentials.test.js.map +1 -1
  150. package/dist/tests/cli.test.js +240 -55
  151. package/dist/tests/cli.test.js.map +1 -1
  152. package/dist/tests/config.test.d.ts +2 -0
  153. package/dist/tests/config.test.d.ts.map +1 -0
  154. package/dist/tests/config.test.js +150 -0
  155. package/dist/tests/config.test.js.map +1 -0
  156. package/dist/tests/gateway.test.d.ts +2 -0
  157. package/dist/tests/gateway.test.d.ts.map +1 -0
  158. package/dist/tests/gateway.test.js +566 -0
  159. package/dist/tests/gateway.test.js.map +1 -0
  160. package/dist/tests/gatewayClient.test.d.ts +2 -0
  161. package/dist/tests/gatewayClient.test.d.ts.map +1 -0
  162. package/dist/tests/gatewayClient.test.js +85 -0
  163. package/dist/tests/gatewayClient.test.js.map +1 -0
  164. package/dist/tests/latchkeyEndpoint.test.d.ts +2 -0
  165. package/dist/tests/latchkeyEndpoint.test.d.ts.map +1 -0
  166. package/dist/tests/latchkeyEndpoint.test.js +385 -0
  167. package/dist/tests/latchkeyEndpoint.test.js.map +1 -0
  168. package/dist/tests/permissions.test.js +15 -0
  169. package/dist/tests/permissions.test.js.map +1 -1
  170. package/dist/tests/playwrightDownload.test.js +2 -2
  171. package/dist/tests/playwrightDownload.test.js.map +1 -1
  172. package/dist/tests/serviceRegistry.test.d.ts +2 -0
  173. package/dist/tests/serviceRegistry.test.d.ts.map +1 -0
  174. package/dist/tests/{registry.test.js → serviceRegistry.test.js} +17 -17
  175. package/dist/tests/serviceRegistry.test.js.map +1 -0
  176. package/dist/tests/servicesAgainstRecordings.test.js +3 -3
  177. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
  178. package/dist/tests/sharedOperations.test.d.ts +2 -0
  179. package/dist/tests/sharedOperations.test.d.ts.map +1 -0
  180. package/dist/tests/sharedOperations.test.js +264 -0
  181. package/dist/tests/sharedOperations.test.js.map +1 -0
  182. package/package.json +10 -3
  183. package/dist/src/apiCredentialStore.d.ts.map +0 -1
  184. package/dist/src/apiCredentialStore.js.map +0 -1
  185. package/dist/src/apiCredentials.d.ts.map +0 -1
  186. package/dist/src/apiCredentials.js.map +0 -1
  187. package/dist/src/apiCredentialsSerialization.d.ts.map +0 -1
  188. package/dist/src/apiCredentialsSerialization.js.map +0 -1
  189. package/dist/src/registry.d.ts.map +0 -1
  190. package/dist/src/registry.js.map +0 -1
  191. package/dist/tests/registry.test.d.ts +0 -2
  192. package/dist/tests/registry.test.d.ts.map +0 -1
  193. package/dist/tests/registry.test.js.map +0 -1
@@ -3,55 +3,41 @@
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';
13
+ import { BrowserFeaturesUnavailableError, loadPlaywright } from './playwrightLoader.js';
12
14
  import { EncryptedStorage } from './encryptedStorage.js';
13
- import { DuplicateServiceNameError, InvalidServiceNameError, REGISTRY, canonicalizeServiceName, } from './registry.js';
15
+ import { DuplicateServiceNameError, InvalidServiceNameError, SERVICE_REGISTRY, canonicalizeServiceName, } from './serviceRegistry.js';
14
16
  import { RegisteredService } from './services/core/registered.js';
15
17
  import { LoginCancelledError, LoginFailedError, NoCurlCredentialsNotSupportedError, } from './services/index.js';
16
- import { extractUrlFromCurlArguments, run as curlRun } from './curl.js';
18
+ import { CurlParseError, extractUrlFromCurlArguments, run as curlRun, runAsync as curlRunAsync, } from './curl.js';
17
19
  import { checkPermission, PermissionCheckError } from './permissions.js';
20
+ import { ErrorMessages } from './errorMessages.js';
18
21
  import { getSkillMdContent } from './skillMd.js';
22
+ import { startGateway } from './gateway/server.js';
23
+ import { callLatchkeyEndpoint, GatewayCommandNotSupportedError, GatewayCurlRewriteError, GatewayRequestError, rewriteCurlArgumentsForGateway, } from './gateway/client.js';
24
+ import { servicesList, servicesInfo, authList, authBrowser, authBrowserPrepare, UnknownServiceError, BrowserNotConfiguredError, PreparationRequiredError, } from './sharedOperations.js';
25
+ import { VERSION } from './version.js';
19
26
  /**
20
27
  * Exit code used when a request is rejected by permission rules.
21
28
  * Uses the Unix convention for "command not permitted" (126).
22
29
  * Curl itself does not use this exit code.
23
30
  */
24
31
  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
32
  /**
48
33
  * Default implementation of CLI dependencies.
49
34
  */
50
35
  export function createDefaultDependencies() {
51
36
  return {
52
- registry: REGISTRY,
37
+ registry: SERVICE_REGISTRY,
53
38
  config: CONFIG,
54
39
  runCurl: curlRun,
40
+ runCurlAsync: curlRunAsync,
55
41
  checkPermission: checkPermission,
56
42
  confirm: defaultConfirm,
57
43
  exit: (code) => process.exit(code),
@@ -61,8 +47,40 @@ export function createDefaultDependencies() {
61
47
  errorLog: (message) => {
62
48
  console.error(message);
63
49
  },
50
+ version: VERSION,
64
51
  };
65
52
  }
53
+ /**
54
+ * Forward a request to the gateway's `/latchkey/` endpoint. On transport or
55
+ * protocol errors the CLI exits with status 1 after logging the error message.
56
+ */
57
+ async function forwardToGateway(deps, request) {
58
+ const gatewayUrl = deps.config.gatewayUrl;
59
+ if (gatewayUrl === null) {
60
+ throw new GatewayCommandNotSupportedError(request.command);
61
+ }
62
+ try {
63
+ return await callLatchkeyEndpoint(gatewayUrl, request);
64
+ }
65
+ catch (error) {
66
+ if (error instanceof GatewayRequestError) {
67
+ deps.errorLog(`Error: ${error.message}`);
68
+ deps.exit(1);
69
+ }
70
+ throw error;
71
+ }
72
+ }
73
+ /**
74
+ * If the CLI is running in gateway mode, log an error and exit. Used for
75
+ * commands that manage local state and cannot be meaningfully delegated.
76
+ */
77
+ function refuseInGatewayMode(deps, commandName) {
78
+ if (deps.config.gatewayUrl !== null) {
79
+ const error = new GatewayCommandNotSupportedError(commandName);
80
+ deps.errorLog(`Error: ${error.message}`);
81
+ deps.exit(1);
82
+ }
83
+ }
66
84
  async function defaultConfirm(message) {
67
85
  const readline = createInterface({
68
86
  input: process.stdin,
@@ -136,44 +154,6 @@ async function clearService(deps, serviceName) {
136
154
  deps.log(`No API credentials found for ${serviceName}.`);
137
155
  }
138
156
  }
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
157
  /**
178
158
  * Register all CLI commands on the given program.
179
159
  */
@@ -187,56 +167,45 @@ export function registerCommands(program, deps) {
187
167
  .option('--builtin', 'Only list built-in services (exclude registered services)')
188
168
  .option('--viable', 'Only list services that either have stored credentials or can be authenticated via a browser.')
189
169
  .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;
170
+ if (deps.config.gatewayUrl !== null) {
171
+ const result = await forwardToGateway(deps, {
172
+ command: 'services list',
173
+ params: options,
206
174
  });
175
+ deps.log(JSON.stringify(result, null, 2));
176
+ return;
207
177
  }
208
- const serviceNames = services.map((service) => service.name).sort();
209
- deps.log(JSON.stringify(serviceNames, null, 2));
178
+ const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
179
+ const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
180
+ const result = servicesList(deps.registry, apiCredentialStore, deps.config, options);
181
+ deps.log(JSON.stringify(result, null, 2));
210
182
  });
211
183
  servicesCommand
212
184
  .command('info')
213
185
  .description('Show information about a service.')
214
186
  .argument('<service_name>', 'Name of the service to get info for')
215
187
  .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);
188
+ if (deps.config.gatewayUrl !== null) {
189
+ const info = await forwardToGateway(deps, {
190
+ command: 'services info',
191
+ params: { serviceName },
192
+ });
193
+ deps.log(JSON.stringify(info, null, 2));
194
+ return;
195
+ }
196
+ try {
197
+ const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
198
+ const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
199
+ const info = await servicesInfo(deps.registry, apiCredentialStore, deps.config, serviceName);
200
+ deps.log(JSON.stringify(info, null, 2));
201
+ }
202
+ catch (error) {
203
+ if (error instanceof UnknownServiceError) {
204
+ deps.errorLog(`Error: ${error.message}`);
205
+ deps.exit(1);
206
+ }
207
+ throw error;
221
208
  }
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
209
  });
241
210
  servicesCommand
242
211
  .command('register')
@@ -246,6 +215,7 @@ export function registerCommands(program, deps) {
246
215
  .option('--service-family <name>', 'Name of the built-in service to use as a template, if any (e.g. gitlab)')
247
216
  .option('--login-url <url>', 'Login URL for browser-based authentication, if applicable')
248
217
  .action((rawServiceName, options) => {
218
+ refuseInGatewayMode(deps, 'services register');
249
219
  let serviceName;
250
220
  try {
251
221
  serviceName = canonicalizeServiceName(rawServiceName);
@@ -303,6 +273,7 @@ export function registerCommands(program, deps) {
303
273
  .description('Deregister a previously registered service instance.')
304
274
  .argument('<service_name>', 'Name of the registered service to remove')
305
275
  .action(async (serviceName) => {
276
+ refuseInGatewayMode(deps, 'services deregister');
306
277
  const service = deps.registry.getByName(serviceName);
307
278
  if (service === null) {
308
279
  deps.errorLog(`Error: Unknown service: ${serviceName}`);
@@ -330,6 +301,7 @@ export function registerCommands(program, deps) {
330
301
  .argument('[service_name]', 'Name of the service to clear API credentials for')
331
302
  .option('-y, --yes', 'Skip confirmation prompt when clearing all data')
332
303
  .action(async (serviceName, options) => {
304
+ refuseInGatewayMode(deps, 'auth clear');
333
305
  if (serviceName === undefined) {
334
306
  await clearAll(deps, options.yes ?? false);
335
307
  }
@@ -341,17 +313,14 @@ export function registerCommands(program, deps) {
341
313
  .command('list')
342
314
  .description('List all stored credentials and their status.')
343
315
  .action(async () => {
316
+ if (deps.config.gatewayUrl !== null) {
317
+ const entries = await forwardToGateway(deps, { command: 'auth list' });
318
+ deps.log(JSON.stringify(entries, null, 2));
319
+ return;
320
+ }
344
321
  const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
345
322
  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));
323
+ const entries = await authList(deps.registry, apiCredentialStore);
355
324
  deps.log(JSON.stringify(entries, null, 2));
356
325
  });
357
326
  authCommand
@@ -362,6 +331,7 @@ export function registerCommands(program, deps) {
362
331
  .allowUnknownOption()
363
332
  .allowExcessArguments()
364
333
  .action(async (_serviceName, _options, command) => {
334
+ refuseInGatewayMode(deps, 'auth set');
365
335
  const [serviceName, ...curlArguments] = command.args;
366
336
  if (serviceName === undefined) {
367
337
  deps.errorLog('Error: Service name is required.');
@@ -394,6 +364,7 @@ export function registerCommands(program, deps) {
394
364
  .addHelpText('after', `\nExample:\n $ latchkey auth set-nocurl aws <access-key-id> <secret-access-key>`)
395
365
  .allowExcessArguments()
396
366
  .action(async (_serviceName, _options, command) => {
367
+ refuseInGatewayMode(deps, 'auth set-nocurl');
397
368
  const [serviceName, ...noCurlArguments] = command.args;
398
369
  if (serviceName === undefined) {
399
370
  deps.errorLog('Error: Service name is required.');
@@ -426,33 +397,45 @@ export function registerCommands(program, deps) {
426
397
  .description('Login to a service via the browser and store the API credentials.')
427
398
  .argument('<service_name>', 'Name of the service to login to')
428
399
  .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);
400
+ if (deps.config.gatewayUrl !== null) {
401
+ await forwardToGateway(deps, {
402
+ command: 'auth browser',
403
+ params: { serviceName },
404
+ });
405
+ deps.log('Done');
406
+ return;
448
407
  }
449
- const launchOptions = getBrowserLaunchOptionsOrExit(deps);
450
408
  try {
451
- const apiCredentials = await session.login(encryptedStorage, launchOptions, oldCredentials ?? undefined);
452
- apiCredentialStore.save(service.name, apiCredentials);
409
+ const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
410
+ const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
411
+ await authBrowser(deps.registry, apiCredentialStore, encryptedStorage, deps.config, serviceName);
453
412
  deps.log('Done');
454
413
  }
455
414
  catch (error) {
415
+ if (error instanceof UnknownServiceError) {
416
+ deps.errorLog(`Error: ${error.message}`);
417
+ deps.exit(1);
418
+ }
419
+ if (error instanceof BrowserFlowsNotSupportedError) {
420
+ deps.errorLog(error.message);
421
+ deps.exit(1);
422
+ }
423
+ if (error instanceof PreparationRequiredError) {
424
+ deps.errorLog(`Error: ${error.message}`);
425
+ deps.exit(1);
426
+ }
427
+ if (error instanceof BrowserDisabledError) {
428
+ deps.errorLog(error.message);
429
+ deps.exit(1);
430
+ }
431
+ if (error instanceof GraphicalEnvironmentNotFoundError) {
432
+ deps.errorLog(error.message);
433
+ deps.exit(1);
434
+ }
435
+ if (error instanceof BrowserNotConfiguredError) {
436
+ deps.errorLog(`Error: ${error.message}`);
437
+ deps.exit(1);
438
+ }
456
439
  if (error instanceof LoginCancelledError) {
457
440
  deps.errorLog('Login cancelled.');
458
441
  deps.exit(1);
@@ -461,6 +444,10 @@ export function registerCommands(program, deps) {
461
444
  deps.errorLog(error.message);
462
445
  deps.exit(1);
463
446
  }
447
+ if (error instanceof BrowserFeaturesUnavailableError) {
448
+ deps.errorLog(error.message);
449
+ deps.exit(1);
450
+ }
464
451
  throw error;
465
452
  }
466
453
  });
@@ -469,32 +456,47 @@ export function registerCommands(program, deps) {
469
456
  .description('Prepare a service to be used with the browser command.')
470
457
  .argument('<service_name>', 'Name of the service to prepare')
471
458
  .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.');
459
+ if (deps.config.gatewayUrl !== null) {
460
+ const result = (await forwardToGateway(deps, {
461
+ command: 'auth browser-prepare',
462
+ params: { serviceName },
463
+ }));
464
+ if (result !== null && result.alreadyPrepared === true) {
465
+ deps.log('Already prepared.');
466
+ }
467
+ else {
468
+ deps.log('Done');
469
+ }
489
470
  return;
490
471
  }
491
- const launchOptions = getBrowserLaunchOptionsOrExit(deps);
492
472
  try {
493
- const apiCredentials = await session.prepare(encryptedStorage, launchOptions);
494
- apiCredentialStore.save(service.name, apiCredentials);
495
- deps.log('Done');
473
+ const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
474
+ const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
475
+ const result = await authBrowserPrepare(deps.registry, apiCredentialStore, encryptedStorage, deps.config, serviceName);
476
+ if (result.alreadyPrepared) {
477
+ deps.log('Already prepared.');
478
+ }
479
+ else {
480
+ deps.log('Done');
481
+ }
496
482
  }
497
483
  catch (error) {
484
+ if (error instanceof UnknownServiceError) {
485
+ deps.errorLog(`Error: ${error.message}`);
486
+ deps.exit(1);
487
+ }
488
+ if (error instanceof BrowserDisabledError) {
489
+ deps.errorLog(error.message);
490
+ deps.exit(1);
491
+ }
492
+ if (error instanceof GraphicalEnvironmentNotFoundError) {
493
+ deps.errorLog(error.message);
494
+ deps.exit(1);
495
+ }
496
+ if (error instanceof BrowserNotConfiguredError) {
497
+ deps.errorLog(`Error: ${error.message}`);
498
+ deps.exit(1);
499
+ }
498
500
  if (error instanceof LoginCancelledError) {
499
501
  deps.errorLog('Preparation cancelled.');
500
502
  deps.exit(1);
@@ -503,6 +505,10 @@ export function registerCommands(program, deps) {
503
505
  deps.errorLog(error.message);
504
506
  deps.exit(1);
505
507
  }
508
+ if (error instanceof BrowserFeaturesUnavailableError) {
509
+ deps.errorLog(error.message);
510
+ deps.exit(1);
511
+ }
506
512
  throw error;
507
513
  }
508
514
  });
@@ -513,51 +519,106 @@ export function registerCommands(program, deps) {
513
519
  .allowExcessArguments()
514
520
  .action(async (_options, command) => {
515
521
  const curlArguments = command.args;
516
- try {
517
- const allowed = await deps.checkPermission(curlArguments, deps.config.permissionsConfigPath, deps.config.permissionsDoNotUseBuiltinSchemas);
518
- if (!allowed) {
519
- deps.errorLog('Error: Request not permitted by the user.');
520
- deps.exit(PERMISSION_DENIED_EXIT_CODE);
522
+ if (deps.config.gatewayUrl !== null) {
523
+ let targetUrl;
524
+ try {
525
+ targetUrl = extractUrlFromCurlArguments(curlArguments);
526
+ }
527
+ catch (error) {
528
+ if (error instanceof CurlParseError) {
529
+ if (error.message) {
530
+ deps.errorLog(`${ErrorMessages.couldNotExtractUrlBrief} ${error.message}`);
531
+ }
532
+ else {
533
+ deps.errorLog(`${ErrorMessages.couldNotExtractUrl} ${error.message}`);
534
+ }
535
+ deps.exit(1);
536
+ }
537
+ throw error;
538
+ }
539
+ if (targetUrl === null) {
540
+ deps.errorLog(ErrorMessages.couldNotExtractUrl);
541
+ deps.exit(1);
542
+ }
543
+ let rewritten;
544
+ try {
545
+ rewritten = rewriteCurlArgumentsForGateway(curlArguments, targetUrl, deps.config.gatewayUrl);
546
+ }
547
+ catch (error) {
548
+ if (error instanceof GatewayCurlRewriteError) {
549
+ deps.errorLog(`Error: ${error.message}`);
550
+ deps.exit(1);
551
+ }
552
+ throw error;
521
553
  }
554
+ const result = deps.runCurl(rewritten);
555
+ deps.exit(result.returncode);
556
+ }
557
+ const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
558
+ const apiCredentialStore = new ApiCredentialStore(deps.config.credentialStorePath, encryptedStorage);
559
+ let finalArguments;
560
+ try {
561
+ finalArguments = await prepareCurlInvocation(curlArguments, apiCredentialStore, {
562
+ registry: deps.registry,
563
+ checkPermission: deps.checkPermission,
564
+ permissionsConfigPath: deps.config.permissionsConfigPath,
565
+ permissionsDoNotUseBuiltinSchemas: deps.config.permissionsDoNotUseBuiltinSchemas,
566
+ passthroughUnknown: deps.config.passthroughUnknown,
567
+ });
522
568
  }
523
569
  catch (error) {
570
+ if (error instanceof RequestNotPermittedError) {
571
+ deps.errorLog(error.message);
572
+ deps.exit(PERMISSION_DENIED_EXIT_CODE);
573
+ }
524
574
  if (error instanceof PermissionCheckError) {
525
575
  deps.errorLog(`Error: ${error.message}`);
526
576
  deps.exit(PERMISSION_DENIED_EXIT_CODE);
527
577
  }
578
+ if (error instanceof UrlExtractionFailedError ||
579
+ error instanceof NoServiceForUrlError ||
580
+ error instanceof NoCredentialsForServiceError ||
581
+ error instanceof CredentialsExpiredError) {
582
+ deps.errorLog(error.message);
583
+ deps.exit(1);
584
+ }
528
585
  throw error;
529
586
  }
530
- const url = extractUrlFromCurlArguments(curlArguments);
531
- if (url === null) {
532
- deps.errorLog('Error: Could not extract URL from curl arguments.');
587
+ const result = deps.runCurl(finalArguments);
588
+ deps.exit(result.returncode);
589
+ });
590
+ program
591
+ .command('gateway')
592
+ .description('Start a local HTTP gateway that proxies requests with credential injection.')
593
+ .option('--port <number>', `Port to listen on (default: ${deps.config.gatewayListenPort.toString()}, configurable via config.json key 'gatewayListenPort')`)
594
+ .option('--host <address>', `Address to bind to (default: ${deps.config.gatewayListenHost}, configurable via config.json key 'gatewayListenHost')`)
595
+ .option('--max-body-size <bytes>', 'Maximum request body size in bytes', String(10 * 1024 * 1024))
596
+ .action(async (options) => {
597
+ refuseInGatewayMode(deps, 'gateway');
598
+ const portString = options.port ?? deps.config.gatewayListenPort.toString();
599
+ const port = parseInt(portString, 10);
600
+ if (isNaN(port) || port < 0 || port > 65535) {
601
+ deps.errorLog(`Error: Invalid port number: ${portString}`);
533
602
  deps.exit(1);
534
603
  }
535
- const service = deps.registry.getByUrl(url);
536
- if (service === null) {
537
- deps.errorLog(`Error: No service matches URL: ${url}`);
604
+ const maxBodySize = parseInt(options.maxBodySize, 10);
605
+ if (isNaN(maxBodySize) || maxBodySize <= 0) {
606
+ deps.errorLog(`Error: Invalid max body size: ${options.maxBodySize}`);
538
607
  deps.exit(1);
539
608
  }
540
609
  const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
541
610
  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);
611
+ const gateway = await startGateway(deps, apiCredentialStore, encryptedStorage, {
612
+ port,
613
+ host: options.host ?? deps.config.gatewayListenHost,
614
+ maxBodySize,
615
+ });
616
+ const shutdown = async () => {
617
+ await gateway.close();
618
+ deps.exit(0);
619
+ };
620
+ process.on('SIGINT', () => void shutdown());
621
+ process.on('SIGTERM', () => void shutdown());
561
622
  });
562
623
  program
563
624
  .command('skill-md')
@@ -570,6 +631,7 @@ export function registerCommands(program, deps) {
570
631
  .description('Ensure a Chrome/Chromium browser is available for Latchkey to use.')
571
632
  .option('--source <sources>', `Comma-separated list of sources to try in order: ${BROWSER_SOURCES.join(', ')}`, DEFAULT_BROWSER_SOURCES.join(','))
572
633
  .action(async (options) => {
634
+ refuseInGatewayMode(deps, 'ensure-browser');
573
635
  const configPath = deps.config.configPath;
574
636
  // Parse and validate sources
575
637
  const sourceList = options.source.split(',').map((s) => s.trim());
@@ -580,6 +642,20 @@ export function registerCommands(program, deps) {
580
642
  deps.exit(1);
581
643
  }
582
644
  const sources = sourceList;
645
+ // In the standalone binary, playwright is not available at runtime.
646
+ // Refuse `ensure-browser` up front rather than silently succeeding via
647
+ // a stale `existing-config` source that points to a browser we could
648
+ // never actually launch anyway.
649
+ try {
650
+ await loadPlaywright();
651
+ }
652
+ catch (error) {
653
+ if (error instanceof BrowserFeaturesUnavailableError) {
654
+ deps.errorLog(error.message);
655
+ deps.exit(1);
656
+ }
657
+ throw error;
658
+ }
583
659
  deps.log(`Discovering browser using sources: ${sources.join(', ')}`);
584
660
  try {
585
661
  const { config, source } = await ensureBrowser(configPath, sources);
@@ -596,6 +672,10 @@ export function registerCommands(program, deps) {
596
672
  deps.errorLog(`Error: ${error.message}`);
597
673
  deps.exit(1);
598
674
  }
675
+ if (error instanceof BrowserFeaturesUnavailableError) {
676
+ deps.errorLog(error.message);
677
+ deps.exit(1);
678
+ }
599
679
  throw error;
600
680
  }
601
681
  });