@lovinka/vitrinka 1.12.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/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
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.14.0 — 2026-07-18
7
+
8
+ ### Added
9
+ - `compose_board` question options accept **`cardRef`** — trace an option to a
10
+ card created in the SAME compose batch (template + inline cards share one ref
11
+ space), so a decision map and its per-option architecture diagrams land in
12
+ ONE call. `cardId` still traces to existing cards; picking flies only on
13
+ ⌘-click now (plain click just stages the answer).
14
+ - The built-in **`decision-map`** template is documented on `compose_board`:
15
+ structured params `{title, context, decisions:[{key, title, stakes, options,
16
+ multiSelect?, kind?: "text"}]}` render the whole brainstorm map — a
17
+ width-filling grid of merged decision tiles — deterministically server-side.
18
+
19
+ ## 1.13.0 — 2026-07-17
20
+
21
+ ### Added
22
+ - `vitrinka import <file>` + `vitrinka schema push` — refreshable ground-truth
23
+ diagrams from docker-compose / OpenAPI / SQL DDL / a live Postgres;
24
+ `--section` lands them inside a journey frame, `--rev` stamps the git SHA.
25
+ - Journey branch capture: `snap --next/--target` groups + journey-from-set.
26
+ - Unified **publish** skill (session · journey · docs · board · artifact).
27
+ - `compose_board` edges accept `fromRegion`/`toRegion`; `arrange` gains a
28
+ journey mode.
29
+
30
+ ### Fixed
31
+ - `add --file` hardened (out-of-root copies, symlinks, non-regular files);
32
+ `push` guards against a huge `--root` and reports tar failures legibly.
33
+ - `doctor` no longer health-checks every Claude MCP server.
34
+
6
35
  ## 1.11.0 — 2026-07-15
7
36
 
8
37
  ### 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`];
@@ -3122,6 +3278,32 @@ export const COMMANDS = [
3122
3278
  examples: ['vitrinka upload report.pdf --board pr-105-vitrinka --section "Evidence"',
3123
3279
  'vitrinka upload spec.docx shots/*.png --board payout-flow'],
3124
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
+ },
3125
3307
  {
3126
3308
  name: 'board-from-set', summary: 'Turn the set into an annotation flow board',
3127
3309
  usage: '[--root <dir>] [--slug <board>] [--btitle <t>] [--actor <name>]',
@@ -3788,6 +3970,192 @@ async function uploadCmd(rest, args) {
3788
3970
  }
3789
3971
  console.log(text);
3790
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
+ }
3791
4159
  // announceLine renders the single stdout line for one work item:
3792
4160
  // №<id> [<intent>] <board>: <prompt, single line, ≤100 chars>
3793
4161
  export function announceLine(it) {
@@ -4075,6 +4443,10 @@ async function runSub(argv) {
4075
4443
  await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args, rest);
4076
4444
  else if (cmd === 'upload')
4077
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);
4078
4450
  else if (cmd === 'board-from-set')
4079
4451
  await boardFromSetCmd(root, args);
4080
4452
  else if (cmd === 'journey-from-set')