@lovinka/vitrinka 1.11.0 → 1.14.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.
package/dist/cli.js CHANGED
@@ -10,8 +10,8 @@
10
10
  // skills/screenshot/gallery.mjs (offline marker, COPYFILE_DISABLE, control-file excludes,
11
11
  // `--key`, repeatable `--chip`). gallery.mjs is now a passthrough shim to this file.
12
12
  // Design: vitrinka/docs/plans/2026-07-03-hand-in-hand-tooling-design.md
13
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, openSync, readSync, closeSync, realpathSync, openAsBlob, } from 'node:fs';
14
- import { join, resolve, basename, dirname, extname, relative, sep } from 'node:path';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, openSync, readSync, closeSync, realpathSync, openAsBlob, lstatSync, constants as fsConstants, } from 'node:fs';
14
+ import { join, resolve, basename, dirname, extname, relative, sep, isAbsolute } from 'node:path';
15
15
  import { tmpdir, hostname } from 'node:os';
16
16
  import { spawnSync, spawn } from 'node:child_process';
17
17
  import { createHash } from 'node:crypto';
@@ -21,6 +21,9 @@ import { readToken, tokenSource, tokenPath, tokenHelp } from "./lib/token.js";
21
21
  import { operatorPath, readOperator, actorHeaderValue } from "./lib/operator.js";
22
22
  import { resolveBaseUrl } from "./lib/base-url.js";
23
23
  const CLI_PATH = fileURLToPath(import.meta.url);
24
+ // Cap on the gzipped tarball push streams to the server. spawnSync kills tar (SIGTERM,
25
+ // empty stderr) if its stdout exceeds this, so pushCmd surfaces the size in its error.
26
+ const TAR_MAX_BUFFER = 512 * 1024 * 1024;
24
27
  export function parseArgs(argv) {
25
28
  const out = {};
26
29
  for (let i = 0; i < argv.length; i++) {
@@ -82,6 +85,55 @@ export function positionals(argv) {
82
85
  }
83
86
  return out;
84
87
  }
88
+ // parseBranches extracts journey branch groups from the RAW argv (the generic
89
+ // parser accumulates repeated flags but loses which --target/--action belongs
90
+ // to which --next). Grammar: each `--next <label>` opens a group; a following
91
+ // `--target <json>` / `--action <text>` belongs to it until the next `--next`.
92
+ // An `--action` BEFORE the first `--next` stays the shot's own linear action.
93
+ export function parseBranches(argv) {
94
+ let branches;
95
+ let linearAction;
96
+ let cur;
97
+ for (let i = 0; i < argv.length; i++) {
98
+ const a = argv[i];
99
+ if (!a.startsWith('--'))
100
+ continue;
101
+ const next = argv[i + 1];
102
+ const val = next === undefined || next.startsWith('--') ? undefined : argv[++i];
103
+ if (a === '--next') {
104
+ if (!val)
105
+ fail('--next needs the target shot\'s label', 2);
106
+ cur = { to: val };
107
+ (branches ??= []).push(cur);
108
+ }
109
+ else if (a === '--target') {
110
+ if (!cur)
111
+ fail('--target must follow a --next (it belongs to that branch)', 2);
112
+ if (!val)
113
+ fail('--target needs a {x,y,w,h} JSON bbox', 2);
114
+ let t;
115
+ try {
116
+ t = JSON.parse(val);
117
+ }
118
+ catch {
119
+ fail(`--target: malformed JSON ${JSON.stringify(val)}`, 2);
120
+ }
121
+ const r = t;
122
+ const nums = [r.x, r.y, r.w, r.h].map(Number);
123
+ if (nums.some((n) => !Number.isFinite(n)) || nums[2] <= 0 || nums[3] <= 0) {
124
+ fail('--target: need {x,y,w,h} numbers with positive w/h (image px)', 2);
125
+ }
126
+ cur.target = { x: nums[0], y: nums[1], w: nums[2], h: nums[3] };
127
+ }
128
+ else if (a === '--action' && val !== undefined) {
129
+ if (cur)
130
+ cur.action = val;
131
+ else
132
+ linearAction = val;
133
+ }
134
+ }
135
+ return { branches, linearAction };
136
+ }
85
137
  function manifestPathOf(root) { return join(root, 'manifest.json'); }
86
138
  function htmlPathOf(root) { return join(root, 'index.html'); }
87
139
  function loadManifest(root) {
@@ -189,21 +241,79 @@ function appendShot(root, shot) {
189
241
  writeFileSync(htmlPath, renderHtml(m.shots, project));
190
242
  return { count: m.shots.length, htmlPath };
191
243
  }
192
- function addCmd(root, args) {
244
+ function addCmd(root, args, rawArgv = []) {
193
245
  const file = args.file;
194
246
  if (!file || file === true) {
195
247
  console.error('cli.ts add: --file <rel-path> is required');
196
248
  process.exit(2);
197
249
  }
250
+ // --file is a manifest reference relative to root: it is stored verbatim, served as a
251
+ // relative img.src in index.html, and becomes the tar member name under `-C root` at push.
252
+ // An absolute or ../-escaping path therefore can't be referenced in place — push (which
253
+ // tars root only) would silently drop it → broken image on the board with no error.
254
+ // But /tmp captures and vault exports are a real workflow, so an out-of-root path that
255
+ // EXISTS is copied into root and the manifest stores the in-root copy. Only an
256
+ // out-of-root path with no bytes behind it is an error (nothing to copy, nothing to
257
+ // serve). In-root rel-paths keep the old contract: existence NOT required, shots are
258
+ // often added before the bytes land.
259
+ let fileStr = String(file);
260
+ const rootAbs = resolve(root);
261
+ const source = resolve(rootAbs, fileStr);
262
+ const relToRoot = relative(rootAbs, source);
263
+ const lexicallyOut = relToRoot === '' || relToRoot === '..' || relToRoot.startsWith(`..${sep}`) || isAbsolute(relToRoot);
264
+ // A symlink source must ALSO be materialized even when lexically in-root: push tars
265
+ // symlinks as symlink entries and the server ingest rejects those, so storing the link
266
+ // verbatim would defer the failure to push — exactly what this guard exists to prevent.
267
+ const sourceLst = lstatSync(source, { throwIfNoEntry: false });
268
+ if (lexicallyOut || sourceLst?.isSymbolicLink()) {
269
+ if (!sourceLst) {
270
+ console.error(`cli.ts add: --file ${fileStr} is outside root (${rootAbs}) and does not exist — an outside file must exist so it can be copied into root`);
271
+ process.exit(2);
272
+ }
273
+ // stat follows symlinks: directories, FIFOs, devices, and dangling symlinks all get
274
+ // the same actionable exit instead of a raw copyFileSync throw (EISDIR, hang on FIFO).
275
+ if (!statSync(source, { throwIfNoEntry: false })?.isFile()) {
276
+ console.error(`cli.ts add: --file ${fileStr} does not resolve to a regular file — only regular files can be stored in root`);
277
+ process.exit(2);
278
+ }
279
+ mkdirSync(rootAbs, { recursive: true });
280
+ if (!lexicallyOut) {
281
+ // In-root symlink: replace it in place with the target's bytes so the manifest
282
+ // name the caller chose stays valid and push ships a regular file.
283
+ const real = realpathSync(source);
284
+ rmSync(source);
285
+ copyFileSync(real, source, fsConstants.COPYFILE_EXCL);
286
+ console.log(`copied ${real} → ${source} (replaced symlink)`);
287
+ }
288
+ else {
289
+ // Basename collisions get a numeric suffix instead of clobbering an earlier shot.
290
+ // lstat, not existsSync: a dangling symlink at the destination must count as a
291
+ // collision, or copyFileSync would follow it and write outside root.
292
+ let name = basename(source);
293
+ for (let i = 2; lstatSync(join(rootAbs, name), { throwIfNoEntry: false }); i++) {
294
+ const ext = extname(basename(source));
295
+ name = `${basename(source, ext)}-${i}${ext}`;
296
+ }
297
+ // COPYFILE_EXCL: if a dirent races in between the lstat loop and the copy,
298
+ // fail loudly rather than follow it.
299
+ copyFileSync(source, join(rootAbs, name), fsConstants.COPYFILE_EXCL);
300
+ console.log(`copied ${source} → ${join(rootAbs, name)}`);
301
+ fileStr = name;
302
+ }
303
+ }
304
+ // Branch groups need the raw argv (which --target belongs to which --next);
305
+ // with branches present the grouped parse also owns the action flags.
306
+ const { branches, linearAction } = parseBranches(rawArgv);
198
307
  const shot = {
199
- file: String(file),
308
+ file: fileStr,
200
309
  surface: args.surface && args.surface !== true ? String(args.surface) : 'other',
201
310
  route: args.route && args.route !== true ? String(args.route) : '',
202
311
  note: args.note && args.note !== true ? String(args.note) : '',
203
312
  // journey fields (vitrinka editorial viewer): stage label + short human title
204
313
  label: args.label && args.label !== true ? String(args.label) : undefined,
205
314
  title: args.title && args.title !== true ? String(args.title) : undefined,
206
- action: args.action && args.action !== true ? String(args.action) : undefined,
315
+ action: branches ? linearAction : (args.action && args.action !== true ? String(args.action) : undefined),
316
+ branches,
207
317
  ts: new Date().toISOString(),
208
318
  ...shotContext(args),
209
319
  };
@@ -861,11 +971,50 @@ function markOffline(root, error) {
861
971
  }
862
972
  writeFileSync(p, JSON.stringify({ ts: new Date().toISOString(), error, ...(warned ? { warned } : {}) }) + '\n');
863
973
  }
974
+ // pushExcluded mirrors the tar --exclude basename patterns below, so the pre-tar
975
+ // file-count guard sees exactly what would be packaged.
976
+ function pushExcluded(name) {
977
+ return name.startsWith('.vitrinka') || name === '.active' || name.startsWith('._') || name === '.DS_Store';
978
+ }
979
+ // countPushFiles counts the files tar would include under root (excludes pruned,
980
+ // dirs not counted). Used to catch a --root pointed at something far bigger than a
981
+ // screenshot set before the expensive tar spawn.
982
+ function countPushFiles(root) {
983
+ let count = 0;
984
+ const walk = (dir) => {
985
+ let entries;
986
+ try {
987
+ entries = readdirSync(dir, { withFileTypes: true });
988
+ }
989
+ catch {
990
+ return; // unreadable dir: tar would report it; the guard just skips it
991
+ }
992
+ for (const e of entries) {
993
+ if (pushExcluded(e.name))
994
+ continue;
995
+ if (e.isDirectory())
996
+ walk(join(dir, e.name));
997
+ else
998
+ count++;
999
+ }
1000
+ };
1001
+ walk(root);
1002
+ return count;
1003
+ }
1004
+ const PUSH_FILE_GUARD = 100;
864
1005
  async function pushCmd(root, args) {
865
1006
  if (!existsSync(vitrinkaCfgPath(root))) {
866
1007
  console.error(`push: no ${vitrinkaCfgPath(root)} — run \`node ${CLI_PATH} remote-init --root ${root}\` first`);
867
1008
  process.exit(2);
868
1009
  }
1010
+ // Guard BEFORE the expensive tar spawn: a --root accidentally pointed at a whole
1011
+ // repo (node_modules and all) blows past tar's maxBuffer. --confirm overrides; it is
1012
+ // intentionally undocumented (not in the push flags/help) — an escape hatch, not a workflow.
1013
+ const fileCount = countPushFiles(root);
1014
+ if (fileCount > PUSH_FILE_GUARD && args.confirm !== true) {
1015
+ console.error(`push: ${fileCount} files under ${root} exceeds the ${PUSH_FILE_GUARD}-file guard — that is far more than a screenshot set. If --root really is your capture dir, re-run with --confirm; otherwise point --root at the right directory.`);
1016
+ process.exit(2);
1017
+ }
869
1018
  const cfg = loadVitrinkaCfg(root);
870
1019
  const projectDir = dirname(root);
871
1020
  // Control files are excluded here AND skipped server-side (belt and suspenders).
@@ -876,9 +1025,24 @@ async function pushCmd(root, args) {
876
1025
  '--exclude', '.active', '--exclude', './.active',
877
1026
  '--exclude', '._*', '--exclude', './._*', '--exclude', '.DS_Store',
878
1027
  '-C', root, '.',
879
- ], { maxBuffer: 512 * 1024 * 1024, env: { ...process.env, COPYFILE_DISABLE: '1' } });
1028
+ ], { maxBuffer: TAR_MAX_BUFFER, env: { ...process.env, COPYFILE_DISABLE: '1' } });
880
1029
  if (tar.status !== 0) {
881
- console.error(`push: tar failed: ${String(tar.stderr)}`);
1030
+ // When spawnSync kills tar for exceeding maxBuffer, the reason is on tar.error/tar.signal
1031
+ // and tar.stderr is an empty Buffer — so the old `String(tar.stderr)` printed a blank line.
1032
+ // Surface every diagnostic field so a maxBuffer kill (or any other failure) is legible.
1033
+ const stderr = tar.stderr ? String(tar.stderr).trim() : '';
1034
+ const stdoutBytes = tar.stdout ? tar.stdout.length : 0;
1035
+ const parts = [
1036
+ stderr && `stderr: ${stderr}`,
1037
+ tar.error && `error: ${tar.error.message}`,
1038
+ `status: ${tar.status}`,
1039
+ tar.signal && `signal: ${tar.signal}`,
1040
+ `stdout: ${stdoutBytes} bytes (maxBuffer ${TAR_MAX_BUFFER})`,
1041
+ ].filter(Boolean);
1042
+ console.error(`push: tar failed — ${parts.join(', ')}`);
1043
+ if (stdoutBytes >= TAR_MAX_BUFFER || tar.error?.code === 'ENOBUFS') {
1044
+ console.error(`push: the tarball exceeded the ${Math.round(TAR_MAX_BUFFER / (1024 * 1024))} MB buffer — check that --root (${root}) points at your capture dir, not a whole repo`);
1045
+ }
882
1046
  process.exit(1);
883
1047
  }
884
1048
  const commit = git(['rev-parse', '--short', 'HEAD'], projectDir);
@@ -1300,10 +1464,12 @@ function pluginRuntimes() {
1300
1464
  }
1301
1465
  }
1302
1466
  out.push({ name: 'claude', present: claudePresent, installed: claudeInstalled });
1303
- const codexPresent = !spawnSync('codex', ['--version'], { stdio: 'ignore' }).error;
1467
+ const codexPresent = !spawnSync('codex', ['--version'], { stdio: 'ignore', timeout: 3_000 }).error;
1304
1468
  let codexInstalled = false;
1305
1469
  if (codexPresent) {
1306
- const r = spawnSync('codex', ['plugin', 'list'], { encoding: 'utf8' });
1470
+ // Already fast in practice (~200ms); timeout is belt-and-suspenders so a
1471
+ // hung codex child cannot freeze doctor the way `claude mcp list` did.
1472
+ const r = spawnSync('codex', ['plugin', 'list'], { encoding: 'utf8', timeout: 5_000 });
1307
1473
  codexInstalled = r.status === 0 && /\bvitrinka\b/.test(r.stdout || '');
1308
1474
  }
1309
1475
  out.push({ name: 'codex', present: codexPresent, installed: codexInstalled });
@@ -1346,12 +1512,55 @@ function rcPathOf(home) {
1346
1512
  // every /mcp request, so registration without a token is refused with the
1347
1513
  // token help. X-Board-Actor rides along when an operator persona exists
1348
1514
  // (UTF-8 bytes latin-1-encoded — header values are ByteStrings).
1515
+ //
1516
+ // mcpRegistered MUST stay O(1)/fast: doctor and install status call it on every
1517
+ // run. Never shell out to `claude mcp list` — that health-checks EVERY approved
1518
+ // MCP server (measured ~60s with ~13 servers; one flaky server can hang longer).
1519
+ // Prefer reading Claude Code's config file; fall back to `claude mcp get
1520
+ // vitrinka` (health-checks only that one server, ~1s) with a hard timeout.
1349
1521
  function claudeCliPresent() {
1350
- return !spawnSync('claude', ['--version'], { stdio: 'ignore' }).error;
1522
+ return !spawnSync('claude', ['--version'], { stdio: 'ignore', timeout: 3_000 }).error;
1523
+ }
1524
+ function mcpRegisteredFromClaudeConfig() {
1525
+ const home = process.env.HOME || '';
1526
+ if (!home)
1527
+ return null;
1528
+ const cfgPath = join(home, '.claude.json');
1529
+ if (!existsSync(cfgPath))
1530
+ return null;
1531
+ try {
1532
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
1533
+ if (cfg.mcpServers && Object.prototype.hasOwnProperty.call(cfg.mcpServers, 'vitrinka')) {
1534
+ return true;
1535
+ }
1536
+ // Project-scoped registrations (less common for our remote HTTP install,
1537
+ // which uses --scope user) — accept any project that names vitrinka.
1538
+ for (const proj of Object.values(cfg.projects || {})) {
1539
+ if (proj?.mcpServers && Object.prototype.hasOwnProperty.call(proj.mcpServers, 'vitrinka')) {
1540
+ return true;
1541
+ }
1542
+ }
1543
+ // Config readable and no vitrinka entry anywhere → definitive no (skip the
1544
+ // slower get-fallback). A missing file / parse error returns null instead.
1545
+ return false;
1546
+ }
1547
+ catch {
1548
+ return null;
1549
+ }
1351
1550
  }
1352
1551
  function mcpRegistered() {
1353
- const r = spawnSync('claude', ['mcp', 'list'], { encoding: 'utf8' });
1354
- return r.status === 0 && /\bvitrinka\b/.test(r.stdout);
1552
+ const fromCfg = mcpRegisteredFromClaudeConfig();
1553
+ if (fromCfg !== null)
1554
+ return fromCfg;
1555
+ // Schema unknown / unreadable config — single-server probe (not fleet list).
1556
+ const r = spawnSync('claude', ['mcp', 'get', 'vitrinka'], {
1557
+ encoding: 'utf8',
1558
+ timeout: 5_000,
1559
+ });
1560
+ if (r.error || r.status !== 0)
1561
+ return false;
1562
+ // "get" prints the server block when registered; otherwise errors / no match.
1563
+ return /\bvitrinka\b/i.test((r.stdout || '') + (r.stderr || ''));
1355
1564
  }
1356
1565
  function mcpTarget() {
1357
1566
  const args = ['--transport', 'http', 'vitrinka', `${resolveBaseUrl()}/mcp`];
@@ -2452,7 +2661,7 @@ export function chooseEncoding(i) {
2452
2661
  }
2453
2662
  return 'original';
2454
2663
  }
2455
- async function snapCmd(surface, args) {
2664
+ async function snapCmd(surface, args, rawArgv = []) {
2456
2665
  if (!['ios', 'android', 'macos', 'web'].includes(surface)) {
2457
2666
  console.error(`snap: surface must be ios|android|macos|web, got ${JSON.stringify(surface)}`);
2458
2667
  process.exit(2);
@@ -2607,6 +2816,9 @@ async function snapCmd(surface, args) {
2607
2816
  : surface === 'android' ? 'adb-screencap'
2608
2817
  : 'screencapture';
2609
2818
  const url = strArg(args.url) || openUrl;
2819
+ // Branch groups (journey signposts) parse from the raw argv — with --next
2820
+ // present, the grouped parse owns the --action flags too.
2821
+ const { branches, linearAction } = parseBranches(rawArgv);
2610
2822
  const { count } = appendShot(root, {
2611
2823
  file: `${day}/${name}`,
2612
2824
  surface,
@@ -2614,7 +2826,8 @@ async function snapCmd(surface, args) {
2614
2826
  note,
2615
2827
  label: strArg(args.label) || undefined,
2616
2828
  title: strArg(args.title) || undefined,
2617
- action: strArg(args.action) || undefined,
2829
+ action: branches ? linearAction : (strArg(args.action) || undefined),
2830
+ branches,
2618
2831
  hq: hq || undefined,
2619
2832
  ts: new Date().toISOString(),
2620
2833
  tool,
@@ -2926,7 +3139,9 @@ const BASE_FLAG = { f: '--base', arg: '<url>', d: 'vitrinka base URL (default $V
2926
3139
  const CTX_FLAGS = [
2927
3140
  { f: '--label', arg: '<STAGE>', d: 'journey stage label' },
2928
3141
  { f: '--title', arg: '<t>', d: 'short human title' },
2929
- { f: '--action', arg: '<a>', d: 'edge label — the action leading to the NEXT shot' },
3142
+ { f: '--action', arg: '<a>', d: 'edge label — the action leading to the NEXT shot (after a --next: that branch\'s wire label)' },
3143
+ { f: '--next', arg: '<LABEL>', d: 'journey branch: this screen leads to the shot labeled <LABEL> (repeatable — 2+ make the screen a signpost; each opens a group for --target/--action)' },
3144
+ { f: '--target', arg: '<json>', d: 'the clicked element\'s {x,y,w,h} bbox in IMAGE px (getBoundingClientRect × devicePixelRatio) — belongs to the preceding --next; the wire leaves from it' },
2930
3145
  { f: '--src', arg: '<file>', d: "implementing source file(s) (repeatable or comma-list)" },
2931
3146
  { f: '--state', arg: '<s>', d: 'app/auth state note' },
2932
3147
  { f: '--device', arg: '<name>', d: 'device name' },
@@ -3063,6 +3278,32 @@ export const COMMANDS = [
3063
3278
  examples: ['vitrinka upload report.pdf --board pr-105-vitrinka --section "Evidence"',
3064
3279
  'vitrinka upload spec.docx shots/*.png --board payout-flow'],
3065
3280
  },
3281
+ {
3282
+ name: 'import', summary: 'Parse a source file (OpenAPI / SQL DDL / docker-compose) into a refreshable diagram card',
3283
+ usage: '<file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"] [--section "Name"] [--rev <sha>] [--actor <name>] [--base <url>]',
3284
+ positional: '<file>',
3285
+ flags: [{ f: '--board', arg: '<slug>', d: 'target board (required)' },
3286
+ { f: '--kind', arg: '<auto|openapi|sqlddl|compose>', d: 'source kind (default: auto-detect from name/content)' },
3287
+ { f: '--title', arg: '<t>', d: 'diagram title (default: derived from the source)' },
3288
+ { f: '--section', arg: '<name>', d: 'land inside the named section frame (docs boards: Architecture/Database/API/Pages)' },
3289
+ { f: '--rev', arg: '<sha>', d: 'source revision (git SHA) stamped on the ↻ label' },
3290
+ { f: '--actor', arg: '<name>', d: 'override the operator attribution' }, BASE_FLAG],
3291
+ dynamic: 'boards',
3292
+ examples: ['vitrinka import openapi.yaml --board arch-docs',
3293
+ 'vitrinka import schema.sql --board myapp-docs --section Database --rev $(git rev-parse --short HEAD)',
3294
+ 'vitrinka import docker-compose.yml --board infra'],
3295
+ },
3296
+ {
3297
+ name: 'schema', summary: 'Introspect a live Postgres LOCALLY (psql) and push its schema as a refreshable diagram — the connection string never leaves the machine',
3298
+ usage: 'push --db <postgres-url> --board <slug> [--title "…"] [--actor <name>] [--base <url>]',
3299
+ positional: 'push',
3300
+ flags: [{ f: '--db', arg: '<postgres-url>', d: 'connection string, passed to local psql (required)' },
3301
+ { f: '--board', arg: '<slug>', d: 'target board (required)' },
3302
+ { f: '--title', arg: '<t>', d: 'diagram title (default: derived from the database name)' },
3303
+ { f: '--actor', arg: '<name>', d: 'override the operator attribution' }, BASE_FLAG],
3304
+ dynamic: 'boards',
3305
+ examples: ['vitrinka schema push --db postgres://localhost/shop --board db-docs'],
3306
+ },
3066
3307
  {
3067
3308
  name: 'board-from-set', summary: 'Turn the set into an annotation flow board',
3068
3309
  usage: '[--root <dir>] [--slug <board>] [--btitle <t>] [--actor <name>]',
@@ -3071,6 +3312,14 @@ export const COMMANDS = [
3071
3312
  dynamic: 'boards',
3072
3313
  examples: ['vitrinka board-from-set --root .screenshots'],
3073
3314
  },
3315
+ {
3316
+ name: 'journey-from-set', summary: 'Turn the set into a journey TREE board — signpost screens fan their branches, wires leave from the clicked element (snap --next/--target)',
3317
+ usage: '[--root <dir>] [--slug <board>] [--btitle <t>] [--actor <name>]',
3318
+ flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
3319
+ { f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
3320
+ dynamic: 'boards',
3321
+ examples: ['vitrinka journey-from-set --root .screenshots'],
3322
+ },
3074
3323
  {
3075
3324
  name: 'watch', summary: 'Monitor the work queue (listen v2); auto-scopes to repo+branch, leases the scope (one listener each), one line per new item',
3076
3325
  usage: '[--board <slug>|--project <p> --branch <b>|--all] [--takeover] [--quiet-grace <sec>] [--once] [--base <url>]',
@@ -3595,7 +3844,10 @@ async function emitHint(cmdline, errorText, helpSection) {
3595
3844
  // board where shots are laid out as the user flow (serpentine, action-labeled
3596
3845
  // arrows). Board slug defaults to the set key; re-running appends the newest
3597
3846
  // import below the existing content (server behavior).
3598
- async function boardFromSetCmd(root, args) {
3847
+ // journey=true (the `journey-from-set` verb, or shots carrying --next
3848
+ // branches — the server auto-detects those) lays the flow out as a TREE:
3849
+ // signpost screens fan their branches, wires leave from the clicked element.
3850
+ async function boardFromSetCmd(root, args, journey = false) {
3599
3851
  if (!existsSync(vitrinkaCfgPath(root))) {
3600
3852
  console.error(`board-from-set: no ${vitrinkaCfgPath(root)} — run remote-init first`);
3601
3853
  process.exit(2);
@@ -3623,7 +3875,10 @@ async function boardFromSetCmd(root, args) {
3623
3875
  }
3624
3876
  const imp = await fetch(`${cfg.base}/api/v1/boards/${encodeURIComponent(slug)}/import-set`, {
3625
3877
  method: 'POST', headers,
3626
- body: JSON.stringify({ project: cfg.project, branch: cfg.slug, selector: cfg.key }),
3878
+ body: JSON.stringify({
3879
+ project: cfg.project, branch: cfg.slug, selector: cfg.key,
3880
+ ...(journey ? { mode: 'journey' } : {}),
3881
+ }),
3627
3882
  signal: AbortSignal.timeout(60_000),
3628
3883
  });
3629
3884
  if (!imp.ok) {
@@ -3631,6 +3886,11 @@ async function boardFromSetCmd(root, args) {
3631
3886
  process.exit(1);
3632
3887
  }
3633
3888
  const out = await imp.json();
3889
+ // A declared path whose target label matched no shot — surface it loudly so
3890
+ // the walker fixes the label (or captures the missing screen) and re-imports.
3891
+ for (const u of out.unresolvedBranches || []) {
3892
+ console.error(`⚠ branch dropped: ${u.from} → ${u.to} (no shot labeled ${JSON.stringify(u.to)} in this set)`);
3893
+ }
3634
3894
  // Prefer the server-authoritative board url (carries /w/<workspace>); compose
3635
3895
  // the workspace-prefixed path only as a fallback for older servers.
3636
3896
  const ws = cfg.workspace ? `/w/${cfg.workspace}` : '';
@@ -3710,6 +3970,192 @@ async function uploadCmd(rest, args) {
3710
3970
  }
3711
3971
  console.log(text);
3712
3972
  }
3973
+ // ---------- import / schema push: refreshable diagram sources ----------
3974
+ //
3975
+ // `vitrinka import <file>` parses a real source (OpenAPI / SQL DDL /
3976
+ // docker-compose) SERVER-SIDE into a living diagram card; `vitrinka schema
3977
+ // push` introspects a live Postgres LOCALLY (the connection string never
3978
+ // leaves the machine) and posts only the parsed schema JSON. Both land on
3979
+ // POST /api/v1/boards/{slug}/import, which stamps payload.source so the card
3980
+ // refreshes in place as the source evolves.
3981
+ // detectImportKind picks the importer kind from an explicit flag, the file
3982
+ // name, then a content sniff. Returns "" when it can't decide.
3983
+ export function detectImportKind(explicit, file, content) {
3984
+ if (explicit && explicit !== 'auto')
3985
+ return explicit;
3986
+ const base = basename(file).toLowerCase();
3987
+ const ext = extname(base);
3988
+ if (/(^|[.-])(docker-)?compose\.ya?ml$/.test(base))
3989
+ return 'compose';
3990
+ if (ext === '.sql')
3991
+ return 'sqlddl';
3992
+ const head = content.slice(0, 4000);
3993
+ if (/^\s*services\s*:/m.test(head))
3994
+ return 'compose';
3995
+ if (/["']?(openapi|swagger)["']?\s*:/.test(head) || /^\s*(openapi|swagger)\s*:/m.test(head))
3996
+ return 'openapi';
3997
+ if (/create\s+table/i.test(head))
3998
+ return 'sqlddl';
3999
+ if (/["']?paths["']?\s*:/.test(head))
4000
+ return 'openapi';
4001
+ if (ext === '.json' || ext === '.yaml' || ext === '.yml')
4002
+ return 'openapi';
4003
+ return '';
4004
+ }
4005
+ // postImport sends a raw source to the board import endpoint and prints the card.
4006
+ async function postImport(board, body, args) {
4007
+ const base = resolveBaseUrl(strArg(args.base));
4008
+ const headers = { 'Content-Type': 'application/json' };
4009
+ const token = readToken();
4010
+ if (token)
4011
+ headers.Authorization = `Bearer ${token}`;
4012
+ const actor = actorFor(args);
4013
+ if (actor)
4014
+ headers['X-Board-Actor'] = actor;
4015
+ const res = await fetch(`${base}/api/v1/boards/${encodeURIComponent(board)}/import`, {
4016
+ method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000),
4017
+ });
4018
+ const text = await res.text();
4019
+ if (!res.ok) {
4020
+ console.error(`import: ${res.status} ${text}`);
4021
+ process.exit(1);
4022
+ }
4023
+ console.log(text);
4024
+ }
4025
+ async function importCmd(rest, args) {
4026
+ const file = rest.find((a) => !a.startsWith('--'));
4027
+ const board = strArg(args.board);
4028
+ if (!file || !board) {
4029
+ console.error('usage: vitrinka import <file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"]');
4030
+ process.exit(2);
4031
+ }
4032
+ if (!existsSync(file)) {
4033
+ console.error(`import: no such file: ${file}`);
4034
+ process.exit(2);
4035
+ }
4036
+ const source = readFileSync(file, 'utf8');
4037
+ const kind = detectImportKind(strArg(args.kind), file, source);
4038
+ if (!kind) {
4039
+ console.error(`import: could not detect the source kind of ${basename(file)} — pass --kind openapi|sqlddl|compose`);
4040
+ process.exit(2);
4041
+ }
4042
+ const body = { kind, source, ref: basename(file) };
4043
+ const title = strArg(args.title);
4044
+ if (title)
4045
+ body.title = title;
4046
+ // Placement + provenance passthrough (docs-boards decisions #2/#6): --section
4047
+ // lands inside a scaffold frame (Architecture/Database/API/Pages on docs
4048
+ // boards); --rev stamps the source revision (git SHA) for the ↻ micro-label.
4049
+ const section = strArg(args.section);
4050
+ if (section)
4051
+ body.section = section;
4052
+ const rev = strArg(args.rev);
4053
+ if (rev)
4054
+ body.rev = rev;
4055
+ await postImport(board, body, args);
4056
+ }
4057
+ // PG_INTROSPECT_SQL emits the pgschema JSON document (one row) from
4058
+ // information_schema — tables, columns (type/nullable/pk/unique) and single-
4059
+ // column foreign keys. Shelled to `psql`; the connection string stays local.
4060
+ const PG_INTROSPECT_SQL = `
4061
+ WITH cols AS (
4062
+ SELECT table_schema, table_name, column_name, data_type, ordinal_position,
4063
+ (is_nullable = 'YES') AS nullable
4064
+ FROM information_schema.columns
4065
+ WHERE table_schema NOT IN ('pg_catalog','information_schema')
4066
+ ),
4067
+ pk AS (
4068
+ SELECT tc.table_schema, tc.table_name, kcu.column_name
4069
+ FROM information_schema.table_constraints tc
4070
+ JOIN information_schema.key_column_usage kcu
4071
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
4072
+ WHERE tc.constraint_type = 'PRIMARY KEY'
4073
+ ),
4074
+ uq AS (
4075
+ SELECT tc.table_schema, tc.table_name, kcu.column_name
4076
+ FROM information_schema.table_constraints tc
4077
+ JOIN information_schema.key_column_usage kcu
4078
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
4079
+ WHERE tc.constraint_type = 'UNIQUE'
4080
+ ),
4081
+ fk AS (
4082
+ SELECT tc.table_schema, tc.table_name, kcu.column_name,
4083
+ ccu.table_schema AS ref_schema, ccu.table_name AS ref_table, ccu.column_name AS ref_column
4084
+ FROM information_schema.table_constraints tc
4085
+ JOIN information_schema.key_column_usage kcu
4086
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
4087
+ JOIN information_schema.constraint_column_usage ccu
4088
+ ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
4089
+ WHERE tc.constraint_type = 'FOREIGN KEY'
4090
+ ),
4091
+ tabs AS (
4092
+ SELECT table_schema, table_name FROM information_schema.tables
4093
+ WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema')
4094
+ )
4095
+ SELECT json_build_object(
4096
+ 'database', current_database(),
4097
+ 'tables', COALESCE(json_agg(json_build_object(
4098
+ 'schema', t.table_schema, 'name', t.table_name,
4099
+ 'columns', (SELECT COALESCE(json_agg(json_build_object(
4100
+ 'name', c.column_name, 'type', c.data_type, 'nullable', c.nullable,
4101
+ 'pk', EXISTS(SELECT 1 FROM pk WHERE pk.table_schema=c.table_schema AND pk.table_name=c.table_name AND pk.column_name=c.column_name),
4102
+ 'unique', EXISTS(SELECT 1 FROM uq WHERE uq.table_schema=c.table_schema AND uq.table_name=c.table_name AND uq.column_name=c.column_name)
4103
+ ) ORDER BY c.ordinal_position), '[]'::json)
4104
+ FROM cols c WHERE c.table_schema=t.table_schema AND c.table_name=t.table_name),
4105
+ 'foreignKeys', (SELECT COALESCE(json_agg(json_build_object(
4106
+ 'column', f.column_name, 'refSchema', f.ref_schema, 'refTable', f.ref_table, 'refColumn', f.ref_column
4107
+ )), '[]'::json)
4108
+ FROM fk f WHERE f.table_schema=t.table_schema AND f.table_name=t.table_name)
4109
+ ) ORDER BY t.table_schema, t.table_name), '[]'::json)
4110
+ ) FROM tabs t;
4111
+ `;
4112
+ async function schemaCmd(rest, args) {
4113
+ const sub = rest.find((a) => !a.startsWith('--'));
4114
+ if (sub !== 'push') {
4115
+ console.error('usage: vitrinka schema push --db <postgres-url> --board <slug> [--title "…"]');
4116
+ process.exit(2);
4117
+ }
4118
+ const db = strArg(args.db);
4119
+ const board = strArg(args.board);
4120
+ if (!db || !board) {
4121
+ console.error('usage: vitrinka schema push --db <postgres-url> --board <slug> [--title "…"]');
4122
+ process.exit(2);
4123
+ }
4124
+ // Introspect LOCALLY via psql; the connection string never leaves the machine.
4125
+ const run = spawnSync('psql', [db, '-X', '-A', '-t', '-c', PG_INTROSPECT_SQL], {
4126
+ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
4127
+ });
4128
+ if (run.error && run.error.code === 'ENOENT') {
4129
+ console.error('schema push: `psql` not found on PATH — install the PostgreSQL client (the introspection runs locally; only the parsed schema is uploaded)');
4130
+ process.exit(1);
4131
+ }
4132
+ if (run.status !== 0) {
4133
+ console.error(`schema push: psql failed:\n${(run.stderr || '').trim()}`);
4134
+ process.exit(1);
4135
+ }
4136
+ const schemaJSON = (run.stdout || '').trim();
4137
+ if (!schemaJSON) {
4138
+ console.error('schema push: psql returned no schema (empty database, or the connection string is wrong)');
4139
+ process.exit(1);
4140
+ }
4141
+ const body = { kind: 'pgschema', source: schemaJSON, ref: pgRefLabel(db) };
4142
+ const title = strArg(args.title);
4143
+ if (title)
4144
+ body.title = title;
4145
+ await postImport(board, body, args);
4146
+ }
4147
+ // pgRefLabel derives a non-secret "host/db" label from a connection string for
4148
+ // payload.source.ref — never the credentials.
4149
+ export function pgRefLabel(db) {
4150
+ try {
4151
+ const u = new URL(db);
4152
+ const name = u.pathname.replace(/^\//, '') || 'postgres';
4153
+ return `${u.hostname || 'localhost'}/${name}`;
4154
+ }
4155
+ catch {
4156
+ return 'postgres';
4157
+ }
4158
+ }
3713
4159
  // announceLine renders the single stdout line for one work item:
3714
4160
  // №<id> [<intent>] <board>: <prompt, single line, ≤100 chars>
3715
4161
  export function announceLine(it) {
@@ -3962,7 +4408,7 @@ async function runSub(argv) {
3962
4408
  const args = parseArgs(rest);
3963
4409
  const root = resolve(strArg(args.root) || '.screenshots');
3964
4410
  if (cmd === 'add')
3965
- addCmd(root, args);
4411
+ addCmd(root, args, rest);
3966
4412
  else if (cmd === 'build') {
3967
4413
  const { count, htmlPath } = buildIndex(root);
3968
4414
  console.log(`built ${htmlPath} (${count} shots)`);
@@ -3994,11 +4440,17 @@ async function runSub(argv) {
3994
4440
  else if (cmd === 'status')
3995
4441
  await statusCmd(root, args);
3996
4442
  else if (cmd === 'snap')
3997
- await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args);
4443
+ await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args, rest);
3998
4444
  else if (cmd === 'upload')
3999
4445
  await uploadCmd(rest, args);
4446
+ else if (cmd === 'import')
4447
+ await importCmd(rest, args);
4448
+ else if (cmd === 'schema')
4449
+ await schemaCmd(rest, args);
4000
4450
  else if (cmd === 'board-from-set')
4001
4451
  await boardFromSetCmd(root, args);
4452
+ else if (cmd === 'journey-from-set')
4453
+ await boardFromSetCmd(root, args, true);
4002
4454
  else if (cmd === 'watch')
4003
4455
  await watchCmd(args);
4004
4456
  else if (cmd === 'index')