imprint-mcp 0.2.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 (97) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/examples/discoverandgo/README.md +57 -0
  5. package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
  6. package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
  7. package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
  8. package/examples/echo/README.md +37 -0
  9. package/examples/echo/echo_test/index.ts +31 -0
  10. package/examples/google-flights/search_google_flights/index.ts +101 -0
  11. package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
  12. package/examples/google-flights/search_google_flights/parser.ts +189 -0
  13. package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
  14. package/examples/google-flights/search_google_flights/workflow.json +48 -0
  15. package/examples/google-hotels/search_google_hotels/index.ts +194 -0
  16. package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
  17. package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
  18. package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
  19. package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
  20. package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
  21. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
  22. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
  23. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
  24. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
  25. package/examples/southwest/README.md +81 -0
  26. package/examples/southwest/search_southwest_flights/backends.json +23 -0
  27. package/examples/southwest/search_southwest_flights/cron.json +19 -0
  28. package/examples/southwest/search_southwest_flights/index.ts +110 -0
  29. package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
  30. package/examples/southwest/search_southwest_flights/workflow.json +54 -0
  31. package/package.json +78 -0
  32. package/prompts/compile-agent.md +580 -0
  33. package/prompts/intent-detection.md +198 -0
  34. package/prompts/playbook-compilation.md +279 -0
  35. package/prompts/request-triage.md +74 -0
  36. package/prompts/tool-candidate-detection.md +104 -0
  37. package/src/cli.ts +1287 -0
  38. package/src/imprint/agent.ts +468 -0
  39. package/src/imprint/app-api-hosts.ts +53 -0
  40. package/src/imprint/backend-ladder.ts +568 -0
  41. package/src/imprint/check.ts +136 -0
  42. package/src/imprint/chromium.ts +211 -0
  43. package/src/imprint/claude-cli-compile.ts +640 -0
  44. package/src/imprint/cli-credential.ts +394 -0
  45. package/src/imprint/codex-cli-compile.ts +712 -0
  46. package/src/imprint/compile-agent-types.ts +40 -0
  47. package/src/imprint/compile-agent.ts +404 -0
  48. package/src/imprint/compile-tools.ts +1389 -0
  49. package/src/imprint/compile.ts +720 -0
  50. package/src/imprint/cookie-jar.ts +246 -0
  51. package/src/imprint/credential-bundle.ts +195 -0
  52. package/src/imprint/credential-extract.ts +290 -0
  53. package/src/imprint/credential-store.ts +707 -0
  54. package/src/imprint/cron.ts +312 -0
  55. package/src/imprint/doctor.ts +223 -0
  56. package/src/imprint/emit.ts +154 -0
  57. package/src/imprint/etld.ts +134 -0
  58. package/src/imprint/freeform-redact.ts +216 -0
  59. package/src/imprint/inject-listener.ts +137 -0
  60. package/src/imprint/install.ts +795 -0
  61. package/src/imprint/integrations.ts +385 -0
  62. package/src/imprint/is-compiled.ts +2 -0
  63. package/src/imprint/json-path.ts +100 -0
  64. package/src/imprint/llm.ts +998 -0
  65. package/src/imprint/load-json.ts +54 -0
  66. package/src/imprint/log.ts +33 -0
  67. package/src/imprint/login.ts +166 -0
  68. package/src/imprint/mcp-compile-server.ts +282 -0
  69. package/src/imprint/mcp-maintenance.ts +1790 -0
  70. package/src/imprint/mcp-server.ts +350 -0
  71. package/src/imprint/multi-progress.ts +69 -0
  72. package/src/imprint/notify.ts +155 -0
  73. package/src/imprint/paths.ts +64 -0
  74. package/src/imprint/playbook-parser.ts +21 -0
  75. package/src/imprint/playbook-runner.ts +465 -0
  76. package/src/imprint/probe-backends.ts +251 -0
  77. package/src/imprint/progress.ts +28 -0
  78. package/src/imprint/record.ts +470 -0
  79. package/src/imprint/redact.ts +550 -0
  80. package/src/imprint/replay-capture.ts +387 -0
  81. package/src/imprint/request-context.ts +66 -0
  82. package/src/imprint/runtime-link.ts +73 -0
  83. package/src/imprint/runtime.ts +942 -0
  84. package/src/imprint/sensitive-keys.ts +156 -0
  85. package/src/imprint/session-diff.ts +409 -0
  86. package/src/imprint/session-merge.ts +198 -0
  87. package/src/imprint/session-writer.ts +149 -0
  88. package/src/imprint/sites.ts +27 -0
  89. package/src/imprint/stealth-fetch.ts +434 -0
  90. package/src/imprint/teach-state.ts +235 -0
  91. package/src/imprint/teach.ts +2120 -0
  92. package/src/imprint/tool-candidates.ts +423 -0
  93. package/src/imprint/tool-loader.ts +186 -0
  94. package/src/imprint/tool-selection.ts +70 -0
  95. package/src/imprint/tracing.ts +508 -0
  96. package/src/imprint/types.ts +472 -0
  97. package/src/imprint/version.ts +21 -0
@@ -0,0 +1,707 @@
1
+ /**
2
+ * Local credential storage with pluggable backends.
3
+ *
4
+ * Resolution order (set once per process):
5
+ * 1. KeyringBackend — `@napi-rs/keyring` against the OS keychain.
6
+ * Used when available; transparent (no passphrase) and OS-secured.
7
+ * 2. EncryptedFileBackend — libsodium secretbox + argon2id over a single
8
+ * JSON file. Passphrase from $IMPRINT_PASSPHRASE or interactive prompt;
9
+ * cached in process. Used in headless contexts (e.g. Linux container
10
+ * without a desktop session) where the keyring isn't available.
11
+ * 3. Legacy JSON read-only — old `~/.config/imprint/credentials/<site>.json`
12
+ * files are surfaced via `loadLegacyStore` for migration only; we never
13
+ * write back to them.
14
+ *
15
+ * Manifest (non-secret) lives at `~/.config/imprint/manifests/<site>.json`
16
+ * and lists which secrets exist for which site. The manifest is what tells
17
+ * a downstream agent (OpenClaw/Hermes) which credentials it needs to ask
18
+ * for when consuming a shared skill.
19
+ */
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readFileSync,
24
+ readdirSync,
25
+ unlinkSync,
26
+ writeFileSync,
27
+ } from 'node:fs';
28
+ import { dirname, join as pathJoin } from 'node:path';
29
+ import { argon2id } from '@noble/hashes/argon2.js';
30
+ import envPaths from 'env-paths';
31
+
32
+ const PATHS = envPaths('imprint', { suffix: '' });
33
+ const SERVICE_NAME = 'imprint';
34
+
35
+ /** What kind of value a credential is. Only used for the manifest UI. */
36
+ export type CredentialKind = 'username' | 'password' | 'email' | 'token' | 'opaque';
37
+
38
+ export interface ManifestEntry {
39
+ name: string;
40
+ kind: CredentialKind;
41
+ /** Optional human description (printed by `imprint credential list`). */
42
+ description?: string;
43
+ recordedAt: string;
44
+ }
45
+
46
+ interface SiteManifest {
47
+ site: string;
48
+ secrets: ManifestEntry[];
49
+ cookies?: Array<{ name: string; value?: never }>; // value is never persisted in the manifest
50
+ storage?: Array<{ origin: string; kind: StorageRecord['kind']; key: string; value?: never }>;
51
+ updatedAt: string;
52
+ }
53
+
54
+ export interface CookieRecord {
55
+ name: string;
56
+ value: string;
57
+ domain: string;
58
+ path: string;
59
+ expires?: number;
60
+ httpOnly?: boolean;
61
+ secure?: boolean;
62
+ sameSite?: string;
63
+ hostOnly?: boolean;
64
+ creationIndex?: number;
65
+ }
66
+
67
+ export interface StorageRecord {
68
+ origin: string;
69
+ kind: 'localStorage' | 'sessionStorage';
70
+ key: string;
71
+ value: string;
72
+ }
73
+
74
+ /** Backend abstraction. Implementations may be sync or async; we always
75
+ * await to keep the call sites uniform. */
76
+ export interface CredentialBackend {
77
+ readonly id: 'keyring' | 'encrypted-file' | 'legacy-json';
78
+ getSecret(site: string, name: string): Promise<string | null>;
79
+ setSecret(site: string, name: string, value: string): Promise<void>;
80
+ deleteSecret(site: string, name: string): Promise<void>;
81
+ listSecrets(site: string): Promise<string[]>;
82
+ /** Cookies are bulk-replaced rather than per-name because a login flow
83
+ * produces a fresh cookie set as one unit. */
84
+ getCookies(site: string): Promise<CookieRecord[]>;
85
+ setCookies(site: string, cookies: CookieRecord[]): Promise<void>;
86
+ getStorage?(site: string): Promise<StorageRecord[]>;
87
+ setStorage?(site: string, storage: StorageRecord[]): Promise<void>;
88
+ /** Best-effort listing of every site this backend has data for. Used by
89
+ * `imprint credential list` (no <site> argument) and migration. */
90
+ listSites(): Promise<string[]>;
91
+ }
92
+
93
+ // ─── KeyringBackend (primary) ──────────────────────────────────────────────
94
+
95
+ class KeyringBackend implements CredentialBackend {
96
+ readonly id = 'keyring' as const;
97
+ // Dynamically loaded; keep the module reference so tests can swap it.
98
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic require shape
99
+ private Entry: any;
100
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic require shape
101
+ private findCredentials: any;
102
+
103
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic require shape
104
+ constructor(mod: { Entry: any; findCredentials: any }) {
105
+ this.Entry = mod.Entry;
106
+ this.findCredentials = mod.findCredentials;
107
+ }
108
+
109
+ private accountFor(site: string, name: string): string {
110
+ return `${site}::${name}`;
111
+ }
112
+
113
+ private cookieAccount(site: string): string {
114
+ return `${site}::__cookies__`;
115
+ }
116
+
117
+ private storageAccount(site: string): string {
118
+ return `${site}::__storage__`;
119
+ }
120
+
121
+ async getSecret(site: string, name: string): Promise<string | null> {
122
+ const entry = new this.Entry(SERVICE_NAME, this.accountFor(site, name));
123
+ try {
124
+ const v = entry.getPassword();
125
+ return typeof v === 'string' && v.length > 0 ? v : null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ async setSecret(site: string, name: string, value: string): Promise<void> {
132
+ const entry = new this.Entry(SERVICE_NAME, this.accountFor(site, name));
133
+ entry.setPassword(value);
134
+ }
135
+
136
+ async deleteSecret(site: string, name: string): Promise<void> {
137
+ const entry = new this.Entry(SERVICE_NAME, this.accountFor(site, name));
138
+ try {
139
+ entry.deletePassword();
140
+ } catch {
141
+ // Already absent — fine.
142
+ }
143
+ }
144
+
145
+ async listSecrets(site: string): Promise<string[]> {
146
+ // findCredentials returns every credential under our service; we filter
147
+ // by site prefix.
148
+ const all = this.findCredentials(SERVICE_NAME) as Array<{ account: string }>;
149
+ const prefix = `${site}::`;
150
+ const cookieAcct = this.cookieAccount(site);
151
+ const storageAcct = this.storageAccount(site);
152
+ return all
153
+ .map((c) => c.account)
154
+ .filter((acct) => acct.startsWith(prefix) && acct !== cookieAcct && acct !== storageAcct)
155
+ .map((acct) => acct.slice(prefix.length));
156
+ }
157
+
158
+ async getCookies(site: string): Promise<CookieRecord[]> {
159
+ const entry = new this.Entry(SERVICE_NAME, this.cookieAccount(site));
160
+ try {
161
+ const v = entry.getPassword();
162
+ if (typeof v !== 'string' || v.length === 0) return [];
163
+ return JSON.parse(v) as CookieRecord[];
164
+ } catch {
165
+ return [];
166
+ }
167
+ }
168
+
169
+ async setCookies(site: string, cookies: CookieRecord[]): Promise<void> {
170
+ const entry = new this.Entry(SERVICE_NAME, this.cookieAccount(site));
171
+ if (cookies.length === 0) {
172
+ try {
173
+ entry.deletePassword();
174
+ } catch {
175
+ /* ignore */
176
+ }
177
+ return;
178
+ }
179
+ entry.setPassword(JSON.stringify(cookies));
180
+ }
181
+
182
+ async getStorage(site: string): Promise<StorageRecord[]> {
183
+ const entry = new this.Entry(SERVICE_NAME, this.storageAccount(site));
184
+ try {
185
+ const v = entry.getPassword();
186
+ if (typeof v !== 'string' || v.length === 0) return [];
187
+ return JSON.parse(v) as StorageRecord[];
188
+ } catch {
189
+ return [];
190
+ }
191
+ }
192
+
193
+ async setStorage(site: string, storage: StorageRecord[]): Promise<void> {
194
+ const entry = new this.Entry(SERVICE_NAME, this.storageAccount(site));
195
+ if (storage.length === 0) {
196
+ try {
197
+ entry.deletePassword();
198
+ } catch {
199
+ /* ignore */
200
+ }
201
+ return;
202
+ }
203
+ entry.setPassword(JSON.stringify(storage));
204
+ }
205
+
206
+ async listSites(): Promise<string[]> {
207
+ const all = this.findCredentials(SERVICE_NAME) as Array<{ account: string }>;
208
+ const sites = new Set<string>();
209
+ for (const c of all) {
210
+ const idx = c.account.indexOf('::');
211
+ if (idx > 0) sites.add(c.account.slice(0, idx));
212
+ }
213
+ return Array.from(sites).sort();
214
+ }
215
+ }
216
+
217
+ // ─── EncryptedFileBackend (fallback) ───────────────────────────────────────
218
+
219
+ interface EncryptedFileShape {
220
+ /** Map of site → { secrets: { name → value }, cookies: CookieRecord[] }. */
221
+ sites: Record<
222
+ string,
223
+ { secrets: Record<string, string>; cookies: CookieRecord[]; storage?: StorageRecord[] }
224
+ >;
225
+ }
226
+
227
+ class EncryptedFileBackend implements CredentialBackend {
228
+ readonly id = 'encrypted-file' as const;
229
+ private filePath: string;
230
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic libsodium ref
231
+ private sodium: any;
232
+ private cachedKey: Uint8Array | null = null;
233
+ /** Cached decrypted shape — flushed back to disk on every mutation. */
234
+ private cachedData: EncryptedFileShape | null = null;
235
+ private passphraseProvider: () => Promise<string>;
236
+
237
+ constructor(opts: {
238
+ filePath: string;
239
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic libsodium ref
240
+ sodium: any;
241
+ passphraseProvider: () => Promise<string>;
242
+ }) {
243
+ this.filePath = opts.filePath;
244
+ this.sodium = opts.sodium;
245
+ this.passphraseProvider = opts.passphraseProvider;
246
+ }
247
+
248
+ private async ensureKeyAndData(opts?: { forWrite?: boolean }): Promise<void> {
249
+ if (this.cachedKey && this.cachedData) return;
250
+ const sodium = this.sodium;
251
+
252
+ if (!existsSync(this.filePath)) {
253
+ // No store yet. For a READ (forWrite !== true), short-circuit with an
254
+ // empty in-memory shape — there cannot be any credentials to return,
255
+ // and prompting for a passphrase here would hang non-interactive
256
+ // callers (CI, MCP server startup, cron). The store is materialised
257
+ // on first WRITE, when we have a real value to protect.
258
+ if (!opts?.forWrite) {
259
+ this.cachedData = { sites: {} };
260
+ return;
261
+ }
262
+ const passphrase = await this.passphraseProvider();
263
+ const salt = sodium.randombytes_buf(16);
264
+ this.cachedKey = deriveKey(passphrase, salt);
265
+ this.cachedData = { sites: {} };
266
+ this.persistWithSalt(salt);
267
+ return;
268
+ }
269
+
270
+ const raw = readFileSync(this.filePath, 'utf8');
271
+ let parsed: { saltB64: string; nonceB64: string; ctB64: string };
272
+ try {
273
+ parsed = JSON.parse(raw);
274
+ } catch {
275
+ throw new Error(
276
+ `${this.filePath} is corrupted (not JSON). Delete it to start fresh, or restore from a credential bundle.`,
277
+ );
278
+ }
279
+ const salt = b64decode(parsed.saltB64);
280
+ const nonce = b64decode(parsed.nonceB64);
281
+ const ct = b64decode(parsed.ctB64);
282
+
283
+ // Try cached passphrase first if the user has set $IMPRINT_PASSPHRASE
284
+ // and re-entered process.
285
+ const passphrase = await this.passphraseProvider();
286
+ const key = deriveKey(passphrase, salt);
287
+ let plain: Uint8Array;
288
+ try {
289
+ plain = sodium.crypto_secretbox_open_easy(ct, nonce, key);
290
+ } catch {
291
+ throw new Error(
292
+ `Wrong passphrase for ${this.filePath}.\n→ set $IMPRINT_PASSPHRASE or re-run with the correct passphrase. To start over, delete the file (you'll lose stored credentials).`,
293
+ );
294
+ }
295
+ const text = new TextDecoder().decode(plain);
296
+ this.cachedKey = key;
297
+ this.cachedData = JSON.parse(text) as EncryptedFileShape;
298
+ }
299
+
300
+ private persistWithSalt(salt: Uint8Array): void {
301
+ if (!this.cachedData || !this.cachedKey) return;
302
+ const sodium = this.sodium;
303
+ const text = JSON.stringify(this.cachedData);
304
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
305
+ const ct = sodium.crypto_secretbox_easy(new TextEncoder().encode(text), nonce, this.cachedKey);
306
+ const wire = {
307
+ version: 1,
308
+ saltB64: b64encode(salt),
309
+ nonceB64: b64encode(nonce),
310
+ ctB64: b64encode(ct),
311
+ };
312
+ mkdirSync(dirname(this.filePath), { recursive: true });
313
+ writeFileSync(this.filePath, JSON.stringify(wire), 'utf8');
314
+ }
315
+
316
+ /** Persist using the SAME salt the file was loaded with. */
317
+ private persist(): void {
318
+ if (!this.cachedData) return;
319
+ const raw = readFileSync(this.filePath, 'utf8');
320
+ const parsed = JSON.parse(raw) as { saltB64: string };
321
+ this.persistWithSalt(b64decode(parsed.saltB64));
322
+ }
323
+
324
+ private siteData(site: string): {
325
+ secrets: Record<string, string>;
326
+ cookies: CookieRecord[];
327
+ storage?: StorageRecord[];
328
+ } {
329
+ if (!this.cachedData) throw new Error('cachedData not initialized');
330
+ let bucket = this.cachedData.sites[site];
331
+ if (!bucket) {
332
+ bucket = { secrets: {}, cookies: [], storage: [] };
333
+ this.cachedData.sites[site] = bucket;
334
+ }
335
+ return bucket;
336
+ }
337
+
338
+ async getSecret(site: string, name: string): Promise<string | null> {
339
+ await this.ensureKeyAndData();
340
+ const bucket = this.cachedData?.sites[site];
341
+ return bucket?.secrets[name] ?? null;
342
+ }
343
+
344
+ async setSecret(site: string, name: string, value: string): Promise<void> {
345
+ await this.ensureKeyAndData({ forWrite: true });
346
+ this.siteData(site).secrets[name] = value;
347
+ this.persist();
348
+ }
349
+
350
+ async deleteSecret(site: string, name: string): Promise<void> {
351
+ await this.ensureKeyAndData({ forWrite: true });
352
+ const bucket = this.cachedData?.sites[site];
353
+ if (bucket && name in bucket.secrets) {
354
+ delete bucket.secrets[name];
355
+ this.persist();
356
+ }
357
+ }
358
+
359
+ async listSecrets(site: string): Promise<string[]> {
360
+ await this.ensureKeyAndData();
361
+ const bucket = this.cachedData?.sites[site];
362
+ return bucket ? Object.keys(bucket.secrets) : [];
363
+ }
364
+
365
+ async getCookies(site: string): Promise<CookieRecord[]> {
366
+ await this.ensureKeyAndData();
367
+ return this.cachedData?.sites[site]?.cookies ?? [];
368
+ }
369
+
370
+ async setCookies(site: string, cookies: CookieRecord[]): Promise<void> {
371
+ await this.ensureKeyAndData({ forWrite: true });
372
+ this.siteData(site).cookies = cookies;
373
+ this.persist();
374
+ }
375
+
376
+ async getStorage(site: string): Promise<StorageRecord[]> {
377
+ await this.ensureKeyAndData();
378
+ return this.cachedData?.sites[site]?.storage ?? [];
379
+ }
380
+
381
+ async setStorage(site: string, storage: StorageRecord[]): Promise<void> {
382
+ await this.ensureKeyAndData({ forWrite: true });
383
+ this.siteData(site).storage = storage;
384
+ this.persist();
385
+ }
386
+
387
+ async listSites(): Promise<string[]> {
388
+ await this.ensureKeyAndData();
389
+ return Object.keys(this.cachedData?.sites ?? {}).sort();
390
+ }
391
+ }
392
+
393
+ // ─── KDF + base64 helpers ──────────────────────────────────────────────────
394
+
395
+ const KDF_OPTS = {
396
+ /** Iterations. Argon2 RFC 9106 minimum recommended interactive. */
397
+ t: 3,
398
+ /** Memory in KiB — 64 MiB. */
399
+ m: 64 * 1024,
400
+ /** Parallelism. */
401
+ p: 4,
402
+ /** Output 32 bytes for crypto_secretbox key. */
403
+ dkLen: 32,
404
+ } as const;
405
+
406
+ function deriveKey(passphrase: string, salt: Uint8Array): Uint8Array {
407
+ return argon2id(new TextEncoder().encode(passphrase), salt, KDF_OPTS);
408
+ }
409
+
410
+ function b64encode(bytes: Uint8Array): string {
411
+ return Buffer.from(bytes).toString('base64');
412
+ }
413
+
414
+ function b64decode(s: string): Uint8Array {
415
+ return new Uint8Array(Buffer.from(s, 'base64'));
416
+ }
417
+
418
+ // ─── Manifest (non-secret, JSON file) ──────────────────────────────────────
419
+
420
+ function manifestPath(site: string): string {
421
+ return pathJoin(PATHS.config, 'manifests', `${site}.json`);
422
+ }
423
+
424
+ export function readSiteManifest(site: string): SiteManifest | null {
425
+ const p = manifestPath(site);
426
+ if (!existsSync(p)) return null;
427
+ try {
428
+ return JSON.parse(readFileSync(p, 'utf8')) as SiteManifest;
429
+ } catch {
430
+ return null;
431
+ }
432
+ }
433
+
434
+ export function writeSiteManifest(manifest: SiteManifest): void {
435
+ const p = manifestPath(manifest.site);
436
+ mkdirSync(dirname(p), { recursive: true });
437
+ writeFileSync(p, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
438
+ }
439
+
440
+ function deleteSiteManifest(site: string): void {
441
+ const p = manifestPath(site);
442
+ if (existsSync(p)) unlinkSync(p);
443
+ }
444
+
445
+ export function listManifestSites(): string[] {
446
+ const dir = pathJoin(PATHS.config, 'manifests');
447
+ if (!existsSync(dir)) return [];
448
+ return readdirSync(dir)
449
+ .filter((f) => f.endsWith('.json'))
450
+ .map((f) => f.slice(0, -'.json'.length))
451
+ .sort();
452
+ }
453
+
454
+ /** Adds or updates a single manifest entry, persisting the file. */
455
+ export function upsertManifestEntry(
456
+ site: string,
457
+ entry: Omit<ManifestEntry, 'recordedAt'> & { recordedAt?: string },
458
+ ): SiteManifest {
459
+ const existing = readSiteManifest(site);
460
+ const recordedAt = entry.recordedAt ?? new Date().toISOString();
461
+ const next: SiteManifest = existing
462
+ ? {
463
+ site,
464
+ secrets: existing.secrets
465
+ .filter((s) => s.name !== entry.name)
466
+ .concat({
467
+ name: entry.name,
468
+ kind: entry.kind,
469
+ description: entry.description,
470
+ recordedAt,
471
+ }),
472
+ cookies: existing.cookies,
473
+ storage: existing.storage,
474
+ updatedAt: new Date().toISOString(),
475
+ }
476
+ : {
477
+ site,
478
+ secrets: [
479
+ {
480
+ name: entry.name,
481
+ kind: entry.kind,
482
+ description: entry.description,
483
+ recordedAt,
484
+ },
485
+ ],
486
+ updatedAt: new Date().toISOString(),
487
+ };
488
+ writeSiteManifest(next);
489
+ return next;
490
+ }
491
+
492
+ export function removeManifestEntry(site: string, name: string): SiteManifest | null {
493
+ const existing = readSiteManifest(site);
494
+ if (!existing) return null;
495
+ const next: SiteManifest = {
496
+ site,
497
+ secrets: existing.secrets.filter((s) => s.name !== name),
498
+ cookies: existing.cookies,
499
+ storage: existing.storage,
500
+ updatedAt: new Date().toISOString(),
501
+ };
502
+ if (next.secrets.length === 0) {
503
+ deleteSiteManifest(site);
504
+ return null;
505
+ }
506
+ writeSiteManifest(next);
507
+ return next;
508
+ }
509
+
510
+ export function setManifestStorageKeys(
511
+ site: string,
512
+ storage: Array<{ origin: string; kind: StorageRecord['kind']; key: string }>,
513
+ ): SiteManifest {
514
+ const existing = readSiteManifest(site);
515
+ const next: SiteManifest = existing
516
+ ? {
517
+ ...existing,
518
+ storage: storage.map((s) => ({ origin: s.origin, kind: s.kind, key: s.key })),
519
+ updatedAt: new Date().toISOString(),
520
+ }
521
+ : {
522
+ site,
523
+ secrets: [],
524
+ storage: storage.map((s) => ({ origin: s.origin, kind: s.kind, key: s.key })),
525
+ updatedAt: new Date().toISOString(),
526
+ };
527
+ writeSiteManifest(next);
528
+ return next;
529
+ }
530
+
531
+ // ─── Backend resolution ────────────────────────────────────────────────────
532
+
533
+ let cachedBackend: CredentialBackend | null = null;
534
+ let cachedBackendOverride: CredentialBackend | null = null;
535
+
536
+ /** For tests: replace the backend used by getCredentialBackend. */
537
+ export function setBackendOverride(backend: CredentialBackend | null): void {
538
+ cachedBackendOverride = backend;
539
+ cachedBackend = null;
540
+ }
541
+
542
+ /** Reset the resolved backend so the next call re-resolves. Tests use this
543
+ * to recover from a swapped-in keyring module. */
544
+ export function resetBackendCache(): void {
545
+ cachedBackend = null;
546
+ }
547
+
548
+ function defaultEncryptedFilePath(): string {
549
+ return pathJoin(PATHS.config, 'secrets.enc');
550
+ }
551
+
552
+ /** Try loading @napi-rs/keyring; return null if it's unavailable in this
553
+ * environment (no Secret Service on Linux without a desktop session,
554
+ * locked keychain on first use, etc.). */
555
+ function tryLoadKeyring(): KeyringBackend | null {
556
+ try {
557
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic require
558
+ const mod = require('@napi-rs/keyring') as any;
559
+ if (!mod?.Entry || !mod?.findCredentials) return null;
560
+ // Smoke-test by listing — this throws if the keyring backend isn't
561
+ // actually usable (Linux Alpine without libsecret, etc.).
562
+ try {
563
+ mod.findCredentials(SERVICE_NAME);
564
+ } catch {
565
+ return null;
566
+ }
567
+ return new KeyringBackend(mod);
568
+ } catch {
569
+ return null;
570
+ }
571
+ }
572
+
573
+ /** Resolves the active credential backend. Cached after first call. Not
574
+ * reentrant-safe but every call site awaits before its next read. */
575
+ export async function getCredentialBackend(opts?: {
576
+ passphraseProvider?: () => Promise<string>;
577
+ forceEncryptedFile?: boolean;
578
+ }): Promise<CredentialBackend> {
579
+ if (cachedBackendOverride) return cachedBackendOverride;
580
+ if (cachedBackend) return cachedBackend;
581
+
582
+ const wantEnc = opts?.forceEncryptedFile === true || process.env.IMPRINT_BACKEND === 'file';
583
+
584
+ if (!wantEnc) {
585
+ const kr = tryLoadKeyring();
586
+ if (kr) {
587
+ cachedBackend = kr;
588
+ return kr;
589
+ }
590
+ }
591
+
592
+ // Fall back to encrypted file. We resolve libsodium lazily because it
593
+ // pulls a chunk of WASM and we want the keyring path to stay zero-cost.
594
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic libsodium ref
595
+ const sodium: any = await import('libsodium-wrappers').then(async (m) => {
596
+ await m.default.ready;
597
+ return m.default;
598
+ });
599
+
600
+ const provider = opts?.passphraseProvider ?? defaultPassphraseProvider();
601
+ cachedBackend = new EncryptedFileBackend({
602
+ filePath: defaultEncryptedFilePath(),
603
+ sodium,
604
+ passphraseProvider: provider,
605
+ });
606
+ return cachedBackend;
607
+ }
608
+
609
+ let cachedPassphrase: string | null = null;
610
+
611
+ function defaultPassphraseProvider(): () => Promise<string> {
612
+ return async () => {
613
+ if (cachedPassphrase !== null) return cachedPassphrase;
614
+ const env = process.env.IMPRINT_PASSPHRASE;
615
+ if (env && env.length > 0) {
616
+ cachedPassphrase = env;
617
+ return env;
618
+ }
619
+ // Lazy-import @clack/prompts so non-interactive callers don't pay for it.
620
+ const p = await import('@clack/prompts');
621
+ const answer = await p.password({
622
+ message: 'Passphrase for the encrypted credential store',
623
+ mask: '*',
624
+ validate: (v) =>
625
+ !v || v.length < 8 ? 'Passphrase must be at least 8 characters.' : undefined,
626
+ });
627
+ if (p.isCancel(answer)) {
628
+ throw new Error(
629
+ 'Passphrase prompt cancelled. Set $IMPRINT_PASSPHRASE to avoid the prompt in non-interactive contexts.',
630
+ );
631
+ }
632
+ cachedPassphrase = answer as string;
633
+ return cachedPassphrase;
634
+ };
635
+ }
636
+
637
+ // ─── Legacy JSON store (read-only fallback) ────────────────────────────────
638
+
639
+ interface LegacyStoreShape {
640
+ site: string;
641
+ cookies: CookieRecord[];
642
+ values: Record<string, string>;
643
+ }
644
+
645
+ export function legacyStorePath(site: string): string {
646
+ return pathJoin(PATHS.config, 'credentials', `${site}.json`);
647
+ }
648
+
649
+ export function readLegacyStore(site: string): LegacyStoreShape | null {
650
+ const p = legacyStorePath(site);
651
+ if (!existsSync(p)) return null;
652
+ try {
653
+ return JSON.parse(readFileSync(p, 'utf8')) as LegacyStoreShape;
654
+ } catch {
655
+ return null;
656
+ }
657
+ }
658
+
659
+ export function listLegacyStoreSites(): string[] {
660
+ const dir = pathJoin(PATHS.config, 'credentials');
661
+ if (!existsSync(dir)) return [];
662
+ return readdirSync(dir)
663
+ .filter((f) => f.endsWith('.json') && !f.endsWith('.migrated'))
664
+ .map((f) => f.slice(0, -'.json'.length))
665
+ .sort();
666
+ }
667
+
668
+ export function markLegacyStoreMigrated(site: string): void {
669
+ const src = legacyStorePath(site);
670
+ if (!existsSync(src)) return;
671
+ const dest = `${src}.migrated`;
672
+ writeFileSync(dest, readFileSync(src, 'utf8'), 'utf8');
673
+ unlinkSync(src);
674
+ }
675
+
676
+ // ─── High-level convenience API ────────────────────────────────────────────
677
+
678
+ /** A site's full credential view: secrets values + cookies. Used by runtime. */
679
+ interface SiteCredentialView {
680
+ site: string;
681
+ cookies: CookieRecord[];
682
+ values: Record<string, string>;
683
+ storage: StorageRecord[];
684
+ }
685
+
686
+ /** Loads everything we know about a site, falling back through the backends.
687
+ * Used at request time by runtime.executeWorkflow. */
688
+ export async function loadSiteCredentials(site: string): Promise<SiteCredentialView> {
689
+ const backend = await getCredentialBackend();
690
+ const names = await backend.listSecrets(site);
691
+ const values: Record<string, string> = {};
692
+ for (const n of names) {
693
+ const v = await backend.getSecret(site, n);
694
+ if (v !== null) values[n] = v;
695
+ }
696
+ const cookies = await backend.getCookies(site);
697
+ const storage = (await backend.getStorage?.(site)) ?? [];
698
+
699
+ // Fall through to legacy if the backend has nothing.
700
+ if (Object.keys(values).length === 0 && cookies.length === 0 && storage.length === 0) {
701
+ const legacy = readLegacyStore(site);
702
+ if (legacy) {
703
+ return { site, cookies: legacy.cookies, values: legacy.values, storage: [] };
704
+ }
705
+ }
706
+ return { site, cookies, values, storage };
707
+ }