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