@agentrhq/webcmd 0.3.0 → 0.3.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 (181) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/chatgpt/ask.js +1 -1
  4. package/clis/chatgpt/ask.test.js +11 -0
  5. package/clis/chatgpt/commands.test.js +102 -1
  6. package/clis/chatgpt/deep-research-result.js +45 -3
  7. package/clis/chatgpt/image.js +22 -2
  8. package/clis/chatgpt/project-file-add.js +24 -1
  9. package/clis/chatgpt/utils.js +258 -52
  10. package/clis/chatgpt/utils.test.js +259 -1
  11. package/clis/claude/ask.js +21 -1
  12. package/clis/facebook/feed.js +110 -24
  13. package/clis/facebook/feed.test.js +62 -0
  14. package/clis/facebook/search.js +185 -38
  15. package/clis/facebook/search.test.js +95 -50
  16. package/clis/instagram/explore.js +30 -12
  17. package/clis/instagram/explore.test.js +77 -0
  18. package/clis/instagram/post.js +14 -1
  19. package/clis/instagram/reel.js +13 -1
  20. package/clis/linkedin/company.js +153 -0
  21. package/clis/linkedin/company.test.js +111 -0
  22. package/clis/linkedin/connections.js +135 -0
  23. package/clis/linkedin/connections.test.js +141 -0
  24. package/clis/mercury/reimbursement-draft.js +12 -1
  25. package/clis/twitter/article-evaluate.test.js +27 -0
  26. package/clis/twitter/article.js +73 -16
  27. package/clis/twitter/article.test.js +209 -0
  28. package/clis/twitter/download.js +6 -1
  29. package/clis/twitter/post.js +14 -1
  30. package/clis/twitter/profile.js +1 -1
  31. package/clis/twitter/profile.test.js +8 -0
  32. package/clis/twitter/quote.js +11 -1
  33. package/clis/twitter/reply.js +11 -1
  34. package/dist/src/browser/base-page.d.ts +26 -0
  35. package/dist/src/browser/base-page.js +23 -8
  36. package/dist/src/browser/base-page.test.js +45 -12
  37. package/dist/src/browser/command-catalog.d.ts +6 -0
  38. package/dist/src/browser/command-catalog.js +259 -0
  39. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  40. package/dist/src/browser/command-catalog.test.js +79 -0
  41. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  42. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  43. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  44. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  45. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +7 -0
  46. package/dist/src/browser/runtime/local-cloak/session-manager.js +23 -9
  47. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  48. package/dist/src/browser/target-resolver.d.ts +1 -0
  49. package/dist/src/browser/target-resolver.js +99 -4
  50. package/dist/src/browser/target-resolver.test.js +141 -1
  51. package/dist/src/build-manifest.d.ts +6 -0
  52. package/dist/src/build-manifest.js +18 -1
  53. package/dist/src/build-manifest.test.js +57 -1
  54. package/dist/src/builtin-command-surface.d.ts +9 -0
  55. package/dist/src/builtin-command-surface.js +16 -0
  56. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  57. package/dist/src/check-hosted-contract.test.js +56 -0
  58. package/dist/src/cli.js +58 -106
  59. package/dist/src/cli.test.js +71 -1
  60. package/dist/src/command-presentation.d.ts +95 -0
  61. package/dist/src/command-presentation.js +486 -0
  62. package/dist/src/command-presentation.test.d.ts +1 -0
  63. package/dist/src/command-presentation.test.js +97 -0
  64. package/dist/src/command-surface.d.ts +43 -0
  65. package/dist/src/command-surface.js +205 -0
  66. package/dist/src/command-surface.test.d.ts +1 -0
  67. package/dist/src/command-surface.test.js +406 -0
  68. package/dist/src/commanderAdapter.d.ts +5 -1
  69. package/dist/src/commanderAdapter.js +12 -55
  70. package/dist/src/commanderAdapter.test.js +15 -3
  71. package/dist/src/community-plugin-sync.d.ts +11 -0
  72. package/dist/src/community-plugin-sync.js +138 -0
  73. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  74. package/dist/src/community-plugin-sync.test.js +123 -0
  75. package/dist/src/completion-fast.d.ts +4 -4
  76. package/dist/src/completion-fast.js +15 -34
  77. package/dist/src/completion-shared.d.ts +4 -0
  78. package/dist/src/completion-shared.js +38 -0
  79. package/dist/src/completion.js +3 -27
  80. package/dist/src/completion.test.js +31 -2
  81. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  82. package/dist/src/docs-sync-review-cli.test.js +322 -0
  83. package/dist/src/docs-sync-review.d.ts +117 -0
  84. package/dist/src/docs-sync-review.js +475 -0
  85. package/dist/src/docs-sync-review.test.d.ts +1 -0
  86. package/dist/src/docs-sync-review.test.js +324 -0
  87. package/dist/src/errors.d.ts +12 -3
  88. package/dist/src/errors.js +11 -7
  89. package/dist/src/errors.test.js +14 -1
  90. package/dist/src/execution.d.ts +1 -2
  91. package/dist/src/execution.js +4 -45
  92. package/dist/src/generate-release-notes-cli.test.js +7 -2
  93. package/dist/src/help.d.ts +4 -0
  94. package/dist/src/help.js +50 -255
  95. package/dist/src/help.test.js +27 -1
  96. package/dist/src/hosted/args.d.ts +2 -7
  97. package/dist/src/hosted/args.js +2 -99
  98. package/dist/src/hosted/args.test.js +15 -1
  99. package/dist/src/hosted/availability.d.ts +13 -0
  100. package/dist/src/hosted/availability.js +16 -0
  101. package/dist/src/hosted/availability.test.d.ts +1 -0
  102. package/dist/src/hosted/availability.test.js +180 -0
  103. package/dist/src/hosted/browser-args.d.ts +18 -0
  104. package/dist/src/hosted/browser-args.js +152 -0
  105. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  106. package/dist/src/hosted/browser-args.test.js +182 -0
  107. package/dist/src/hosted/client.d.ts +31 -2
  108. package/dist/src/hosted/client.js +441 -53
  109. package/dist/src/hosted/client.test.js +651 -32
  110. package/dist/src/hosted/config.d.ts +7 -2
  111. package/dist/src/hosted/config.js +26 -4
  112. package/dist/src/hosted/config.test.js +34 -1
  113. package/dist/src/hosted/contract.d.ts +94 -0
  114. package/dist/src/hosted/contract.js +208 -0
  115. package/dist/src/hosted/contract.test.d.ts +1 -0
  116. package/dist/src/hosted/contract.test.js +361 -0
  117. package/dist/src/hosted/credentials.d.ts +38 -0
  118. package/dist/src/hosted/credentials.js +248 -0
  119. package/dist/src/hosted/credentials.test.d.ts +1 -0
  120. package/dist/src/hosted/credentials.test.js +93 -0
  121. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  122. package/dist/src/hosted/file-contract.test.js +174 -0
  123. package/dist/src/hosted/files.d.ts +29 -0
  124. package/dist/src/hosted/files.js +296 -0
  125. package/dist/src/hosted/files.test.d.ts +1 -0
  126. package/dist/src/hosted/files.test.js +231 -0
  127. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  128. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  129. package/dist/src/hosted/manifest.d.ts +5 -0
  130. package/dist/src/hosted/manifest.js +21 -66
  131. package/dist/src/hosted/manifest.test.js +120 -2
  132. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  133. package/dist/src/hosted/output-parity.test.js +108 -0
  134. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  135. package/dist/src/hosted/root-command-surface.test.js +646 -0
  136. package/dist/src/hosted/runner.d.ts +7 -0
  137. package/dist/src/hosted/runner.js +554 -253
  138. package/dist/src/hosted/runner.test.js +1471 -53
  139. package/dist/src/hosted/setup.d.ts +3 -2
  140. package/dist/src/hosted/setup.js +45 -15
  141. package/dist/src/hosted/setup.test.js +133 -6
  142. package/dist/src/hosted/types.d.ts +101 -23
  143. package/dist/src/main.js +120 -108
  144. package/dist/src/manifest-types.d.ts +2 -0
  145. package/dist/src/output.d.ts +17 -2
  146. package/dist/src/output.js +88 -81
  147. package/dist/src/output.test.js +141 -51
  148. package/dist/src/pipeline/executor.test.js +1 -0
  149. package/dist/src/pipeline/steps/download.test.js +1 -0
  150. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  151. package/dist/src/plugin-create-cli.test.js +37 -0
  152. package/dist/src/plugin-manifest.d.ts +12 -0
  153. package/dist/src/plugin-manifest.js +16 -0
  154. package/dist/src/plugin-manifest.test.js +19 -1
  155. package/dist/src/plugin-scaffold.d.ts +4 -1
  156. package/dist/src/plugin-scaffold.js +4 -1
  157. package/dist/src/plugin-scaffold.test.js +23 -8
  158. package/dist/src/plugin.js +4 -1
  159. package/dist/src/plugin.test.js +13 -0
  160. package/dist/src/registry.d.ts +2 -0
  161. package/dist/src/release-notes.js +12 -4
  162. package/dist/src/release-notes.test.js +27 -15
  163. package/dist/src/root-command-surface.d.ts +31 -0
  164. package/dist/src/root-command-surface.js +106 -0
  165. package/dist/src/serialization.d.ts +1 -16
  166. package/dist/src/serialization.js +5 -55
  167. package/dist/src/stream-write.d.ts +12 -0
  168. package/dist/src/stream-write.js +91 -0
  169. package/dist/src/stream-write.test.d.ts +1 -0
  170. package/dist/src/stream-write.test.js +186 -0
  171. package/dist/src/types.d.ts +5 -0
  172. package/dist/src/utils.d.ts +1 -1
  173. package/dist/src/utils.js +2 -8
  174. package/dist/src/utils.test.js +50 -0
  175. package/hosted-contract.json +36748 -0
  176. package/package.json +6 -1
  177. package/scripts/check-hosted-contract.mjs +108 -0
  178. package/scripts/docs-sync-review.ts +332 -0
  179. package/scripts/sync-community-plugins.ts +14 -0
  180. package/skills/webcmd-autofix/SKILL.md +26 -37
  181. package/skills/webcmd-usage/SKILL.md +7 -0
@@ -0,0 +1,361 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Strategy } from '../registry.js';
3
+ import { HOSTED_CONTRACT_SCHEMA_VERSION, buildHostedContract, serializeHostedContract, } from './contract.js';
4
+ describe('buildHostedContract', () => {
5
+ const commands = [
6
+ {
7
+ site: 'web',
8
+ name: 'profile',
9
+ aliases: ['me'],
10
+ description: 'Read a profile',
11
+ access: 'read',
12
+ strategy: Strategy.COOKIE,
13
+ browser: true,
14
+ domain: 'example.com',
15
+ args: [
16
+ {
17
+ name: 'user',
18
+ type: 'str',
19
+ positional: true,
20
+ required: true,
21
+ help: 'Profile handle',
22
+ choices: ['alice', 'bob'],
23
+ },
24
+ {
25
+ name: 'include-private',
26
+ type: 'bool',
27
+ default: false,
28
+ help: 'Include private fields',
29
+ },
30
+ ],
31
+ columns: ['name', 'bio'],
32
+ defaultFormat: 'plain',
33
+ example: 'webcmd web profile alice',
34
+ },
35
+ {
36
+ site: 'local',
37
+ name: 'cache',
38
+ description: 'Read local cache',
39
+ access: 'read',
40
+ strategy: Strategy.LOCAL,
41
+ browser: false,
42
+ args: [],
43
+ },
44
+ {
45
+ site: 'files',
46
+ name: 'transfer',
47
+ description: 'Transfer a file',
48
+ access: 'write',
49
+ strategy: Strategy.PUBLIC,
50
+ browser: false,
51
+ args: [
52
+ {
53
+ name: 'source',
54
+ type: 'string',
55
+ positional: true,
56
+ required: true,
57
+ help: 'Source document',
58
+ file: {
59
+ direction: 'input',
60
+ pathKind: 'file',
61
+ contentTypes: ['application/pdf'],
62
+ maxBytes: 2_000_000,
63
+ },
64
+ },
65
+ {
66
+ name: 'destination',
67
+ type: 'string',
68
+ help: 'Destination file',
69
+ file: { direction: 'output', pathKind: 'directory', multiple: true },
70
+ },
71
+ {
72
+ name: 'retries',
73
+ type: 'int',
74
+ default: 2,
75
+ help: 'Retry count',
76
+ },
77
+ ],
78
+ defaultFormat: 'markdown',
79
+ },
80
+ {
81
+ site: 'app',
82
+ name: 'editor',
83
+ description: 'Control a desktop editor',
84
+ access: 'write',
85
+ strategy: Strategy.UI,
86
+ browser: true,
87
+ domain: 'localhost',
88
+ args: [],
89
+ },
90
+ ];
91
+ const browserCatalog = [
92
+ {
93
+ command: 'bind',
94
+ aliases: [],
95
+ description: 'Bind an existing local tab',
96
+ positionals: [],
97
+ options: [],
98
+ sessionPolicy: 'local-only',
99
+ },
100
+ {
101
+ command: 'open',
102
+ aliases: ['navigate'],
103
+ description: 'Open a browser page',
104
+ positionals: [{
105
+ name: 'url',
106
+ type: 'string',
107
+ description: 'URL to open',
108
+ positional: true,
109
+ required: true,
110
+ variadic: false,
111
+ }],
112
+ options: [],
113
+ sessionPolicy: 'create-or-reuse',
114
+ action: 'navigate',
115
+ },
116
+ ];
117
+ it('emits the deterministic versioned public contract shape', () => {
118
+ const contract = buildHostedContract(commands, browserCatalog, '9.8.7');
119
+ expect(contract).toEqual({
120
+ schemaVersion: HOSTED_CONTRACT_SCHEMA_VERSION,
121
+ webcmdVersion: '9.8.7',
122
+ outputFormats: ['table', 'plain', 'json', 'yaml', 'md', 'csv'],
123
+ traceModes: ['off', 'on', 'retain-on-failure'],
124
+ commonOptions: [
125
+ {
126
+ name: 'format',
127
+ flags: '-f, --format <fmt>',
128
+ type: 'string',
129
+ description: 'Output format: table, plain, json, yaml, md, csv',
130
+ positional: false,
131
+ required: false,
132
+ variadic: false,
133
+ default: 'table',
134
+ choices: ['table', 'plain', 'json', 'yaml', 'md', 'csv'],
135
+ },
136
+ {
137
+ name: 'trace',
138
+ flags: '--trace <mode>',
139
+ type: 'string',
140
+ description: 'Trace capture: off, on, retain-on-failure',
141
+ positional: false,
142
+ required: false,
143
+ variadic: false,
144
+ default: 'off',
145
+ choices: ['off', 'on', 'retain-on-failure'],
146
+ },
147
+ {
148
+ name: 'verbose',
149
+ flags: '-v, --verbose',
150
+ type: 'boolean',
151
+ description: 'Debug output',
152
+ positional: false,
153
+ required: false,
154
+ variadic: false,
155
+ default: false,
156
+ },
157
+ {
158
+ name: 'help',
159
+ flags: '-h, --help',
160
+ type: 'boolean',
161
+ description: 'display help for command',
162
+ positional: false,
163
+ required: false,
164
+ variadic: false,
165
+ },
166
+ ],
167
+ commands: [
168
+ {
169
+ command: 'app/editor',
170
+ site: 'app',
171
+ name: 'editor',
172
+ description: 'Control a desktop editor',
173
+ access: 'write',
174
+ strategy: 'UI',
175
+ browser: true,
176
+ domain: 'localhost',
177
+ positionals: [],
178
+ options: [],
179
+ columns: [],
180
+ aliases: [],
181
+ defaultFormat: 'table',
182
+ fileArguments: [],
183
+ sessionPolicy: 'local-only',
184
+ availability: { mode: 'local-only', reason: 'desktop-app' },
185
+ },
186
+ {
187
+ command: 'files/transfer',
188
+ site: 'files',
189
+ name: 'transfer',
190
+ description: 'Transfer a file',
191
+ access: 'write',
192
+ strategy: 'PUBLIC',
193
+ browser: false,
194
+ positionals: [
195
+ {
196
+ name: 'source',
197
+ type: 'string',
198
+ description: 'Source document',
199
+ positional: true,
200
+ required: true,
201
+ variadic: false,
202
+ },
203
+ ],
204
+ options: [
205
+ {
206
+ name: 'destination',
207
+ type: 'string',
208
+ description: 'Destination file',
209
+ positional: false,
210
+ required: false,
211
+ variadic: false,
212
+ },
213
+ {
214
+ name: 'retries',
215
+ type: 'int',
216
+ description: 'Retry count',
217
+ positional: false,
218
+ required: false,
219
+ variadic: false,
220
+ default: 2,
221
+ },
222
+ ],
223
+ columns: [],
224
+ aliases: [],
225
+ defaultFormat: 'md',
226
+ fileArguments: [
227
+ {
228
+ name: 'source',
229
+ direction: 'input',
230
+ pathKind: 'file',
231
+ multiple: false,
232
+ required: true,
233
+ contentTypes: ['application/pdf'],
234
+ maxBytes: 2_000_000,
235
+ },
236
+ {
237
+ name: 'destination',
238
+ direction: 'output',
239
+ pathKind: 'directory',
240
+ multiple: true,
241
+ required: false,
242
+ },
243
+ ],
244
+ sessionPolicy: 'create-or-reuse',
245
+ availability: { mode: 'hosted' },
246
+ },
247
+ {
248
+ command: 'local/cache',
249
+ site: 'local',
250
+ name: 'cache',
251
+ description: 'Read local cache',
252
+ access: 'read',
253
+ strategy: 'LOCAL',
254
+ browser: false,
255
+ positionals: [],
256
+ options: [],
257
+ columns: [],
258
+ aliases: [],
259
+ defaultFormat: 'table',
260
+ fileArguments: [],
261
+ sessionPolicy: 'local-only',
262
+ availability: { mode: 'local-only', reason: 'local-tool' },
263
+ },
264
+ {
265
+ command: 'web/profile',
266
+ site: 'web',
267
+ name: 'profile',
268
+ aliases: ['me'],
269
+ description: 'Read a profile',
270
+ access: 'read',
271
+ strategy: 'COOKIE',
272
+ browser: true,
273
+ domain: 'example.com',
274
+ positionals: [
275
+ {
276
+ name: 'user',
277
+ type: 'string',
278
+ description: 'Profile handle',
279
+ positional: true,
280
+ required: true,
281
+ variadic: false,
282
+ choices: ['alice', 'bob'],
283
+ },
284
+ ],
285
+ options: [
286
+ {
287
+ name: 'include-private',
288
+ type: 'boolean',
289
+ description: 'Include private fields',
290
+ positional: false,
291
+ required: false,
292
+ variadic: false,
293
+ default: false,
294
+ },
295
+ ],
296
+ columns: ['name', 'bio'],
297
+ defaultFormat: 'plain',
298
+ example: 'webcmd web profile alice',
299
+ fileArguments: [],
300
+ sessionPolicy: 'create-or-reuse',
301
+ availability: { mode: 'hosted' },
302
+ },
303
+ ],
304
+ browserCommands: [browserCatalog[0], browserCatalog[1]],
305
+ });
306
+ expect(serializeHostedContract(contract)).toBe(serializeHostedContract(buildHostedContract([...commands].reverse(), [...browserCatalog].reverse(), '9.8.7')));
307
+ });
308
+ it('rejects duplicate canonical commands and aliases', () => {
309
+ const duplicate = { ...commands[0] };
310
+ expect(() => buildHostedContract([commands[0], duplicate], [], '1.0.0'))
311
+ .toThrow('Duplicate canonical command: web/profile');
312
+ const aliasCollision = {
313
+ ...commands[0],
314
+ name: 'me',
315
+ aliases: undefined,
316
+ };
317
+ expect(() => buildHostedContract([commands[0], aliasCollision], [], '1.0.0'))
318
+ .toThrow('Duplicate command alias: web/me');
319
+ });
320
+ it('rejects incomplete file and browser session metadata', () => {
321
+ const missingDirection = {
322
+ ...commands[2],
323
+ args: [{
324
+ name: 'source',
325
+ positional: true,
326
+ file: {},
327
+ }],
328
+ };
329
+ expect(() => buildHostedContract([missingDirection], [], '1.0.0'))
330
+ .toThrow('File argument files/transfer source must declare direction');
331
+ const missingPathKind = {
332
+ ...commands[2],
333
+ args: [{
334
+ name: 'source',
335
+ positional: true,
336
+ file: { direction: 'input' },
337
+ }],
338
+ };
339
+ expect(() => buildHostedContract([missingPathKind], [], '1.0.0'))
340
+ .toThrow('File argument files/transfer source must declare pathKind');
341
+ const unsupportedSeparator = {
342
+ ...commands[2],
343
+ args: [{
344
+ name: 'source',
345
+ positional: true,
346
+ file: { direction: 'input', pathKind: 'file', separator: ':' },
347
+ }],
348
+ };
349
+ expect(() => buildHostedContract([unsupportedSeparator], [], '1.0.0'))
350
+ .toThrow('File argument files/transfer source declares unsupported separator');
351
+ const missingPolicy = [{
352
+ command: 'open',
353
+ aliases: [],
354
+ description: 'Open',
355
+ positionals: [],
356
+ options: [],
357
+ }];
358
+ expect(() => buildHostedContract([], missingPolicy, '1.0.0'))
359
+ .toThrow('Browser command open must declare a known session policy');
360
+ });
361
+ });
@@ -0,0 +1,38 @@
1
+ import { type ConfigIo, type HostedManifestCache, type HostedWebcmdConfig } from './config.js';
2
+ export type HostedCredentialBackend = 'os' | 'file-fallback';
3
+ export interface HostedCredentialStore {
4
+ put(reference: string, secret: string): Promise<void>;
5
+ get(reference: string): Promise<string | null>;
6
+ delete(reference: string): Promise<void>;
7
+ backend(): HostedCredentialBackend;
8
+ }
9
+ export interface HostedCredentialIo extends ConfigIo {
10
+ credentialStore?: HostedCredentialStore;
11
+ randomUUID?: () => string;
12
+ platform?: NodeJS.Platform;
13
+ }
14
+ export interface HostedCredentialResolution {
15
+ apiKey: string;
16
+ backend?: HostedCredentialBackend;
17
+ migrated: boolean;
18
+ migrationError?: Error;
19
+ }
20
+ interface StoredHostedConfigInput {
21
+ apiBaseUrl: string;
22
+ apiKeyRef: string;
23
+ credentialBackend: HostedCredentialBackend;
24
+ manifestCache?: HostedManifestCache;
25
+ now?: Date;
26
+ }
27
+ export declare function getHostedCredentialPath(io?: Pick<ConfigIo, 'env' | 'homeDir'>): string;
28
+ export declare function storeHostedApiKey(apiKey: string, io?: HostedCredentialIo): Promise<{
29
+ apiKeyRef: string;
30
+ credentialBackend: HostedCredentialBackend;
31
+ }>;
32
+ export declare function resolveHostedApiKey(config: HostedWebcmdConfig, io?: HostedCredentialIo & {
33
+ migrate?: boolean;
34
+ }): Promise<HostedCredentialResolution>;
35
+ export declare function makeStoredHostedConfig(input: StoredHostedConfigInput): HostedWebcmdConfig;
36
+ declare function isMacOsKeychainAvailable(io: HostedCredentialIo): boolean;
37
+ export declare const _isMacOsKeychainAvailableForTest: typeof isMacOsKeychainAvailable;
38
+ export {};
@@ -0,0 +1,248 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import { mkdir, readFile, rename, rm, writeFile, chmod } from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { ConfigError } from '../errors.js';
8
+ import { getConfigDir, normalizeApiBaseUrl, saveWebcmdConfig, } from './config.js';
9
+ const execFile = promisify(execFileCallback);
10
+ const MACOS_KEYCHAIN_SERVICE = 'dev.webcmd.hosted-api-key';
11
+ const MACOS_KEYCHAIN_TIMEOUT_MS = 5_000;
12
+ export function getHostedCredentialPath(io = {}) {
13
+ return path.join(getConfigDir(io), 'hosted-credentials.json');
14
+ }
15
+ export async function storeHostedApiKey(apiKey, io = {}) {
16
+ const apiKeyRef = createHostedCredentialReference(io);
17
+ const candidates = createCredentialStoreCandidates(io);
18
+ let lastError;
19
+ for (const store of candidates) {
20
+ try {
21
+ await store.put(apiKeyRef, apiKey);
22
+ const readBack = await store.get(apiKeyRef);
23
+ if (readBack !== apiKey) {
24
+ throw new Error(`Hosted credential store ${store.backend()} did not verify the saved key.`);
25
+ }
26
+ return { apiKeyRef, credentialBackend: store.backend() };
27
+ }
28
+ catch (error) {
29
+ lastError = error instanceof Error ? error : new Error(String(error));
30
+ try {
31
+ await store.delete(apiKeyRef);
32
+ }
33
+ catch {
34
+ // Best-effort cleanup only; a failed candidate must not hide fallback stores.
35
+ }
36
+ }
37
+ }
38
+ throw lastError ?? new Error('No hosted credential store is available.');
39
+ }
40
+ export async function resolveHostedApiKey(config, io = {}) {
41
+ if (config.hosted.apiKeyRef) {
42
+ const store = createCredentialStoreForBackend(config.hosted.credentialBackend, io);
43
+ const apiKey = await store.get(config.hosted.apiKeyRef);
44
+ if (!apiKey) {
45
+ throw new ConfigError('Webcmd hosted credentials are missing.', 'Run `webcmd setup` again to reconnect hosted mode.');
46
+ }
47
+ return {
48
+ apiKey,
49
+ backend: store.backend(),
50
+ migrated: false,
51
+ };
52
+ }
53
+ if (config.hosted.apiKey) {
54
+ const apiKey = config.hosted.apiKey;
55
+ if (io.migrate === false)
56
+ return { apiKey, migrated: false };
57
+ try {
58
+ const stored = await storeHostedApiKey(apiKey, io);
59
+ saveWebcmdConfig(makeStoredHostedConfig({
60
+ apiBaseUrl: config.hosted.apiBaseUrl,
61
+ apiKeyRef: stored.apiKeyRef,
62
+ credentialBackend: stored.credentialBackend,
63
+ ...(config.hosted.manifestCache ? { manifestCache: config.hosted.manifestCache } : {}),
64
+ now: new Date(config.updatedAt),
65
+ }), io);
66
+ return {
67
+ apiKey,
68
+ backend: stored.credentialBackend,
69
+ migrated: true,
70
+ };
71
+ }
72
+ catch (error) {
73
+ return {
74
+ apiKey,
75
+ migrated: false,
76
+ migrationError: error instanceof Error ? error : new Error(String(error)),
77
+ };
78
+ }
79
+ }
80
+ throw new ConfigError('Webcmd hosted mode is missing its API key reference.', 'Run `webcmd setup` again to reconnect hosted mode.');
81
+ }
82
+ export function makeStoredHostedConfig(input) {
83
+ return {
84
+ mode: 'hosted',
85
+ updatedAt: (input.now ?? new Date()).toISOString(),
86
+ hosted: {
87
+ apiBaseUrl: normalizeApiBaseUrl(input.apiBaseUrl),
88
+ apiKeyRef: input.apiKeyRef,
89
+ credentialBackend: input.credentialBackend,
90
+ ...(input.manifestCache ? { manifestCache: input.manifestCache } : {}),
91
+ },
92
+ };
93
+ }
94
+ function createHostedCredentialReference(io) {
95
+ const id = (io.randomUUID ?? randomUUID)().replace(/[^A-Za-z0-9_-]/g, '');
96
+ return `wcmd_cred_${id}`;
97
+ }
98
+ function createCredentialStoreCandidates(io) {
99
+ if (io.credentialStore)
100
+ return [io.credentialStore];
101
+ const stores = [];
102
+ if (isMacOsKeychainAvailable(io))
103
+ stores.push(new MacOsKeychainCredentialStore());
104
+ stores.push(new FileHostedCredentialStore(io));
105
+ return stores;
106
+ }
107
+ function createCredentialStoreForBackend(backend, io) {
108
+ if (io.credentialStore)
109
+ return io.credentialStore;
110
+ if (backend === 'os' && isMacOsKeychainAvailable(io))
111
+ return new MacOsKeychainCredentialStore();
112
+ if (backend === 'os') {
113
+ throw new ConfigError('The configured OS credential store is not available on this machine.', 'Run `webcmd setup` again to reconnect hosted mode on this machine.');
114
+ }
115
+ return new FileHostedCredentialStore(io);
116
+ }
117
+ function isMacOsKeychainAvailable(io) {
118
+ const env = io.env ?? process.env;
119
+ if (env.WEBCMD_CREDENTIAL_BACKEND === 'file')
120
+ return false;
121
+ const platform = io.platform ?? process.platform;
122
+ const existsSync = io.existsSync ?? fs.existsSync;
123
+ return platform === 'darwin' && existsSync('/usr/bin/security');
124
+ }
125
+ class MacOsKeychainCredentialStore {
126
+ backend() {
127
+ return 'os';
128
+ }
129
+ async put(reference, secret) {
130
+ validateCredentialReference(reference);
131
+ await execFile('/usr/bin/security', [
132
+ 'add-generic-password',
133
+ '-a',
134
+ reference,
135
+ '-s',
136
+ MACOS_KEYCHAIN_SERVICE,
137
+ '-w',
138
+ secret,
139
+ '-U',
140
+ ], { timeout: MACOS_KEYCHAIN_TIMEOUT_MS });
141
+ }
142
+ async get(reference) {
143
+ validateCredentialReference(reference);
144
+ try {
145
+ const { stdout } = await execFile('/usr/bin/security', [
146
+ 'find-generic-password',
147
+ '-a',
148
+ reference,
149
+ '-s',
150
+ MACOS_KEYCHAIN_SERVICE,
151
+ '-w',
152
+ ], { timeout: MACOS_KEYCHAIN_TIMEOUT_MS });
153
+ return stdout.replace(/\r?\n$/, '');
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ }
159
+ async delete(reference) {
160
+ validateCredentialReference(reference);
161
+ try {
162
+ await execFile('/usr/bin/security', [
163
+ 'delete-generic-password',
164
+ '-a',
165
+ reference,
166
+ '-s',
167
+ MACOS_KEYCHAIN_SERVICE,
168
+ ], { timeout: MACOS_KEYCHAIN_TIMEOUT_MS });
169
+ }
170
+ catch {
171
+ // Missing credentials are already deleted for the caller's purposes.
172
+ }
173
+ }
174
+ }
175
+ class FileHostedCredentialStore {
176
+ io;
177
+ constructor(io = {}) {
178
+ this.io = io;
179
+ }
180
+ backend() {
181
+ return 'file-fallback';
182
+ }
183
+ async put(reference, secret) {
184
+ validateCredentialReference(reference);
185
+ const document = await this.readDocument();
186
+ document.credentials[reference] = secret;
187
+ document.updatedAt = new Date().toISOString();
188
+ await this.writeDocument(document);
189
+ }
190
+ async get(reference) {
191
+ validateCredentialReference(reference);
192
+ const document = await this.readDocument();
193
+ return document.credentials[reference] ?? null;
194
+ }
195
+ async delete(reference) {
196
+ validateCredentialReference(reference);
197
+ const document = await this.readDocument();
198
+ if (!Object.prototype.hasOwnProperty.call(document.credentials, reference))
199
+ return;
200
+ delete document.credentials[reference];
201
+ document.updatedAt = new Date().toISOString();
202
+ await this.writeDocument(document);
203
+ }
204
+ async readDocument() {
205
+ try {
206
+ const parsed = JSON.parse(await readFile(getHostedCredentialPath(this.io), 'utf8'));
207
+ if (parsed.version === 1 && parsed.credentials && typeof parsed.credentials === 'object') {
208
+ const credentials = {};
209
+ for (const [key, value] of Object.entries(parsed.credentials)) {
210
+ if (typeof value === 'string')
211
+ credentials[key] = value;
212
+ }
213
+ return {
214
+ version: 1,
215
+ credentials,
216
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date(0).toISOString(),
217
+ };
218
+ }
219
+ }
220
+ catch {
221
+ // Missing or malformed fallback storage starts empty. The main config still
222
+ // holds only an opaque reference, so malformed fallback credentials surface
223
+ // later as a missing credential rather than as config corruption.
224
+ }
225
+ return { version: 1, credentials: {}, updatedAt: new Date(0).toISOString() };
226
+ }
227
+ async writeDocument(document) {
228
+ const target = getHostedCredentialPath(this.io);
229
+ await mkdir(path.dirname(target), { recursive: true });
230
+ const temp = `${target}.${process.pid}.${Date.now()}.tmp`;
231
+ try {
232
+ await writeFile(temp, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
233
+ await chmod(temp, 0o600).catch(() => undefined);
234
+ await rename(temp, target);
235
+ await chmod(target, 0o600).catch(() => undefined);
236
+ }
237
+ catch (error) {
238
+ await rm(temp, { force: true }).catch(() => undefined);
239
+ throw error;
240
+ }
241
+ }
242
+ }
243
+ function validateCredentialReference(reference) {
244
+ if (!/^[A-Za-z0-9_-]{8,160}$/.test(reference)) {
245
+ throw new Error('Hosted credential reference must be opaque and URL-safe.');
246
+ }
247
+ }
248
+ export const _isMacOsKeychainAvailableForTest = isMacOsKeychainAvailable;
@@ -0,0 +1 @@
1
+ export {};