@lovinka/vitrinka 1.12.0 → 1.15.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/CHANGELOG.md CHANGED
@@ -3,6 +3,45 @@
3
3
  `vitrinka update` prints the sections newer than your previous version after
4
4
  updating — keep entries short and user-facing.
5
5
 
6
+ ## 1.15.0 — 2026-07-18
7
+
8
+ ### Added
9
+ - Update notifier v2 — the daily detached check now asks YOUR vitrinka server
10
+ first (new `GET /api/v1/version`: a deploy announces the version, works on
11
+ the mesh with npm unreachable) and falls back to the npm registry. Agent
12
+ shells (non-TTY — an AI session driving the CLI) see the
13
+ `update available … · run: vitrinka update` line on every run so the session
14
+ can offer the update; interactive terminals see it at most once a day.
15
+
16
+ ## 1.14.0 — 2026-07-18
17
+
18
+ ### Added
19
+ - `compose_board` question options accept **`cardRef`** — trace an option to a
20
+ card created in the SAME compose batch (template + inline cards share one ref
21
+ space), so a decision map and its per-option architecture diagrams land in
22
+ ONE call. `cardId` still traces to existing cards; picking flies only on
23
+ ⌘-click now (plain click just stages the answer).
24
+ - The built-in **`decision-map`** template is documented on `compose_board`:
25
+ structured params `{title, context, decisions:[{key, title, stakes, options,
26
+ multiSelect?, kind?: "text"}]}` render the whole brainstorm map — a
27
+ width-filling grid of merged decision tiles — deterministically server-side.
28
+
29
+ ## 1.13.0 — 2026-07-17
30
+
31
+ ### Added
32
+ - `vitrinka import <file>` + `vitrinka schema push` — refreshable ground-truth
33
+ diagrams from docker-compose / OpenAPI / SQL DDL / a live Postgres;
34
+ `--section` lands them inside a journey frame, `--rev` stamps the git SHA.
35
+ - Journey branch capture: `snap --next/--target` groups + journey-from-set.
36
+ - Unified **publish** skill (session · journey · docs · board · artifact).
37
+ - `compose_board` edges accept `fromRegion`/`toRegion`; `arrange` gains a
38
+ journey mode.
39
+
40
+ ### Fixed
41
+ - `add --file` hardened (out-of-root copies, symlinks, non-regular files);
42
+ `push` guards against a huge `--root` and reports tar failures legibly.
43
+ - `doctor` no longer health-checks every Claude MCP server.
44
+
6
45
  ## 1.11.0 — 2026-07-15
7
46
 
8
47
  ### Added
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++) {
@@ -244,11 +247,65 @@ function addCmd(root, args, rawArgv = []) {
244
247
  console.error('cli.ts add: --file <rel-path> is required');
245
248
  process.exit(2);
246
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
+ }
247
304
  // Branch groups need the raw argv (which --target belongs to which --next);
248
305
  // with branches present the grouped parse also owns the action flags.
249
306
  const { branches, linearAction } = parseBranches(rawArgv);
250
307
  const shot = {
251
- file: String(file),
308
+ file: fileStr,
252
309
  surface: args.surface && args.surface !== true ? String(args.surface) : 'other',
253
310
  route: args.route && args.route !== true ? String(args.route) : '',
254
311
  note: args.note && args.note !== true ? String(args.note) : '',
@@ -914,11 +971,50 @@ function markOffline(root, error) {
914
971
  }
915
972
  writeFileSync(p, JSON.stringify({ ts: new Date().toISOString(), error, ...(warned ? { warned } : {}) }) + '\n');
916
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;
917
1005
  async function pushCmd(root, args) {
918
1006
  if (!existsSync(vitrinkaCfgPath(root))) {
919
1007
  console.error(`push: no ${vitrinkaCfgPath(root)} — run \`node ${CLI_PATH} remote-init --root ${root}\` first`);
920
1008
  process.exit(2);
921
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
+ }
922
1018
  const cfg = loadVitrinkaCfg(root);
923
1019
  const projectDir = dirname(root);
924
1020
  // Control files are excluded here AND skipped server-side (belt and suspenders).
@@ -929,9 +1025,24 @@ async function pushCmd(root, args) {
929
1025
  '--exclude', '.active', '--exclude', './.active',
930
1026
  '--exclude', '._*', '--exclude', './._*', '--exclude', '.DS_Store',
931
1027
  '-C', root, '.',
932
- ], { maxBuffer: 512 * 1024 * 1024, env: { ...process.env, COPYFILE_DISABLE: '1' } });
1028
+ ], { maxBuffer: TAR_MAX_BUFFER, env: { ...process.env, COPYFILE_DISABLE: '1' } });
933
1029
  if (tar.status !== 0) {
934
- 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
+ }
935
1046
  process.exit(1);
936
1047
  }
937
1048
  const commit = git(['rev-parse', '--short', 'HEAD'], projectDir);
@@ -1353,10 +1464,12 @@ function pluginRuntimes() {
1353
1464
  }
1354
1465
  }
1355
1466
  out.push({ name: 'claude', present: claudePresent, installed: claudeInstalled });
1356
- const codexPresent = !spawnSync('codex', ['--version'], { stdio: 'ignore' }).error;
1467
+ const codexPresent = !spawnSync('codex', ['--version'], { stdio: 'ignore', timeout: 3_000 }).error;
1357
1468
  let codexInstalled = false;
1358
1469
  if (codexPresent) {
1359
- 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 });
1360
1473
  codexInstalled = r.status === 0 && /\bvitrinka\b/.test(r.stdout || '');
1361
1474
  }
1362
1475
  out.push({ name: 'codex', present: codexPresent, installed: codexInstalled });
@@ -1399,12 +1512,55 @@ function rcPathOf(home) {
1399
1512
  // every /mcp request, so registration without a token is refused with the
1400
1513
  // token help. X-Board-Actor rides along when an operator persona exists
1401
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.
1402
1521
  function claudeCliPresent() {
1403
- 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
+ }
1404
1550
  }
1405
1551
  function mcpRegistered() {
1406
- const r = spawnSync('claude', ['mcp', 'list'], { encoding: 'utf8' });
1407
- 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 || ''));
1408
1564
  }
1409
1565
  function mcpTarget() {
1410
1566
  const args = ['--transport', 'http', 'vitrinka', `${resolveBaseUrl()}/mcp`];
@@ -2196,12 +2352,33 @@ const HINT_SKIP = new Set(['install', 'install-claude', 'update', 'uninstall',
2196
2352
  function updateCheckPath() {
2197
2353
  return join(process.env.HOME || '', '.config', 'vitrinka', 'update-check');
2198
2354
  }
2199
- // maybeNotifyUpdateone dim stderr line when a newer published version is
2355
+ // updateNoticePURE decision core of the notifier (exported for tests).
2356
+ // Returns the notice line to print (or null) and whether the detached check
2357
+ // should refresh the cache. TTY sessions see the line at most once a day
2358
+ // (notifiedAt throttle); non-TTY consumers (agent shells — Claude runs the
2359
+ // CLI constantly) get it on EVERY run so the session can't miss it and can
2360
+ // offer `vitrinka update` to the user.
2361
+ export function updateNotice(current, cache, isTTY, now) {
2362
+ const DAY = 24 * 3600 * 1000;
2363
+ const newer = !!(cache.latest && current && cmpSemver(cache.latest, current) > 0);
2364
+ const throttled = isTTY && now - (cache.notifiedAt || 0) < DAY;
2365
+ const line = newer && !throttled
2366
+ ? `update available ${current} → ${cache.latest} · run: vitrinka update`
2367
+ : null;
2368
+ return { line, check: now - (cache.ts || 0) >= DAY, markNotified: newer && !throttled && isTTY };
2369
+ }
2370
+ // maybeNotifyUpdate — one quiet stderr line when a newer published version is
2200
2371
  // known, npm installs only (a repo checkout is a dev seat that pulls itself).
2201
- // The registry check runs DETACHED at most once a day and writes a cache file;
2202
- // the hot path only ever reads it — a command never waits on the network.
2372
+ // The check runs DETACHED at most once a day and writes a cache file; the hot
2373
+ // path only ever reads it — a command never waits on the network. The check
2374
+ // asks the VITRINKA SERVER first (GET /api/v1/version — the deploy is the
2375
+ // announcement, works on the mesh) and falls back to the npm registry.
2203
2376
  function maybeNotifyUpdate(cmd) {
2204
- if (HINT_SKIP.has(cmd) || !process.stderr.isTTY || !process.env.HOME)
2377
+ if (HINT_SKIP.has(cmd) || !process.env.HOME)
2378
+ return;
2379
+ // Opt-outs: explicit, CI, and test runners (spawned CLIs inherit
2380
+ // NODE_TEST_CONTEXT) — a notifier must never add load or noise there.
2381
+ if (process.env.VITRINKA_NO_UPDATE_CHECK || process.env.CI || process.env.NODE_TEST_CONTEXT)
2205
2382
  return;
2206
2383
  if (git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH)))
2207
2384
  return;
@@ -2213,16 +2390,30 @@ function maybeNotifyUpdate(cmd) {
2213
2390
  catch {
2214
2391
  // Absent or corrupt cache — the background refresh below rewrites it.
2215
2392
  }
2216
- const current = pkgVersion();
2217
- if (cache.latest && current && cmpSemver(cache.latest, current) > 0) {
2218
- console.error(`\x1b[2mupdate available ${current} → ${cache.latest} · run: vitrinka update\x1b[0m`);
2393
+ const isTTY = !!process.stderr.isTTY;
2394
+ const { line, check, markNotified } = updateNotice(pkgVersion(), cache, isTTY, Date.now());
2395
+ if (line)
2396
+ console.error(isTTY ? `\x1b[2m${line}\x1b[0m` : line);
2397
+ if (markNotified) {
2398
+ try {
2399
+ mkdirSync(dirname(p), { recursive: true });
2400
+ writeFileSync(p, JSON.stringify({ ...cache, notifiedAt: Date.now() }));
2401
+ }
2402
+ catch {
2403
+ // Throttle state is best-effort; worst case the line repeats.
2404
+ }
2219
2405
  }
2220
- if (Date.now() - (cache.ts || 0) < 24 * 3600 * 1000)
2406
+ if (!check)
2221
2407
  return;
2408
+ const versionUrl = `${resolveBaseUrl()}/api/v1/version`;
2222
2409
  const script = `const fs=require('fs'),path=require('path'),f=${JSON.stringify(p)};`
2223
- + `fetch('https://registry.npmjs.org/@lovinka/vitrinka/latest',{signal:AbortSignal.timeout(10000)})`
2224
- + `.then(r=>r.json()).then(d=>{fs.mkdirSync(path.dirname(f),{recursive:true});`
2225
- + `fs.writeFileSync(f,JSON.stringify({ts:Date.now(),latest:String(d.version||'')}))}).catch(()=>{})`;
2410
+ + `const save=v=>{fs.mkdirSync(path.dirname(f),{recursive:true});`
2411
+ + `let prev={};try{prev=JSON.parse(fs.readFileSync(f,'utf8'))}catch{}`
2412
+ + `fs.writeFileSync(f,JSON.stringify({...prev,ts:Date.now(),latest:String(v||'')}))};`
2413
+ + `fetch(${JSON.stringify(versionUrl)},{signal:AbortSignal.timeout(4000)})`
2414
+ + `.then(r=>r.ok?r.json():Promise.reject(0)).then(d=>{if(!d.cli)throw 0;save(d.cli)})`
2415
+ + `.catch(()=>fetch('https://registry.npmjs.org/@lovinka/vitrinka/latest',{signal:AbortSignal.timeout(10000)})`
2416
+ + `.then(r=>r.json()).then(d=>save(d.version)).catch(()=>{}))`;
2226
2417
  try {
2227
2418
  spawn(process.execPath, ['-e', script], { detached: true, stdio: 'ignore' }).unref();
2228
2419
  }
@@ -3122,6 +3313,32 @@ export const COMMANDS = [
3122
3313
  examples: ['vitrinka upload report.pdf --board pr-105-vitrinka --section "Evidence"',
3123
3314
  'vitrinka upload spec.docx shots/*.png --board payout-flow'],
3124
3315
  },
3316
+ {
3317
+ name: 'import', summary: 'Parse a source file (OpenAPI / SQL DDL / docker-compose) into a refreshable diagram card',
3318
+ usage: '<file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"] [--section "Name"] [--rev <sha>] [--actor <name>] [--base <url>]',
3319
+ positional: '<file>',
3320
+ flags: [{ f: '--board', arg: '<slug>', d: 'target board (required)' },
3321
+ { f: '--kind', arg: '<auto|openapi|sqlddl|compose>', d: 'source kind (default: auto-detect from name/content)' },
3322
+ { f: '--title', arg: '<t>', d: 'diagram title (default: derived from the source)' },
3323
+ { f: '--section', arg: '<name>', d: 'land inside the named section frame (docs boards: Architecture/Database/API/Pages)' },
3324
+ { f: '--rev', arg: '<sha>', d: 'source revision (git SHA) stamped on the ↻ label' },
3325
+ { f: '--actor', arg: '<name>', d: 'override the operator attribution' }, BASE_FLAG],
3326
+ dynamic: 'boards',
3327
+ examples: ['vitrinka import openapi.yaml --board arch-docs',
3328
+ 'vitrinka import schema.sql --board myapp-docs --section Database --rev $(git rev-parse --short HEAD)',
3329
+ 'vitrinka import docker-compose.yml --board infra'],
3330
+ },
3331
+ {
3332
+ name: 'schema', summary: 'Introspect a live Postgres LOCALLY (psql) and push its schema as a refreshable diagram — the connection string never leaves the machine',
3333
+ usage: 'push --db <postgres-url> --board <slug> [--title "…"] [--actor <name>] [--base <url>]',
3334
+ positional: 'push',
3335
+ flags: [{ f: '--db', arg: '<postgres-url>', d: 'connection string, passed to local psql (required)' },
3336
+ { f: '--board', arg: '<slug>', d: 'target board (required)' },
3337
+ { f: '--title', arg: '<t>', d: 'diagram title (default: derived from the database name)' },
3338
+ { f: '--actor', arg: '<name>', d: 'override the operator attribution' }, BASE_FLAG],
3339
+ dynamic: 'boards',
3340
+ examples: ['vitrinka schema push --db postgres://localhost/shop --board db-docs'],
3341
+ },
3125
3342
  {
3126
3343
  name: 'board-from-set', summary: 'Turn the set into an annotation flow board',
3127
3344
  usage: '[--root <dir>] [--slug <board>] [--btitle <t>] [--actor <name>]',
@@ -3788,6 +4005,192 @@ async function uploadCmd(rest, args) {
3788
4005
  }
3789
4006
  console.log(text);
3790
4007
  }
4008
+ // ---------- import / schema push: refreshable diagram sources ----------
4009
+ //
4010
+ // `vitrinka import <file>` parses a real source (OpenAPI / SQL DDL /
4011
+ // docker-compose) SERVER-SIDE into a living diagram card; `vitrinka schema
4012
+ // push` introspects a live Postgres LOCALLY (the connection string never
4013
+ // leaves the machine) and posts only the parsed schema JSON. Both land on
4014
+ // POST /api/v1/boards/{slug}/import, which stamps payload.source so the card
4015
+ // refreshes in place as the source evolves.
4016
+ // detectImportKind picks the importer kind from an explicit flag, the file
4017
+ // name, then a content sniff. Returns "" when it can't decide.
4018
+ export function detectImportKind(explicit, file, content) {
4019
+ if (explicit && explicit !== 'auto')
4020
+ return explicit;
4021
+ const base = basename(file).toLowerCase();
4022
+ const ext = extname(base);
4023
+ if (/(^|[.-])(docker-)?compose\.ya?ml$/.test(base))
4024
+ return 'compose';
4025
+ if (ext === '.sql')
4026
+ return 'sqlddl';
4027
+ const head = content.slice(0, 4000);
4028
+ if (/^\s*services\s*:/m.test(head))
4029
+ return 'compose';
4030
+ if (/["']?(openapi|swagger)["']?\s*:/.test(head) || /^\s*(openapi|swagger)\s*:/m.test(head))
4031
+ return 'openapi';
4032
+ if (/create\s+table/i.test(head))
4033
+ return 'sqlddl';
4034
+ if (/["']?paths["']?\s*:/.test(head))
4035
+ return 'openapi';
4036
+ if (ext === '.json' || ext === '.yaml' || ext === '.yml')
4037
+ return 'openapi';
4038
+ return '';
4039
+ }
4040
+ // postImport sends a raw source to the board import endpoint and prints the card.
4041
+ async function postImport(board, body, args) {
4042
+ const base = resolveBaseUrl(strArg(args.base));
4043
+ const headers = { 'Content-Type': 'application/json' };
4044
+ const token = readToken();
4045
+ if (token)
4046
+ headers.Authorization = `Bearer ${token}`;
4047
+ const actor = actorFor(args);
4048
+ if (actor)
4049
+ headers['X-Board-Actor'] = actor;
4050
+ const res = await fetch(`${base}/api/v1/boards/${encodeURIComponent(board)}/import`, {
4051
+ method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000),
4052
+ });
4053
+ const text = await res.text();
4054
+ if (!res.ok) {
4055
+ console.error(`import: ${res.status} ${text}`);
4056
+ process.exit(1);
4057
+ }
4058
+ console.log(text);
4059
+ }
4060
+ async function importCmd(rest, args) {
4061
+ const file = rest.find((a) => !a.startsWith('--'));
4062
+ const board = strArg(args.board);
4063
+ if (!file || !board) {
4064
+ console.error('usage: vitrinka import <file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"]');
4065
+ process.exit(2);
4066
+ }
4067
+ if (!existsSync(file)) {
4068
+ console.error(`import: no such file: ${file}`);
4069
+ process.exit(2);
4070
+ }
4071
+ const source = readFileSync(file, 'utf8');
4072
+ const kind = detectImportKind(strArg(args.kind), file, source);
4073
+ if (!kind) {
4074
+ console.error(`import: could not detect the source kind of ${basename(file)} — pass --kind openapi|sqlddl|compose`);
4075
+ process.exit(2);
4076
+ }
4077
+ const body = { kind, source, ref: basename(file) };
4078
+ const title = strArg(args.title);
4079
+ if (title)
4080
+ body.title = title;
4081
+ // Placement + provenance passthrough (docs-boards decisions #2/#6): --section
4082
+ // lands inside a scaffold frame (Architecture/Database/API/Pages on docs
4083
+ // boards); --rev stamps the source revision (git SHA) for the ↻ micro-label.
4084
+ const section = strArg(args.section);
4085
+ if (section)
4086
+ body.section = section;
4087
+ const rev = strArg(args.rev);
4088
+ if (rev)
4089
+ body.rev = rev;
4090
+ await postImport(board, body, args);
4091
+ }
4092
+ // PG_INTROSPECT_SQL emits the pgschema JSON document (one row) from
4093
+ // information_schema — tables, columns (type/nullable/pk/unique) and single-
4094
+ // column foreign keys. Shelled to `psql`; the connection string stays local.
4095
+ const PG_INTROSPECT_SQL = `
4096
+ WITH cols AS (
4097
+ SELECT table_schema, table_name, column_name, data_type, ordinal_position,
4098
+ (is_nullable = 'YES') AS nullable
4099
+ FROM information_schema.columns
4100
+ WHERE table_schema NOT IN ('pg_catalog','information_schema')
4101
+ ),
4102
+ pk AS (
4103
+ SELECT tc.table_schema, tc.table_name, kcu.column_name
4104
+ FROM information_schema.table_constraints tc
4105
+ JOIN information_schema.key_column_usage kcu
4106
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
4107
+ WHERE tc.constraint_type = 'PRIMARY KEY'
4108
+ ),
4109
+ uq AS (
4110
+ SELECT tc.table_schema, tc.table_name, kcu.column_name
4111
+ FROM information_schema.table_constraints tc
4112
+ JOIN information_schema.key_column_usage kcu
4113
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
4114
+ WHERE tc.constraint_type = 'UNIQUE'
4115
+ ),
4116
+ fk AS (
4117
+ SELECT tc.table_schema, tc.table_name, kcu.column_name,
4118
+ ccu.table_schema AS ref_schema, ccu.table_name AS ref_table, ccu.column_name AS ref_column
4119
+ FROM information_schema.table_constraints tc
4120
+ JOIN information_schema.key_column_usage kcu
4121
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
4122
+ JOIN information_schema.constraint_column_usage ccu
4123
+ ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
4124
+ WHERE tc.constraint_type = 'FOREIGN KEY'
4125
+ ),
4126
+ tabs AS (
4127
+ SELECT table_schema, table_name FROM information_schema.tables
4128
+ WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema')
4129
+ )
4130
+ SELECT json_build_object(
4131
+ 'database', current_database(),
4132
+ 'tables', COALESCE(json_agg(json_build_object(
4133
+ 'schema', t.table_schema, 'name', t.table_name,
4134
+ 'columns', (SELECT COALESCE(json_agg(json_build_object(
4135
+ 'name', c.column_name, 'type', c.data_type, 'nullable', c.nullable,
4136
+ '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),
4137
+ '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)
4138
+ ) ORDER BY c.ordinal_position), '[]'::json)
4139
+ FROM cols c WHERE c.table_schema=t.table_schema AND c.table_name=t.table_name),
4140
+ 'foreignKeys', (SELECT COALESCE(json_agg(json_build_object(
4141
+ 'column', f.column_name, 'refSchema', f.ref_schema, 'refTable', f.ref_table, 'refColumn', f.ref_column
4142
+ )), '[]'::json)
4143
+ FROM fk f WHERE f.table_schema=t.table_schema AND f.table_name=t.table_name)
4144
+ ) ORDER BY t.table_schema, t.table_name), '[]'::json)
4145
+ ) FROM tabs t;
4146
+ `;
4147
+ async function schemaCmd(rest, args) {
4148
+ const sub = rest.find((a) => !a.startsWith('--'));
4149
+ if (sub !== 'push') {
4150
+ console.error('usage: vitrinka schema push --db <postgres-url> --board <slug> [--title "…"]');
4151
+ process.exit(2);
4152
+ }
4153
+ const db = strArg(args.db);
4154
+ const board = strArg(args.board);
4155
+ if (!db || !board) {
4156
+ console.error('usage: vitrinka schema push --db <postgres-url> --board <slug> [--title "…"]');
4157
+ process.exit(2);
4158
+ }
4159
+ // Introspect LOCALLY via psql; the connection string never leaves the machine.
4160
+ const run = spawnSync('psql', [db, '-X', '-A', '-t', '-c', PG_INTROSPECT_SQL], {
4161
+ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
4162
+ });
4163
+ if (run.error && run.error.code === 'ENOENT') {
4164
+ console.error('schema push: `psql` not found on PATH — install the PostgreSQL client (the introspection runs locally; only the parsed schema is uploaded)');
4165
+ process.exit(1);
4166
+ }
4167
+ if (run.status !== 0) {
4168
+ console.error(`schema push: psql failed:\n${(run.stderr || '').trim()}`);
4169
+ process.exit(1);
4170
+ }
4171
+ const schemaJSON = (run.stdout || '').trim();
4172
+ if (!schemaJSON) {
4173
+ console.error('schema push: psql returned no schema (empty database, or the connection string is wrong)');
4174
+ process.exit(1);
4175
+ }
4176
+ const body = { kind: 'pgschema', source: schemaJSON, ref: pgRefLabel(db) };
4177
+ const title = strArg(args.title);
4178
+ if (title)
4179
+ body.title = title;
4180
+ await postImport(board, body, args);
4181
+ }
4182
+ // pgRefLabel derives a non-secret "host/db" label from a connection string for
4183
+ // payload.source.ref — never the credentials.
4184
+ export function pgRefLabel(db) {
4185
+ try {
4186
+ const u = new URL(db);
4187
+ const name = u.pathname.replace(/^\//, '') || 'postgres';
4188
+ return `${u.hostname || 'localhost'}/${name}`;
4189
+ }
4190
+ catch {
4191
+ return 'postgres';
4192
+ }
4193
+ }
3791
4194
  // announceLine renders the single stdout line for one work item:
3792
4195
  // №<id> [<intent>] <board>: <prompt, single line, ≤100 chars>
3793
4196
  export function announceLine(it) {
@@ -4075,6 +4478,10 @@ async function runSub(argv) {
4075
4478
  await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args, rest);
4076
4479
  else if (cmd === 'upload')
4077
4480
  await uploadCmd(rest, args);
4481
+ else if (cmd === 'import')
4482
+ await importCmd(rest, args);
4483
+ else if (cmd === 'schema')
4484
+ await schemaCmd(rest, args);
4078
4485
  else if (cmd === 'board-from-set')
4079
4486
  await boardFromSetCmd(root, args);
4080
4487
  else if (cmd === 'journey-from-set')