@lovinka/vitrinka 1.18.0 → 1.21.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 +46 -1
- package/dist/cli.js +452 -46
- package/dist/cli.js.map +1 -1
- package/dist/lib/registry.js +13 -0
- package/dist/lib/registry.js.map +1 -1
- package/dist/lib/tui.js +79 -0
- package/dist/lib/tui.js.map +1 -0
- package/dist/mcp.js +33 -1
- package/dist/mcp.js.map +1 -1
- package/dist/work.js +4 -1
- package/dist/work.js.map +1 -1
- package/package.json +5 -1
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// cli.ts — the unified vitrinka CLI. Maintains `.screenshots/manifest.json` + a self-contained,
|
|
2
|
+
// cli.ts — the unified vitrinka CLI. Maintains `.vitrinka/screenshots/manifest.json` + a self-contained,
|
|
3
3
|
// clickable `index.html` gallery, publishes sets to vitrinka (the internal artifacts app), and
|
|
4
4
|
// one-shots capture (`snap`) + artifact scaffolding (`artifact-init`, `artifact-from-set`).
|
|
5
5
|
//
|
|
@@ -10,7 +10,7 @@
|
|
|
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, lstatSync, constants as fsConstants, } from 'node:fs';
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, renameSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, openSync, readSync, closeSync, realpathSync, openAsBlob, lstatSync, constants as fsConstants, } from 'node:fs';
|
|
14
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';
|
|
@@ -20,7 +20,8 @@ import { createInterface } from 'node:readline';
|
|
|
20
20
|
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
|
-
import { recordProject } from "./lib/registry.js";
|
|
23
|
+
import { recordProject, registeredPath } from "./lib/registry.js";
|
|
24
|
+
import * as tui from "./lib/tui.js";
|
|
24
25
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
25
26
|
// Cap on the gzipped tarball push streams to the server. spawnSync kills tar (SIGTERM,
|
|
26
27
|
// empty stderr) if its stdout exceeds this, so pushCmd surfaces the size in its error.
|
|
@@ -162,7 +163,7 @@ function saveManifest(root, m) {
|
|
|
162
163
|
}
|
|
163
164
|
function buildIndex(root) {
|
|
164
165
|
const m = loadManifest(root);
|
|
165
|
-
const project = basename(
|
|
166
|
+
const project = basename(projectDirOf(root)) || 'project';
|
|
166
167
|
const htmlPath = htmlPathOf(root);
|
|
167
168
|
// A fresh/nonexistent root builds an empty gallery — mirror saveManifest's
|
|
168
169
|
// dir creation instead of crashing with a raw ENOENT.
|
|
@@ -229,7 +230,7 @@ function appendShot(root, shot) {
|
|
|
229
230
|
// v2: stamp the session's code identity (last append wins — the freshest
|
|
230
231
|
// commit is the one the newest pixels came from).
|
|
231
232
|
m.version = Math.max(m.version || 1, 2);
|
|
232
|
-
const projectDir =
|
|
233
|
+
const projectDir = projectDirOf(root);
|
|
233
234
|
const commit = git(['rev-parse', '--short', 'HEAD'], projectDir);
|
|
234
235
|
const branch = git(['branch', '--show-current'], projectDir);
|
|
235
236
|
if (commit)
|
|
@@ -237,7 +238,7 @@ function appendShot(root, shot) {
|
|
|
237
238
|
if (branch)
|
|
238
239
|
m.branch = branch;
|
|
239
240
|
saveManifest(root, m);
|
|
240
|
-
const project = basename(
|
|
241
|
+
const project = basename(projectDirOf(root)) || 'project';
|
|
241
242
|
const htmlPath = htmlPathOf(root);
|
|
242
243
|
writeFileSync(htmlPath, renderHtml(m.shots, project));
|
|
243
244
|
return { count: m.shots.length, htmlPath };
|
|
@@ -513,6 +514,41 @@ export function mainWorktreeTop(cwd) {
|
|
|
513
514
|
return dirname(common);
|
|
514
515
|
return git(['rev-parse', '--show-toplevel'], cwd);
|
|
515
516
|
}
|
|
517
|
+
// ── .vitrinka/ home (decisions 2026-07-23) ────────────────────────────────
|
|
518
|
+
// Everything vitrinka writes into a target project lives under ONE dir:
|
|
519
|
+
// .vitrinka/screenshots/ (was ./.screenshots)
|
|
520
|
+
// .vitrinka/artifacts/<slug>/ (was ./.artifacts/<slug>)
|
|
521
|
+
// .vitrinka/scratch/<topic>/ (sanctioned home for ad-hoc session output)
|
|
522
|
+
// Legacy roots auto-migrate on first touch (rename + one notice); the home
|
|
523
|
+
// gets a single `.vitrinka/` .gitignore line on top of the per-root
|
|
524
|
+
// info/exclude self-ignore.
|
|
525
|
+
const VITRINKA_HOME = '.vitrinka';
|
|
526
|
+
function homeRoot(kind, cwd = process.cwd()) {
|
|
527
|
+
const home = resolve(cwd, VITRINKA_HOME);
|
|
528
|
+
const next = join(home, kind);
|
|
529
|
+
const legacy = kind === 'scratch' ? '' : resolve(cwd, kind === 'screenshots' ? '.screenshots' : '.artifacts');
|
|
530
|
+
if (legacy && existsSync(legacy) && !existsSync(next)) {
|
|
531
|
+
mkdirSync(home, { recursive: true });
|
|
532
|
+
renameSync(legacy, next);
|
|
533
|
+
console.error(`vitrinka: migrated ${relative(cwd, legacy)} → ${relative(cwd, next)}`);
|
|
534
|
+
}
|
|
535
|
+
if (existsSync(home))
|
|
536
|
+
ensureGitIgnored(cwd, `${VITRINKA_HOME}/`);
|
|
537
|
+
return next;
|
|
538
|
+
}
|
|
539
|
+
// The project dir for a root under the home (<p>/.vitrinka/screenshots) is
|
|
540
|
+
// <p>, NOT the home dir — deriving the project name from dirname(root) would
|
|
541
|
+
// call every project ".vitrinka". Legacy layouts keep their old parents.
|
|
542
|
+
function projectDirOf(root) {
|
|
543
|
+
let d = dirname(root);
|
|
544
|
+
if (basename(d) === '.artifacts')
|
|
545
|
+
d = dirname(d); // legacy artifact container
|
|
546
|
+
if (['screenshots', 'artifacts', 'scratch'].includes(basename(d)) && basename(dirname(d)) === VITRINKA_HOME)
|
|
547
|
+
d = dirname(d);
|
|
548
|
+
if (basename(d) === VITRINKA_HOME)
|
|
549
|
+
d = dirname(d);
|
|
550
|
+
return d;
|
|
551
|
+
}
|
|
516
552
|
// originRepo normalizes the origin remote to a schemeless slug, e.g.
|
|
517
553
|
// "github.com/LEFTEQ/fixit". "" outside a repo / without a remote.
|
|
518
554
|
function originRepo(dir) {
|
|
@@ -680,6 +716,21 @@ export function buildProjectIndex(projectDir) {
|
|
|
680
716
|
function indexCachePath(projectDir) {
|
|
681
717
|
return join(git(['rev-parse', '--path-format=absolute', '--git-common-dir'], projectDir), 'vitrinka-index.cache');
|
|
682
718
|
}
|
|
719
|
+
// setupNudge — one dim stderr line when the repo isn't fully set up (setup-project
|
|
720
|
+
// decision #6: registry entry AND index stamp both present = healthy): shown
|
|
721
|
+
// exactly where the pain shows (edit/status), silent on healthy repos. The
|
|
722
|
+
// registry check catches a best-effort recordProject that failed to write.
|
|
723
|
+
// Callers gate on being inside a git repo.
|
|
724
|
+
function setupNudge(projectDir, project, top) {
|
|
725
|
+
try {
|
|
726
|
+
if (registeredPath(project) !== top || !existsSync(indexCachePath(projectDir))) {
|
|
727
|
+
console.error(tui.pc.dim('hint: repo not set up for vitrinka — run: vitrinka setup-project'));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
// best-effort — a nudge must never break the command that hosts it
|
|
732
|
+
}
|
|
733
|
+
}
|
|
683
734
|
/** pushProjectIndex builds + pushes the index unless nothing changed since
|
|
684
735
|
* the last successful push. Never throws and never exits — every caller is
|
|
685
736
|
* a touchpoint where index freshness must not fail the primary job.
|
|
@@ -749,7 +800,7 @@ export async function pushProjectIndex(projectDir, base, project, verify = false
|
|
|
749
800
|
// failure (unlike the piggybacked pushes) so scripts can rely on it; verifies
|
|
750
801
|
// the server actually holds the index rather than trusting the local stamp.
|
|
751
802
|
async function indexCmd(root, args) {
|
|
752
|
-
const projectDir =
|
|
803
|
+
const projectDir = projectDirOf(root);
|
|
753
804
|
const cfg = existsSync(vitrinkaCfgPath(root)) ? loadVitrinkaCfg(root) : null;
|
|
754
805
|
const base = (strArg(args.base) || cfg?.base || process.env.VITRINKA_URL || 'https://vitrinka.in').replace(/\/+$/, '');
|
|
755
806
|
const project = sanitizeSeg(strArg(args.project) || cfg?.project || basename(mainWorktreeTop(projectDir) || projectDir), 64);
|
|
@@ -824,7 +875,7 @@ function scanComponents(projectDir, srcDirs) {
|
|
|
824
875
|
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
825
876
|
}
|
|
826
877
|
async function indexComponentsCmd(root, args) {
|
|
827
|
-
const projectDir =
|
|
878
|
+
const projectDir = projectDirOf(root);
|
|
828
879
|
const library = strArg(args.library);
|
|
829
880
|
if (!library)
|
|
830
881
|
throw new Error('index-components: --library <kit> is required (one per UI library, e.g. "web" or "expo")');
|
|
@@ -848,6 +899,10 @@ async function indexComponentsCmd(root, args) {
|
|
|
848
899
|
console.log(JSON.stringify({ project, library, components }, null, 2));
|
|
849
900
|
return;
|
|
850
901
|
}
|
|
902
|
+
await pushComponents(base, project, library, components);
|
|
903
|
+
}
|
|
904
|
+
// pushComponents — the PUT shared by index-components and setup-project.
|
|
905
|
+
async function pushComponents(base, project, library, components) {
|
|
851
906
|
const token = readToken();
|
|
852
907
|
const headers = { 'Content-Type': 'application/json' };
|
|
853
908
|
if (token)
|
|
@@ -861,6 +916,42 @@ async function indexComponentsCmd(root, args) {
|
|
|
861
916
|
throw new Error(`index-components: push rejected (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
862
917
|
console.log(`components ↑ ${project}/${library}: ${components.length} indexed`);
|
|
863
918
|
}
|
|
919
|
+
// detectComponentDirs — the setup-project heuristic: well-known component roots
|
|
920
|
+
// (plus one level of apps/* and packages/* for monorepos) that actually contain
|
|
921
|
+
// a meaningful number of exported components, best first. Deliberately cheap —
|
|
922
|
+
// it reuses the index-components scanner and never walks the whole repo.
|
|
923
|
+
export function detectComponentDirs(top) {
|
|
924
|
+
const roots = new Set(['src/components', 'components', 'src/ui', 'ui', 'lib/components']);
|
|
925
|
+
for (const container of ['apps', 'packages']) {
|
|
926
|
+
let entries;
|
|
927
|
+
try {
|
|
928
|
+
entries = readdirSync(join(top, container), { withFileTypes: true });
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
continue; // not a monorepo container — normal
|
|
932
|
+
}
|
|
933
|
+
for (const e of entries) {
|
|
934
|
+
if (!e.isDirectory() || e.name.startsWith('.'))
|
|
935
|
+
continue;
|
|
936
|
+
roots.add(`${container}/${e.name}/src/components`);
|
|
937
|
+
roots.add(`${container}/${e.name}/components`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const out = [];
|
|
941
|
+
for (const dir of roots) {
|
|
942
|
+
if (!existsSync(join(top, dir)))
|
|
943
|
+
continue;
|
|
944
|
+
const count = scanComponents(top, [dir]).length;
|
|
945
|
+
if (count >= 3)
|
|
946
|
+
out.push({ dir, count }); // fewer reads as noise, not a library
|
|
947
|
+
}
|
|
948
|
+
return out.sort((a, b) => b.count - a.count).slice(0, 6);
|
|
949
|
+
}
|
|
950
|
+
// guessLibraryName — "apps/web/src/components" → "web"; flat repos → "web".
|
|
951
|
+
export function guessLibraryName(dir) {
|
|
952
|
+
const m = dir.match(/^(?:apps|packages)\/([^/]+)\//);
|
|
953
|
+
return m ? m[1] : 'web';
|
|
954
|
+
}
|
|
864
955
|
function loadVitrinkaCfg(root) {
|
|
865
956
|
const raw = readFileSync(vitrinkaCfgPath(root), 'utf8'); // caller checks existsSync
|
|
866
957
|
try {
|
|
@@ -913,16 +1004,15 @@ async function remoteInitCmd(root, args) {
|
|
|
913
1004
|
console.log(`vitrinka set (existing) → ${shareUrl(cfg)}`);
|
|
914
1005
|
return cfg;
|
|
915
1006
|
}
|
|
916
|
-
const projectDir =
|
|
1007
|
+
const projectDir = projectDirOf(root);
|
|
917
1008
|
const base = (args.base && args.base !== true ? String(args.base) : process.env.VITRINKA_URL || 'https://vitrinka.in').replace(/\/+$/, '');
|
|
918
1009
|
// Project identity comes from the MAIN worktree (a linked worktree's dir is
|
|
919
1010
|
// named after its branch — using it would fork each branch into its own
|
|
920
1011
|
// project; the branch already lives in cfg.branch).
|
|
921
1012
|
const gitTop = mainWorktreeTop(projectDir);
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
const
|
|
925
|
-
const project = sanitizeSeg(args.project && args.project !== true ? String(args.project) : basename(gitTop || fallbackDir), 64);
|
|
1013
|
+
// Outside a git repo the fallback is the real project dir — projectDirOf
|
|
1014
|
+
// already stepped out of the .vitrinka/ home and legacy containers.
|
|
1015
|
+
const project = sanitizeSeg(args.project && args.project !== true ? String(args.project) : basename(gitTop || projectDir), 64);
|
|
926
1016
|
const branch = (args.branch && args.branch !== true ? String(args.branch) : git(['branch', '--show-current'], projectDir)) || 'main';
|
|
927
1017
|
const d = new Date();
|
|
928
1018
|
const p2 = (n) => String(n).padStart(2, '0');
|
|
@@ -1017,7 +1107,7 @@ async function pushCmd(root, args) {
|
|
|
1017
1107
|
process.exit(2);
|
|
1018
1108
|
}
|
|
1019
1109
|
const cfg = loadVitrinkaCfg(root);
|
|
1020
|
-
const projectDir =
|
|
1110
|
+
const projectDir = projectDirOf(root);
|
|
1021
1111
|
// Control files are excluded here AND skipped server-side (belt and suspenders).
|
|
1022
1112
|
// COPYFILE_DISABLE stops macOS bsdtar from emitting AppleDouble (._*) junk entries.
|
|
1023
1113
|
const tar = spawnSync('tar', [
|
|
@@ -1324,6 +1414,85 @@ async function operatorCmd(rest, args) {
|
|
|
1324
1414
|
}
|
|
1325
1415
|
}
|
|
1326
1416
|
// ---------- doctor: setup health check ----------
|
|
1417
|
+
// tidyCmd sweeps legacy vitrinka output and shot-shaped session litter into
|
|
1418
|
+
// the .vitrinka/ home. Report-first: prints the plan and exits unless --yes.
|
|
1419
|
+
// Conservative by design — only known patterns move by default (.screenshots/
|
|
1420
|
+
// + .artifacts/ legacy roots, .screenshots-<topic> session dirs, loose
|
|
1421
|
+
// shot-*.png); other loose root images and .aud_*/.audit_* text litter are
|
|
1422
|
+
// reported and move only under --all. Git-TRACKED paths never move.
|
|
1423
|
+
function tidyCmd(args) {
|
|
1424
|
+
const cwd = process.cwd();
|
|
1425
|
+
const top = git(['rev-parse', '--show-toplevel'], cwd) || cwd;
|
|
1426
|
+
const all = args.all === true;
|
|
1427
|
+
const tracked = new Set((git(['ls-files'], top) || '').split('\n').filter(Boolean));
|
|
1428
|
+
const trackedUnder = (rel) => {
|
|
1429
|
+
if (tracked.has(rel))
|
|
1430
|
+
return true;
|
|
1431
|
+
const prefix = rel + '/';
|
|
1432
|
+
for (const t of tracked)
|
|
1433
|
+
if (t.startsWith(prefix))
|
|
1434
|
+
return true;
|
|
1435
|
+
return false;
|
|
1436
|
+
};
|
|
1437
|
+
const moves = [];
|
|
1438
|
+
const skipped = [];
|
|
1439
|
+
const plan = (from, to) => {
|
|
1440
|
+
if (trackedUnder(from)) {
|
|
1441
|
+
skipped.push(`${from} (git-tracked — not touching)`);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (existsSync(join(top, to))) {
|
|
1445
|
+
skipped.push(`${from} (target ${to} already exists)`);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
moves.push({ from, to });
|
|
1449
|
+
};
|
|
1450
|
+
if (existsSync(join(top, '.screenshots')))
|
|
1451
|
+
plan('.screenshots', `${VITRINKA_HOME}/screenshots`);
|
|
1452
|
+
if (existsSync(join(top, '.artifacts')))
|
|
1453
|
+
plan('.artifacts', `${VITRINKA_HOME}/artifacts`);
|
|
1454
|
+
for (const e of readdirSync(top, { withFileTypes: true })) {
|
|
1455
|
+
if (e.name === VITRINKA_HOME || e.name === '.git')
|
|
1456
|
+
continue;
|
|
1457
|
+
if (e.isDirectory()) {
|
|
1458
|
+
const m = e.name.match(/^\.screenshots[-_](.+)$/);
|
|
1459
|
+
if (m)
|
|
1460
|
+
plan(e.name, `${VITRINKA_HOME}/scratch/${m[1]}`);
|
|
1461
|
+
}
|
|
1462
|
+
else if (e.isFile()) {
|
|
1463
|
+
if (trackedUnder(e.name))
|
|
1464
|
+
continue; // committed files are repo content, not litter
|
|
1465
|
+
const isShot = /^shot-.*\.(png|jpe?g|webp)$/i.test(e.name);
|
|
1466
|
+
const isLooseImage = /\.(png|jpe?g|webp)$/i.test(e.name);
|
|
1467
|
+
const isAudit = /^\.aud(it)?_/.test(e.name);
|
|
1468
|
+
if (isShot || (all && (isLooseImage || isAudit)))
|
|
1469
|
+
plan(e.name, `${VITRINKA_HOME}/scratch/loose/${e.name}`);
|
|
1470
|
+
else if (isLooseImage || isAudit)
|
|
1471
|
+
skipped.push(`${e.name} (loose litter — rerun with --all to move)`);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (!moves.length && !skipped.length) {
|
|
1475
|
+
console.log('tidy: nothing to do — repo root is clean');
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
for (const mv of moves)
|
|
1479
|
+
console.log(` ${mv.from} → ${mv.to}`);
|
|
1480
|
+
for (const s of skipped)
|
|
1481
|
+
console.log(` skip ${s}`);
|
|
1482
|
+
if (!moves.length)
|
|
1483
|
+
return;
|
|
1484
|
+
if (args.yes !== true) {
|
|
1485
|
+
console.log(`\ntidy: ${moves.length} move(s) planned — rerun with --yes to apply`);
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
for (const mv of moves) {
|
|
1489
|
+
const dest = join(top, mv.to);
|
|
1490
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
1491
|
+
renameSync(join(top, mv.from), dest);
|
|
1492
|
+
}
|
|
1493
|
+
ensureGitIgnored(top, `${VITRINKA_HOME}/`);
|
|
1494
|
+
console.log(`tidy: moved ${moves.length} item(s) into ${VITRINKA_HOME}/`);
|
|
1495
|
+
}
|
|
1327
1496
|
// doctorCmd checks this machine's vitrinka setup: server reachable, write
|
|
1328
1497
|
// token present AND accepted by the server, operator persona set. The token
|
|
1329
1498
|
// probe is `PUT /api/v1/operator` with an empty JSON body — auth runs before
|
|
@@ -1626,6 +1795,9 @@ async function installCmd(args) {
|
|
|
1626
1795
|
if (!home)
|
|
1627
1796
|
fail('install: $HOME is not set');
|
|
1628
1797
|
const local = args.local === true;
|
|
1798
|
+
// --yes (setup-project's inline delegate, or direct use): behave exactly like a
|
|
1799
|
+
// non-TTY run — skip every prompt, config-touchers print their manual command.
|
|
1800
|
+
const noPrompt = args.yes === true || !process.stdin.isTTY;
|
|
1629
1801
|
const base = resolveBaseUrl(strArg(args.base));
|
|
1630
1802
|
const mcpScope = local ? 'project' : 'user';
|
|
1631
1803
|
// ---- probe (before touching anything) ----
|
|
@@ -1701,7 +1873,7 @@ async function installCmd(args) {
|
|
|
1701
1873
|
else if (!mcpOk) {
|
|
1702
1874
|
if (args['no-mcp'] === true)
|
|
1703
1875
|
console.log('mcp: skipped (--no-mcp)');
|
|
1704
|
-
else if (args.mcp === true || promptYesNo(`Register the vitrinka MCP for Claude Code (scope ${mcpScope})? [Y/n] `)) {
|
|
1876
|
+
else if (args.mcp === true || (!noPrompt && promptYesNo(`Register the vitrinka MCP for Claude Code (scope ${mcpScope})? [Y/n] `))) {
|
|
1705
1877
|
if (registerMcp(mcpScope))
|
|
1706
1878
|
console.log(`✓ mcp registered (--scope ${mcpScope})`);
|
|
1707
1879
|
else
|
|
@@ -1713,7 +1885,7 @@ async function installCmd(args) {
|
|
|
1713
1885
|
}
|
|
1714
1886
|
// 2. Write token (reads work without one; pushes/board writes 401).
|
|
1715
1887
|
if (tokenSrc === 'missing') {
|
|
1716
|
-
if (
|
|
1888
|
+
if (!noPrompt) {
|
|
1717
1889
|
console.log('No write token yet — reads work without one, writes 401. It is the');
|
|
1718
1890
|
console.log('server\'s VITRINKA_TOKEN (one shared secret — copy it from a configured');
|
|
1719
1891
|
console.log('machine or the server admin; details: vitrinka doctor).');
|
|
@@ -1739,7 +1911,7 @@ async function installCmd(args) {
|
|
|
1739
1911
|
await operatorCmd(['operator', opArg], args);
|
|
1740
1912
|
}
|
|
1741
1913
|
else if (!op) {
|
|
1742
|
-
const name = promptLine('Operator name — how should vitrinka credit you on boards? (Enter to skip): ');
|
|
1914
|
+
const name = noPrompt ? '' : promptLine('Operator name — how should vitrinka credit you on boards? (Enter to skip): ');
|
|
1743
1915
|
if (name)
|
|
1744
1916
|
await operatorCmd(['operator', name], args);
|
|
1745
1917
|
else
|
|
@@ -1754,10 +1926,10 @@ async function installCmd(args) {
|
|
|
1754
1926
|
console.log('claude ui: skipped (--no-claude)');
|
|
1755
1927
|
}
|
|
1756
1928
|
else if (uiOk || args.claude === true
|
|
1757
|
-
|| promptYesNo('Wire Claude Code UI (clickable 🧷 board footer badge)? [Y/n] ')) {
|
|
1929
|
+
|| (!noPrompt && promptYesNo('Wire Claude Code UI (clickable 🧷 board footer badge)? [Y/n] '))) {
|
|
1758
1930
|
installClaudeCmd(args);
|
|
1759
1931
|
}
|
|
1760
|
-
else if (
|
|
1932
|
+
else if (noPrompt) {
|
|
1761
1933
|
console.log('claude ui: skipped (non-interactive — run `vitrinka install-claude`, or pass --claude)');
|
|
1762
1934
|
}
|
|
1763
1935
|
else {
|
|
@@ -2190,8 +2362,11 @@ function editCmd(rest, args) {
|
|
|
2190
2362
|
// discovers this checkout. Same project derivation as statusCmd; the stored
|
|
2191
2363
|
// path is the MAIN worktree top (the app enumerates worktrees itself).
|
|
2192
2364
|
const top = mainWorktreeTop(absPath);
|
|
2193
|
-
if (top)
|
|
2194
|
-
|
|
2365
|
+
if (top) {
|
|
2366
|
+
const project = sanitizeSeg(strArg(args.project) || basename(top), 64);
|
|
2367
|
+
recordProject(project, top);
|
|
2368
|
+
setupNudge(absPath, project, top);
|
|
2369
|
+
}
|
|
2195
2370
|
const url = buildEditUrl(absPath);
|
|
2196
2371
|
if (args.print === true) {
|
|
2197
2372
|
console.log(url);
|
|
@@ -2208,6 +2383,189 @@ function editCmd(rest, args) {
|
|
|
2208
2383
|
if (r.error || r.status !== 0)
|
|
2209
2384
|
console.error(`edit: could not launch open — URL: ${url}`);
|
|
2210
2385
|
}
|
|
2386
|
+
// ---------- setup-project: the repo onboarding wizard ----------
|
|
2387
|
+
//
|
|
2388
|
+
// One front door for everything a REPO needs before the editor and the board
|
|
2389
|
+
// @ autocomplete work (docs/specs/2026-07-23-setup-project-decisions.md):
|
|
2390
|
+
// register the repo in the desktop app's project registry, push the file/route
|
|
2391
|
+
// index, offer the component index, check the desktop app, and optionally
|
|
2392
|
+
// create a hello board. Status-first like install: re-runs refresh the cheap
|
|
2393
|
+
// idempotent bits and only prompt for genuinely missing config; --yes and
|
|
2394
|
+
// non-TTY runs take every default.
|
|
2395
|
+
async function setupProjectCmd(args) {
|
|
2396
|
+
const auto = args.yes === true || !tui.interactive();
|
|
2397
|
+
const base = resolveBaseUrl(strArg(args.base));
|
|
2398
|
+
const cwd = resolve('.');
|
|
2399
|
+
const top = mainWorktreeTop(cwd);
|
|
2400
|
+
if (!top)
|
|
2401
|
+
fail('setup-project: not inside a git repository — run it from the repo you want to onboard');
|
|
2402
|
+
tui.intro(`vitrinka setup-project — ${base}`);
|
|
2403
|
+
// ---- machine gate: offer install inline instead of a dead end (decision #5) ----
|
|
2404
|
+
const missing = [];
|
|
2405
|
+
if (tokenSource() === 'missing')
|
|
2406
|
+
missing.push('token');
|
|
2407
|
+
for (const rt of pluginRuntimes())
|
|
2408
|
+
if (rt.present && !rt.installed)
|
|
2409
|
+
missing.push(`plugin·${rt.name}`);
|
|
2410
|
+
if (claudeCliPresent() && !mcpRegistered())
|
|
2411
|
+
missing.push('mcp');
|
|
2412
|
+
if (missing.length) {
|
|
2413
|
+
tui.note(`missing: ${missing.join(', ')}`, 'machine not fully onboarded');
|
|
2414
|
+
if (await tui.confirm('Run the machine install now?', true, auto))
|
|
2415
|
+
await installCmd(args);
|
|
2416
|
+
else
|
|
2417
|
+
tui.step('skipped — run it later: vitrinka install');
|
|
2418
|
+
}
|
|
2419
|
+
// ---- project identity + registry (the name is asked only while THIS repo is
|
|
2420
|
+
// unregistered — an entry for the same name but another path is a basename
|
|
2421
|
+
// collision with a different checkout, so the prompt is the escape hatch,
|
|
2422
|
+
// never a silent overwrite of the other project's mapping) ----
|
|
2423
|
+
const defaultName = sanitizeSeg(strArg(args.project) || basename(top), 64);
|
|
2424
|
+
let project = defaultName;
|
|
2425
|
+
if (registeredPath(defaultName) !== top) {
|
|
2426
|
+
project = sanitizeSeg(await tui.text('Project name (how boards + indexes file this repo)', defaultName, auto), 64) || defaultName;
|
|
2427
|
+
}
|
|
2428
|
+
recordProject(project, top);
|
|
2429
|
+
tui.step(`registry ✓ ${project} → ${top}`);
|
|
2430
|
+
// ---- file/route index — verify=true so a wiped server row re-pushes even on
|
|
2431
|
+
// a local stamp hit (pushProjectIndex prints its own ↑ line on a real push) ----
|
|
2432
|
+
const sp = tui.spinner();
|
|
2433
|
+
sp.start('pushing the file/route index…');
|
|
2434
|
+
const idxOk = await pushProjectIndex(cwd, base, project, true);
|
|
2435
|
+
sp.stop(idxOk ? 'index ✓ (@ autocomplete + editor file map)' : 'index ✗ — server unreachable? retry: vitrinka index');
|
|
2436
|
+
// ---- component index (decision #3: auto-detect, offer, never mandatory) ----
|
|
2437
|
+
await setupComponentsStep(base, project, top, auto);
|
|
2438
|
+
// ---- desktop app ----
|
|
2439
|
+
const appPath = refreshDesktopAppFlag();
|
|
2440
|
+
if (appPath)
|
|
2441
|
+
tui.step(`app ✓ ${appPath}`);
|
|
2442
|
+
else if (process.platform === 'darwin') {
|
|
2443
|
+
tui.step('app ✗ Vitrinka.app not installed — editor links open in the browser (from the vitrinka repo: cd apps/desktop && make install)');
|
|
2444
|
+
}
|
|
2445
|
+
// ---- hello board (decision #3: a closing "want to try it?" — offered only
|
|
2446
|
+
// while the project has no board at all; default no, so --yes never spams) ----
|
|
2447
|
+
await setupBoardStep(base, project, args, auto);
|
|
2448
|
+
// The index is the core deliverable (editor file map + @ autocomplete) — a
|
|
2449
|
+
// failed push means the repo is NOT ready, and scripts must see a non-zero exit.
|
|
2450
|
+
if (!idxOk) {
|
|
2451
|
+
tui.outro('repo NOT ready — the index push failed (see above); retry once the server is reachable: vitrinka setup-project');
|
|
2452
|
+
process.exit(1);
|
|
2453
|
+
}
|
|
2454
|
+
tui.outro('repo ready — open the editor: vitrinka edit . dashboard: vitrinka status');
|
|
2455
|
+
}
|
|
2456
|
+
// setupComponentsStep — the component-index leg of setup-project. Skips loudly
|
|
2457
|
+
// but harmlessly whenever the server or the scan has nothing to offer.
|
|
2458
|
+
async function setupComponentsStep(base, project, top, auto) {
|
|
2459
|
+
try {
|
|
2460
|
+
// Unauthenticated compact read — already indexed means a ✓ row, no prompt.
|
|
2461
|
+
const res = await fetch(`${base}/api/v1/projects/${encodeURIComponent(project)}/components`, { signal: AbortSignal.timeout(15_000) });
|
|
2462
|
+
if (res.ok) {
|
|
2463
|
+
const d = await res.json();
|
|
2464
|
+
const comps = Array.isArray(d.components) ? d.components : [];
|
|
2465
|
+
if (comps.length) {
|
|
2466
|
+
const libs = [...new Set(comps.map((c) => c.library).filter(Boolean))];
|
|
2467
|
+
tui.step(`components ✓ ${comps.length} indexed (${libs.join(', ')})`);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
catch (e) {
|
|
2473
|
+
tui.step(`components ? server unreachable (${e instanceof Error ? e.message : e}) — index later: vitrinka index-components`);
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
const cands = detectComponentDirs(top);
|
|
2477
|
+
if (!cands.length) {
|
|
2478
|
+
tui.step('components — no component dirs detected; index later: vitrinka index-components --library <kit> --src <dirs>');
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
const dir = await tui.select('Component library to index (powers @ component autocomplete on boards)', [
|
|
2482
|
+
...cands.map((c) => ({ value: c.dir, label: c.dir, hint: `${c.count} exported components` })),
|
|
2483
|
+
{ value: '', label: 'skip', hint: 'index later: vitrinka index-components' },
|
|
2484
|
+
], auto);
|
|
2485
|
+
if (!dir)
|
|
2486
|
+
return;
|
|
2487
|
+
const library = sanitizeSeg(await tui.text('Library name (one per UI kit, e.g. "web" or "expo")', guessLibraryName(dir), auto), 32) || 'web';
|
|
2488
|
+
try {
|
|
2489
|
+
await pushComponents(base, project, library, scanComponents(top, [dir]));
|
|
2490
|
+
}
|
|
2491
|
+
catch (e) {
|
|
2492
|
+
tui.step(`components ✗ ${e.message}`);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
// helloBoardSlug — the hello board's slug, kept within the server's 64-char
|
|
2496
|
+
// boardSlugRe even for a maximal 64-char project name (57 + "-hello" = 63);
|
|
2497
|
+
// the truncation never leaves a dangling separator before the suffix.
|
|
2498
|
+
export function helloBoardSlug(project) {
|
|
2499
|
+
return `${project.slice(0, 57).replace(/[-.]+$/, '') || 'x'}-hello`;
|
|
2500
|
+
}
|
|
2501
|
+
// setupBoardStep — the closing hello-board offer. Every failure path is a quiet
|
|
2502
|
+
// row, never an exit: the board is a nicety on top of an already-onboarded repo.
|
|
2503
|
+
async function setupBoardStep(base, project, args, auto) {
|
|
2504
|
+
let boards;
|
|
2505
|
+
try {
|
|
2506
|
+
const res = await fetch(`${base}/api/v1/boards`, { signal: AbortSignal.timeout(15_000) });
|
|
2507
|
+
if (!res.ok)
|
|
2508
|
+
return; // older server — the offer is a nicety, not a step
|
|
2509
|
+
boards = (await res.json()).boards || [];
|
|
2510
|
+
}
|
|
2511
|
+
catch {
|
|
2512
|
+
return; // unreachable — the index step already surfaced that loudly
|
|
2513
|
+
}
|
|
2514
|
+
const mine = boards.filter((b) => b.project === project);
|
|
2515
|
+
if (mine.length) {
|
|
2516
|
+
tui.step(`boards ✓ ${mine.length} (latest: ${mine[0].url || mine[0].slug})`);
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
if (!await tui.confirm('Create a hello board to try the round-trip (annotate → work queue)?', false, auto))
|
|
2520
|
+
return;
|
|
2521
|
+
const slug = helloBoardSlug(project);
|
|
2522
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
2523
|
+
const token = readToken();
|
|
2524
|
+
if (token)
|
|
2525
|
+
headers.Authorization = `Bearer ${token}`;
|
|
2526
|
+
const actor = actorFor(args);
|
|
2527
|
+
if (actor)
|
|
2528
|
+
headers['X-Board-Actor'] = actor;
|
|
2529
|
+
try {
|
|
2530
|
+
const res = await fetch(`${base}/api/v1/boards`, {
|
|
2531
|
+
method: 'POST', headers,
|
|
2532
|
+
body: JSON.stringify({ slug, title: `${project} — hello`, project, subgroup: 'testing' }),
|
|
2533
|
+
signal: AbortSignal.timeout(30_000),
|
|
2534
|
+
});
|
|
2535
|
+
if (res.ok) {
|
|
2536
|
+
const url = String((await res.json()).url || `${base}/boards/${slug}`);
|
|
2537
|
+
tui.step(`board ✓ ${url}`);
|
|
2538
|
+
if (process.platform === 'darwin' && tui.interactive())
|
|
2539
|
+
spawnSync('open', [url], { stdio: 'ignore' });
|
|
2540
|
+
}
|
|
2541
|
+
else if (res.status === 409) {
|
|
2542
|
+
// 409 = the slug exists. Re-list and reuse it when it's OURS (a race or a
|
|
2543
|
+
// half-finished earlier run); a different project's board is a genuine
|
|
2544
|
+
// collision — never silently adopt it.
|
|
2545
|
+
let ours;
|
|
2546
|
+
try {
|
|
2547
|
+
const list = await fetch(`${base}/api/v1/boards`, { signal: AbortSignal.timeout(15_000) });
|
|
2548
|
+
if (list.ok) {
|
|
2549
|
+
ours = ((await list.json()).boards || [])
|
|
2550
|
+
.find((b) => b.slug === slug && b.project === project);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
catch {
|
|
2554
|
+
// unreachable mid-step — fall through to the collision line
|
|
2555
|
+
}
|
|
2556
|
+
if (ours)
|
|
2557
|
+
tui.step(`board ✓ ${ours.url || slug} (reused)`);
|
|
2558
|
+
else
|
|
2559
|
+
tui.step(`board — slug ${slug} already exists (another project's?) — create one later: /vitrinka:board`);
|
|
2560
|
+
}
|
|
2561
|
+
else {
|
|
2562
|
+
tui.step(`board ✗ create failed (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
catch (e) {
|
|
2566
|
+
tui.step(`board ✗ ${e.message}`);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2211
2569
|
// ---------- status: the session dashboard ----------
|
|
2212
2570
|
// statusCmd — one glance at this repo+branch's vitrinka world: the live board,
|
|
2213
2571
|
// the open work queue (same scope derivation as `watch`), and who holds the
|
|
@@ -2218,8 +2576,10 @@ async function statusCmd(root, args) {
|
|
|
2218
2576
|
const top = mainWorktreeTop(cwd);
|
|
2219
2577
|
const project = sanitizeSeg(strArg(args.project) || basename(top || cwd), 64);
|
|
2220
2578
|
const branch = sanitizeSeg(strArg(args.branch) || git(['branch', '--show-current'], cwd) || 'main', 100);
|
|
2221
|
-
if (top)
|
|
2579
|
+
if (top) {
|
|
2222
2580
|
recordProject(project, top); // feed the desktop app's project registry
|
|
2581
|
+
setupNudge(cwd, project, top);
|
|
2582
|
+
}
|
|
2223
2583
|
console.log(`vitrinka status — ${project}/${branch}`);
|
|
2224
2584
|
const board = boardUrlOf(root);
|
|
2225
2585
|
console.log(board ? ` board ${board}` : ' board none yet (publish one: vitrinka board-from-set)');
|
|
@@ -2682,8 +3042,8 @@ async function snapCmd(surface, args, rawArgv = []) {
|
|
|
2682
3042
|
console.error(`snap: surface must be ios|android|macos|web, got ${JSON.stringify(surface)}`);
|
|
2683
3043
|
process.exit(2);
|
|
2684
3044
|
}
|
|
2685
|
-
const root = resolve(strArg(args.root)
|
|
2686
|
-
const projectDir =
|
|
3045
|
+
const root = strArg(args.root) ? resolve(strArg(args.root)) : homeRoot('screenshots');
|
|
3046
|
+
const projectDir = projectDirOf(root);
|
|
2687
3047
|
const route = strArg(args.route);
|
|
2688
3048
|
const note = strArg(args.note);
|
|
2689
3049
|
if (!route) {
|
|
@@ -3001,6 +3361,7 @@ function scaffoldHead(title) {
|
|
|
3001
3361
|
"react-dom/client": "/vendor/react-dom-client.mjs",
|
|
3002
3362
|
"htm": "/vendor/htm.mjs",
|
|
3003
3363
|
"kit-1": "/vendor/kit-1.mjs",
|
|
3364
|
+
"kit-2": "/vendor/kit-2.mjs",
|
|
3004
3365
|
"zipstore-1": "/vendor/zipstore-1.mjs"
|
|
3005
3366
|
} }
|
|
3006
3367
|
</script>
|
|
@@ -3008,7 +3369,26 @@ function scaffoldHead(title) {
|
|
|
3008
3369
|
"@xyflow/react": "/vendor/xyflow-react.mjs"
|
|
3009
3370
|
and add to <head>:
|
|
3010
3371
|
<link rel="stylesheet" href="/vendor/xyflow-react.css">
|
|
3372
|
+
shadcn ui components resolve via the map above: import { Button, Card, Tabs, … } from "kit-2"
|
|
3373
|
+
More built-ins (relative-import bundles — no map entry needed;
|
|
3374
|
+
GET /api/v1/runtime lists what the server has):
|
|
3375
|
+
recharts: import { LineChart, Line, XAxis, … } from "/vendor/recharts.mjs"
|
|
3376
|
+
tables: import { useReactTable, … } from "/vendor/tanstack-table.mjs"
|
|
3377
|
+
motion: import { motion, AnimatePresence } from "/vendor/motion.mjs"
|
|
3378
|
+
highlight: import hljs from "/vendor/hljs.mjs" + <link rel="stylesheet" href="/vendor/hljs.css">
|
|
3379
|
+
math: import katex from "/vendor/katex.mjs" + <link rel="stylesheet" href="/vendor/katex.css">
|
|
3380
|
+
api ref: import { ApiRefBody, parseSpec } from "/vendor/apiref-1.mjs"
|
|
3011
3381
|
-->
|
|
3382
|
+
<link rel="modulepreload" href="/vendor/react.mjs">
|
|
3383
|
+
<link rel="modulepreload" href="/vendor/scheduler.mjs">
|
|
3384
|
+
<link rel="modulepreload" href="/vendor/react-dom.mjs">
|
|
3385
|
+
<link rel="modulepreload" href="/vendor/react-dom-client.mjs">
|
|
3386
|
+
<link rel="modulepreload" href="/vendor/htm.mjs">
|
|
3387
|
+
<link rel="modulepreload" href="/vendor/kit-1.mjs">
|
|
3388
|
+
<link rel="modulepreload" href="/vendor/zipstore-1.mjs">
|
|
3389
|
+
<!-- importing shelf libs (kit-2, recharts, …)? add a matching modulepreload —
|
|
3390
|
+
it flattens the import waterfall into one parallel fetch round. Only
|
|
3391
|
+
preload what the App actually imports; the scaffold body uses kit-1. -->
|
|
3012
3392
|
<script src="/vendor/tailwind.js"></script>
|
|
3013
3393
|
</head>
|
|
3014
3394
|
<body class="bg-[#f6f2ee] text-[#101113] antialiased">
|
|
@@ -3090,7 +3470,7 @@ async function artifactInitCmd(args) {
|
|
|
3090
3470
|
assertValidKey(slug, 'artifact-init'); // the slug becomes the set key — fail before any writes
|
|
3091
3471
|
const kind = strArg(args.kind) || 'report';
|
|
3092
3472
|
const title = strArg(args.title) || slug;
|
|
3093
|
-
const dir =
|
|
3473
|
+
const dir = join(homeRoot('artifacts'), slug);
|
|
3094
3474
|
const indexPath = join(dir, 'index.html');
|
|
3095
3475
|
if (existsSync(indexPath)) {
|
|
3096
3476
|
console.error(`artifact-init: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
|
|
@@ -3098,7 +3478,7 @@ async function artifactInitCmd(args) {
|
|
|
3098
3478
|
}
|
|
3099
3479
|
mkdirSync(dir, { recursive: true });
|
|
3100
3480
|
writeFileSync(indexPath, blankScaffold(title, existsSync(join(dir, 'data.json'))));
|
|
3101
|
-
ensureGitIgnored(process.cwd(),
|
|
3481
|
+
ensureGitIgnored(process.cwd(), `${VITRINKA_HOME}/`);
|
|
3102
3482
|
await remoteInitCmd(dir, { ...args, key: slug, kind });
|
|
3103
3483
|
console.log(`scaffolded ${indexPath}`);
|
|
3104
3484
|
console.log(`author the App, then: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
|
|
@@ -3111,8 +3491,8 @@ async function artifactFromSetCmd(args) {
|
|
|
3111
3491
|
}
|
|
3112
3492
|
const slug = sanitizeSeg(slugRaw, 64);
|
|
3113
3493
|
assertValidKey(slug, 'artifact-from-set'); // the slug becomes the set key — fail before any writes
|
|
3114
|
-
const from = resolve(strArg(args.from)
|
|
3115
|
-
const dir =
|
|
3494
|
+
const from = strArg(args.from) ? resolve(strArg(args.from)) : homeRoot('screenshots');
|
|
3495
|
+
const dir = join(homeRoot('artifacts'), slug);
|
|
3116
3496
|
const indexPath = join(dir, 'index.html');
|
|
3117
3497
|
if (existsSync(indexPath)) {
|
|
3118
3498
|
console.error(`artifact-from-set: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
|
|
@@ -3128,7 +3508,7 @@ async function artifactFromSetCmd(args) {
|
|
|
3128
3508
|
const fromManifest = loadManifest(from);
|
|
3129
3509
|
const title = strArg(args.title) || (fromManifest.journey && fromManifest.journey.title) || slug;
|
|
3130
3510
|
writeFileSync(indexPath, composedScaffold(title, `${slug}.zip`));
|
|
3131
|
-
ensureGitIgnored(process.cwd(),
|
|
3511
|
+
ensureGitIgnored(process.cwd(), `${VITRINKA_HOME}/`);
|
|
3132
3512
|
await remoteInitCmd(dir, { ...args, key: slug, kind: strArg(args.kind) || 'report' });
|
|
3133
3513
|
// Record the source screenshot set (project/branchSlug/key) so push sends ?source= and
|
|
3134
3514
|
// vitrinka can cross-link set ↔ artifact both ways.
|
|
@@ -3150,7 +3530,7 @@ async function artifactFromSetCmd(args) {
|
|
|
3150
3530
|
console.log(`scaffolded ${indexPath} (${res.count} shots embedded${source ? `, source ${source}` : ''})`);
|
|
3151
3531
|
console.log(`write the narrative sections, then \`push\`: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
|
|
3152
3532
|
}
|
|
3153
|
-
const ROOT_FLAG = { f: '--root', arg: '<dir>', d: 'capture root (default .screenshots)' };
|
|
3533
|
+
const ROOT_FLAG = { f: '--root', arg: '<dir>', d: 'capture root (default .vitrinka/screenshots)' };
|
|
3154
3534
|
const BASE_FLAG = { f: '--base', arg: '<url>', d: 'vitrinka base URL (default $VITRINKA_URL or the mesh host)' };
|
|
3155
3535
|
const CTX_FLAGS = [
|
|
3156
3536
|
{ f: '--label', arg: '<STAGE>', d: 'journey stage label' },
|
|
@@ -3170,17 +3550,17 @@ export const COMMANDS = [
|
|
|
3170
3550
|
flags: [ROOT_FLAG, { f: '--file', arg: '<rel>', d: 'screenshot path relative to root (required)' },
|
|
3171
3551
|
{ f: '--surface', arg: '<ios|android|web|macos>', d: 'platform (default other)' },
|
|
3172
3552
|
{ f: '--route', arg: '<r>', d: 'app route' }, { f: '--note', arg: '<n>', d: 'caption' }, ...CTX_FLAGS],
|
|
3173
|
-
examples: ['vitrinka add --root .screenshots --file 2026-07-06/01-web-home.png --surface web --route /home --note "landing"'],
|
|
3553
|
+
examples: ['vitrinka add --root .vitrinka/screenshots --file 2026-07-06/01-web-home.png --surface web --route /home --note "landing"'],
|
|
3174
3554
|
},
|
|
3175
3555
|
{ name: 'build', summary: 'Rebuild index.html from the manifest', usage: '--root <dir>', flags: [ROOT_FLAG],
|
|
3176
|
-
examples: ['vitrinka build --root .screenshots'] },
|
|
3556
|
+
examples: ['vitrinka build --root .vitrinka/screenshots'] },
|
|
3177
3557
|
{
|
|
3178
3558
|
name: 'meta', summary: 'Set the journey header (kicker/title/accent/intro/chips)',
|
|
3179
3559
|
usage: '--root <dir> [--kicker <k>] [--title <t>] [--accent <substr>] [--intro <p>] [--chip "K=V" ...]',
|
|
3180
3560
|
flags: [ROOT_FLAG, { f: '--kicker', arg: '<k>', d: 'eyebrow kicker' }, { f: '--title', arg: '<t>', d: 'display title' },
|
|
3181
3561
|
{ f: '--accent', arg: '<substr>', d: 'substring of the title to accent' }, { f: '--intro', arg: '<p>', d: 'intro paragraph' },
|
|
3182
3562
|
{ f: '--chip', arg: '"K=V"', d: 'meta chip (repeatable)' }],
|
|
3183
|
-
examples: ['vitrinka meta --root .screenshots --kicker "FLOW" --title "Payout flow" --accent "Payout"'],
|
|
3563
|
+
examples: ['vitrinka meta --root .vitrinka/screenshots --kicker "FLOW" --title "Payout flow" --accent "Payout"'],
|
|
3184
3564
|
},
|
|
3185
3565
|
{
|
|
3186
3566
|
name: 'remote-init', summary: 'Create a .vitrinka set config for publishing',
|
|
@@ -3189,7 +3569,7 @@ export const COMMANDS = [
|
|
|
3189
3569
|
{ f: '--branch', arg: '<b>', d: 'branch (default: current)' }, { f: '--kind', arg: '<k>', d: 'set kind (default screenshots)' },
|
|
3190
3570
|
{ f: '--key', arg: '<k>', d: 'set key (default: timestamped)' }, { f: '--issue', arg: '<ref>', d: 'linked issue ref' }],
|
|
3191
3571
|
dynamic: 'projects',
|
|
3192
|
-
examples: ['vitrinka remote-init --root .screenshots --project fixit --branch main'],
|
|
3572
|
+
examples: ['vitrinka remote-init --root .vitrinka/screenshots --project fixit --branch main'],
|
|
3193
3573
|
},
|
|
3194
3574
|
{
|
|
3195
3575
|
name: 'push', summary: 'Publish the set to vitrinka (tar+PUT), refresh the index',
|
|
@@ -3197,7 +3577,7 @@ export const COMMANDS = [
|
|
|
3197
3577
|
flags: [ROOT_FLAG, { f: '--title', arg: '<t>', d: 'human title (re-sent every push)' },
|
|
3198
3578
|
{ f: '--name', arg: '<slug>', d: 'name the set after publish (sticky)' },
|
|
3199
3579
|
{ f: '--source', arg: '<ref>', d: 'cross-link source set project/branchSlug/key' }],
|
|
3200
|
-
examples: ['vitrinka push --root .screenshots --title "Payout flow"'],
|
|
3580
|
+
examples: ['vitrinka push --root .vitrinka/screenshots --title "Payout flow"'],
|
|
3201
3581
|
},
|
|
3202
3582
|
{
|
|
3203
3583
|
name: 'name', summary: "Set or clear a set's custom URL name",
|
|
@@ -3222,6 +3602,13 @@ export const COMMANDS = [
|
|
|
3222
3602
|
flags: [{ f: '--fix', d: 'repair everything repairable: refresh skills, rewrite the shim, re-register the MCP, refresh the Claude UI wiring' }, BASE_FLAG],
|
|
3223
3603
|
examples: ['vitrinka doctor', 'vitrinka doctor --fix'],
|
|
3224
3604
|
},
|
|
3605
|
+
{
|
|
3606
|
+
name: 'tidy', summary: 'Sweep legacy roots + shot-shaped litter into .vitrinka/ (report-first)',
|
|
3607
|
+
usage: '[--yes] [--all]',
|
|
3608
|
+
flags: [{ f: '--yes', d: 'apply the planned moves (default: report only)' },
|
|
3609
|
+
{ f: '--all', d: 'also move loose root images and .aud_*/.audit_* text litter (default: report them)' }],
|
|
3610
|
+
examples: ['vitrinka tidy', 'vitrinka tidy --yes', 'vitrinka tidy --yes --all'],
|
|
3611
|
+
},
|
|
3225
3612
|
{
|
|
3226
3613
|
name: 'install', summary: 'Onboard this machine: status table, then skills + shim + completion + MCP + token + operator + Claude UI',
|
|
3227
3614
|
usage: '[--local] [--operator <name>] [--mcp|--no-mcp] [--claude|--no-claude] [--no-completion]',
|
|
@@ -3231,9 +3618,19 @@ export const COMMANDS = [
|
|
|
3231
3618
|
{ f: '--no-mcp', d: 'skip the MCP registration' },
|
|
3232
3619
|
{ f: '--claude', d: 'wire the Claude Code UI without asking' },
|
|
3233
3620
|
{ f: '--no-claude', d: 'skip the Claude Code UI wiring' },
|
|
3234
|
-
{ f: '--no-completion', d: 'skip appending the shell-completion line' }
|
|
3621
|
+
{ f: '--no-completion', d: 'skip appending the shell-completion line' },
|
|
3622
|
+
{ f: '--yes', d: 'non-interactive: skip every prompt (config-touchers print their manual command)' }],
|
|
3235
3623
|
examples: ['vitrinka install', 'vitrinka install --local', 'vitrinka install --operator "Lukáš" --mcp --claude'],
|
|
3236
3624
|
},
|
|
3625
|
+
{
|
|
3626
|
+
name: 'setup-project', summary: 'Onboard THIS repo: registry + file/route index + component index + editor check (interactive, idempotent)',
|
|
3627
|
+
usage: '[--yes] [--base <url>] [--project <p>]',
|
|
3628
|
+
flags: [BASE_FLAG,
|
|
3629
|
+
{ f: '--project', arg: '<p>', d: 'project name (default: repo dir name)' },
|
|
3630
|
+
{ f: '--yes', d: 'non-interactive: take every default, prompt for nothing' }],
|
|
3631
|
+
examples: ['vitrinka setup-project', 'vitrinka setup-project --yes'],
|
|
3632
|
+
write: true,
|
|
3633
|
+
},
|
|
3237
3634
|
{
|
|
3238
3635
|
name: 'uninstall', summary: 'Remove everything install put here (skills, shim, completion, Claude wiring, MCP); token/operator survive',
|
|
3239
3636
|
usage: '[--purge] [--yes] [--local]',
|
|
@@ -3339,7 +3736,7 @@ export const COMMANDS = [
|
|
|
3339
3736
|
flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
|
|
3340
3737
|
{ f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
|
|
3341
3738
|
dynamic: 'boards',
|
|
3342
|
-
examples: ['vitrinka board-from-set --root .screenshots'],
|
|
3739
|
+
examples: ['vitrinka board-from-set --root .vitrinka/screenshots'],
|
|
3343
3740
|
},
|
|
3344
3741
|
{
|
|
3345
3742
|
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)',
|
|
@@ -3347,7 +3744,7 @@ export const COMMANDS = [
|
|
|
3347
3744
|
flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
|
|
3348
3745
|
{ f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
|
|
3349
3746
|
dynamic: 'boards',
|
|
3350
|
-
examples: ['vitrinka journey-from-set --root .screenshots'],
|
|
3747
|
+
examples: ['vitrinka journey-from-set --root .vitrinka/screenshots'],
|
|
3351
3748
|
},
|
|
3352
3749
|
{
|
|
3353
3750
|
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',
|
|
@@ -3368,7 +3765,7 @@ export const COMMANDS = [
|
|
|
3368
3765
|
usage: '[--root <dir>] [--base <url>] [--project <p>]',
|
|
3369
3766
|
flags: [ROOT_FLAG, BASE_FLAG, { f: '--project', arg: '<p>', d: 'project slug (default: worktree name)' }],
|
|
3370
3767
|
dynamic: 'projects',
|
|
3371
|
-
examples: ['vitrinka index --root .screenshots'],
|
|
3768
|
+
examples: ['vitrinka index --root .vitrinka/screenshots'],
|
|
3372
3769
|
},
|
|
3373
3770
|
{
|
|
3374
3771
|
name: 'index-components', summary: 'Scan a UI library for exported components + push the component index',
|
|
@@ -3389,7 +3786,7 @@ export const COMMANDS = [
|
|
|
3389
3786
|
usage: '[--root <dir>] [--select 1,3-5] [--size 720] [--quality 85] --out <dir>/data.json',
|
|
3390
3787
|
flags: [ROOT_FLAG, { f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--size', arg: '<px>', d: 'longest edge (default 720)' },
|
|
3391
3788
|
{ f: '--quality', arg: '<q>', d: 'JPEG quality (default 85)' }, { f: '--out', arg: '<file>', d: 'output data.json (required)' }],
|
|
3392
|
-
examples: ['vitrinka embed-shots --root .screenshots --out .artifacts/x/data.json'],
|
|
3789
|
+
examples: ['vitrinka embed-shots --root .vitrinka/screenshots --out .vitrinka/artifacts/x/data.json'],
|
|
3393
3790
|
},
|
|
3394
3791
|
{
|
|
3395
3792
|
name: 'artifact-init', summary: 'Scaffold a blank artifact report + set config',
|
|
@@ -3400,11 +3797,11 @@ export const COMMANDS = [
|
|
|
3400
3797
|
},
|
|
3401
3798
|
{
|
|
3402
3799
|
name: 'artifact-from-set', summary: 'Scaffold an artifact report from a screenshot set',
|
|
3403
|
-
usage: '--slug <s> [--from .screenshots] [--select 1,3-5] [--title <t>] [--kind report] [--source <ref>]',
|
|
3404
|
-
flags: [{ f: '--slug', arg: '<s>', d: 'artifact slug (= set key, required)' }, { f: '--from', arg: '<dir>', d: 'source set root (default .screenshots)' },
|
|
3800
|
+
usage: '--slug <s> [--from .vitrinka/screenshots] [--select 1,3-5] [--title <t>] [--kind report] [--source <ref>]',
|
|
3801
|
+
flags: [{ f: '--slug', arg: '<s>', d: 'artifact slug (= set key, required)' }, { f: '--from', arg: '<dir>', d: 'source set root (default .vitrinka/screenshots)' },
|
|
3405
3802
|
{ f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--title', arg: '<t>', d: 'artifact title' },
|
|
3406
3803
|
{ f: '--kind', arg: '<k>', d: 'set kind (default report)' }, { f: '--source', arg: '<ref>', d: 'source set ref for cross-linking' }, BASE_FLAG],
|
|
3407
|
-
examples: ['vitrinka artifact-from-set --slug payout-report --from .screenshots'],
|
|
3804
|
+
examples: ['vitrinka artifact-from-set --slug payout-report --from .vitrinka/screenshots'],
|
|
3408
3805
|
},
|
|
3409
3806
|
// Meta commands — documented + completable, kept out of the compact usage grid.
|
|
3410
3807
|
{ name: 'help', summary: 'Show help (all commands, or one command in detail)', usage: '[<command>]', positional: '[<command>]', hidden: true,
|
|
@@ -3885,7 +4282,7 @@ async function boardFromSetCmd(root, args, journey = false) {
|
|
|
3885
4282
|
const cfg = loadVitrinkaCfg(root);
|
|
3886
4283
|
// The .vitrinka descriptor names the project — record its main worktree top so
|
|
3887
4284
|
// the desktop app can discover this checkout (instant-editor decision #6).
|
|
3888
|
-
const projTop = mainWorktreeTop(
|
|
4285
|
+
const projTop = mainWorktreeTop(projectDirOf(root));
|
|
3889
4286
|
if (cfg.project && projTop)
|
|
3890
4287
|
recordProject(sanitizeSeg(cfg.project, 64), projTop);
|
|
3891
4288
|
const slug = (strArg(args.slug) || cfg.key).toLowerCase();
|
|
@@ -3932,7 +4329,7 @@ async function boardFromSetCmd(root, args, journey = false) {
|
|
|
3932
4329
|
const boardUrl = out.board?.url || `${cfg.base}${ws}/boards/${slug}`;
|
|
3933
4330
|
console.log(`flow board: ${boardUrl} (${out.cards.length} cards, ${out.edges.length} edges)`);
|
|
3934
4331
|
// The board is about to be annotated — make sure its autocomplete index is fresh.
|
|
3935
|
-
await pushProjectIndex(
|
|
4332
|
+
await pushProjectIndex(projectDirOf(root), cfg.base, cfg.project);
|
|
3936
4333
|
}
|
|
3937
4334
|
// ---------- upload: files → board cards (mcp-file-upload 2026-07-15) ----------
|
|
3938
4335
|
//
|
|
@@ -4535,7 +4932,12 @@ async function watchCmd(args) {
|
|
|
4535
4932
|
async function runSub(argv) {
|
|
4536
4933
|
const [cmd, ...rest] = argv;
|
|
4537
4934
|
const args = parseArgs(rest);
|
|
4538
|
-
|
|
4935
|
+
// The default root may auto-migrate a legacy .screenshots/ — compute it ONLY
|
|
4936
|
+
// for commands that actually take --root. Anything else (tidy, which owns
|
|
4937
|
+
// migration report-first; artifact-from-set, whose --from must keep seeing
|
|
4938
|
+
// the legacy path) must not move dirs as a side effect of dispatch.
|
|
4939
|
+
const takesRoot = COMMAND_MAP[cmd]?.flags?.some((f) => f.f === '--root') ?? false;
|
|
4940
|
+
const root = strArg(args.root) ? resolve(strArg(args.root)) : takesRoot ? homeRoot('screenshots') : '';
|
|
4539
4941
|
if (cmd === 'add')
|
|
4540
4942
|
addCmd(root, args, rest);
|
|
4541
4943
|
else if (cmd === 'build') {
|
|
@@ -4556,8 +4958,12 @@ async function runSub(argv) {
|
|
|
4556
4958
|
await loginCmd(args);
|
|
4557
4959
|
else if (cmd === 'doctor')
|
|
4558
4960
|
await doctorCmd(args);
|
|
4961
|
+
else if (cmd === 'tidy')
|
|
4962
|
+
tidyCmd(args);
|
|
4559
4963
|
else if (cmd === 'install')
|
|
4560
4964
|
await installCmd(args);
|
|
4965
|
+
else if (cmd === 'setup-project')
|
|
4966
|
+
await setupProjectCmd(args);
|
|
4561
4967
|
else if (cmd === 'install-claude')
|
|
4562
4968
|
installClaudeCmd(args);
|
|
4563
4969
|
else if (cmd === 'uninstall')
|