latchkey 0.1.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 (176) hide show
  1. package/.nvmrc +1 -0
  2. package/.pre-commit-config.yaml +22 -0
  3. package/.prettierignore +4 -0
  4. package/.prettierrc +7 -0
  5. package/CLAUDE.md +13 -0
  6. package/LICENSE +7 -0
  7. package/README.md +167 -0
  8. package/dist/scripts/cryptFile.d.ts +21 -0
  9. package/dist/scripts/cryptFile.d.ts.map +1 -0
  10. package/dist/scripts/cryptFile.js +106 -0
  11. package/dist/scripts/cryptFile.js.map +1 -0
  12. package/dist/scripts/encryptFile.d.ts +21 -0
  13. package/dist/scripts/encryptFile.d.ts.map +1 -0
  14. package/dist/scripts/encryptFile.js +101 -0
  15. package/dist/scripts/encryptFile.js.map +1 -0
  16. package/dist/scripts/recordBrowserSession.d.ts +18 -0
  17. package/dist/scripts/recordBrowserSession.d.ts.map +1 -0
  18. package/dist/scripts/recordBrowserSession.js +213 -0
  19. package/dist/scripts/recordBrowserSession.js.map +1 -0
  20. package/dist/src/apiCredentialStore.d.ts +19 -0
  21. package/dist/src/apiCredentialStore.d.ts.map +1 -0
  22. package/dist/src/apiCredentialStore.js +65 -0
  23. package/dist/src/apiCredentialStore.js.map +1 -0
  24. package/dist/src/apiCredentials.d.ts +134 -0
  25. package/dist/src/apiCredentials.d.ts.map +1 -0
  26. package/dist/src/apiCredentials.js +139 -0
  27. package/dist/src/apiCredentials.js.map +1 -0
  28. package/dist/src/browserConfig.d.ts +90 -0
  29. package/dist/src/browserConfig.d.ts.map +1 -0
  30. package/dist/src/browserConfig.js +259 -0
  31. package/dist/src/browserConfig.js.map +1 -0
  32. package/dist/src/browserState.d.ts +8 -0
  33. package/dist/src/browserState.d.ts.map +1 -0
  34. package/dist/src/browserState.js +21 -0
  35. package/dist/src/browserState.js.map +1 -0
  36. package/dist/src/cli.d.ts +6 -0
  37. package/dist/src/cli.d.ts.map +1 -0
  38. package/dist/src/cli.js +25 -0
  39. package/dist/src/cli.js.map +1 -0
  40. package/dist/src/cliCommands.d.ts +29 -0
  41. package/dist/src/cliCommands.d.ts.map +1 -0
  42. package/dist/src/cliCommands.js +264 -0
  43. package/dist/src/cliCommands.js.map +1 -0
  44. package/dist/src/config.d.ts +35 -0
  45. package/dist/src/config.d.ts.map +1 -0
  46. package/dist/src/config.js +96 -0
  47. package/dist/src/config.js.map +1 -0
  48. package/dist/src/curl.d.ts +29 -0
  49. package/dist/src/curl.d.ts.map +1 -0
  50. package/dist/src/curl.js +53 -0
  51. package/dist/src/curl.js.map +1 -0
  52. package/dist/src/encryptedStorage.d.ts +39 -0
  53. package/dist/src/encryptedStorage.d.ts.map +1 -0
  54. package/dist/src/encryptedStorage.js +128 -0
  55. package/dist/src/encryptedStorage.js.map +1 -0
  56. package/dist/src/encryption.d.ts +28 -0
  57. package/dist/src/encryption.d.ts.map +1 -0
  58. package/dist/src/encryption.js +86 -0
  59. package/dist/src/encryption.js.map +1 -0
  60. package/dist/src/index.d.ts +14 -0
  61. package/dist/src/index.d.ts.map +1 -0
  62. package/dist/src/index.js +17 -0
  63. package/dist/src/index.js.map +1 -0
  64. package/dist/src/keychain.d.ts +33 -0
  65. package/dist/src/keychain.d.ts.map +1 -0
  66. package/dist/src/keychain.js +94 -0
  67. package/dist/src/keychain.js.map +1 -0
  68. package/dist/src/playwrightUtils.d.ts +27 -0
  69. package/dist/src/playwrightUtils.d.ts.map +1 -0
  70. package/dist/src/playwrightUtils.js +122 -0
  71. package/dist/src/playwrightUtils.js.map +1 -0
  72. package/dist/src/registry.d.ts +12 -0
  73. package/dist/src/registry.d.ts.map +1 -0
  74. package/dist/src/registry.js +30 -0
  75. package/dist/src/registry.js.map +1 -0
  76. package/dist/src/services/base.d.ts +98 -0
  77. package/dist/src/services/base.d.ts.map +1 -0
  78. package/dist/src/services/base.js +137 -0
  79. package/dist/src/services/base.js.map +1 -0
  80. package/dist/src/services/discord.d.ts +20 -0
  81. package/dist/src/services/discord.d.ts.map +1 -0
  82. package/dist/src/services/discord.js +55 -0
  83. package/dist/src/services/discord.js.map +1 -0
  84. package/dist/src/services/dropbox.d.ts +23 -0
  85. package/dist/src/services/dropbox.d.ts.map +1 -0
  86. package/dist/src/services/dropbox.js +136 -0
  87. package/dist/src/services/dropbox.js.map +1 -0
  88. package/dist/src/services/github.d.ts +23 -0
  89. package/dist/src/services/github.d.ts.map +1 -0
  90. package/dist/src/services/github.js +110 -0
  91. package/dist/src/services/github.js.map +1 -0
  92. package/dist/src/services/index.d.ts +12 -0
  93. package/dist/src/services/index.d.ts.map +1 -0
  94. package/dist/src/services/index.js +11 -0
  95. package/dist/src/services/index.js.map +1 -0
  96. package/dist/src/services/linear.d.ts +23 -0
  97. package/dist/src/services/linear.d.ts.map +1 -0
  98. package/dist/src/services/linear.js +110 -0
  99. package/dist/src/services/linear.js.map +1 -0
  100. package/dist/src/services/slack.d.ts +21 -0
  101. package/dist/src/services/slack.d.ts.map +1 -0
  102. package/dist/src/services/slack.js +67 -0
  103. package/dist/src/services/slack.js.map +1 -0
  104. package/dist/tests/apiCredentialStore.test.d.ts +2 -0
  105. package/dist/tests/apiCredentialStore.test.d.ts.map +1 -0
  106. package/dist/tests/apiCredentialStore.test.js +130 -0
  107. package/dist/tests/apiCredentialStore.test.js.map +1 -0
  108. package/dist/tests/apiCredentials.test.d.ts +2 -0
  109. package/dist/tests/apiCredentials.test.d.ts.map +1 -0
  110. package/dist/tests/apiCredentials.test.js +169 -0
  111. package/dist/tests/apiCredentials.test.js.map +1 -0
  112. package/dist/tests/cli.test.d.ts +2 -0
  113. package/dist/tests/cli.test.d.ts.map +1 -0
  114. package/dist/tests/cli.test.js +584 -0
  115. package/dist/tests/cli.test.js.map +1 -0
  116. package/dist/tests/encryptedStorage.test.d.ts +2 -0
  117. package/dist/tests/encryptedStorage.test.d.ts.map +1 -0
  118. package/dist/tests/encryptedStorage.test.js +126 -0
  119. package/dist/tests/encryptedStorage.test.js.map +1 -0
  120. package/dist/tests/encryption.test.d.ts +2 -0
  121. package/dist/tests/encryption.test.d.ts.map +1 -0
  122. package/dist/tests/encryption.test.js +121 -0
  123. package/dist/tests/encryption.test.js.map +1 -0
  124. package/dist/tests/lint.test.d.ts +2 -0
  125. package/dist/tests/lint.test.d.ts.map +1 -0
  126. package/dist/tests/lint.test.js +18 -0
  127. package/dist/tests/lint.test.js.map +1 -0
  128. package/dist/tests/registry.test.d.ts +2 -0
  129. package/dist/tests/registry.test.d.ts.map +1 -0
  130. package/dist/tests/registry.test.js +85 -0
  131. package/dist/tests/registry.test.js.map +1 -0
  132. package/dist/tests/servicesAgainstRecordings.test.d.ts +20 -0
  133. package/dist/tests/servicesAgainstRecordings.test.d.ts.map +1 -0
  134. package/dist/tests/servicesAgainstRecordings.test.js +157 -0
  135. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -0
  136. package/dist/tests/typecheck.test.d.ts +2 -0
  137. package/dist/tests/typecheck.test.d.ts.map +1 -0
  138. package/dist/tests/typecheck.test.js +18 -0
  139. package/dist/tests/typecheck.test.js.map +1 -0
  140. package/docs/development.md +94 -0
  141. package/eslint.config.js +30 -0
  142. package/integrations/SKILL.md +62 -0
  143. package/package.json +68 -0
  144. package/scripts/cryptFile.ts +123 -0
  145. package/scripts/recordBrowserSession.ts +280 -0
  146. package/scripts/tsconfig.json +10 -0
  147. package/src/apiCredentialStore.ts +87 -0
  148. package/src/apiCredentials.ts +180 -0
  149. package/src/cli.ts +32 -0
  150. package/src/cliCommands.ts +321 -0
  151. package/src/config.ts +115 -0
  152. package/src/curl.ts +78 -0
  153. package/src/encryptedStorage.ts +161 -0
  154. package/src/encryption.ts +106 -0
  155. package/src/index.ts +65 -0
  156. package/src/keychain.ts +105 -0
  157. package/src/playwrightUtils.ts +143 -0
  158. package/src/registry.ts +35 -0
  159. package/src/services/base.ts +234 -0
  160. package/src/services/discord.ts +73 -0
  161. package/src/services/dropbox.ts +173 -0
  162. package/src/services/github.ts +139 -0
  163. package/src/services/index.ts +13 -0
  164. package/src/services/linear.ts +134 -0
  165. package/src/services/slack.ts +85 -0
  166. package/tests/apiCredentialStore.test.ts +162 -0
  167. package/tests/apiCredentials.test.ts +195 -0
  168. package/tests/cli.test.ts +798 -0
  169. package/tests/encryptedStorage.test.ts +173 -0
  170. package/tests/encryption.test.ts +169 -0
  171. package/tests/lint.test.ts +19 -0
  172. package/tests/registry.test.ts +103 -0
  173. package/tests/servicesAgainstRecordings.test.ts +230 -0
  174. package/tests/typecheck.test.ts +19 -0
  175. package/tsconfig.json +24 -0
  176. package/vitest.config.ts +13 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * API credential store for persisting and loading API credentials.
3
+ */
4
+
5
+ import {
6
+ ApiCredentials,
7
+ ApiCredentialsSchema,
8
+ deserializeCredentials,
9
+ serializeCredentials,
10
+ } from './apiCredentials.js';
11
+ import { EncryptedStorage } from './encryptedStorage.js';
12
+
13
+ export class ApiCredentialStoreError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = 'ApiCredentialStoreError';
17
+ }
18
+ }
19
+
20
+ type StoreData = Record<string, unknown>;
21
+
22
+ export class ApiCredentialStore {
23
+ readonly path: string;
24
+ private readonly encryptedStorage: EncryptedStorage;
25
+
26
+ constructor(path: string, encryptedStorage: EncryptedStorage) {
27
+ this.path = path;
28
+ this.encryptedStorage = encryptedStorage;
29
+ }
30
+
31
+ private loadStoreData(): StoreData {
32
+ try {
33
+ const content = this.encryptedStorage.readFile(this.path);
34
+ if (content === null) {
35
+ return {};
36
+ }
37
+ return JSON.parse(content) as StoreData;
38
+ } catch (error) {
39
+ throw new ApiCredentialStoreError(
40
+ `Failed to read credential store: ${error instanceof Error ? error.message : String(error)}`
41
+ );
42
+ }
43
+ }
44
+
45
+ private saveStoreData(data: StoreData): void {
46
+ try {
47
+ this.encryptedStorage.writeFile(this.path, JSON.stringify(data, null, 2));
48
+ } catch (error) {
49
+ throw new ApiCredentialStoreError(
50
+ `Failed to write credential store: ${error instanceof Error ? error.message : String(error)}`
51
+ );
52
+ }
53
+ }
54
+
55
+ get(serviceName: string): ApiCredentials | null {
56
+ const data = this.loadStoreData();
57
+ const credentialData = data[serviceName];
58
+ if (credentialData === undefined) {
59
+ return null;
60
+ }
61
+
62
+ const parseResult = ApiCredentialsSchema.safeParse(credentialData);
63
+ if (!parseResult.success) {
64
+ throw new ApiCredentialStoreError(
65
+ `Invalid credential data for service ${serviceName}: ${parseResult.error.message}`
66
+ );
67
+ }
68
+
69
+ return deserializeCredentials(parseResult.data);
70
+ }
71
+
72
+ save(serviceName: string, apiCredentials: ApiCredentials): void {
73
+ const data = this.loadStoreData();
74
+ data[serviceName] = serializeCredentials(apiCredentials);
75
+ this.saveStoreData(data);
76
+ }
77
+
78
+ delete(serviceName: string): boolean {
79
+ const data = this.loadStoreData();
80
+ if (!(serviceName in data)) {
81
+ return false;
82
+ }
83
+ const { [serviceName]: _, ...rest } = data;
84
+ this.saveStoreData(rest);
85
+ return true;
86
+ }
87
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * API credentials types and utilities for authentication with various services.
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ export enum ApiCredentialStatus {
8
+ Missing = 'missing',
9
+ Valid = 'valid',
10
+ Invalid = 'invalid',
11
+ }
12
+
13
+ /**
14
+ * Base interface for all API credentials.
15
+ * Each credential type must specify how to convert itself to curl arguments.
16
+ */
17
+ export interface ApiCredentials {
18
+ readonly objectType: string;
19
+ asCurlArguments(): readonly string[];
20
+ }
21
+
22
+ /**
23
+ * Bearer token authentication (Authorization: Bearer <token>).
24
+ */
25
+ export const AuthorizationBearerSchema = z.object({
26
+ objectType: z.literal('authorizationBearer'),
27
+ token: z.string(),
28
+ });
29
+
30
+ export type AuthorizationBearerData = z.infer<typeof AuthorizationBearerSchema>;
31
+
32
+ export class AuthorizationBearer implements ApiCredentials {
33
+ readonly objectType = 'authorizationBearer' as const;
34
+ readonly token: string;
35
+
36
+ constructor(token: string) {
37
+ this.token = token;
38
+ }
39
+
40
+ asCurlArguments(): readonly string[] {
41
+ return ['-H', `Authorization: Bearer ${this.token}`];
42
+ }
43
+
44
+ toJSON(): AuthorizationBearerData {
45
+ return {
46
+ objectType: this.objectType,
47
+ token: this.token,
48
+ };
49
+ }
50
+
51
+ static fromJSON(data: AuthorizationBearerData): AuthorizationBearer {
52
+ return new AuthorizationBearer(data.token);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Raw authorization header (Authorization: <token>).
58
+ */
59
+ export const AuthorizationBareSchema = z.object({
60
+ objectType: z.literal('authorizationBare'),
61
+ token: z.string(),
62
+ });
63
+
64
+ export type AuthorizationBareData = z.infer<typeof AuthorizationBareSchema>;
65
+
66
+ export class AuthorizationBare implements ApiCredentials {
67
+ readonly objectType = 'authorizationBare' as const;
68
+ readonly token: string;
69
+
70
+ constructor(token: string) {
71
+ this.token = token;
72
+ }
73
+
74
+ asCurlArguments(): readonly string[] {
75
+ return ['-H', `Authorization: ${this.token}`];
76
+ }
77
+
78
+ toJSON(): AuthorizationBareData {
79
+ return {
80
+ objectType: this.objectType,
81
+ token: this.token,
82
+ };
83
+ }
84
+
85
+ static fromJSON(data: AuthorizationBareData): AuthorizationBare {
86
+ return new AuthorizationBare(data.token);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Slack-specific credentials (token + d cookie).
92
+ */
93
+ export const SlackApiCredentialsSchema = z.object({
94
+ objectType: z.literal('slack'),
95
+ token: z.string(),
96
+ dCookie: z.string(),
97
+ });
98
+
99
+ export type SlackApiCredentialsData = z.infer<typeof SlackApiCredentialsSchema>;
100
+
101
+ export class SlackApiCredentials implements ApiCredentials {
102
+ readonly objectType = 'slack' as const;
103
+ readonly token: string;
104
+ readonly dCookie: string;
105
+
106
+ constructor(token: string, dCookie: string) {
107
+ this.token = token;
108
+ this.dCookie = dCookie;
109
+ }
110
+
111
+ asCurlArguments(): readonly string[] {
112
+ return ['-H', `Authorization: Bearer ${this.token}`, '-H', `Cookie: d=${this.dCookie}`];
113
+ }
114
+
115
+ toJSON(): SlackApiCredentialsData {
116
+ return {
117
+ objectType: this.objectType,
118
+ token: this.token,
119
+ dCookie: this.dCookie,
120
+ };
121
+ }
122
+
123
+ static fromJSON(data: SlackApiCredentialsData): SlackApiCredentials {
124
+ return new SlackApiCredentials(data.token, data.dCookie);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Union schema for all credential types.
130
+ */
131
+ export const ApiCredentialsSchema = z.discriminatedUnion('objectType', [
132
+ AuthorizationBearerSchema,
133
+ AuthorizationBareSchema,
134
+ SlackApiCredentialsSchema,
135
+ ]);
136
+
137
+ export type ApiCredentialsData = z.infer<typeof ApiCredentialsSchema>;
138
+
139
+ /**
140
+ * Deserialize credentials from JSON data.
141
+ */
142
+ export function deserializeCredentials(data: ApiCredentialsData): ApiCredentials {
143
+ switch (data.objectType) {
144
+ case 'authorizationBearer':
145
+ return AuthorizationBearer.fromJSON(data);
146
+ case 'authorizationBare':
147
+ return AuthorizationBare.fromJSON(data);
148
+ case 'slack':
149
+ return SlackApiCredentials.fromJSON(data);
150
+ default: {
151
+ const exhaustiveCheck: never = data;
152
+ throw new ApiCredentialsSerializationError(
153
+ `Unknown credential type: ${(exhaustiveCheck as { objectType: string }).objectType}`
154
+ );
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Serialize credentials to JSON data.
161
+ */
162
+ export function serializeCredentials(credentials: ApiCredentials): ApiCredentialsData {
163
+ if (credentials instanceof AuthorizationBearer) {
164
+ return credentials.toJSON();
165
+ }
166
+ if (credentials instanceof AuthorizationBare) {
167
+ return credentials.toJSON();
168
+ }
169
+ if (credentials instanceof SlackApiCredentials) {
170
+ return credentials.toJSON();
171
+ }
172
+ throw new ApiCredentialsSerializationError(`Unknown credential type: ${credentials.objectType}`);
173
+ }
174
+
175
+ export class ApiCredentialsSerializationError extends Error {
176
+ constructor(message: string) {
177
+ super(message);
178
+ this.name = 'ApiCredentialsSerializationError';
179
+ }
180
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Command-line interface entry point for latchkey.
5
+ */
6
+
7
+ import { program } from 'commander';
8
+ import { registerCommands, createDefaultDependencies } from './cliCommands.js';
9
+ import { InsecureFilePermissionsError } from './config.js';
10
+
11
+ const deps = createDefaultDependencies();
12
+
13
+ try {
14
+ deps.config.checkSensitiveFilePermissions();
15
+ } catch (error) {
16
+ if (error instanceof InsecureFilePermissionsError) {
17
+ console.error(`Error: ${error.message}`);
18
+ process.exit(1);
19
+ }
20
+ throw error;
21
+ }
22
+
23
+ program
24
+ .name('latchkey')
25
+ .description(
26
+ 'A command-line tool that injects API credentials to curl requests to known public APIs.'
27
+ )
28
+ .version('0.1.0');
29
+
30
+ registerCommands(program, deps);
31
+
32
+ program.parse();
@@ -0,0 +1,321 @@
1
+ /**
2
+ * CLI command implementations with dependency injection for testability.
3
+ */
4
+
5
+ import type { Command } from 'commander';
6
+ import { existsSync, unlinkSync } from 'node:fs';
7
+ import { createInterface } from 'node:readline';
8
+ import { ApiCredentialStore } from './apiCredentialStore.js';
9
+ import { ApiCredentialStatus, ApiCredentials } from './apiCredentials.js';
10
+ import { Config, CONFIG } from './config.js';
11
+ import type { CurlResult } from './curl.js';
12
+ import { EncryptedStorage } from './encryptedStorage.js';
13
+ import { Registry, REGISTRY } from './registry.js';
14
+ import { LoginCancelledError, LoginFailedError } from './services/index.js';
15
+ import { run as curlRun } from './curl.js';
16
+
17
+ // Curl flags that don't affect the HTTP request semantics but may not be supported by URL extraction.
18
+ const CURL_PASSTHROUGH_FLAGS = new Set(['-v', '--verbose']);
19
+
20
+ /**
21
+ * Dependencies that can be injected for testing.
22
+ */
23
+ export interface CliDependencies {
24
+ readonly registry: Registry;
25
+ readonly config: Config;
26
+ readonly runCurl: (args: readonly string[]) => CurlResult;
27
+ readonly confirm: (message: string) => Promise<boolean>;
28
+ readonly exit: (code: number) => never;
29
+ readonly log: (message: string) => void;
30
+ readonly errorLog: (message: string) => void;
31
+ }
32
+
33
+ /**
34
+ * Default implementation of CLI dependencies.
35
+ */
36
+ export function createDefaultDependencies(): CliDependencies {
37
+ return {
38
+ registry: REGISTRY,
39
+ config: CONFIG,
40
+ runCurl: curlRun,
41
+ confirm: defaultConfirm,
42
+ exit: (code: number) => process.exit(code),
43
+ log: (message: string) => {
44
+ console.log(message);
45
+ },
46
+ errorLog: (message: string) => {
47
+ console.error(message);
48
+ },
49
+ };
50
+ }
51
+
52
+ function filterPassthroughFlags(args: string[]): string[] {
53
+ return args.filter((arg) => !CURL_PASSTHROUGH_FLAGS.has(arg));
54
+ }
55
+
56
+ export function extractUrlFromCurlArguments(args: string[]): string | null {
57
+ const filteredArgs = filterPassthroughFlags(args);
58
+
59
+ // Simple URL extraction: look for arguments that look like URLs
60
+ // or parse known curl argument patterns
61
+ for (let i = 0; i < filteredArgs.length; i++) {
62
+ const arg = filteredArgs[i];
63
+ if (arg === undefined) continue;
64
+
65
+ // Skip flags and their values
66
+ if (arg.startsWith('-')) {
67
+ // Skip flags that take a value
68
+ if (['-H', '-d', '-X', '-o', '-w', '-u', '-A', '-e', '-b', '-c', '-F', '-T'].includes(arg)) {
69
+ i++; // Skip the next argument which is the value
70
+ }
71
+ continue;
72
+ }
73
+
74
+ // This looks like a URL
75
+ if (arg.startsWith('http://') || arg.startsWith('https://')) {
76
+ return arg;
77
+ }
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ async function defaultConfirm(message: string): Promise<boolean> {
84
+ const readline = createInterface({
85
+ input: process.stdin,
86
+ output: process.stdout,
87
+ });
88
+
89
+ return new Promise((resolve) => {
90
+ readline.question(`${message} (y/N) `, (answer) => {
91
+ readline.close();
92
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
93
+ });
94
+ });
95
+ }
96
+
97
+ async function clearAll(deps: CliDependencies, yes: boolean): Promise<void> {
98
+ const latchkeyStore = deps.config.credentialStorePath;
99
+ const browserState = deps.config.browserStatePath;
100
+
101
+ const filesToDelete: string[] = [];
102
+ if (existsSync(latchkeyStore)) {
103
+ filesToDelete.push(latchkeyStore);
104
+ }
105
+ if (existsSync(browserState)) {
106
+ filesToDelete.push(browserState);
107
+ }
108
+
109
+ if (filesToDelete.length === 0) {
110
+ deps.log('No files to delete.');
111
+ return;
112
+ }
113
+
114
+ if (!yes) {
115
+ deps.log('This will delete the following files:');
116
+ for (const filePath of filesToDelete) {
117
+ deps.log(` ${filePath}`);
118
+ }
119
+
120
+ const confirmed = await deps.confirm('Are you sure you want to continue?');
121
+ if (!confirmed) {
122
+ deps.log('Aborted.');
123
+ deps.exit(1);
124
+ }
125
+ }
126
+
127
+ for (const filePath of filesToDelete) {
128
+ unlinkSync(filePath);
129
+ if (filePath === latchkeyStore) {
130
+ deps.log(`Deleted credentials store: ${filePath}`);
131
+ } else {
132
+ deps.log(`Deleted browser state: ${filePath}`);
133
+ }
134
+ }
135
+ }
136
+
137
+ function createEncryptedStorageFromConfig(config: Config) {
138
+ return new EncryptedStorage({
139
+ encryptionKeyOverride: config.encryptionKeyOverride,
140
+ serviceName: config.serviceName,
141
+ accountName: config.accountName,
142
+ });
143
+ }
144
+
145
+ function clearService(deps: CliDependencies, serviceName: string): void {
146
+ const service = deps.registry.getByName(serviceName);
147
+ if (service === null) {
148
+ deps.errorLog(`Error: Unknown service: ${serviceName}`);
149
+ deps.errorLog("Use 'latchkey services' to see available services.");
150
+ deps.exit(1);
151
+ }
152
+
153
+ const encryptedStorage = createEncryptedStorageFromConfig(deps.config);
154
+ const apiCredentialStore = new ApiCredentialStore(
155
+ deps.config.credentialStorePath,
156
+ encryptedStorage
157
+ );
158
+ const deleted = apiCredentialStore.delete(serviceName);
159
+
160
+ if (deleted) {
161
+ deps.log(`API credentials for ${serviceName} have been cleared.`);
162
+ } else {
163
+ deps.log(`No API credentials found for ${serviceName}.`);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Register all CLI commands on the given program.
169
+ */
170
+ export function registerCommands(program: Command, deps: CliDependencies): void {
171
+ program
172
+ .command('services')
173
+ .description('List known and supported third-party services.')
174
+ .action(() => {
175
+ const serviceNames = deps.registry.services.map((service) => service.name);
176
+ deps.log(serviceNames.join(' '));
177
+ });
178
+
179
+ program
180
+ .command('clear')
181
+ .description('Clear stored API credentials.')
182
+ .argument('[service_name]', 'Name of the service to clear API credentials for')
183
+ .option('-y, --yes', 'Skip confirmation prompt when clearing all data')
184
+ .action(async (serviceName: string | undefined, options: { yes?: boolean }) => {
185
+ if (serviceName === undefined) {
186
+ await clearAll(deps, options.yes ?? false);
187
+ } else {
188
+ clearService(deps, serviceName);
189
+ }
190
+ });
191
+
192
+ program
193
+ .command('status')
194
+ .description('Check the API credential status for a service.')
195
+ .argument('[service_name]', 'Name of the service to check status for')
196
+ .action((serviceName: string | undefined) => {
197
+ const encryptedStorage = createEncryptedStorageFromConfig(deps.config);
198
+ const apiCredentialStore = new ApiCredentialStore(
199
+ deps.config.credentialStorePath,
200
+ encryptedStorage
201
+ );
202
+
203
+ if (serviceName === undefined) {
204
+ for (const service of deps.registry.services) {
205
+ const apiCredentials = apiCredentialStore.get(service.name);
206
+ if (apiCredentials === null) {
207
+ deps.log(`${service.name}: ${ApiCredentialStatus.Missing}`);
208
+ } else {
209
+ const status = service.checkApiCredentials(apiCredentials);
210
+ deps.log(`${service.name}: ${status}`);
211
+ }
212
+ }
213
+ return;
214
+ }
215
+
216
+ const service = deps.registry.getByName(serviceName);
217
+ if (service === null) {
218
+ deps.errorLog(`Error: Unknown service: ${serviceName}`);
219
+ deps.errorLog("Use 'latchkey services' to see available services.");
220
+ deps.exit(1);
221
+ }
222
+
223
+ const apiCredentials = apiCredentialStore.get(serviceName);
224
+
225
+ if (apiCredentials === null) {
226
+ deps.log(ApiCredentialStatus.Missing);
227
+ return;
228
+ }
229
+
230
+ const apiCredentialStatus = service.checkApiCredentials(apiCredentials);
231
+ deps.log(apiCredentialStatus);
232
+ });
233
+
234
+ program
235
+ .command('login')
236
+ .description('Login to a service and store the API credentials.')
237
+ .argument('<service_name>', 'Name of the service to login to')
238
+ .action(async (serviceName: string) => {
239
+ const service = deps.registry.getByName(serviceName);
240
+ if (service === null) {
241
+ deps.errorLog(`Error: Unknown service: ${serviceName}`);
242
+ deps.errorLog("Use 'latchkey services' to see available services.");
243
+ deps.exit(1);
244
+ }
245
+
246
+ const encryptedStorage = createEncryptedStorageFromConfig(deps.config);
247
+ const apiCredentialStore = new ApiCredentialStore(
248
+ deps.config.credentialStorePath,
249
+ encryptedStorage
250
+ );
251
+
252
+ try {
253
+ const apiCredentials = await service
254
+ .getSession()
255
+ .login(encryptedStorage, deps.config.browserStatePath);
256
+ apiCredentialStore.save(service.name, apiCredentials);
257
+ deps.log('Done');
258
+ } catch (error) {
259
+ if (error instanceof LoginCancelledError) {
260
+ deps.errorLog('Login cancelled.');
261
+ deps.exit(1);
262
+ }
263
+ if (error instanceof LoginFailedError) {
264
+ deps.errorLog(error.message);
265
+ deps.exit(1);
266
+ }
267
+ throw error;
268
+ }
269
+ });
270
+
271
+ program
272
+ .command('curl')
273
+ .description('Run curl with API credential injection.')
274
+ .allowUnknownOption()
275
+ .allowExcessArguments()
276
+ .action(async (_options: unknown, command: { args: string[] }) => {
277
+ const curlArguments = command.args;
278
+
279
+ const url = extractUrlFromCurlArguments(curlArguments);
280
+ if (url === null) {
281
+ deps.errorLog('Error: Could not extract URL from curl arguments.');
282
+ deps.exit(1);
283
+ }
284
+
285
+ const service = deps.registry.getByUrl(url);
286
+ if (service === null) {
287
+ deps.errorLog(`Error: No service matches URL: ${url}`);
288
+ deps.exit(1);
289
+ }
290
+
291
+ const encryptedStorage = createEncryptedStorageFromConfig(deps.config);
292
+ const apiCredentialStore = new ApiCredentialStore(
293
+ deps.config.credentialStorePath,
294
+ encryptedStorage
295
+ );
296
+ let apiCredentials: ApiCredentials | null = apiCredentialStore.get(service.name);
297
+
298
+ if (apiCredentials === null) {
299
+ try {
300
+ apiCredentials = await service
301
+ .getSession()
302
+ .login(encryptedStorage, deps.config.browserStatePath);
303
+ apiCredentialStore.save(service.name, apiCredentials);
304
+ } catch (error) {
305
+ if (error instanceof LoginCancelledError) {
306
+ deps.errorLog('Login cancelled.');
307
+ deps.exit(1);
308
+ }
309
+ if (error instanceof LoginFailedError) {
310
+ deps.errorLog(error.message);
311
+ deps.exit(1);
312
+ }
313
+ throw error;
314
+ }
315
+ }
316
+
317
+ const allArguments = [...apiCredentials.asCurlArguments(), ...curlArguments];
318
+ const result = deps.runCurl(allArguments);
319
+ deps.exit(result.returncode);
320
+ });
321
+ }