@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 +29 -0
- package/dist/cli.js +382 -10
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +5 -5
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
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:
|
|
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:
|
|
1028
|
+
], { maxBuffer: TAR_MAX_BUFFER, env: { ...process.env, COPYFILE_DISABLE: '1' } });
|
|
933
1029
|
if (tar.status !== 0) {
|
|
934
|
-
|
|
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
|
-
|
|
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
|
|
1407
|
-
|
|
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')
|