@jobshimo/browser-link 0.20.0 → 0.22.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 (45) hide show
  1. package/README.md +74 -70
  2. package/dist/bridge/events.d.ts +1 -1
  3. package/dist/bridge/events.js +6 -1
  4. package/dist/bridge/events.js.map +1 -1
  5. package/dist/bridge/ipc-client.d.ts +6 -7
  6. package/dist/bridge/ipc-client.js +4 -3
  7. package/dist/bridge/ipc-client.js.map +1 -1
  8. package/dist/bridge/protocol.d.ts +28 -16
  9. package/dist/bridge/protocol.js +23 -7
  10. package/dist/bridge/protocol.js.map +1 -1
  11. package/dist/bridge/server.d.ts +8 -10
  12. package/dist/bridge/server.js.map +1 -1
  13. package/dist/bridge/ws-bridge.d.ts +53 -18
  14. package/dist/bridge/ws-bridge.js +129 -21
  15. package/dist/bridge/ws-bridge.js.map +1 -1
  16. package/dist/cli.js +62 -2
  17. package/dist/cli.js.map +1 -1
  18. package/dist/commands/config.d.ts +5 -0
  19. package/dist/commands/config.js +62 -8
  20. package/dist/commands/config.js.map +1 -1
  21. package/dist/commands/map.d.ts +2 -0
  22. package/dist/commands/map.js +698 -0
  23. package/dist/commands/map.js.map +1 -0
  24. package/dist/config.d.ts +19 -0
  25. package/dist/config.js.map +1 -1
  26. package/dist/extension/background.js +424 -1
  27. package/dist/extension/background.js.map +1 -1
  28. package/dist/extension/flow-recording-policy.d.ts +47 -0
  29. package/dist/extension/flow-recording-policy.js +54 -0
  30. package/dist/extension/flow-recording-policy.js.map +1 -0
  31. package/dist/extension/inpage/recorder.d.ts +35 -0
  32. package/dist/extension/inpage/recorder.js +333 -0
  33. package/dist/extension/inpage/recorder.js.map +1 -0
  34. package/dist/extension/manifest.json +1 -1
  35. package/dist/extension/popup.html +56 -0
  36. package/dist/extension/popup.js +195 -4
  37. package/dist/extension/popup.js.map +1 -1
  38. package/dist/extension/recording.d.ts +158 -0
  39. package/dist/extension/recording.js +214 -0
  40. package/dist/extension/recording.js.map +1 -0
  41. package/dist/map/queries.d.ts +107 -0
  42. package/dist/map/queries.js +174 -0
  43. package/dist/map/queries.js.map +1 -1
  44. package/dist/messages.d.ts +53 -12
  45. package/package.json +1 -1
@@ -0,0 +1,698 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { getDb } from '../map/db.js';
3
+ import { deleteFlow, findApp, findAppByOriginAndKey, findAppCandidates, forget, listApps, listAppsWithCounts, listEntries, listFlows, restoreEntry, restoreFlow, upsertApp, } from '../map/queries.js';
4
+ import { validateFlowSteps } from '../tools/browser-dispatch.js';
5
+ /** The flags that are pure switches and NEVER take a value. Without this
6
+ * set, `map forget --yes my-app` would swallow "my-app" as the value of
7
+ * `--yes` and then fail with a usage error — flag position must not
8
+ * matter for a boolean switch. */
9
+ const BOOLEAN_FLAGS = new Set(['yes', 'replace']);
10
+ /** Minimal `--flag value` / `--flag` parser — good enough for this
11
+ * command family's small, fixed flag set (`--yes`, `--flow`, `--out`,
12
+ * `--replace`). Flags in `BOOLEAN_FLAGS` are always switches; any other
13
+ * flag consumes the next token as its value unless that token is itself
14
+ * another `--flag` or there is no next token. */
15
+ function parseArgs(argv) {
16
+ const positionals = [];
17
+ const flags = {};
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const token = argv[i];
20
+ if (token.startsWith('--')) {
21
+ const key = token.slice(2);
22
+ if (BOOLEAN_FLAGS.has(key)) {
23
+ flags[key] = true;
24
+ continue;
25
+ }
26
+ const hasValue = i + 1 < argv.length && !argv[i + 1].startsWith('--');
27
+ if (hasValue) {
28
+ flags[key] = argv[i + 1];
29
+ i++;
30
+ }
31
+ else {
32
+ flags[key] = true;
33
+ }
34
+ continue;
35
+ }
36
+ positionals.push(token);
37
+ }
38
+ return { positionals, flags };
39
+ }
40
+ /** Simple aligned table: column widths from the longest cell (header
41
+ * included), two spaces between columns, trailing whitespace trimmed. */
42
+ function renderTable(header, rows) {
43
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
44
+ const renderRow = (cells) => cells
45
+ .map((c, i) => c.padEnd(widths[i] ?? 0))
46
+ .join(' ')
47
+ .trimEnd();
48
+ return [renderRow(header), ...rows.map(renderRow)].join('\n');
49
+ }
50
+ const ES_UNIT_PLURAL = {
51
+ segundo: 'segundos',
52
+ minuto: 'minutos',
53
+ hora: 'horas',
54
+ día: 'días',
55
+ mes: 'meses',
56
+ año: 'años',
57
+ };
58
+ /** Human-relative time ("3 minutes ago" / "hace 3 minutos") for
59
+ * `last_seen_at`. Falls back to the raw ISO string for an unparsable
60
+ * date rather than throwing — a display helper must never crash `map ls`
61
+ * over one malformed timestamp. */
62
+ function formatRelative(iso, language) {
63
+ const then = new Date(iso).getTime();
64
+ if (!Number.isFinite(then))
65
+ return iso;
66
+ const diffSec = Math.max(0, Math.round((Date.now() - then) / 1000));
67
+ if (diffSec < 10)
68
+ return language === 'es' ? 'justo ahora' : 'just now';
69
+ const steps = [
70
+ { limitSec: 60, unitSec: 1, en: 'second', es: 'segundo' },
71
+ { limitSec: 3_600, unitSec: 60, en: 'minute', es: 'minuto' },
72
+ { limitSec: 86_400, unitSec: 3_600, en: 'hour', es: 'hora' },
73
+ { limitSec: 2_592_000, unitSec: 86_400, en: 'day', es: 'día' },
74
+ { limitSec: 31_536_000, unitSec: 2_592_000, en: 'month', es: 'mes' },
75
+ { limitSec: Number.POSITIVE_INFINITY, unitSec: 31_536_000, en: 'year', es: 'año' },
76
+ ];
77
+ const step = steps.find((s) => diffSec < s.limitSec) ?? steps[steps.length - 1];
78
+ const n = Math.max(1, Math.floor(diffSec / step.unitSec));
79
+ if (language === 'es') {
80
+ const unit = n === 1 ? step.es : (ES_UNIT_PLURAL[step.es] ?? `${step.es}s`);
81
+ return `hace ${n} ${unit}`;
82
+ }
83
+ const unit = n === 1 ? step.en : `${step.en}s`;
84
+ return `${n} ${unit} ago`;
85
+ }
86
+ /** Compact one-line rendering of an entry payload for `map show` — the
87
+ * selector for a `selector` entry, the free-form body/steps otherwise,
88
+ * truncated so one wide payload cannot blow out the terminal. Entry
89
+ * payloads always come from `JSON.parse` (see `hydrate` in queries.ts),
90
+ * so `JSON.stringify` here can never hit the (function/symbol/bare
91
+ * `undefined`) inputs where it would return `undefined` instead of a
92
+ * string. */
93
+ function payloadSnippet(payload, max = 90) {
94
+ const raw = typeof payload === 'string' ? payload : JSON.stringify(payload);
95
+ return raw.length > max ? `${raw.slice(0, max - 1)}…` : raw;
96
+ }
97
+ const MAP_I18N = {
98
+ en: {
99
+ lsEmpty: 'No apps known yet. The map fills in as an agent calls browser.map.save while working on a page.',
100
+ colAppKey: 'APP_KEY',
101
+ colOrigin: 'ORIGIN',
102
+ colEntries: 'ENTRIES',
103
+ colFlows: 'FLOWS',
104
+ colLastSeen: 'LAST_SEEN',
105
+ showUsage: 'Usage: browser-link map show <app>',
106
+ showHeader: (appKey, origin) => `${appKey} (${origin})`,
107
+ titleLabel: 'title:',
108
+ lastSeenLabel: 'last seen:',
109
+ entriesHeader: (n) => `Entries (${n}):`,
110
+ flowsHeader: (n) => `Flows (${n}):`,
111
+ none: '(none)',
112
+ statusVerified: 'verified',
113
+ statusFailed: 'FAILED',
114
+ statusUnverified: 'unverified',
115
+ appNotFound: (identifier, known) => known.length === 0
116
+ ? `No app matches "${identifier}" — the map is empty.`
117
+ : `No app matches "${identifier}" by app_key or origin. Known app_keys: ${known.join(', ')}`,
118
+ forgetUsage: 'Usage: browser-link map forget <app> [--flow <name>] [--yes]',
119
+ flowNotFound: (flowName, appKey, origin) => `No flow named "${flowName}" on app "${appKey}" (${origin}).`,
120
+ flowForgotten: (flowName, appKey, origin) => `Deleted flow "${flowName}" from "${appKey}" (${origin}).`,
121
+ flowForgetAmbiguous: (flowName, appKey, origin, identifier, candidateCount) => `"${identifier}" matches ${candidateCount} apps (same app_key on different origins).\n` +
122
+ `This would delete flow "${flowName}" from "${appKey}" (${origin}) — the most recently seen match.\n` +
123
+ `Nothing was deleted. If that is the right app, re-run with --yes to confirm:\n` +
124
+ ` browser-link map forget ${identifier} --flow "${flowName}" --yes\n` +
125
+ `Otherwise, pass the app's origin instead of its app_key to disambiguate.`,
126
+ forgetDryRun: (appKey, origin, entries, flows) => `This would delete app "${appKey}" (${origin}) — ${entries} entr${entries === 1 ? 'y' : 'ies'} and ${flows} flow${flows === 1 ? '' : 's'}.\n` +
127
+ `Nothing was deleted. Re-run with --yes to confirm:\n` +
128
+ ` browser-link map forget ${appKey} --yes`,
129
+ appForgotten: (appKey, deletedEntries) => `Deleted app "${appKey}" and its ${deletedEntries} entr${deletedEntries === 1 ? 'y' : 'ies'} (flows deleted along with it).`,
130
+ exportWritten: (path, appCount) => `Wrote ${appCount} app${appCount === 1 ? '' : 's'} to ${path}.`,
131
+ exportWriteFailed: (path, detail) => `Could not write "${path}": ${detail}`,
132
+ exportPrivacyNote: 'This file may contain UI structure and flow steps you saved — review it before sharing. The map only ever stores placeholders instead of real data, but it is only as clean as what was saved.',
133
+ importUsage: 'Usage: browser-link map import <file.json> [--replace]',
134
+ importFileNotFound: (path) => `Could not read "${path}".`,
135
+ importInvalidJson: 'That file is not valid JSON.',
136
+ importNotAnExport: 'That file is not a browser-link map export (missing browser_link_map_export / apps).',
137
+ importUnsupportedVersion: (found, supported) => `Unsupported export version ${found} (this binary supports up to ${supported}). Update browser-link and retry.`,
138
+ importMalformedApp: 'Malformed app entry in the export file (missing origin or app_key).',
139
+ importFlowError: (appKey, flowName, error) => `${appKey} / flow "${flowName}": ${error}`,
140
+ importEntryError: (appKey, index, error) => `${appKey} / entry #${index}: ${error}`,
141
+ importCapExceeded: (what, found, max) => `too many ${what} in one file: ${found} (max ${max})`,
142
+ importAborted: (errors) => `Import aborted — ${errors.length} invalid item${errors.length === 1 ? '' : 's'}, nothing was written:\n` +
143
+ errors.map((e) => ` - ${e}`).join('\n'),
144
+ importSummary: (s) => `Imported ${s.appsImported} app${s.appsImported === 1 ? '' : 's'}` +
145
+ (s.appsReplaced > 0 ? ` (${s.appsReplaced} replaced)` : '') +
146
+ `, ${s.entriesImported} entr${s.entriesImported === 1 ? 'y' : 'ies'}` +
147
+ (s.entriesSkipped > 0 ? ` (${s.entriesSkipped} duplicate skipped)` : '') +
148
+ `, ${s.flowsImported} flow${s.flowsImported === 1 ? '' : 's'}.`,
149
+ unknownAction: (action) => `Unknown map action: ${action}. Use (no arg for list) | show | forget | export | import.`,
150
+ },
151
+ es: {
152
+ lsEmpty: 'Todavía no hay apps registradas. El mapa se completa cuando un agente llama a browser.map.save mientras trabaja en una página.',
153
+ colAppKey: 'APP_KEY',
154
+ colOrigin: 'ORIGIN',
155
+ colEntries: 'ENTRADAS',
156
+ colFlows: 'FLOWS',
157
+ colLastSeen: 'ÚLTIMA_VEZ',
158
+ showUsage: 'Uso: browser-link map show <app>',
159
+ showHeader: (appKey, origin) => `${appKey} (${origin})`,
160
+ titleLabel: 'título:',
161
+ lastSeenLabel: 'última vez:',
162
+ entriesHeader: (n) => `Entradas (${n}):`,
163
+ flowsHeader: (n) => `Flows (${n}):`,
164
+ none: '(ninguno)',
165
+ statusVerified: 'verificado',
166
+ statusFailed: 'FALLÓ',
167
+ statusUnverified: 'sin verificar',
168
+ appNotFound: (identifier, known) => known.length === 0
169
+ ? `Ninguna app coincide con "${identifier}" — el mapa está vacío.`
170
+ : `Ninguna app coincide con "${identifier}" por app_key u origen. App_keys conocidas: ${known.join(', ')}`,
171
+ forgetUsage: 'Uso: browser-link map forget <app> [--flow <nombre>] [--yes]',
172
+ flowNotFound: (flowName, appKey, origin) => `No hay un flow llamado "${flowName}" en la app "${appKey}" (${origin}).`,
173
+ flowForgotten: (flowName, appKey, origin) => `Se eliminó el flow "${flowName}" de "${appKey}" (${origin}).`,
174
+ flowForgetAmbiguous: (flowName, appKey, origin, identifier, candidateCount) => `"${identifier}" coincide con ${candidateCount} apps (mismo app_key en distintos orígenes).\n` +
175
+ `Esto eliminaría el flow "${flowName}" de "${appKey}" (${origin}) — la coincidencia vista más recientemente.\n` +
176
+ `No se eliminó nada. Si esa es la app correcta, volvé a correr con --yes para confirmar:\n` +
177
+ ` browser-link map forget ${identifier} --flow "${flowName}" --yes\n` +
178
+ `Si no, pasá el origin de la app en lugar de su app_key para desambiguar.`,
179
+ forgetDryRun: (appKey, origin, entries, flows) => `Esto eliminaría la app "${appKey}" (${origin}) — ${entries} entrada${entries === 1 ? '' : 's'} y ${flows} flow${flows === 1 ? '' : 's'}.\n` +
180
+ `No se eliminó nada. Volvé a correr con --yes para confirmar:\n` +
181
+ ` browser-link map forget ${appKey} --yes`,
182
+ appForgotten: (appKey, deletedEntries) => `Se eliminó la app "${appKey}" y sus ${deletedEntries} entrada${deletedEntries === 1 ? '' : 's'} (los flows se eliminaron junto con ella).`,
183
+ exportWritten: (path, appCount) => `Se escribieron ${appCount} app${appCount === 1 ? '' : 's'} en ${path}.`,
184
+ exportWriteFailed: (path, detail) => `No se pudo escribir "${path}": ${detail}`,
185
+ exportPrivacyNote: 'Este archivo puede contener estructura de UI y pasos de flows que se guardaron — revisalo antes de compartirlo. El mapa solo guarda placeholders en vez de datos reales, pero queda tan limpio como lo que se haya guardado.',
186
+ importUsage: 'Uso: browser-link map import <archivo.json> [--replace]',
187
+ importFileNotFound: (path) => `No se pudo leer "${path}".`,
188
+ importInvalidJson: 'Ese archivo no es JSON válido.',
189
+ importNotAnExport: 'Ese archivo no es una exportación de browser-link map (falta browser_link_map_export / apps).',
190
+ importUnsupportedVersion: (found, supported) => `Versión de exportación no soportada: ${found} (este binario soporta hasta ${supported}). Actualizá browser-link y reintentá.`,
191
+ importMalformedApp: 'Entrada de app inválida en el archivo de exportación (falta origin o app_key).',
192
+ importFlowError: (appKey, flowName, error) => `${appKey} / flow "${flowName}": ${error}`,
193
+ importEntryError: (appKey, index, error) => `${appKey} / entrada #${index}: ${error}`,
194
+ importCapExceeded: (what, found, max) => `demasiados ${what} en un solo archivo: ${found} (máximo ${max})`,
195
+ importAborted: (errors) => `Importación abortada — ${errors.length} elemento${errors.length === 1 ? '' : 's'} inválido${errors.length === 1 ? '' : 's'}, no se escribió nada:\n` +
196
+ errors.map((e) => ` - ${e}`).join('\n'),
197
+ importSummary: (s) => `Se importaron ${s.appsImported} app${s.appsImported === 1 ? '' : 's'}` +
198
+ (s.appsReplaced > 0
199
+ ? ` (${s.appsReplaced} reemplazada${s.appsReplaced === 1 ? '' : 's'})`
200
+ : '') +
201
+ `, ${s.entriesImported} entrada${s.entriesImported === 1 ? '' : 's'}` +
202
+ (s.entriesSkipped > 0
203
+ ? ` (${s.entriesSkipped} duplicada${s.entriesSkipped === 1 ? '' : 's'} omitida${s.entriesSkipped === 1 ? '' : 's'})`
204
+ : '') +
205
+ `, ${s.flowsImported} flow${s.flowsImported === 1 ? '' : 's'}.`,
206
+ unknownAction: (action) => `Acción de map desconocida: ${action}. Usá (sin argumento para listar) | show | forget | export | import.`,
207
+ },
208
+ };
209
+ function resolveAppOrThrow(identifier, t) {
210
+ const app = findApp(identifier);
211
+ if (app)
212
+ return app;
213
+ throw new Error(t.appNotFound(identifier, listApps().map((a) => a.app_key)));
214
+ }
215
+ // === map ls ==================================================================
216
+ function runLs(language) {
217
+ const t = MAP_I18N[language];
218
+ const apps = listAppsWithCounts();
219
+ if (apps.length === 0)
220
+ return t.lsEmpty;
221
+ const header = [t.colAppKey, t.colOrigin, t.colEntries, t.colFlows, t.colLastSeen];
222
+ const rows = apps.map((a) => [
223
+ a.app_key,
224
+ a.origin,
225
+ String(a.entries),
226
+ String(a.flows),
227
+ formatRelative(a.last_seen_at, language),
228
+ ]);
229
+ return renderTable(header, rows);
230
+ }
231
+ // === map show ================================================================
232
+ function runShow(argv, language) {
233
+ const t = MAP_I18N[language];
234
+ const identifier = argv[0];
235
+ if (!identifier)
236
+ throw new Error(t.showUsage);
237
+ const app = resolveAppOrThrow(identifier, t);
238
+ const entries = listEntries(app.id);
239
+ const flows = listFlows(app.id);
240
+ const lines = [];
241
+ lines.push(t.showHeader(app.app_key, app.origin));
242
+ if (app.title)
243
+ lines.push(` ${t.titleLabel} ${app.title}`);
244
+ lines.push(` ${t.lastSeenLabel} ${formatRelative(app.last_seen_at, language)}`);
245
+ lines.push('');
246
+ lines.push(t.entriesHeader(entries.length));
247
+ if (entries.length === 0) {
248
+ lines.push(` ${t.none}`);
249
+ }
250
+ else {
251
+ for (const e of entries) {
252
+ const failedIsNewer = e.failed_at !== null && (e.verified_at === null || e.failed_at > e.verified_at);
253
+ const status = failedIsNewer
254
+ ? t.statusFailed
255
+ : e.verified_at
256
+ ? t.statusVerified
257
+ : t.statusUnverified;
258
+ lines.push(` [${e.kind}] ${e.url_pattern} — ${e.purpose} (${status})`);
259
+ lines.push(` ${payloadSnippet(e.payload)}`);
260
+ }
261
+ }
262
+ lines.push('');
263
+ lines.push(t.flowsHeader(flows.length));
264
+ if (flows.length === 0) {
265
+ lines.push(` ${t.none}`);
266
+ }
267
+ else {
268
+ for (const f of flows) {
269
+ const stepCount = Array.isArray(f.steps) ? f.steps.length : 0;
270
+ const desc = f.description ? ` — ${f.description}` : '';
271
+ lines.push(` ${f.name} (${stepCount} steps, used ${f.use_count}×)${desc}`);
272
+ }
273
+ }
274
+ return lines.join('\n');
275
+ }
276
+ // === map forget ==============================================================
277
+ function runForget(argv, language) {
278
+ const t = MAP_I18N[language];
279
+ const { positionals, flags } = parseArgs(argv);
280
+ const identifier = positionals[0];
281
+ if (!identifier)
282
+ throw new Error(t.forgetUsage);
283
+ // Destructive command — resolve through findAppCandidates, not findApp,
284
+ // so an ambiguous identifier (same app_key saved on two origins) is
285
+ // DETECTED instead of silently resolving to the most-recently-seen app
286
+ // and deleting the wrong origin's data.
287
+ const candidates = findAppCandidates(identifier);
288
+ if (candidates.length === 0) {
289
+ throw new Error(t.appNotFound(identifier, listApps().map((a) => a.app_key)));
290
+ }
291
+ const app = candidates[0];
292
+ const flowName = typeof flags.flow === 'string' ? flags.flow : undefined;
293
+ if (flowName !== undefined) {
294
+ // Check existence BEFORE the ambiguity gate so a typo'd flow name gets
295
+ // its clear not-found error (naming the resolved origin) either way.
296
+ const exists = listFlows(app.id).some((f) => f.name === flowName);
297
+ if (!exists)
298
+ throw new Error(t.flowNotFound(flowName, app.app_key, app.origin));
299
+ if (candidates.length > 1 && flags.yes !== true) {
300
+ return t.flowForgetAmbiguous(flowName, app.app_key, app.origin, identifier, candidates.length);
301
+ }
302
+ deleteFlow(app.id, flowName);
303
+ return t.flowForgotten(flowName, app.app_key, app.origin);
304
+ }
305
+ if (flags.yes !== true) {
306
+ const summary = listAppsWithCounts().find((a) => a.id === app.id);
307
+ return t.forgetDryRun(app.app_key, app.origin, summary?.entries ?? 0, summary?.flows ?? 0);
308
+ }
309
+ const result = forget({ app_id: app.id });
310
+ return t.appForgotten(app.app_key, result.deleted_entries);
311
+ }
312
+ const MAP_EXPORT_VERSION = 1;
313
+ function buildExport(appFilter, t) {
314
+ const apps = appFilter ? [resolveAppOrThrow(appFilter, t)] : listApps();
315
+ return {
316
+ browser_link_map_export: MAP_EXPORT_VERSION,
317
+ exported_at: new Date().toISOString(),
318
+ apps: apps.map((app) => ({
319
+ origin: app.origin,
320
+ app_key: app.app_key,
321
+ title: app.title,
322
+ notes: app.notes,
323
+ entries: listEntries(app.id).map((e) => ({
324
+ url_pattern: e.url_pattern,
325
+ kind: e.kind,
326
+ purpose: e.purpose,
327
+ payload: e.payload,
328
+ notes: e.notes,
329
+ verified_at: e.verified_at,
330
+ failed_at: e.failed_at,
331
+ })),
332
+ flows: listFlows(app.id).map((f) => ({
333
+ name: f.name,
334
+ description: f.description,
335
+ steps: f.steps,
336
+ use_count: f.use_count,
337
+ })),
338
+ })),
339
+ };
340
+ }
341
+ function runExport(argv, language) {
342
+ const t = MAP_I18N[language];
343
+ const { positionals, flags } = parseArgs(argv);
344
+ const file = buildExport(positionals[0], t);
345
+ const json = JSON.stringify(file, null, 2);
346
+ const outPath = typeof flags.out === 'string' ? flags.out : undefined;
347
+ if (!outPath)
348
+ return json;
349
+ try {
350
+ writeFileSync(outPath, `${json}\n`, 'utf8');
351
+ }
352
+ catch (err) {
353
+ throw new Error(t.exportWriteFailed(outPath, err instanceof Error ? err.message : String(err)), { cause: err });
354
+ }
355
+ return `${t.exportWritten(outPath, file.apps.length)}\n${t.exportPrivacyNote}`;
356
+ }
357
+ function isRecord(v) {
358
+ return typeof v === 'object' && v !== null;
359
+ }
360
+ /** Sanity caps on what a single import file may contain. These exist so a
361
+ * corrupted or hostile file fails with a clean, aggregated abort message
362
+ * instead of grinding through millions of SQLite writes (or one giant
363
+ * string) inside the import transaction. Generous compared to any real
364
+ * map: 500 apps / 5000 entries / 1000 flows per FILE, and 1 MiB per
365
+ * serialized payload or steps array. */
366
+ const MAX_IMPORT_APPS = 500;
367
+ const MAX_IMPORT_ENTRIES = 5_000;
368
+ const MAX_IMPORT_FLOWS = 1_000;
369
+ const MAX_IMPORT_ITEM_BYTES = 1_048_576; // 1 MiB
370
+ const ENTRY_KINDS = new Set(['selector', 'flow', 'gotcha']);
371
+ /** Parse + container-shape checks. Throws (single error, no aggregation)
372
+ * only for problems where a per-item report makes no sense: not JSON, not
373
+ * an export file at all, a newer export version than this binary knows
374
+ * how to read, or an app object missing its identity. */
375
+ function parseExportFile(raw, t) {
376
+ let data;
377
+ try {
378
+ data = JSON.parse(raw);
379
+ }
380
+ catch {
381
+ throw new Error(t.importInvalidJson);
382
+ }
383
+ if (!isRecord(data) || !('browser_link_map_export' in data) || !Array.isArray(data.apps)) {
384
+ throw new Error(t.importNotAnExport);
385
+ }
386
+ const version = data.browser_link_map_export;
387
+ if (typeof version !== 'number')
388
+ throw new Error(t.importNotAnExport);
389
+ if (version > MAP_EXPORT_VERSION) {
390
+ throw new Error(t.importUnsupportedVersion(version, MAP_EXPORT_VERSION));
391
+ }
392
+ return data.apps.map((app) => {
393
+ if (!isRecord(app) || typeof app.origin !== 'string' || typeof app.app_key !== 'string') {
394
+ throw new Error(t.importMalformedApp);
395
+ }
396
+ return {
397
+ origin: app.origin,
398
+ app_key: app.app_key,
399
+ title: typeof app.title === 'string' ? app.title : null,
400
+ notes: typeof app.notes === 'string' ? app.notes : null,
401
+ entries: Array.isArray(app.entries) ? app.entries : [],
402
+ flows: Array.isArray(app.flows) ? app.flows : [],
403
+ };
404
+ });
405
+ }
406
+ /** `undefined`, `null` or a string — the shape every nullable text column
407
+ * accepts on restore. */
408
+ function isNullableString(v) {
409
+ return v === undefined || v === null || typeof v === 'string';
410
+ }
411
+ /**
412
+ * Item-level validation for an import — EVERY problem across EVERY app is
413
+ * collected into one aggregated error list, so the user fixes the file
414
+ * once instead of replaying one crash per problem. Nothing may reach the
415
+ * write phase unvalidated: every field a DB constraint would reject
416
+ * (NOT NULL name/url_pattern/purpose/payload, the kind CHECK) is caught
417
+ * here with a readable per-item message instead of a raw SqliteError —
418
+ * the transaction in `writeImport` would roll back either way, but the
419
+ * promised clean abort report must come from THIS pass. Flow `steps` are
420
+ * validated with the exact `validateFlowSteps` rules `browser.flow`
421
+ * enforces, and flow `name` with the same non-empty rule
422
+ * `browser.map.save` applies (see map/tools.ts). Detailed rule text stays
423
+ * in English on purpose, matching how `validateFlowSteps` errors already
424
+ * pass through untranslated; the surrounding report is localized.
425
+ *
426
+ * Returns the typed apps (only meaningful when `errors` is empty).
427
+ */
428
+ function validateImportFile(rawApps, t) {
429
+ const errors = [];
430
+ const apps = [];
431
+ if (rawApps.length > MAX_IMPORT_APPS) {
432
+ errors.push(t.importCapExceeded('apps', rawApps.length, MAX_IMPORT_APPS));
433
+ }
434
+ let totalEntries = 0;
435
+ let totalFlows = 0;
436
+ for (const rawApp of rawApps) {
437
+ totalEntries += rawApp.entries.length;
438
+ totalFlows += rawApp.flows.length;
439
+ const entries = [];
440
+ rawApp.entries.forEach((item, index) => {
441
+ if (!isRecord(item)) {
442
+ errors.push(t.importEntryError(rawApp.app_key, index, 'must be an object'));
443
+ return;
444
+ }
445
+ const problems = [];
446
+ if (typeof item.url_pattern !== 'string' || item.url_pattern.length === 0) {
447
+ problems.push('url_pattern must be a non-empty string');
448
+ }
449
+ if (typeof item.kind !== 'string' || !ENTRY_KINDS.has(item.kind)) {
450
+ problems.push('kind must be one of selector | flow | gotcha');
451
+ }
452
+ if (typeof item.purpose !== 'string' || item.purpose.length === 0) {
453
+ problems.push('purpose must be a non-empty string');
454
+ }
455
+ if (item.payload === undefined) {
456
+ problems.push('payload is required');
457
+ }
458
+ else if (JSON.stringify(item.payload).length > MAX_IMPORT_ITEM_BYTES) {
459
+ problems.push(`payload exceeds ${MAX_IMPORT_ITEM_BYTES} bytes when serialized`);
460
+ }
461
+ if (!isNullableString(item.notes))
462
+ problems.push('notes must be a string or null');
463
+ if (!isNullableString(item.verified_at))
464
+ problems.push('verified_at must be a string or null');
465
+ if (!isNullableString(item.failed_at))
466
+ problems.push('failed_at must be a string or null');
467
+ if (problems.length > 0) {
468
+ for (const p of problems)
469
+ errors.push(t.importEntryError(rawApp.app_key, index, p));
470
+ return;
471
+ }
472
+ entries.push({
473
+ url_pattern: item.url_pattern,
474
+ kind: item.kind,
475
+ purpose: item.purpose,
476
+ payload: item.payload,
477
+ notes: item.notes ?? null,
478
+ verified_at: item.verified_at ?? null,
479
+ failed_at: item.failed_at ?? null,
480
+ });
481
+ });
482
+ const flows = [];
483
+ rawApp.flows.forEach((item, index) => {
484
+ if (!isRecord(item)) {
485
+ errors.push(t.importFlowError(rawApp.app_key, `#${index}`, 'must be an object'));
486
+ return;
487
+ }
488
+ const name = typeof item.name === 'string' && item.name.trim().length > 0 ? item.name : null;
489
+ const label = name ?? `#${index}`;
490
+ const problems = [];
491
+ if (name === null)
492
+ problems.push('name must be a non-empty string');
493
+ if (!isNullableString(item.description))
494
+ problems.push('description must be a string or null');
495
+ const validated = validateFlowSteps(item.steps);
496
+ if (!validated.ok) {
497
+ problems.push(validated.error);
498
+ }
499
+ else if (JSON.stringify(validated.steps).length > MAX_IMPORT_ITEM_BYTES) {
500
+ problems.push(`steps exceed ${MAX_IMPORT_ITEM_BYTES} bytes when serialized`);
501
+ }
502
+ if (problems.length > 0 || name === null || !validated.ok) {
503
+ for (const p of problems)
504
+ errors.push(t.importFlowError(rawApp.app_key, label, p));
505
+ return;
506
+ }
507
+ flows.push({
508
+ name,
509
+ description: item.description ?? null,
510
+ steps: validated.steps,
511
+ use_count: typeof item.use_count === 'number' &&
512
+ Number.isInteger(item.use_count) &&
513
+ item.use_count >= 0
514
+ ? item.use_count
515
+ : 0,
516
+ });
517
+ });
518
+ apps.push({
519
+ origin: rawApp.origin,
520
+ app_key: rawApp.app_key,
521
+ title: rawApp.title,
522
+ notes: rawApp.notes,
523
+ entries,
524
+ flows,
525
+ });
526
+ }
527
+ if (totalEntries > MAX_IMPORT_ENTRIES) {
528
+ errors.push(t.importCapExceeded('entries', totalEntries, MAX_IMPORT_ENTRIES));
529
+ }
530
+ if (totalFlows > MAX_IMPORT_FLOWS) {
531
+ errors.push(t.importCapExceeded('flows', totalFlows, MAX_IMPORT_FLOWS));
532
+ }
533
+ return { errors, apps };
534
+ }
535
+ /** Structural equality good enough for JSON-shaped values (objects,
536
+ * arrays, primitives) — used to detect an "exact duplicate" entry on
537
+ * merge import, where key order between the stored payload and the
538
+ * freshly parsed import payload is not guaranteed to match, so a plain
539
+ * `JSON.stringify` comparison would false-negative. */
540
+ function deepEqual(a, b) {
541
+ if (a === b)
542
+ return true;
543
+ if (typeof a !== typeof b || a === null || b === null)
544
+ return false;
545
+ if (typeof a !== 'object')
546
+ return false;
547
+ if (Array.isArray(a) !== Array.isArray(b))
548
+ return false;
549
+ if (Array.isArray(a) && Array.isArray(b)) {
550
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
551
+ }
552
+ const aRec = a;
553
+ const bRec = b;
554
+ const aKeys = Object.keys(aRec);
555
+ const bKeys = Object.keys(bRec);
556
+ return aKeys.length === bKeys.length && aKeys.every((k) => deepEqual(aRec[k], bRec[k]));
557
+ }
558
+ /**
559
+ * Write an already-validated import to the DB inside one transaction —
560
+ * either everything lands or (on an unexpected DB error) nothing does.
561
+ * All item-level validation happens BEFORE this is called (see
562
+ * `validateImportFile`), so this function only ever writes flows already
563
+ * proven to pass `validateFlowSteps` and entries whose DB-constrained
564
+ * fields are known-good.
565
+ *
566
+ * Entries and flows are written through the restore-only
567
+ * `restoreEntry`/`restoreFlow` helpers, NOT the agent-facing
568
+ * `saveEntry`/`saveFlow`: a restore must preserve each entry's
569
+ * `verified_at`/`failed_at` and each flow's `use_count` instead of
570
+ * re-stamping "freshly verified, never failed, never used" — a backup
571
+ * restore that silently healed known-broken selectors would lie to the
572
+ * next agent session.
573
+ *
574
+ * Merge semantics (default, `replace = false`):
575
+ * - apps: upserted via `upsertApp` (same (origin, app_key) identity the
576
+ * DB's UNIQUE constraint uses).
577
+ * - flows: upserted by (app, name); an existing flow's `use_count` is
578
+ * never lowered (see `restoreFlow`).
579
+ * - entries: appended, EXCEPT an entry whose (url_pattern, kind,
580
+ * purpose) already exists with a structurally-equal payload — that
581
+ * one is skipped so a re-import of the same export is a no-op. An
582
+ * entry that shares the key but NOT the payload overwrites, carrying
583
+ * the imported verified_at/failed_at with it.
584
+ *
585
+ * Replace semantics (`replace = true`): each imported app's EXISTING
586
+ * data (found by exact (origin, app_key)) is deleted first via
587
+ * `forget()`, then every entry/flow in the file is written fresh — no
588
+ * duplicate check needed since the slate is clean.
589
+ */
590
+ function writeImport(apps, replace) {
591
+ const summary = {
592
+ appsImported: 0,
593
+ appsReplaced: 0,
594
+ entriesImported: 0,
595
+ entriesSkipped: 0,
596
+ flowsImported: 0,
597
+ };
598
+ const runTxn = getDb().transaction((toImport) => {
599
+ for (const appData of toImport) {
600
+ if (replace) {
601
+ const existing = findAppByOriginAndKey(appData.origin, appData.app_key);
602
+ if (existing) {
603
+ forget({ app_id: existing.id });
604
+ summary.appsReplaced++;
605
+ }
606
+ }
607
+ const app = upsertApp({
608
+ origin: appData.origin,
609
+ app_key: appData.app_key,
610
+ title: appData.title,
611
+ notes: appData.notes,
612
+ });
613
+ summary.appsImported++;
614
+ const known = replace ? [] : listEntries(app.id);
615
+ for (const entryData of appData.entries) {
616
+ const idx = known.findIndex((e) => e.url_pattern === entryData.url_pattern &&
617
+ e.kind === entryData.kind &&
618
+ e.purpose === entryData.purpose);
619
+ if (idx !== -1 && deepEqual(known[idx]?.payload, entryData.payload)) {
620
+ summary.entriesSkipped++;
621
+ continue;
622
+ }
623
+ const { entry } = restoreEntry({
624
+ origin: app.origin,
625
+ app_key: app.app_key,
626
+ url_pattern: entryData.url_pattern,
627
+ kind: entryData.kind,
628
+ purpose: entryData.purpose,
629
+ payload: entryData.payload,
630
+ notes: entryData.notes,
631
+ verified_at: entryData.verified_at,
632
+ failed_at: entryData.failed_at,
633
+ });
634
+ if (idx !== -1)
635
+ known[idx] = entry;
636
+ else
637
+ known.push(entry);
638
+ summary.entriesImported++;
639
+ }
640
+ for (const flowData of appData.flows) {
641
+ restoreFlow({
642
+ origin: app.origin,
643
+ app_key: app.app_key,
644
+ name: flowData.name,
645
+ description: flowData.description,
646
+ steps: flowData.steps,
647
+ use_count: flowData.use_count,
648
+ });
649
+ summary.flowsImported++;
650
+ }
651
+ }
652
+ });
653
+ runTxn(apps);
654
+ return summary;
655
+ }
656
+ function runImport(argv, language) {
657
+ const t = MAP_I18N[language];
658
+ const { positionals, flags } = parseArgs(argv);
659
+ const filePath = positionals[0];
660
+ if (!filePath)
661
+ throw new Error(t.importUsage);
662
+ let raw;
663
+ try {
664
+ raw = readFileSync(filePath, 'utf8');
665
+ }
666
+ catch {
667
+ throw new Error(t.importFileNotFound(filePath));
668
+ }
669
+ const rawApps = parseExportFile(raw, t);
670
+ // Validate EVERYTHING, across EVERY app, before writing anything — one
671
+ // aggregated report, never a raw SqliteError, never a partial write.
672
+ const { errors, apps } = validateImportFile(rawApps, t);
673
+ if (errors.length > 0)
674
+ throw new Error(t.importAborted(errors));
675
+ const summary = writeImport(apps, flags.replace === true);
676
+ return t.importSummary(summary);
677
+ }
678
+ // === dispatch ================================================================
679
+ export function runMapCommand(argv, language = 'en') {
680
+ const t = MAP_I18N[language];
681
+ const action = argv[0] ?? 'ls';
682
+ const rest = argv.slice(1);
683
+ switch (action) {
684
+ case 'ls':
685
+ return runLs(language);
686
+ case 'show':
687
+ return runShow(rest, language);
688
+ case 'forget':
689
+ return runForget(rest, language);
690
+ case 'export':
691
+ return runExport(rest, language);
692
+ case 'import':
693
+ return runImport(rest, language);
694
+ default:
695
+ throw new Error(t.unknownAction(action));
696
+ }
697
+ }
698
+ //# sourceMappingURL=map.js.map