@lovinka/vitrinka 1.1.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/README.md +142 -0
- package/dist/bin-mcp.js +33 -0
- package/dist/bin-mcp.js.map +1 -0
- package/dist/cli.js +2543 -0
- package/dist/cli.js.map +1 -0
- package/dist/lib/base-url.js +14 -0
- package/dist/lib/base-url.js.map +1 -0
- package/dist/lib/http.js +39 -0
- package/dist/lib/http.js.map +1 -0
- package/dist/lib/operator.js +46 -0
- package/dist/lib/operator.js.map +1 -0
- package/dist/lib/token.js +19 -0
- package/dist/lib/token.js.map +1 -0
- package/dist/mcp.js +405 -0
- package/dist/mcp.js.map +1 -0
- package/dist/work.js +143 -0
- package/dist/work.js.map +1 -0
- package/package.json +44 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2543 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cli.ts — the unified vitrinka CLI. Maintains `.screenshots/manifest.json` + a self-contained,
|
|
3
|
+
// clickable `index.html` gallery, publishes sets to vitrinka (the internal artifacts app), and
|
|
4
|
+
// one-shots capture (`snap`) + artifact scaffolding (`artifact-init`, `artifact-from-set`).
|
|
5
|
+
//
|
|
6
|
+
// Zero dependencies (node: builtins only). Erasable TypeScript only — Node 24 type-stripping
|
|
7
|
+
// and bun both run it directly: `node cli.ts <cmd>`.
|
|
8
|
+
//
|
|
9
|
+
// add / build / meta / remote-init / push are ported verbatim in behavior from
|
|
10
|
+
// skills/screenshot/gallery.mjs (offline marker, COPYFILE_DISABLE, control-file excludes,
|
|
11
|
+
// `--key`, repeatable `--chip`). gallery.mjs is now a passthrough shim to this file.
|
|
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, } from 'node:fs';
|
|
14
|
+
import { join, resolve, basename, dirname, extname, relative, sep } from 'node:path';
|
|
15
|
+
import { tmpdir, hostname } from 'node:os';
|
|
16
|
+
import { spawnSync, spawn } from 'node:child_process';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
|
+
import { createInterface } from 'node:readline';
|
|
20
|
+
import { readToken } from "./lib/token.js";
|
|
21
|
+
import { operatorPath, readOperator, actorHeaderValue } from "./lib/operator.js";
|
|
22
|
+
import { resolveBaseUrl } from "./lib/base-url.js";
|
|
23
|
+
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
24
|
+
export function parseArgs(argv) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
28
|
+
if (!a.startsWith('--'))
|
|
29
|
+
continue;
|
|
30
|
+
const key = a.slice(2);
|
|
31
|
+
const next = argv[i + 1];
|
|
32
|
+
const val = next === undefined || next.startsWith('--') ? true : argv[++i];
|
|
33
|
+
const cur = out[key];
|
|
34
|
+
if (cur === undefined)
|
|
35
|
+
out[key] = val; // repeated flags (e.g. --chip) accumulate
|
|
36
|
+
else if (Array.isArray(cur))
|
|
37
|
+
cur.push(val);
|
|
38
|
+
else
|
|
39
|
+
out[key] = [cur, val];
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
// Last-wins string extraction for the new commands (ported commands keep their original
|
|
44
|
+
// inline `args.x && args.x !== true` expressions so behavior stays verbatim).
|
|
45
|
+
function strArg(v) {
|
|
46
|
+
if (v === undefined || v === true)
|
|
47
|
+
return '';
|
|
48
|
+
if (Array.isArray(v)) {
|
|
49
|
+
const last = v[v.length - 1];
|
|
50
|
+
return last === true ? '' : String(last);
|
|
51
|
+
}
|
|
52
|
+
return String(v);
|
|
53
|
+
}
|
|
54
|
+
function numArg(v, dflt, flag) {
|
|
55
|
+
const s = strArg(v);
|
|
56
|
+
if (!s)
|
|
57
|
+
return dflt;
|
|
58
|
+
const n = Number(s);
|
|
59
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
60
|
+
console.error(`${flag}: expected a positive number, got ${JSON.stringify(s)}`);
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
return n;
|
|
64
|
+
}
|
|
65
|
+
function fail(msg, code = 1) {
|
|
66
|
+
console.error(msg);
|
|
67
|
+
process.exit(code);
|
|
68
|
+
}
|
|
69
|
+
// positionals extracts the non-flag arguments, honoring parseArgs's
|
|
70
|
+
// flag-value consumption (a non-"--" token right after a flag is its value).
|
|
71
|
+
export function positionals(argv) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const a = argv[i];
|
|
75
|
+
if (a.startsWith('--')) {
|
|
76
|
+
const next = argv[i + 1];
|
|
77
|
+
if (next !== undefined && !next.startsWith('--'))
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
out.push(a);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
function manifestPathOf(root) { return join(root, 'manifest.json'); }
|
|
86
|
+
function htmlPathOf(root) { return join(root, 'index.html'); }
|
|
87
|
+
function loadManifest(root) {
|
|
88
|
+
const manifestPath = manifestPathOf(root);
|
|
89
|
+
if (!existsSync(manifestPath))
|
|
90
|
+
return { version: 1, shots: [] };
|
|
91
|
+
const raw = readFileSync(manifestPath, 'utf8'); // ENOENT handled above; other read errors surface loudly
|
|
92
|
+
let m;
|
|
93
|
+
try {
|
|
94
|
+
m = JSON.parse(raw);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
// Corrupt existing manifest is unexpected — never silently overwrite (would lose history).
|
|
98
|
+
throw new Error(`Corrupt manifest ${manifestPath} (${e.message}). Fix or delete it manually; refusing to overwrite.`);
|
|
99
|
+
}
|
|
100
|
+
if (!m || !Array.isArray(m.shots)) {
|
|
101
|
+
throw new Error(`Malformed manifest ${manifestPath}: missing shots[]. Fix or delete it manually.`);
|
|
102
|
+
}
|
|
103
|
+
return m;
|
|
104
|
+
}
|
|
105
|
+
function saveManifest(root, m) {
|
|
106
|
+
mkdirSync(root, { recursive: true });
|
|
107
|
+
ensureRootSelfIgnored(root);
|
|
108
|
+
writeFileSync(manifestPathOf(root), JSON.stringify(m, null, 2) + '\n');
|
|
109
|
+
}
|
|
110
|
+
function buildIndex(root) {
|
|
111
|
+
const m = loadManifest(root);
|
|
112
|
+
const project = basename(dirname(root)) || 'project';
|
|
113
|
+
const htmlPath = htmlPathOf(root);
|
|
114
|
+
writeFileSync(htmlPath, renderHtml(m.shots, project));
|
|
115
|
+
return { count: m.shots.length, htmlPath };
|
|
116
|
+
}
|
|
117
|
+
// shotContext builds the v2 capture-context fields from CLI flags:
|
|
118
|
+
// --src (repeatable or comma-separated), --state, --device <name>,
|
|
119
|
+
// --viewport WxH[@scale]. Empty object stays off the manifest entirely.
|
|
120
|
+
export function shotContext(args) {
|
|
121
|
+
const out = {};
|
|
122
|
+
const rawSrc = args.src === undefined ? [] : Array.isArray(args.src) ? args.src : [args.src];
|
|
123
|
+
const src = rawSrc.filter((v) => typeof v === 'string')
|
|
124
|
+
.flatMap((v) => v.split(',')).map((v) => v.trim()).filter(Boolean);
|
|
125
|
+
if (src.length)
|
|
126
|
+
out.src = src;
|
|
127
|
+
const state = strArg(args.state);
|
|
128
|
+
if (state)
|
|
129
|
+
out.state = state;
|
|
130
|
+
const deviceName = strArg(args.device);
|
|
131
|
+
const vp = strArg(args.viewport);
|
|
132
|
+
if (deviceName || vp) {
|
|
133
|
+
const device = {};
|
|
134
|
+
if (deviceName)
|
|
135
|
+
device.device = deviceName;
|
|
136
|
+
const m = vp ? /^(\d+)x(\d+)(?:@(\d+(?:\.\d+)?))?$/.exec(vp) : null;
|
|
137
|
+
if (vp && !m)
|
|
138
|
+
fail(`invalid --viewport ${JSON.stringify(vp)} — expected WxH or WxH@scale (e.g. 393x852@3)`, 2);
|
|
139
|
+
if (m) {
|
|
140
|
+
device.w = Number(m[1]);
|
|
141
|
+
device.h = Number(m[2]);
|
|
142
|
+
if (m[3])
|
|
143
|
+
device.scale = Number(m[3]);
|
|
144
|
+
}
|
|
145
|
+
out.device = device;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function appendShot(root, shot) {
|
|
150
|
+
const m = loadManifest(root);
|
|
151
|
+
m.shots.push(shot);
|
|
152
|
+
// v2: stamp the session's code identity (last append wins — the freshest
|
|
153
|
+
// commit is the one the newest pixels came from).
|
|
154
|
+
m.version = Math.max(m.version || 1, 2);
|
|
155
|
+
const projectDir = dirname(root);
|
|
156
|
+
const commit = git(['rev-parse', '--short', 'HEAD'], projectDir);
|
|
157
|
+
const branch = git(['branch', '--show-current'], projectDir);
|
|
158
|
+
if (commit)
|
|
159
|
+
m.commit = commit;
|
|
160
|
+
if (branch)
|
|
161
|
+
m.branch = branch;
|
|
162
|
+
saveManifest(root, m);
|
|
163
|
+
const project = basename(dirname(root)) || 'project';
|
|
164
|
+
const htmlPath = htmlPathOf(root);
|
|
165
|
+
writeFileSync(htmlPath, renderHtml(m.shots, project));
|
|
166
|
+
return { count: m.shots.length, htmlPath };
|
|
167
|
+
}
|
|
168
|
+
function addCmd(root, args) {
|
|
169
|
+
const file = args.file;
|
|
170
|
+
if (!file || file === true) {
|
|
171
|
+
console.error('cli.ts add: --file <rel-path> is required');
|
|
172
|
+
process.exit(2);
|
|
173
|
+
}
|
|
174
|
+
const shot = {
|
|
175
|
+
file: String(file),
|
|
176
|
+
surface: args.surface && args.surface !== true ? String(args.surface) : 'other',
|
|
177
|
+
route: args.route && args.route !== true ? String(args.route) : '',
|
|
178
|
+
note: args.note && args.note !== true ? String(args.note) : '',
|
|
179
|
+
// journey fields (vitrinka editorial viewer): stage label + short human title
|
|
180
|
+
label: args.label && args.label !== true ? String(args.label) : undefined,
|
|
181
|
+
title: args.title && args.title !== true ? String(args.title) : undefined,
|
|
182
|
+
action: args.action && args.action !== true ? String(args.action) : undefined,
|
|
183
|
+
ts: new Date().toISOString(),
|
|
184
|
+
...shotContext(args),
|
|
185
|
+
};
|
|
186
|
+
const { count, htmlPath } = appendShot(root, shot);
|
|
187
|
+
console.log(`+ ${file} (${count} shots) → ${htmlPath}`);
|
|
188
|
+
}
|
|
189
|
+
export function esc(s) {
|
|
190
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
191
|
+
}
|
|
192
|
+
function renderHtml(shots, project) {
|
|
193
|
+
// Data is inlined and rendered client-side via textContent/setAttribute → XSS-safe by construction.
|
|
194
|
+
// Escape </script> so the JSON payload can't break out of the <script> tag.
|
|
195
|
+
const data = JSON.stringify(shots).replace(/</g, '\\u003c');
|
|
196
|
+
return `<!doctype html>
|
|
197
|
+
<html lang="en">
|
|
198
|
+
<head>
|
|
199
|
+
<meta charset="utf-8">
|
|
200
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
201
|
+
<title>Screenshots — ${esc(project)}</title>
|
|
202
|
+
<style>
|
|
203
|
+
:root { color-scheme: dark; }
|
|
204
|
+
* { box-sizing: border-box; }
|
|
205
|
+
body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
206
|
+
background: #0d1117; color: #e6edf3; }
|
|
207
|
+
header { position: sticky; top: 0; z-index: 5; display: flex; align-items: baseline; gap: 12px;
|
|
208
|
+
padding: 14px 22px; background: #161b22ee; backdrop-filter: blur(8px);
|
|
209
|
+
border-bottom: 1px solid #30363d; }
|
|
210
|
+
header h1 { font-size: 15px; margin: 0; font-weight: 600; }
|
|
211
|
+
header .sub { color: #8b949e; font-size: 12px; }
|
|
212
|
+
main { max-width: 1080px; margin: 0 auto; padding: 8px 22px 80px; }
|
|
213
|
+
.day { margin-top: 28px; }
|
|
214
|
+
.day-h { display: flex; align-items: baseline; gap: 10px; position: sticky; top: 49px;
|
|
215
|
+
background: #0d1117; padding: 8px 0; border-bottom: 1px solid #21262d; z-index: 2; }
|
|
216
|
+
.day-h .date { font-size: 13px; font-weight: 600; color: #58a6ff; }
|
|
217
|
+
.day-h .count { font-size: 11px; color: #8b949e; }
|
|
218
|
+
.card { display: flex; gap: 18px; padding: 18px 0; border-bottom: 1px solid #21262d; }
|
|
219
|
+
.card a.shot { flex: 0 0 auto; }
|
|
220
|
+
.card img { display: block; width: 360px; max-width: 42vw; height: auto; border-radius: 8px;
|
|
221
|
+
border: 1px solid #30363d; background: #010409; }
|
|
222
|
+
.meta { min-width: 0; display: flex; flex-direction: column; gap: 8px; padding-top: 2px; }
|
|
223
|
+
.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
224
|
+
.badge { font-size: 10px; letter-spacing: .04em; text-transform: uppercase; font-weight: 700;
|
|
225
|
+
padding: 2px 7px; border-radius: 999px; color: #0d1117; background: #8b949e; }
|
|
226
|
+
.badge.ios { background: #58a6ff; } .badge.android { background: #3fb950; }
|
|
227
|
+
.badge.web { background: #d29922; } .badge.macos { background: #bc8cff; }
|
|
228
|
+
.route { font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; color: #e6edf3;
|
|
229
|
+
background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 2px 7px; }
|
|
230
|
+
.note { margin: 0; color: #c9d1d9; }
|
|
231
|
+
.foot { display: flex; gap: 12px; align-items: center; margin-top: auto; color: #6e7681; font-size: 11px; }
|
|
232
|
+
.foot .file { font-family: ui-monospace, monospace; }
|
|
233
|
+
.empty { color: #8b949e; padding: 40px 0; text-align: center; }
|
|
234
|
+
@media (max-width: 620px) { .card { flex-direction: column; } .card img { width: 100%; max-width: 100%; } }
|
|
235
|
+
</style>
|
|
236
|
+
</head>
|
|
237
|
+
<body>
|
|
238
|
+
<header><h1>📸 ${esc(project)}</h1><span class="sub" id="sub"></span></header>
|
|
239
|
+
<main id="app"></main>
|
|
240
|
+
<script>
|
|
241
|
+
const SHOTS = ${data};
|
|
242
|
+
const app = document.getElementById('app');
|
|
243
|
+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
|
|
244
|
+
SHOTS.sort((a, b) => String(a.ts || '').localeCompare(String(b.ts || '')));
|
|
245
|
+
document.getElementById('sub').textContent = SHOTS.length + (SHOTS.length === 1 ? ' shot' : ' shots');
|
|
246
|
+
const dayOf = (s) => {
|
|
247
|
+
const seg = String(s.file || '').split('/')[0];
|
|
248
|
+
if (/^\\d{4}-\\d{2}-\\d{2}$/.test(seg)) return seg;
|
|
249
|
+
return String(s.ts || '').slice(0, 10) || 'undated';
|
|
250
|
+
};
|
|
251
|
+
const groups = {};
|
|
252
|
+
for (const s of SHOTS) (groups[dayOf(s)] ||= []).push(s);
|
|
253
|
+
const days = Object.keys(groups).sort();
|
|
254
|
+
if (!SHOTS.length) app.appendChild(el('p', 'empty', 'No screenshots captured yet.'));
|
|
255
|
+
for (const day of days) {
|
|
256
|
+
const sec = el('section', 'day');
|
|
257
|
+
const h = el('div', 'day-h');
|
|
258
|
+
h.appendChild(el('span', 'date', day));
|
|
259
|
+
h.appendChild(el('span', 'count', groups[day].length + (groups[day].length === 1 ? ' shot' : ' shots')));
|
|
260
|
+
sec.appendChild(h);
|
|
261
|
+
for (const s of groups[day]) {
|
|
262
|
+
const card = el('div', 'card');
|
|
263
|
+
const a = el('a', 'shot'); a.href = s.file; a.target = '_blank'; a.rel = 'noopener';
|
|
264
|
+
const img = el('img'); img.src = s.file; img.loading = 'lazy'; img.alt = s.route || s.file;
|
|
265
|
+
a.appendChild(img); card.appendChild(a);
|
|
266
|
+
const meta = el('div', 'meta');
|
|
267
|
+
const row = el('div', 'row');
|
|
268
|
+
row.appendChild(el('span', 'badge ' + (s.surface || 'other'), s.surface || 'other'));
|
|
269
|
+
if (s.route) row.appendChild(el('code', 'route', s.route));
|
|
270
|
+
meta.appendChild(row);
|
|
271
|
+
if (s.note) meta.appendChild(el('p', 'note', s.note));
|
|
272
|
+
const foot = el('div', 'foot');
|
|
273
|
+
foot.appendChild(el('span', 'file', s.file));
|
|
274
|
+
if (s.ts) { const t = new Date(s.ts); if (!isNaN(t)) foot.appendChild(el('time', 'ts', t.toLocaleString())); }
|
|
275
|
+
meta.appendChild(foot);
|
|
276
|
+
card.appendChild(meta);
|
|
277
|
+
sec.appendChild(card);
|
|
278
|
+
}
|
|
279
|
+
app.appendChild(sec);
|
|
280
|
+
}
|
|
281
|
+
</script>
|
|
282
|
+
</body>
|
|
283
|
+
</html>
|
|
284
|
+
`;
|
|
285
|
+
}
|
|
286
|
+
// Set/update the manifest's journey header (vitrinka editorial viewer): kicker,
|
|
287
|
+
// display title (+ red accent substring), intro paragraph, and meta chips.
|
|
288
|
+
function metaCmd(root, args) {
|
|
289
|
+
const m = loadManifest(root);
|
|
290
|
+
m.version = 2;
|
|
291
|
+
const j = m.journey && typeof m.journey === 'object' ? m.journey : {};
|
|
292
|
+
for (const k of ['kicker', 'title', 'accent', 'intro']) {
|
|
293
|
+
const v = args[k];
|
|
294
|
+
if (v && v !== true)
|
|
295
|
+
j[k] = String(Array.isArray(v) ? v[v.length - 1] : v);
|
|
296
|
+
}
|
|
297
|
+
if (args.chip) {
|
|
298
|
+
const chips = (Array.isArray(args.chip) ? args.chip : [args.chip]).filter((c) => c !== true);
|
|
299
|
+
j.chips = chips.map((c) => {
|
|
300
|
+
const idx = String(c).indexOf('=');
|
|
301
|
+
if (idx < 1) {
|
|
302
|
+
console.error(`meta: --chip expects "Key=Value", got ${JSON.stringify(String(c))}`);
|
|
303
|
+
process.exit(2);
|
|
304
|
+
}
|
|
305
|
+
return { k: String(c).slice(0, idx).trim(), v: String(c).slice(idx + 1).trim() };
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
m.journey = j;
|
|
309
|
+
saveManifest(root, m);
|
|
310
|
+
buildIndex(root);
|
|
311
|
+
console.log(`journey meta set (${Object.keys(j).join(', ')}) → ${manifestPathOf(root)}`);
|
|
312
|
+
}
|
|
313
|
+
function vitrinkaCfgPath(root) { return join(root, '.vitrinka'); }
|
|
314
|
+
function offlineMarkerPath(root) { return join(root, '.vitrinka-offline'); }
|
|
315
|
+
// Mirrors vitrinka's server-side slug rules (contract: [a-z0-9._-], collapse '-', trim).
|
|
316
|
+
export function sanitizeSeg(s, max) {
|
|
317
|
+
const out = String(s).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^[-.]+|[-.]+$/g, '');
|
|
318
|
+
return out.slice(0, max) || 'x';
|
|
319
|
+
}
|
|
320
|
+
// Mirrors the server's validKey (internal/web/validate.go): within GET
|
|
321
|
+
// /{project}/{branch}/{selector} a key shares the path slot with versions, "latest" and
|
|
322
|
+
// "{version}.zip", so the server rejects such keys at PUT time. Fail fast at mint time
|
|
323
|
+
// instead of printing a share URL whose first push can only 400.
|
|
324
|
+
export function validKey(k) {
|
|
325
|
+
if (k === 'latest' || k.endsWith('.zip'))
|
|
326
|
+
return false;
|
|
327
|
+
return /[^0-9]/.test(k); // purely numeric keys resolve as version selectors
|
|
328
|
+
}
|
|
329
|
+
function assertValidKey(key, cmd) {
|
|
330
|
+
if (validKey(key))
|
|
331
|
+
return;
|
|
332
|
+
fail(`${cmd}: invalid set key ${JSON.stringify(key)} — a key must not be purely numeric, "latest", or end in .zip (those collide with the server's version selectors)`, 2);
|
|
333
|
+
}
|
|
334
|
+
function git(gitArgs, cwd) {
|
|
335
|
+
const r = spawnSync('git', gitArgs, { cwd, encoding: 'utf8' });
|
|
336
|
+
return r.status === 0 ? r.stdout.trim() : '';
|
|
337
|
+
}
|
|
338
|
+
// Capture roots must never enter a repo's history — agents sometimes invent
|
|
339
|
+
// non-standard roots (".screenshots4") that repo .gitignore globs don't cover,
|
|
340
|
+
// and a later `git add -A` sweeps the set metadata into a commit. Self-ignore
|
|
341
|
+
// every root via .git/info/exclude (local-only, shared across worktrees, never
|
|
342
|
+
// dirties the repo). Memoized per-root; a no-op outside a git repo.
|
|
343
|
+
const selfIgnoredRoots = new Set();
|
|
344
|
+
function ensureRootSelfIgnored(root) {
|
|
345
|
+
const abs = resolve(root);
|
|
346
|
+
if (selfIgnoredRoots.has(abs))
|
|
347
|
+
return;
|
|
348
|
+
selfIgnoredRoots.add(abs);
|
|
349
|
+
try {
|
|
350
|
+
const top = git(['rev-parse', '--show-toplevel'], abs);
|
|
351
|
+
if (!top)
|
|
352
|
+
return; // not inside a git repo
|
|
353
|
+
const rel = relative(top, abs).split(sep).join('/');
|
|
354
|
+
if (!rel || rel.startsWith('..'))
|
|
355
|
+
return; // never ignore the repo root itself
|
|
356
|
+
const excl = git(['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], abs);
|
|
357
|
+
if (!excl)
|
|
358
|
+
return;
|
|
359
|
+
const entry = `/${rel}/`;
|
|
360
|
+
const cur = existsSync(excl) ? readFileSync(excl, 'utf8') : '';
|
|
361
|
+
if (cur.split(/\r?\n/).some((l) => l.trim() === entry))
|
|
362
|
+
return;
|
|
363
|
+
mkdirSync(dirname(excl), { recursive: true });
|
|
364
|
+
writeFileSync(excl, cur + (cur && !cur.endsWith('\n') ? '\n' : '') + entry + '\n');
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
// Best-effort (read-only .git etc.) — warn, never block a capture.
|
|
368
|
+
console.error(`warn: could not self-ignore ${root}: ${e.message}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// mainWorktreeTop resolves the MAIN worktree's root for a checkout. Linked
|
|
372
|
+
// worktrees are typically named after their branch (repo-branch dirs), so
|
|
373
|
+
// deriving the project from their toplevel forks every branch into its own
|
|
374
|
+
// "project" on vitrinka. The common git dir always lives at <main>/.git.
|
|
375
|
+
export function mainWorktreeTop(cwd) {
|
|
376
|
+
const common = git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
|
|
377
|
+
if (common && basename(common) === '.git')
|
|
378
|
+
return dirname(common);
|
|
379
|
+
return git(['rev-parse', '--show-toplevel'], cwd);
|
|
380
|
+
}
|
|
381
|
+
// originRepo normalizes the origin remote to a schemeless slug, e.g.
|
|
382
|
+
// "github.com/LEFTEQ/fixit". "" outside a repo / without a remote.
|
|
383
|
+
function originRepo(dir) {
|
|
384
|
+
return git(['remote', 'get-url', 'origin'], dir)
|
|
385
|
+
.replace(/^git@([^:]+):/, '$1/').replace(/^[a-z+]+:\/\//, '').replace(/\.git$/, '');
|
|
386
|
+
}
|
|
387
|
+
// Binary/asset blobs the autocomplete should never offer — they only bloat
|
|
388
|
+
// the index (the server caps entries at 50k).
|
|
389
|
+
const INDEX_EXCLUDE = /\.(png|jpe?g|gif|webp|avif|heic|ico|icns|svgz|pdf|zip|tar|gz|tgz|bz2|7z|jar|mp[34]|mov|avi|webm|wav|ogg|flac|ttf|otf|woff2?|eot|keystore|jks|p8|p12|mobileprovision|dylib|so|a|o|node|wasm|pyc|class|min\.js|min\.css|map|lock|lockb)$|(^|\/)(package-lock\.json|bun\.lock|yarn\.lock|pnpm-lock\.yaml|go\.sum)$/i;
|
|
390
|
+
const ROUTE_EXTS = /\.(tsx|jsx|ts|js)$/;
|
|
391
|
+
/** extractRoutes derives route paths from file-based routing conventions.
|
|
392
|
+
* Router roots are found at the repo root AND inside workspace containers
|
|
393
|
+
* (`apps/<name>/`, `packages/<name>/`, each with optional `src/`) — FixIt-style
|
|
394
|
+
* monorepos keep Expo Router at apps/client/app/**. Per ROOT: Next.js
|
|
395
|
+
* app-router mode (any app/**\/page.* under THAT root) makes ONLY page.*
|
|
396
|
+
* files routes; otherwise app/** screens follow Expo Router rules (groups
|
|
397
|
+
* "(tabs)" stripped, trailing "index" collapsed, "_layout"/"+not-found"
|
|
398
|
+
* skipped). pages/** always follows the Next pages-router rules. Route paths
|
|
399
|
+
* stay plain, so two roots CAN produce the same path (e.g. apps/web/app/
|
|
400
|
+
* index.tsx and apps/client/app/index.tsx both yield "/") — the first file
|
|
401
|
+
* in input order keeps the path, later duplicates are dropped. Unrecognized
|
|
402
|
+
* layouts yield [] — the files tier still carries the index. */
|
|
403
|
+
const APP_ROOT_RE = /^(?:(?:apps|packages)\/[^/]+\/)?(?:src\/)?app\/(.+)$/;
|
|
404
|
+
const PAGES_ROOT_RE = /^(?:(?:apps|packages)\/[^/]+\/)?(?:src\/)?pages\/(.+)$/;
|
|
405
|
+
export function extractRoutes(files) {
|
|
406
|
+
const routes = new Map();
|
|
407
|
+
// Next-mode is PER router root, not global: a monorepo can hold a Next app
|
|
408
|
+
// (apps/web) and an Expo app (apps/client) side by side. One exec per file:
|
|
409
|
+
// the root is derived from the match already in hand.
|
|
410
|
+
const rootOfMatch = (f, rel) => f.slice(0, f.length - rel.length);
|
|
411
|
+
const nextRoots = new Set();
|
|
412
|
+
for (const f of files) {
|
|
413
|
+
const m = APP_ROOT_RE.exec(f);
|
|
414
|
+
if (m && /\/page\.(tsx|jsx|ts|js)$/.test(f))
|
|
415
|
+
nextRoots.add(rootOfMatch(f, m[1]));
|
|
416
|
+
}
|
|
417
|
+
const add = (rel, file) => {
|
|
418
|
+
const segs = rel.split('/').filter((s) => s && !(s.startsWith('(') && s.endsWith(')')));
|
|
419
|
+
if (segs[segs.length - 1] === 'index')
|
|
420
|
+
segs.pop();
|
|
421
|
+
const p = '/' + segs.join('/');
|
|
422
|
+
if (!routes.has(p))
|
|
423
|
+
routes.set(p, file);
|
|
424
|
+
};
|
|
425
|
+
for (const f of files) {
|
|
426
|
+
if (!ROUTE_EXTS.test(f))
|
|
427
|
+
continue;
|
|
428
|
+
const app = APP_ROOT_RE.exec(f);
|
|
429
|
+
if (app) {
|
|
430
|
+
const rel = app[1].replace(ROUTE_EXTS, '');
|
|
431
|
+
const base = rel.split('/').pop();
|
|
432
|
+
if (nextRoots.has(rootOfMatch(f, app[1]))) {
|
|
433
|
+
if (base === 'page')
|
|
434
|
+
add(rel.replace(/\/?page$/, ''), f);
|
|
435
|
+
}
|
|
436
|
+
else if (!base.startsWith('_') && !base.startsWith('+')) {
|
|
437
|
+
add(rel, f);
|
|
438
|
+
}
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const pages = PAGES_ROOT_RE.exec(f);
|
|
442
|
+
if (pages) {
|
|
443
|
+
const rel = pages[1].replace(ROUTE_EXTS, '');
|
|
444
|
+
const base = rel.split('/').pop();
|
|
445
|
+
if (base.startsWith('_') || rel === 'api' || rel.startsWith('api/'))
|
|
446
|
+
continue;
|
|
447
|
+
add(rel, f);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return [...routes.entries()].map(([p, f]) => ({ p, f }))
|
|
451
|
+
.sort((a, b) => (a.p < b.p ? -1 : a.p > b.p ? 1 : 0));
|
|
452
|
+
}
|
|
453
|
+
// cmdDescription pulls the one-line `description:` out of a command/skill
|
|
454
|
+
// md frontmatter — the popover hint. "" when absent/unreadable.
|
|
455
|
+
function cmdDescription(mdPath) {
|
|
456
|
+
let head;
|
|
457
|
+
try {
|
|
458
|
+
head = readFileSync(mdPath, 'utf8').slice(0, 2048);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return ''; // vanished/unreadable between readdir and read — hint only, skip
|
|
462
|
+
}
|
|
463
|
+
const m = /^description:\s*["']?(.+?)["']?\s*$/m.exec(head);
|
|
464
|
+
if (!m)
|
|
465
|
+
return '';
|
|
466
|
+
const d = m[1].replace(/\s+/g, ' ').trim();
|
|
467
|
+
return d.length > 80 ? d.slice(0, 79) + '…' : d;
|
|
468
|
+
}
|
|
469
|
+
/** collectCommands indexes the slash-command palette exactly as Claude Code
|
|
470
|
+
* resolves it: `<root>/commands/**\/*.md` → `/ns:name` (dirs join with ":"),
|
|
471
|
+
* `<root>/skills/<ns…>/<name>/SKILL.md` → `/ns:name` — from the PROJECT's
|
|
472
|
+
* .claude first, then the HOME ~/.claude (project wins name collisions).
|
|
473
|
+
* Underscore-prefixed files/dirs (e.g. skills/git/_shared) are internals. */
|
|
474
|
+
export function collectCommands(projectDir, homeClaude = join(process.env.HOME || '', '.claude')) {
|
|
475
|
+
const out = new Map();
|
|
476
|
+
const add = (segs, mdPath) => {
|
|
477
|
+
if (segs.some((s) => !s || s.startsWith('_') || s.startsWith('.')))
|
|
478
|
+
return;
|
|
479
|
+
const c = '/' + segs.join(':');
|
|
480
|
+
if (out.has(c) || out.size >= 1000)
|
|
481
|
+
return;
|
|
482
|
+
const d = cmdDescription(mdPath);
|
|
483
|
+
out.set(c, d ? { c, d } : { c });
|
|
484
|
+
};
|
|
485
|
+
const list = (dir) => {
|
|
486
|
+
try {
|
|
487
|
+
return readdirSync(dir, { recursive: true, encoding: 'utf8' });
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return []; // root absent (no .claude here) — normal, not an error
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
for (const root of [join(projectDir, '.claude'), homeClaude]) {
|
|
494
|
+
for (const rel of list(join(root, 'commands'))) {
|
|
495
|
+
if (!rel.endsWith('.md'))
|
|
496
|
+
continue;
|
|
497
|
+
add(rel.slice(0, -3).split('/'), join(root, 'commands', rel));
|
|
498
|
+
}
|
|
499
|
+
for (const rel of list(join(root, 'skills'))) {
|
|
500
|
+
if (!rel.endsWith('/SKILL.md'))
|
|
501
|
+
continue;
|
|
502
|
+
add(rel.slice(0, -'/SKILL.md'.length).split('/'), join(root, 'skills', rel));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return [...out.values()].sort((a, b) => (a.c < b.c ? -1 : a.c > b.c ? 1 : 0));
|
|
506
|
+
}
|
|
507
|
+
/** buildProjectIndex lists the ACTUAL checkout's non-gitignored files
|
|
508
|
+
* (tracked + untracked-unignored — a just-created screen completes before
|
|
509
|
+
* its first commit), derives routes, and indexes the slash-command palette.
|
|
510
|
+
* null outside a git repo. */
|
|
511
|
+
export function buildProjectIndex(projectDir) {
|
|
512
|
+
const top = git(['rev-parse', '--show-toplevel'], projectDir);
|
|
513
|
+
if (!top)
|
|
514
|
+
return null;
|
|
515
|
+
const ls = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { cwd: top, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
516
|
+
if (ls.status !== 0) {
|
|
517
|
+
console.error(`index: git ls-files failed: ${String(ls.stderr).trim()}`);
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
let files = ls.stdout.split('\n').filter((f) => f && !INDEX_EXCLUDE.test(f)).sort();
|
|
521
|
+
if (files.length > 50_000) {
|
|
522
|
+
console.error(`index: ${files.length} files exceeds the 50k server cap — keeping the first 50k (tune INDEX_EXCLUDE)`);
|
|
523
|
+
files = files.slice(0, 50_000);
|
|
524
|
+
}
|
|
525
|
+
const routes = extractRoutes(files);
|
|
526
|
+
const cmds = collectCommands(projectDir);
|
|
527
|
+
// Key order mirrors the server's canonical marshal (v, repo, routes, files, cmds).
|
|
528
|
+
const body = JSON.stringify({ v: 1, repo: originRepo(projectDir) || undefined, routes, files, cmds });
|
|
529
|
+
return {
|
|
530
|
+
body, hash: createHash('sha256').update(body).digest('hex'),
|
|
531
|
+
routes: routes.length, files: files.length, cmds: cmds.length,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
// The last successfully pushed index (base+project+hash) is remembered inside
|
|
535
|
+
// the repo's common .git dir — per-repo, never committed, shared by worktrees.
|
|
536
|
+
function indexCachePath(projectDir) {
|
|
537
|
+
return join(git(['rev-parse', '--path-format=absolute', '--git-common-dir'], projectDir), 'vitrinka-index.cache');
|
|
538
|
+
}
|
|
539
|
+
/** pushProjectIndex builds + pushes the index unless nothing changed since
|
|
540
|
+
* the last successful push. Never throws and never exits — every caller is
|
|
541
|
+
* a touchpoint where index freshness must not fail the primary job.
|
|
542
|
+
*
|
|
543
|
+
* `verify` (the explicit `vitrinka index` / daemon path): a matching local
|
|
544
|
+
* stamp alone must not claim success — the server row may have been wiped
|
|
545
|
+
* (DB reset). On a stamp hit it confirms with one conditional GET against
|
|
546
|
+
* the server's own hash (304 = in sync, zero body); a 404 or a diverged
|
|
547
|
+
* ETag invalidates the stamp and falls through to a re-PUT. Piggybacked
|
|
548
|
+
* callers keep verify=false — an unchanged repo costs zero requests there. */
|
|
549
|
+
export async function pushProjectIndex(projectDir, base, project, verify = false) {
|
|
550
|
+
const built = buildProjectIndex(projectDir);
|
|
551
|
+
if (!built)
|
|
552
|
+
return false;
|
|
553
|
+
const cachePath = indexCachePath(projectDir);
|
|
554
|
+
const endpoint = `${base}/api/v1/projects/${encodeURIComponent(project)}/index`;
|
|
555
|
+
const cached = existsSync(cachePath) ? readFileSync(cachePath, 'utf8').trim().split(' ') : [];
|
|
556
|
+
const stampHit = cached[0] === base && cached[1] === project && cached[2] === built.hash;
|
|
557
|
+
let serverHash = cached[3] || ''; // the server's canonical hash from the last PUT's response
|
|
558
|
+
if (stampHit && !verify)
|
|
559
|
+
return true;
|
|
560
|
+
if (stampHit && verify) {
|
|
561
|
+
try {
|
|
562
|
+
const res = await fetch(endpoint, {
|
|
563
|
+
headers: serverHash ? { 'If-None-Match': `"${serverHash}"` } : {},
|
|
564
|
+
signal: AbortSignal.timeout(30_000),
|
|
565
|
+
});
|
|
566
|
+
if (res.status === 304 || (res.ok && !serverHash))
|
|
567
|
+
return true; // in sync / present
|
|
568
|
+
// 404 (row wiped) or a diverged ETag → the stamp lied; re-push below.
|
|
569
|
+
}
|
|
570
|
+
catch (e) {
|
|
571
|
+
console.error(`index: vitrinka unreachable (${e instanceof Error ? e.message : e}) — next touchpoint retries`);
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
576
|
+
const token = readToken();
|
|
577
|
+
if (token)
|
|
578
|
+
headers.Authorization = `Bearer ${token}`;
|
|
579
|
+
try {
|
|
580
|
+
const res = await fetch(endpoint, {
|
|
581
|
+
method: 'PUT', headers, body: built.body, signal: AbortSignal.timeout(30_000),
|
|
582
|
+
});
|
|
583
|
+
if (!res.ok && res.status !== 204) {
|
|
584
|
+
console.error(`index: push rejected (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
if (res.status !== 204) {
|
|
588
|
+
try {
|
|
589
|
+
serverHash = String((await res.json()).hash || serverHash);
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
// 2xx without the JSON hash (older server): keep the previous serverHash.
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch (e) {
|
|
597
|
+
console.error(`index: vitrinka unreachable (${e instanceof Error ? e.message : e}) — next touchpoint retries`);
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
writeFileSync(cachePath, `${base} ${project} ${built.hash} ${serverHash}`.trimEnd() + '\n');
|
|
601
|
+
console.log(`index ↑ ${project}: ${built.routes} routes, ${built.files} files, ${built.cmds} commands`);
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
// indexCmd — the explicit `vitrinka index` touchpoint. Exits non-zero on
|
|
605
|
+
// failure (unlike the piggybacked pushes) so scripts can rely on it; verifies
|
|
606
|
+
// the server actually holds the index rather than trusting the local stamp.
|
|
607
|
+
async function indexCmd(root, args) {
|
|
608
|
+
const projectDir = dirname(root);
|
|
609
|
+
const cfg = existsSync(vitrinkaCfgPath(root)) ? loadVitrinkaCfg(root) : null;
|
|
610
|
+
const base = (strArg(args.base) || cfg?.base || process.env.VITRINKA_URL || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
|
|
611
|
+
const project = sanitizeSeg(strArg(args.project) || cfg?.project || basename(mainWorktreeTop(projectDir) || projectDir), 64);
|
|
612
|
+
if (!(await pushProjectIndex(projectDir, base, project, true)))
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
function loadVitrinkaCfg(root) {
|
|
616
|
+
const raw = readFileSync(vitrinkaCfgPath(root), 'utf8'); // caller checks existsSync
|
|
617
|
+
try {
|
|
618
|
+
return JSON.parse(raw);
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
throw new Error(`Corrupt ${vitrinkaCfgPath(root)} (${e.message}). Delete it and re-run remote-init.`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function shareUrl(cfg) {
|
|
625
|
+
return `${cfg.base}/${cfg.project}/${cfg.slug}/${cfg.key}`;
|
|
626
|
+
}
|
|
627
|
+
function remoteInitCmd(root, args) {
|
|
628
|
+
if (existsSync(vitrinkaCfgPath(root))) {
|
|
629
|
+
// Sticky per session dir — reuse, so re-activation never forks the set.
|
|
630
|
+
const cfg = loadVitrinkaCfg(root);
|
|
631
|
+
console.log(`vitrinka set (existing) → ${shareUrl(cfg)}`);
|
|
632
|
+
return cfg;
|
|
633
|
+
}
|
|
634
|
+
const projectDir = dirname(root);
|
|
635
|
+
const base = (args.base && args.base !== true ? String(args.base) : process.env.VITRINKA_URL || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
|
|
636
|
+
// Project identity comes from the MAIN worktree (a linked worktree's dir is
|
|
637
|
+
// named after its branch — using it would fork each branch into its own
|
|
638
|
+
// project; the branch already lives in cfg.branch).
|
|
639
|
+
const gitTop = mainWorktreeTop(projectDir);
|
|
640
|
+
// Artifact roots live at <project>/.artifacts/<slug> — outside a git repo the project
|
|
641
|
+
// fallback must be the real project dir, not the '.artifacts' container itself.
|
|
642
|
+
const fallbackDir = basename(projectDir) === '.artifacts' ? dirname(projectDir) : projectDir;
|
|
643
|
+
const project = sanitizeSeg(args.project && args.project !== true ? String(args.project) : basename(gitTop || fallbackDir), 64);
|
|
644
|
+
const branch = (args.branch && args.branch !== true ? String(args.branch) : git(['branch', '--show-current'], projectDir)) || 'main';
|
|
645
|
+
const d = new Date();
|
|
646
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
647
|
+
const key = args.key && args.key !== true
|
|
648
|
+
? String(args.key).replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 64) // meaningful keys, e.g. artifact slugs
|
|
649
|
+
: `s-${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}`;
|
|
650
|
+
assertValidKey(key, 'remote-init');
|
|
651
|
+
const cfg = {
|
|
652
|
+
base,
|
|
653
|
+
project,
|
|
654
|
+
branch,
|
|
655
|
+
slug: sanitizeSeg(branch, 100),
|
|
656
|
+
key,
|
|
657
|
+
kind: args.kind && args.kind !== true ? String(args.kind) : 'screenshots',
|
|
658
|
+
issue: args.issue && args.issue !== true ? String(args.issue) : '',
|
|
659
|
+
};
|
|
660
|
+
mkdirSync(root, { recursive: true });
|
|
661
|
+
ensureRootSelfIgnored(root);
|
|
662
|
+
writeFileSync(vitrinkaCfgPath(root), JSON.stringify(cfg, null, 2) + '\n');
|
|
663
|
+
console.log(`vitrinka set → ${shareUrl(cfg)}`);
|
|
664
|
+
return cfg;
|
|
665
|
+
}
|
|
666
|
+
// Token / operator resolution + the X-Board-Actor latin-1 encoder are shared
|
|
667
|
+
// with the MCP server — single-sourced in ./lib (readToken, readOperator,
|
|
668
|
+
// operatorPath, actorHeaderValue are imported at the top).
|
|
669
|
+
// actorFor resolves the X-Board-Actor header value for a board write: an
|
|
670
|
+
// explicit --actor flag wins, else the configured operator name, else ''
|
|
671
|
+
// (anonymous). The CLI uses the truthiness operator semantics (a SET-but-empty
|
|
672
|
+
// $VITRINKA_OPERATOR falls through to the operator file).
|
|
673
|
+
function actorFor(args) {
|
|
674
|
+
return actorHeaderValue((strArg(args.actor) || readOperator(true)).trim());
|
|
675
|
+
}
|
|
676
|
+
function markOffline(root, error) {
|
|
677
|
+
const p = offlineMarkerPath(root);
|
|
678
|
+
// Merge with any existing marker: a repeated push failure inside one offline episode
|
|
679
|
+
// must not clear the `warned` flag snap set (or snap would warn on every shot).
|
|
680
|
+
// A successful push deletes the marker, so a NEW episode still warns exactly once.
|
|
681
|
+
let warned = false;
|
|
682
|
+
if (existsSync(p)) {
|
|
683
|
+
try {
|
|
684
|
+
warned = JSON.parse(readFileSync(p, 'utf8')).warned === true;
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
// Malformed marker (half-written by a killed push) — treat as not-yet-warned.
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
writeFileSync(p, JSON.stringify({ ts: new Date().toISOString(), error, ...(warned ? { warned } : {}) }) + '\n');
|
|
691
|
+
}
|
|
692
|
+
async function pushCmd(root, args) {
|
|
693
|
+
if (!existsSync(vitrinkaCfgPath(root))) {
|
|
694
|
+
console.error(`push: no ${vitrinkaCfgPath(root)} — run \`node ${CLI_PATH} remote-init --root ${root}\` first`);
|
|
695
|
+
process.exit(2);
|
|
696
|
+
}
|
|
697
|
+
const cfg = loadVitrinkaCfg(root);
|
|
698
|
+
const projectDir = dirname(root);
|
|
699
|
+
// Control files are excluded here AND skipped server-side (belt and suspenders).
|
|
700
|
+
// COPYFILE_DISABLE stops macOS bsdtar from emitting AppleDouble (._*) junk entries.
|
|
701
|
+
const tar = spawnSync('tar', [
|
|
702
|
+
'-czf', '-',
|
|
703
|
+
'--exclude', '.vitrinka*', '--exclude', './.vitrinka*',
|
|
704
|
+
'--exclude', '.active', '--exclude', './.active',
|
|
705
|
+
'--exclude', '._*', '--exclude', './._*', '--exclude', '.DS_Store',
|
|
706
|
+
'-C', root, '.',
|
|
707
|
+
], { maxBuffer: 512 * 1024 * 1024, env: { ...process.env, COPYFILE_DISABLE: '1' } });
|
|
708
|
+
if (tar.status !== 0) {
|
|
709
|
+
console.error(`push: tar failed: ${String(tar.stderr)}`);
|
|
710
|
+
process.exit(1);
|
|
711
|
+
}
|
|
712
|
+
const commit = git(['rev-parse', '--short', 'HEAD'], projectDir);
|
|
713
|
+
const repo = originRepo(projectDir);
|
|
714
|
+
const q = new URLSearchParams();
|
|
715
|
+
q.set('kind', cfg.kind || 'screenshots');
|
|
716
|
+
if (cfg.branch)
|
|
717
|
+
q.set('branch', cfg.branch);
|
|
718
|
+
if (commit)
|
|
719
|
+
q.set('commit', commit);
|
|
720
|
+
if (repo)
|
|
721
|
+
q.set('repo', repo);
|
|
722
|
+
if (cfg.issue)
|
|
723
|
+
q.set('issue', cfg.issue);
|
|
724
|
+
// Source set cross-link: --source flag wins, else the ref recorded in .vitrinka
|
|
725
|
+
// (by artifact-from-set, or persisted from an earlier explicit push). Same for --title.
|
|
726
|
+
// The server clears omitted ?source=/?title= on every re-sync, so both must be re-sent.
|
|
727
|
+
const source = (args.source && args.source !== true ? String(args.source) : '') || cfg.source || '';
|
|
728
|
+
if (source)
|
|
729
|
+
q.set('source', source);
|
|
730
|
+
const title = (args.title && args.title !== true ? String(args.title) : '') || cfg.title || '';
|
|
731
|
+
if (title)
|
|
732
|
+
q.set('title', title);
|
|
733
|
+
const endpoint = `${cfg.base}/api/v1/sets/${encodeURIComponent(cfg.project)}/${encodeURIComponent(cfg.slug)}/${encodeURIComponent(cfg.key)}/content?${q}`;
|
|
734
|
+
const headers = { 'Content-Type': 'application/gzip' };
|
|
735
|
+
const token = readToken();
|
|
736
|
+
if (token)
|
|
737
|
+
headers.Authorization = `Bearer ${token}`;
|
|
738
|
+
let res;
|
|
739
|
+
try {
|
|
740
|
+
res = await fetch(endpoint, { method: 'PUT', headers, body: tar.stdout, signal: AbortSignal.timeout(120_000) });
|
|
741
|
+
}
|
|
742
|
+
catch (e) {
|
|
743
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
744
|
+
markOffline(root, msg);
|
|
745
|
+
console.error(`push: vitrinka unreachable (${msg}) — shots stay local; any later push backfills the whole set`);
|
|
746
|
+
process.exit(1);
|
|
747
|
+
}
|
|
748
|
+
const text = await res.text();
|
|
749
|
+
if (!res.ok) {
|
|
750
|
+
markOffline(root, `HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
751
|
+
console.error(`push: server rejected (HTTP ${res.status}): ${text.slice(0, 300)}`);
|
|
752
|
+
// Additive AI hint for a 4xx (client-side) rejection — never blocks the exit.
|
|
753
|
+
if (res.status >= 400 && res.status < 500) {
|
|
754
|
+
await emitHint(`vitrinka push --root ${root}`, `HTTP ${res.status}: ${text.slice(0, 300)}`, renderCmdHelp('push'));
|
|
755
|
+
}
|
|
756
|
+
process.exit(1);
|
|
757
|
+
}
|
|
758
|
+
let out = {};
|
|
759
|
+
try {
|
|
760
|
+
out = JSON.parse(text);
|
|
761
|
+
}
|
|
762
|
+
catch (e) {
|
|
763
|
+
// 2xx with a non-JSON body is unexpected — log it, but the sync itself succeeded.
|
|
764
|
+
console.error(`push: warning — success response was not JSON (${e.message}); using alias URL`);
|
|
765
|
+
}
|
|
766
|
+
if (existsSync(offlineMarkerPath(root)))
|
|
767
|
+
rmSync(offlineMarkerPath(root));
|
|
768
|
+
// Persist explicitly-passed --source/--title (on success only, so a server-rejected
|
|
769
|
+
// value never sticks) — the next plain `push` re-sends them instead of silently
|
|
770
|
+
// clearing the cross-link chip / human title server-side.
|
|
771
|
+
const explicitSource = args.source && args.source !== true ? String(args.source) : '';
|
|
772
|
+
const explicitTitle = args.title && args.title !== true ? String(args.title) : '';
|
|
773
|
+
if ((explicitSource && explicitSource !== cfg.source) || (explicitTitle && explicitTitle !== cfg.title)) {
|
|
774
|
+
if (explicitSource)
|
|
775
|
+
cfg.source = explicitSource;
|
|
776
|
+
if (explicitTitle)
|
|
777
|
+
cfg.title = explicitTitle;
|
|
778
|
+
writeFileSync(vitrinkaCfgPath(root), JSON.stringify(cfg, null, 2) + '\n');
|
|
779
|
+
}
|
|
780
|
+
console.log(`↑ synced ${out.files ?? '?'} files → ${out.url || shareUrl(cfg)}`);
|
|
781
|
+
// Touchpoint refresh of the project's file/route index (autocomplete
|
|
782
|
+
// source) — hash-gated both ends, never fails the set push.
|
|
783
|
+
await pushProjectIndex(projectDir, cfg.base, cfg.project);
|
|
784
|
+
// --name: name the set right after publish (PATCH; names are sticky
|
|
785
|
+
// server-side, so unlike --title it is never re-sent on later pushes).
|
|
786
|
+
// A naming failure warns loudly but exits 0 — the content itself is up.
|
|
787
|
+
const name = strArg(args.name);
|
|
788
|
+
if (name) {
|
|
789
|
+
const nameRes = await patchSetMeta(cfg.base, cfg.project, cfg.slug, cfg.key, { name: name.toLowerCase() });
|
|
790
|
+
if (nameRes.ok)
|
|
791
|
+
console.log(` name → ${cfg.base}/${cfg.project}/${cfg.slug}/${encodeURIComponent(name.toLowerCase())}`);
|
|
792
|
+
else
|
|
793
|
+
console.error(`push: warning — naming failed (${nameRes.error}); content is up, retry with: node ${CLI_PATH} name ${cfg.project}/${cfg.slug}/${cfg.key} ${name}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// ---------- set metadata (name/title) ----------
|
|
797
|
+
// patchSetMeta PATCHes /api/v1/sets/{project}/{slug}/{selector}; never throws.
|
|
798
|
+
async function patchSetMeta(base, project, slug, selector, body) {
|
|
799
|
+
const endpoint = `${base}/api/v1/sets/${encodeURIComponent(project)}/${encodeURIComponent(slug)}/${encodeURIComponent(selector)}`;
|
|
800
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
801
|
+
const token = readToken();
|
|
802
|
+
if (token)
|
|
803
|
+
headers.Authorization = `Bearer ${token}`;
|
|
804
|
+
let res;
|
|
805
|
+
try {
|
|
806
|
+
res = await fetch(endpoint, {
|
|
807
|
+
method: 'PATCH', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000),
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
catch (e) {
|
|
811
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
812
|
+
}
|
|
813
|
+
const text = await res.text();
|
|
814
|
+
if (!res.ok)
|
|
815
|
+
return { ok: false, error: `HTTP ${res.status}: ${text.slice(0, 200)}` };
|
|
816
|
+
try {
|
|
817
|
+
return { ok: true, json: JSON.parse(text) };
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
return { ok: true, json: {} }; // 2xx with non-JSON body — the PATCH itself succeeded
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
// nameCmd: `name <project/branch/selector> <new-name>` (or `--clear`) — set or
|
|
824
|
+
// clear the custom, URL-addressable name of an existing set.
|
|
825
|
+
async function nameCmd(rest, args) {
|
|
826
|
+
const pos = positionals(rest).slice(1); // drop the command word itself
|
|
827
|
+
const ref = pos[0] || '';
|
|
828
|
+
const segs = ref.split('/');
|
|
829
|
+
if (segs.length !== 3 || segs.some((s) => !s)) {
|
|
830
|
+
fail(`name: expected <project>/<branchSlug>/<selector> (selector = version, key, or current name), got ${JSON.stringify(ref)}`, 2);
|
|
831
|
+
}
|
|
832
|
+
const clear = args.clear === true;
|
|
833
|
+
const newName = (pos[1] || '').toLowerCase();
|
|
834
|
+
if (!clear && !newName)
|
|
835
|
+
fail('name: expected a new name (or --clear)', 2);
|
|
836
|
+
if (clear && newName)
|
|
837
|
+
fail('name: pass either a new name or --clear, not both', 2);
|
|
838
|
+
const base = (strArg(args.base) || process.env.VITRINKA_URL || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
|
|
839
|
+
const res = await patchSetMeta(base, segs[0], segs[1], segs[2], { name: clear ? '' : newName });
|
|
840
|
+
if (!res.ok)
|
|
841
|
+
fail(`name: ${res.error}`);
|
|
842
|
+
if (clear)
|
|
843
|
+
console.log(`cleared name of ${segs[0]}/${segs[1]}/${segs[2]} → ${res.json?.url || ''}`);
|
|
844
|
+
else
|
|
845
|
+
console.log(`named ${segs[0]}/${segs[1]}/${segs[2]} → ${base}/${segs[0]}/${segs[1]}/${encodeURIComponent(newName)}`);
|
|
846
|
+
}
|
|
847
|
+
// ---------- operator: the board-attribution persona ----------
|
|
848
|
+
// operatorCmd manages the operator persona name — how vitrinka credits this
|
|
849
|
+
// machine's writes on boards.
|
|
850
|
+
// operator → print the current local + server values
|
|
851
|
+
// operator <name> → write ~/.config/vitrinka/operator AND PUT the server
|
|
852
|
+
// default (needs the write token; server errors are
|
|
853
|
+
// reported but never fail the local write).
|
|
854
|
+
async function operatorCmd(rest, args) {
|
|
855
|
+
const pos = positionals(rest).slice(1); // drop the command word itself
|
|
856
|
+
const base = (strArg(args.base) || process.env.VITRINKA_URL || process.env.VITRINKA_BASE_URL
|
|
857
|
+
|| 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
|
|
858
|
+
const name = pos.join(' ').trim();
|
|
859
|
+
if (!name) {
|
|
860
|
+
const local = readOperator(true);
|
|
861
|
+
console.log(`operator (local): ${local || '(unset)'}`);
|
|
862
|
+
try {
|
|
863
|
+
const res = await fetch(`${base}/api/v1/operator`, { signal: AbortSignal.timeout(15_000) });
|
|
864
|
+
if (res.ok) {
|
|
865
|
+
const d = await res.json();
|
|
866
|
+
console.log(`operator (server): ${d.operator || '(unset)'}`);
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
console.log(`operator (server): unavailable (HTTP ${res.status})`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
catch (e) {
|
|
873
|
+
console.log(`operator (server): unreachable (${e instanceof Error ? e.message : String(e)})`);
|
|
874
|
+
}
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (name.length > 64)
|
|
878
|
+
fail('operator: name too long (max 64 chars)', 2);
|
|
879
|
+
// Local write first — it never needs the network.
|
|
880
|
+
const p = operatorPath();
|
|
881
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
882
|
+
writeFileSync(p, name + '\n');
|
|
883
|
+
console.log(`operator (local): ${name} → ${p}`);
|
|
884
|
+
// Then publish the server default (Bearer). Reachability/auth problems are
|
|
885
|
+
// reported but do not undo the local write.
|
|
886
|
+
const token = readToken();
|
|
887
|
+
if (!token) {
|
|
888
|
+
console.error('operator: no write token — server default NOT set (add ~/.config/vitrinka/token or $VITRINKA_TOKEN, then re-run)');
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
const res = await fetch(`${base}/api/v1/operator`, {
|
|
893
|
+
method: 'PUT',
|
|
894
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
895
|
+
body: JSON.stringify({ operator: name }),
|
|
896
|
+
signal: AbortSignal.timeout(15_000),
|
|
897
|
+
});
|
|
898
|
+
if (res.ok)
|
|
899
|
+
console.log(`operator (server): ${name} → ${base}`);
|
|
900
|
+
else
|
|
901
|
+
console.error(`operator: server rejected (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
902
|
+
}
|
|
903
|
+
catch (e) {
|
|
904
|
+
console.error(`operator: server unreachable (${e instanceof Error ? e.message : String(e)}) — local name is set; re-run to publish`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
// ---------- install: PATH shim ----------
|
|
908
|
+
// installCmd writes an executable shim `vitrinka` into ~/.local/bin (so the
|
|
909
|
+
// CLI works in interactive shells AND non-interactive AI/tool shells, which a
|
|
910
|
+
// zshrc alias would not) and makes sure ~/.local/bin is on PATH via a
|
|
911
|
+
// marker-guarded ~/.zshrc block. Idempotent: re-running reports and no-ops.
|
|
912
|
+
function installCmd(args) {
|
|
913
|
+
const home = process.env.HOME;
|
|
914
|
+
if (!home)
|
|
915
|
+
fail('install: $HOME is not set');
|
|
916
|
+
const binDir = join(home, '.local', 'bin');
|
|
917
|
+
const shimPath = join(binDir, 'vitrinka');
|
|
918
|
+
const shim = `#!/bin/sh\nexec node "${CLI_PATH}" "$@"\n`;
|
|
919
|
+
if (existsSync(shimPath) && readFileSync(shimPath, 'utf8') === shim) {
|
|
920
|
+
console.log(`shim already installed: ${shimPath}`);
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
mkdirSync(binDir, { recursive: true });
|
|
924
|
+
writeFileSync(shimPath, shim);
|
|
925
|
+
chmodSync(shimPath, 0o755);
|
|
926
|
+
console.log(`installed shim: ${shimPath} → node ${CLI_PATH}`);
|
|
927
|
+
}
|
|
928
|
+
const marker = '# vitrinka-cli';
|
|
929
|
+
const zshrc = join(home, '.zshrc');
|
|
930
|
+
const zshrcContent = existsSync(zshrc) ? readFileSync(zshrc, 'utf8') : '';
|
|
931
|
+
if (zshrcContent.includes(marker)) {
|
|
932
|
+
console.log(`PATH block already in ${zshrc}`);
|
|
933
|
+
}
|
|
934
|
+
else if ((process.env.PATH || '').split(':').includes(binDir)) {
|
|
935
|
+
console.log(`${binDir} already on PATH — ${zshrc} left untouched`);
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
appendFileSync(zshrc, `\n${marker}\nexport PATH="$HOME/.local/bin:$PATH"\n`);
|
|
939
|
+
console.log(`added PATH block to ${zshrc} (open a new shell, or: source ${zshrc})`);
|
|
940
|
+
}
|
|
941
|
+
// Shell completion — state-aware, opt-out via --no-completion. Appends a
|
|
942
|
+
// single guarded `eval "$(vitrinka completion zsh)"` line (skipped if present).
|
|
943
|
+
if (args['no-completion'] === true) {
|
|
944
|
+
console.log('completion: skipped (--no-completion)');
|
|
945
|
+
}
|
|
946
|
+
else {
|
|
947
|
+
const compMarker = '# vitrinka-completion';
|
|
948
|
+
const shellName = (process.env.SHELL || '').includes('bash') ? 'bash' : 'zsh';
|
|
949
|
+
const rc = join(home, shellName === 'bash' ? '.bashrc' : '.zshrc');
|
|
950
|
+
const rcContent = existsSync(rc) ? readFileSync(rc, 'utf8') : '';
|
|
951
|
+
if (rcContent.includes(compMarker)) {
|
|
952
|
+
console.log(`completion already enabled in ${rc}`);
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
appendFileSync(rc, `\n${compMarker}\neval "$(vitrinka completion ${shellName})"\n`);
|
|
956
|
+
console.log(`added ${shellName} completion to ${rc} (open a new shell, or: source ${rc})`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
console.log('done — try: vitrinka --help');
|
|
960
|
+
}
|
|
961
|
+
// ---------- snap: one-shot capture → manifest → detached push ----------
|
|
962
|
+
// Filename slug: diacritics folded (Přihlášení → prihlaseni), non-alnum → '-', capped.
|
|
963
|
+
export function kebab(s) {
|
|
964
|
+
const out = String(s)
|
|
965
|
+
.normalize('NFKD')
|
|
966
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
967
|
+
.toLowerCase()
|
|
968
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
969
|
+
.replace(/^-+|-+$/g, '')
|
|
970
|
+
.slice(0, 60)
|
|
971
|
+
.replace(/-+$/, '');
|
|
972
|
+
return out || 'shot';
|
|
973
|
+
}
|
|
974
|
+
// NN sequence = max already-used prefix in the dated dir + 1 (ignores index.html etc.).
|
|
975
|
+
// Max-based, not count-based: after a manual shot deletion a count would re-issue a
|
|
976
|
+
// surviving file's number and the next snap would silently overwrite it.
|
|
977
|
+
export function nextSeq(dir) {
|
|
978
|
+
let max = 0;
|
|
979
|
+
if (existsSync(dir)) {
|
|
980
|
+
for (const f of readdirSync(dir)) {
|
|
981
|
+
const m = /^(\d{2,})-/.exec(f);
|
|
982
|
+
if (m)
|
|
983
|
+
max = Math.max(max, Number(m[1]));
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return String(max + 1).padStart(2, '0');
|
|
987
|
+
}
|
|
988
|
+
export function deriveShotName(seq, surface, titleOrRoute, ext = '.png') {
|
|
989
|
+
return `${seq}-${surface}-${kebab(titleOrRoute)}${ext}`;
|
|
990
|
+
}
|
|
991
|
+
const UDID_RE = /^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$/;
|
|
992
|
+
// BFS for a UUID-shaped string; keys containing "udid" win over any other UUID-shaped value.
|
|
993
|
+
export function findUdid(v) {
|
|
994
|
+
const queue = [v];
|
|
995
|
+
const preferred = [];
|
|
996
|
+
const fallback = [];
|
|
997
|
+
while (queue.length) {
|
|
998
|
+
const cur = queue.shift();
|
|
999
|
+
if (!cur || typeof cur !== 'object')
|
|
1000
|
+
continue;
|
|
1001
|
+
for (const [k, val] of Object.entries(cur)) {
|
|
1002
|
+
if (typeof val === 'string' && UDID_RE.test(val.trim())) {
|
|
1003
|
+
(/udid/i.test(k) ? preferred : fallback).push(val.trim());
|
|
1004
|
+
}
|
|
1005
|
+
else if (val && typeof val === 'object') {
|
|
1006
|
+
queue.push(val);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return preferred[0] || fallback[0] || '';
|
|
1011
|
+
}
|
|
1012
|
+
function udidFromWorktreeFile(projectDir) {
|
|
1013
|
+
const p = join(projectDir, '.fixit-worktree.json');
|
|
1014
|
+
if (!existsSync(p))
|
|
1015
|
+
return '';
|
|
1016
|
+
let parsed;
|
|
1017
|
+
try {
|
|
1018
|
+
parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
1019
|
+
}
|
|
1020
|
+
catch (e) {
|
|
1021
|
+
// Defensive read by design: a malformed worktree file must not block capture — note it, fall through.
|
|
1022
|
+
console.error(`snap: ignoring malformed ${p} (${e.message})`);
|
|
1023
|
+
return '';
|
|
1024
|
+
}
|
|
1025
|
+
return findUdid(parsed);
|
|
1026
|
+
}
|
|
1027
|
+
function bootedSims() {
|
|
1028
|
+
const r = spawnSync('xcrun', ['simctl', 'list', 'devices', 'booted', '-j'], { encoding: 'utf8' });
|
|
1029
|
+
if (r.error)
|
|
1030
|
+
fail(`snap ios: xcrun unavailable: ${r.error.message}`);
|
|
1031
|
+
if (r.status !== 0)
|
|
1032
|
+
fail(`snap ios: simctl list failed: ${(r.stderr || '').trim()}`);
|
|
1033
|
+
let parsed;
|
|
1034
|
+
try {
|
|
1035
|
+
parsed = JSON.parse(r.stdout);
|
|
1036
|
+
}
|
|
1037
|
+
catch (e) {
|
|
1038
|
+
fail(`snap ios: could not parse simctl list output (${e.message})`);
|
|
1039
|
+
}
|
|
1040
|
+
const out = [];
|
|
1041
|
+
for (const list of Object.values(parsed.devices || {})) {
|
|
1042
|
+
for (const d of list) {
|
|
1043
|
+
if (d.state === 'Booted' && d.udid)
|
|
1044
|
+
out.push({ udid: d.udid, name: d.name || '' });
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return out;
|
|
1048
|
+
}
|
|
1049
|
+
// Also carries the simulator NAME when the resolution path already listed
|
|
1050
|
+
// sims — the v2 device field fills for free, with no extra xcrun call and no
|
|
1051
|
+
// new failure mode on the --udid / worktree-file fast paths.
|
|
1052
|
+
function resolveIosUdid(args, projectDir) {
|
|
1053
|
+
const flag = strArg(args.udid);
|
|
1054
|
+
if (flag)
|
|
1055
|
+
return { udid: flag };
|
|
1056
|
+
const fromFile = udidFromWorktreeFile(projectDir);
|
|
1057
|
+
if (fromFile)
|
|
1058
|
+
return { udid: fromFile };
|
|
1059
|
+
const booted = bootedSims();
|
|
1060
|
+
if (booted.length === 1)
|
|
1061
|
+
return booted[0];
|
|
1062
|
+
if (booted.length === 0)
|
|
1063
|
+
fail('snap ios: no booted simulator (and no --udid / .fixit-worktree.json)');
|
|
1064
|
+
fail(`snap ios: ${booted.length} booted simulators — pass --udid. Booted:\n`
|
|
1065
|
+
+ booted.map((b) => ` ${b.udid} ${b.name}`).join('\n'));
|
|
1066
|
+
}
|
|
1067
|
+
// The detached push means push failures are invisible to the caller — so snap surfaces a
|
|
1068
|
+
// pre-existing offline marker instead, exactly once (the marker records that it was shown;
|
|
1069
|
+
// markOffline carries the flag across repeated failures, and a successful push deletes the
|
|
1070
|
+
// marker, re-arming the warning for the next offline episode).
|
|
1071
|
+
function maybeWarnOffline(root) {
|
|
1072
|
+
const p = offlineMarkerPath(root);
|
|
1073
|
+
if (!existsSync(p))
|
|
1074
|
+
return;
|
|
1075
|
+
let marker = {};
|
|
1076
|
+
try {
|
|
1077
|
+
marker = JSON.parse(readFileSync(p, 'utf8'));
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
// Malformed marker (half-written by a killed push) is recoverable: still warn, then rewrite it.
|
|
1081
|
+
}
|
|
1082
|
+
if (marker.warned)
|
|
1083
|
+
return;
|
|
1084
|
+
console.error(`vitrinka unreachable on a previous push (${marker.error || 'unknown error'}) — shots stay local; any later successful push backfills the whole set`);
|
|
1085
|
+
writeFileSync(p, JSON.stringify({ ...marker, warned: true }) + '\n');
|
|
1086
|
+
}
|
|
1087
|
+
// safeSize returns a file's byte size, or undefined if it can't be stat'd
|
|
1088
|
+
// (missing/racing) — the encoding decision then degrades to "keep original".
|
|
1089
|
+
function safeSize(path) {
|
|
1090
|
+
try {
|
|
1091
|
+
return statSync(path).size;
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
return undefined;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
// chooseEncoding is the pure decision behind snap's WebP normalization:
|
|
1098
|
+
// - no cwebp → keep the original (PNG/JPEG untouched).
|
|
1099
|
+
// - normal → lossy q85 WebP (small review-page assets).
|
|
1100
|
+
// - --hq → lossless WebP ONLY when it's smaller than the
|
|
1101
|
+
// original (pixel-identical, fewer bytes); else
|
|
1102
|
+
// keep the original PNG. NEVER lossy, NEVER resize.
|
|
1103
|
+
export function chooseEncoding(i) {
|
|
1104
|
+
if (!i.cwebpAvailable)
|
|
1105
|
+
return 'original';
|
|
1106
|
+
if (!i.hq)
|
|
1107
|
+
return 'lossy';
|
|
1108
|
+
if (i.losslessSize !== undefined && i.pngSize !== undefined && i.losslessSize < i.pngSize) {
|
|
1109
|
+
return 'lossless';
|
|
1110
|
+
}
|
|
1111
|
+
return 'original';
|
|
1112
|
+
}
|
|
1113
|
+
async function snapCmd(surface, args) {
|
|
1114
|
+
if (!['ios', 'android', 'macos', 'web'].includes(surface)) {
|
|
1115
|
+
console.error(`snap: surface must be ios|android|macos|web, got ${JSON.stringify(surface)}`);
|
|
1116
|
+
process.exit(2);
|
|
1117
|
+
}
|
|
1118
|
+
const root = resolve(strArg(args.root) || '.screenshots');
|
|
1119
|
+
const projectDir = dirname(root);
|
|
1120
|
+
const route = strArg(args.route);
|
|
1121
|
+
const note = strArg(args.note);
|
|
1122
|
+
if (!route) {
|
|
1123
|
+
console.error('snap: --route is required');
|
|
1124
|
+
process.exit(2);
|
|
1125
|
+
}
|
|
1126
|
+
if (!note) {
|
|
1127
|
+
console.error('snap: --note is required');
|
|
1128
|
+
process.exit(2);
|
|
1129
|
+
}
|
|
1130
|
+
const adopt = strArg(args.file);
|
|
1131
|
+
if (surface === 'web' && !adopt) {
|
|
1132
|
+
console.error('snap web: --file <path> is required (capture via the browser MCP, then adopt the file)');
|
|
1133
|
+
process.exit(2);
|
|
1134
|
+
}
|
|
1135
|
+
// --open + --settle: navigate-and-wait live INSIDE the tool, so a capture is
|
|
1136
|
+
// ONE command — never a shell `sleep N; openurl; sleep M; snap` chain (which
|
|
1137
|
+
// agent harnesses rightly block). --settle defaults to 4 s when --open is
|
|
1138
|
+
// used (deep-link → render time), 0 otherwise; both are overridable.
|
|
1139
|
+
const openUrl = strArg(args.open);
|
|
1140
|
+
if (openUrl && surface === 'web')
|
|
1141
|
+
fail('snap web: --open is not supported (drive the browser via the MCP, then adopt with --file)', 2);
|
|
1142
|
+
if (openUrl && adopt)
|
|
1143
|
+
fail('snap: --open cannot be combined with --file (adopt captures nothing)', 2);
|
|
1144
|
+
const settle = numArg(args.settle, openUrl ? 4 : 0, '--settle');
|
|
1145
|
+
let iosUdid = '';
|
|
1146
|
+
let iosSimName = '';
|
|
1147
|
+
if (surface === 'ios' && !adopt) {
|
|
1148
|
+
const sim = resolveIosUdid(args, projectDir);
|
|
1149
|
+
iosUdid = sim.udid;
|
|
1150
|
+
iosSimName = sim.name || '';
|
|
1151
|
+
}
|
|
1152
|
+
if (openUrl) {
|
|
1153
|
+
if (surface === 'ios') {
|
|
1154
|
+
const r = spawnSync('xcrun', ['simctl', 'openurl', iosUdid, openUrl], { encoding: 'utf8' });
|
|
1155
|
+
if (r.error)
|
|
1156
|
+
fail(`snap ios: xcrun unavailable: ${r.error.message}`);
|
|
1157
|
+
if (r.status !== 0)
|
|
1158
|
+
fail(`snap ios: simctl openurl failed: ${(r.stderr || '').trim()}`);
|
|
1159
|
+
}
|
|
1160
|
+
else if (surface === 'android') {
|
|
1161
|
+
const r = spawnSync('adb', ['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl], { encoding: 'utf8' });
|
|
1162
|
+
if (r.error)
|
|
1163
|
+
fail(`snap android: adb unavailable: ${r.error.message}`);
|
|
1164
|
+
if (r.status !== 0)
|
|
1165
|
+
fail(`snap android: am start failed: ${(r.stderr || '').trim()}`);
|
|
1166
|
+
}
|
|
1167
|
+
else { // macos
|
|
1168
|
+
const r = spawnSync('open', [openUrl], { encoding: 'utf8' });
|
|
1169
|
+
if (r.error)
|
|
1170
|
+
fail(`snap macos: open unavailable: ${r.error.message}`);
|
|
1171
|
+
if (r.status !== 0)
|
|
1172
|
+
fail(`snap macos: open failed: ${(r.stderr || '').trim()}`);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (settle > 0)
|
|
1176
|
+
await new Promise((r) => setTimeout(r, settle * 1000));
|
|
1177
|
+
const d = new Date();
|
|
1178
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1179
|
+
const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
1180
|
+
const dir = join(root, day);
|
|
1181
|
+
mkdirSync(dir, { recursive: true });
|
|
1182
|
+
const ext = adopt ? (extname(adopt).toLowerCase() || '.png') : '.png';
|
|
1183
|
+
let name = deriveShotName(nextSeq(dir), surface, strArg(args.title) || route, ext);
|
|
1184
|
+
let dest = join(dir, name);
|
|
1185
|
+
if (adopt) {
|
|
1186
|
+
const src = resolve(adopt);
|
|
1187
|
+
if (!existsSync(src)) {
|
|
1188
|
+
console.error(`snap: --file ${src} does not exist`);
|
|
1189
|
+
process.exit(2);
|
|
1190
|
+
}
|
|
1191
|
+
copyFileSync(src, dest);
|
|
1192
|
+
}
|
|
1193
|
+
else if (surface === 'ios') {
|
|
1194
|
+
const r = spawnSync('xcrun', ['simctl', 'io', iosUdid, 'screenshot', dest], { encoding: 'utf8' });
|
|
1195
|
+
if (r.error)
|
|
1196
|
+
fail(`snap ios: xcrun unavailable: ${r.error.message}`);
|
|
1197
|
+
if (r.status !== 0)
|
|
1198
|
+
fail(`snap ios: simctl screenshot failed: ${(r.stderr || '').trim()}`);
|
|
1199
|
+
}
|
|
1200
|
+
else if (surface === 'android') {
|
|
1201
|
+
const r = spawnSync('adb', ['exec-out', 'screencap', '-p'], { maxBuffer: 64 * 1024 * 1024 });
|
|
1202
|
+
if (r.error)
|
|
1203
|
+
fail(`snap android: adb unavailable: ${r.error.message}`);
|
|
1204
|
+
if (r.status !== 0)
|
|
1205
|
+
fail(`snap android: screencap failed: ${String(r.stderr || '').trim()}`);
|
|
1206
|
+
writeFileSync(dest, r.stdout);
|
|
1207
|
+
}
|
|
1208
|
+
else { // macos
|
|
1209
|
+
const region = strArg(args.region);
|
|
1210
|
+
const scArgs = ['-x', ...(region ? ['-R', region] : []), dest];
|
|
1211
|
+
const r = spawnSync('screencapture', scArgs, { encoding: 'utf8' });
|
|
1212
|
+
if (r.error)
|
|
1213
|
+
fail(`snap macos: screencapture unavailable: ${r.error.message}`);
|
|
1214
|
+
if (r.status !== 0)
|
|
1215
|
+
fail(`snap macos: screencapture failed: ${(r.stderr || '').trim()}`);
|
|
1216
|
+
}
|
|
1217
|
+
// ---------- WebP normalization ----------
|
|
1218
|
+
// Native captures are lossless PNG (a 3x sim frame is 2–5 MB); the review
|
|
1219
|
+
// page doesn't need lossless, and `push` re-uploads the WHOLE set as one
|
|
1220
|
+
// PUT — full-res PNGs blow the proxy body cap once a set spans sessions
|
|
1221
|
+
// (2026-07-03: 317 MB set → HTTP 413, silently, on every detached push).
|
|
1222
|
+
// WebP q85 keeps native resolution at ~5–10% of the PNG bytes. cwebp is a
|
|
1223
|
+
// soft dependency (brew install webp): when missing, keep the original.
|
|
1224
|
+
//
|
|
1225
|
+
// `--hq` (marketing / full-fidelity): NEVER lossy-re-encode, NEVER resize —
|
|
1226
|
+
// keep the original PNG, but if cwebp is present try `-lossless` and keep it
|
|
1227
|
+
// only when it comes out smaller than the PNG (pixel-identical, fewer bytes).
|
|
1228
|
+
const hq = args.hq === true;
|
|
1229
|
+
if (/\.(png|jpe?g)$/i.test(name)) {
|
|
1230
|
+
const webpName = name.replace(/\.(png|jpe?g)$/i, '.webp');
|
|
1231
|
+
const webpDest = join(dir, webpName);
|
|
1232
|
+
const cwebpArgs = hq
|
|
1233
|
+
? ['-quiet', '-lossless', dest, '-o', webpDest]
|
|
1234
|
+
: ['-quiet', '-q', '85', dest, '-o', webpDest];
|
|
1235
|
+
const w = spawnSync('cwebp', cwebpArgs, { encoding: 'utf8' });
|
|
1236
|
+
const cwebpOk = !w.error && w.status === 0 && existsSync(webpDest);
|
|
1237
|
+
const choice = chooseEncoding({
|
|
1238
|
+
hq,
|
|
1239
|
+
cwebpAvailable: cwebpOk,
|
|
1240
|
+
pngSize: safeSize(dest),
|
|
1241
|
+
losslessSize: hq && cwebpOk ? safeSize(webpDest) : undefined,
|
|
1242
|
+
});
|
|
1243
|
+
if (choice === 'original') {
|
|
1244
|
+
rmSync(webpDest, { force: true });
|
|
1245
|
+
if (!hq && !cwebpOk) {
|
|
1246
|
+
console.error('snap: cwebp unavailable/failed — keeping the original (brew install webp for q85 WebP sets)');
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
rmSync(dest);
|
|
1251
|
+
name = webpName;
|
|
1252
|
+
dest = webpDest;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
const ctx = shotContext(args);
|
|
1256
|
+
// iOS knows its simulator — auto-fill the device name when the resolution
|
|
1257
|
+
// path already learned it (zero extra xcrun calls).
|
|
1258
|
+
if (surface === 'ios' && iosSimName && !ctx.device?.device) {
|
|
1259
|
+
ctx.device = { ...ctx.device, device: iosSimName };
|
|
1260
|
+
}
|
|
1261
|
+
const { count } = appendShot(root, {
|
|
1262
|
+
file: `${day}/${name}`,
|
|
1263
|
+
surface,
|
|
1264
|
+
route,
|
|
1265
|
+
note,
|
|
1266
|
+
label: strArg(args.label) || undefined,
|
|
1267
|
+
title: strArg(args.title) || undefined,
|
|
1268
|
+
action: strArg(args.action) || undefined,
|
|
1269
|
+
hq: hq || undefined,
|
|
1270
|
+
ts: new Date().toISOString(),
|
|
1271
|
+
...ctx,
|
|
1272
|
+
});
|
|
1273
|
+
maybeWarnOffline(root);
|
|
1274
|
+
// (shot N) = 1-based manifest position — what embed-shots/artifact-from-set --select
|
|
1275
|
+
// addresses. The filename NN restarts per dated dir; the position never does.
|
|
1276
|
+
console.log(`saved ${dest} (shot ${count})`);
|
|
1277
|
+
if (existsSync(vitrinkaCfgPath(root))) {
|
|
1278
|
+
// Detached, fire-and-forget: the harness gets no background task; the next snap/push backfills.
|
|
1279
|
+
spawn(process.execPath, [CLI_PATH, 'push', '--root', root], { detached: true, stdio: 'ignore' }).unref();
|
|
1280
|
+
console.log(`share ${shareUrl(loadVitrinkaCfg(root))}`);
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
console.log(`(no ${vitrinkaCfgPath(root)} — run remote-init to publish; shot saved locally)`);
|
|
1284
|
+
}
|
|
1285
|
+
console.log('NOW READ THE IMAGE TO VERIFY');
|
|
1286
|
+
}
|
|
1287
|
+
// ---------- embed-shots: manifest shots → resized JPEG data URIs in one data.json ----------
|
|
1288
|
+
// "1,3-5" → [1,3,4,5] — 1-based manifest positions, deduped, ascending.
|
|
1289
|
+
export function parseSelect(spec, count) {
|
|
1290
|
+
const picked = new Set();
|
|
1291
|
+
for (const tok of String(spec).split(',')) {
|
|
1292
|
+
const t = tok.trim();
|
|
1293
|
+
if (!t)
|
|
1294
|
+
continue;
|
|
1295
|
+
const m = t.match(/^(\d+)(?:-(\d+))?$/);
|
|
1296
|
+
if (!m)
|
|
1297
|
+
throw new Error(`--select: bad token ${JSON.stringify(t)} (expected N or A-B)`);
|
|
1298
|
+
const a = Number(m[1]);
|
|
1299
|
+
const b = m[2] === undefined ? a : Number(m[2]);
|
|
1300
|
+
if (a < 1 || b < a)
|
|
1301
|
+
throw new Error(`--select: bad range "${t}"`);
|
|
1302
|
+
if (b > count)
|
|
1303
|
+
throw new Error(`--select: ${b} out of range (set has ${count} shots)`);
|
|
1304
|
+
for (let i = a; i <= b; i++)
|
|
1305
|
+
picked.add(i);
|
|
1306
|
+
}
|
|
1307
|
+
if (!picked.size)
|
|
1308
|
+
throw new Error('--select: empty selection');
|
|
1309
|
+
return [...picked].sort((x, y) => x - y);
|
|
1310
|
+
}
|
|
1311
|
+
export function mimeFor(file) {
|
|
1312
|
+
const e = extname(String(file)).toLowerCase();
|
|
1313
|
+
if (e === '.png')
|
|
1314
|
+
return 'image/png';
|
|
1315
|
+
if (e === '.jpg' || e === '.jpeg')
|
|
1316
|
+
return 'image/jpeg';
|
|
1317
|
+
if (e === '.webp')
|
|
1318
|
+
return 'image/webp';
|
|
1319
|
+
if (e === '.gif')
|
|
1320
|
+
return 'image/gif';
|
|
1321
|
+
return 'application/octet-stream';
|
|
1322
|
+
}
|
|
1323
|
+
function embedShots(root, opts) {
|
|
1324
|
+
const m = loadManifest(root);
|
|
1325
|
+
if (!m.shots.length)
|
|
1326
|
+
fail(`embed-shots: no shots in ${manifestPathOf(root)}`, 2);
|
|
1327
|
+
let indices;
|
|
1328
|
+
try {
|
|
1329
|
+
indices = opts.select ? parseSelect(opts.select, m.shots.length) : m.shots.map((_, i) => i + 1);
|
|
1330
|
+
}
|
|
1331
|
+
catch (e) {
|
|
1332
|
+
fail(`embed-shots: ${e.message}`, 2);
|
|
1333
|
+
}
|
|
1334
|
+
const tmp = mkdtempSync(join(tmpdir(), 'vitrinka-embed-'));
|
|
1335
|
+
let sipsMissing = false;
|
|
1336
|
+
const shots = [];
|
|
1337
|
+
try {
|
|
1338
|
+
for (const i of indices) {
|
|
1339
|
+
const s = m.shots[i - 1];
|
|
1340
|
+
const srcPath = join(root, s.file);
|
|
1341
|
+
if (!existsSync(srcPath))
|
|
1342
|
+
fail(`embed-shots: shot ${i} file missing: ${srcPath} (manifest and folder out of sync)`);
|
|
1343
|
+
let src = '';
|
|
1344
|
+
if (!sipsMissing) {
|
|
1345
|
+
const outPath = join(tmp, `${i}.jpg`);
|
|
1346
|
+
const r = spawnSync('sips', [
|
|
1347
|
+
'-Z', String(opts.size),
|
|
1348
|
+
'-s', 'format', 'jpeg',
|
|
1349
|
+
'-s', 'formatOptions', String(opts.quality),
|
|
1350
|
+
srcPath, '--out', outPath,
|
|
1351
|
+
], { encoding: 'utf8' });
|
|
1352
|
+
if (r.error && r.error.code === 'ENOENT') {
|
|
1353
|
+
sipsMissing = true; // no sips (non-macOS): embed originals, warn once
|
|
1354
|
+
console.error('embed-shots: sips not found — embedding original files unresized');
|
|
1355
|
+
}
|
|
1356
|
+
else if (r.error) {
|
|
1357
|
+
throw new Error(`embed-shots: sips failed: ${r.error.message}`);
|
|
1358
|
+
}
|
|
1359
|
+
else if (r.status !== 0) {
|
|
1360
|
+
throw new Error(`embed-shots: sips failed on ${srcPath}: ${(r.stderr || '').trim()}`);
|
|
1361
|
+
}
|
|
1362
|
+
else {
|
|
1363
|
+
src = `data:image/jpeg;base64,${readFileSync(outPath).toString('base64')}`;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
if (sipsMissing) {
|
|
1367
|
+
src = `data:${mimeFor(s.file)};base64,${readFileSync(srcPath).toString('base64')}`;
|
|
1368
|
+
}
|
|
1369
|
+
shots.push({ label: s.label, title: s.title, route: s.route, note: s.note, ts: s.ts, src });
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
finally {
|
|
1373
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
1374
|
+
}
|
|
1375
|
+
const data = { journey: m.journey && typeof m.journey === 'object' ? m.journey : {}, shots };
|
|
1376
|
+
const outAbs = resolve(opts.out);
|
|
1377
|
+
mkdirSync(dirname(outAbs), { recursive: true });
|
|
1378
|
+
writeFileSync(outAbs, JSON.stringify(data, null, 2) + '\n');
|
|
1379
|
+
return { out: outAbs, count: shots.length, sipsMissing };
|
|
1380
|
+
}
|
|
1381
|
+
function embedShotsCmd(root, args) {
|
|
1382
|
+
const out = strArg(args.out);
|
|
1383
|
+
if (!out) {
|
|
1384
|
+
console.error('embed-shots: --out <dir>/data.json is required');
|
|
1385
|
+
process.exit(2);
|
|
1386
|
+
}
|
|
1387
|
+
const res = embedShots(root, {
|
|
1388
|
+
select: strArg(args.select) || undefined,
|
|
1389
|
+
size: numArg(args.size, 720, '--size'),
|
|
1390
|
+
quality: numArg(args.quality, 85, '--quality'),
|
|
1391
|
+
out,
|
|
1392
|
+
});
|
|
1393
|
+
console.log(`embedded ${res.count} shots → ${res.out}${res.sipsMissing ? ' (originals — sips unavailable)' : ''}`);
|
|
1394
|
+
}
|
|
1395
|
+
// ---------- artifact scaffolding ----------
|
|
1396
|
+
function ensureGitIgnored(projectDir, entry) {
|
|
1397
|
+
const top = git(['rev-parse', '--show-toplevel'], projectDir) || projectDir;
|
|
1398
|
+
const gi = join(top, '.gitignore');
|
|
1399
|
+
const cur = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
|
|
1400
|
+
const bare = entry.replace(/\/$/, '');
|
|
1401
|
+
const has = cur.split(/\r?\n/).some((l) => {
|
|
1402
|
+
const t = l.trim();
|
|
1403
|
+
return t === entry || t === bare || t === `/${entry}` || t === `/${bare}`;
|
|
1404
|
+
});
|
|
1405
|
+
if (!has)
|
|
1406
|
+
writeFileSync(gi, cur + (cur && !cur.endsWith('\n') ? '\n' : '') + entry + '\n');
|
|
1407
|
+
}
|
|
1408
|
+
// Shared <head> + module prologue: pinned import map (kit-1 / zipstore-1 are versioned,
|
|
1409
|
+
// immutable vendor files), tailwind, htm binding. xyflow stays commented until needed.
|
|
1410
|
+
function scaffoldHead(title) {
|
|
1411
|
+
return `<!doctype html>
|
|
1412
|
+
<html lang="en">
|
|
1413
|
+
<head>
|
|
1414
|
+
<meta charset="utf-8">
|
|
1415
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1416
|
+
<title>${esc(title)}</title>
|
|
1417
|
+
<script type="importmap">
|
|
1418
|
+
{ "imports": {
|
|
1419
|
+
"react": "/vendor/react.mjs",
|
|
1420
|
+
"react-dom/client": "/vendor/react-dom-client.mjs",
|
|
1421
|
+
"htm": "/vendor/htm.mjs",
|
|
1422
|
+
"kit-1": "/vendor/kit-1.mjs",
|
|
1423
|
+
"zipstore-1": "/vendor/zipstore-1.mjs"
|
|
1424
|
+
} }
|
|
1425
|
+
</script>
|
|
1426
|
+
<!-- Flow diagram? Add inside "imports" above:
|
|
1427
|
+
"@xyflow/react": "/vendor/xyflow-react.mjs"
|
|
1428
|
+
and add to <head>:
|
|
1429
|
+
<link rel="stylesheet" href="/vendor/xyflow-react.css">
|
|
1430
|
+
-->
|
|
1431
|
+
<script src="/vendor/tailwind.js"></script>
|
|
1432
|
+
</head>
|
|
1433
|
+
<body class="bg-[#f6f2ee] text-[#101113] antialiased">
|
|
1434
|
+
<div id="root"></div>
|
|
1435
|
+
<script type="module">
|
|
1436
|
+
import React from 'react';
|
|
1437
|
+
import { createRoot } from 'react-dom/client';
|
|
1438
|
+
import htm from 'htm';
|
|
1439
|
+
import { Hero, Chips, Section, ShotCard, StatRow, DownloadAllButton } from 'kit-1';
|
|
1440
|
+
const html = htm.bind(React.createElement);
|
|
1441
|
+
`;
|
|
1442
|
+
}
|
|
1443
|
+
const DATA_FETCH = `
|
|
1444
|
+
const res = await fetch('./data.json');
|
|
1445
|
+
if (!res.ok) throw new Error('data.json: HTTP ' + res.status);
|
|
1446
|
+
const DATA = await res.json();
|
|
1447
|
+
`;
|
|
1448
|
+
// The authored title is emitted as an htm interpolation (title=\${"…"}), never as a quoted
|
|
1449
|
+
// attribute — htm has no escape for an inner '"', which would truncate the Hero prop.
|
|
1450
|
+
function blankScaffold(title, hasData) {
|
|
1451
|
+
return scaffoldHead(title) + (hasData ? DATA_FETCH : `
|
|
1452
|
+
// TODO: inline your data here (or write a sibling ./data.json and fetch it)
|
|
1453
|
+
const DATA = {};
|
|
1454
|
+
`) + `
|
|
1455
|
+
function App() {
|
|
1456
|
+
return html\`<main class="mx-auto max-w-5xl px-8 py-12">
|
|
1457
|
+
<\${Hero} kicker="TODO" title=\${${JSON.stringify(title)}} accent="TODO accent phrase" intro="TODO 1-2 sentence intro" />
|
|
1458
|
+
<!-- TODO: compose the report from kit-1: Section, ShotCard, StatRow, Chips, DownloadAllButton -->
|
|
1459
|
+
</main>\`;
|
|
1460
|
+
}
|
|
1461
|
+
createRoot(document.getElementById('root')).render(html\`<\${App} />\`);
|
|
1462
|
+
</script>
|
|
1463
|
+
</body>
|
|
1464
|
+
</html>
|
|
1465
|
+
`;
|
|
1466
|
+
}
|
|
1467
|
+
function composedScaffold(title, zipName) {
|
|
1468
|
+
return scaffoldHead(title) + DATA_FETCH + `
|
|
1469
|
+
const kebab = (s) => String(s || 'shot').normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'shot';
|
|
1470
|
+
const extOf = (src) => String(src).startsWith('data:image/png') ? '.png' : '.jpg';
|
|
1471
|
+
const files = DATA.shots.map((s, i) => ({
|
|
1472
|
+
name: String(i + 1).padStart(2, '0') + '-' + kebab(s.title || s.route) + extOf(s.src),
|
|
1473
|
+
dataUri: s.src,
|
|
1474
|
+
}));
|
|
1475
|
+
|
|
1476
|
+
function App() {
|
|
1477
|
+
const j = DATA.journey || {};
|
|
1478
|
+
return html\`<main class="mx-auto max-w-5xl px-8 py-12">
|
|
1479
|
+
<\${Hero} kicker=\${j.kicker} title=\${j.title} accent=\${j.accent} intro=\${j.intro} chips=\${j.chips} />
|
|
1480
|
+
|
|
1481
|
+
<!-- TODO: opening narrative — what this session shipped and why it matters -->
|
|
1482
|
+
|
|
1483
|
+
\${DATA.shots.map((s, i) => html\`
|
|
1484
|
+
<\${Section} key=\${i} label=\${s.label} title=\${s.title} note=\${s.note}>
|
|
1485
|
+
<\${ShotCard} src=\${s.src} alt=\${s.title || s.route || 'screenshot'} caption=\${s.note} />
|
|
1486
|
+
<//>
|
|
1487
|
+
\`)}
|
|
1488
|
+
|
|
1489
|
+
<!-- TODO: closing narrative / findings (StatRow and Chips from kit-1 are available) -->
|
|
1490
|
+
|
|
1491
|
+
<div class="mt-12">
|
|
1492
|
+
<\${DownloadAllButton} files=\${files} zipName=${JSON.stringify(zipName)} />
|
|
1493
|
+
</div>
|
|
1494
|
+
</main>\`;
|
|
1495
|
+
}
|
|
1496
|
+
createRoot(document.getElementById('root')).render(html\`<\${App} />\`);
|
|
1497
|
+
</script>
|
|
1498
|
+
</body>
|
|
1499
|
+
</html>
|
|
1500
|
+
`;
|
|
1501
|
+
}
|
|
1502
|
+
function artifactInitCmd(args) {
|
|
1503
|
+
const slugRaw = strArg(args.slug);
|
|
1504
|
+
if (!slugRaw) {
|
|
1505
|
+
console.error('artifact-init: --slug <slug> is required');
|
|
1506
|
+
process.exit(2);
|
|
1507
|
+
}
|
|
1508
|
+
const slug = sanitizeSeg(slugRaw, 64);
|
|
1509
|
+
assertValidKey(slug, 'artifact-init'); // the slug becomes the set key — fail before any writes
|
|
1510
|
+
const kind = strArg(args.kind) || 'report';
|
|
1511
|
+
const title = strArg(args.title) || slug;
|
|
1512
|
+
const dir = resolve('.artifacts', slug);
|
|
1513
|
+
const indexPath = join(dir, 'index.html');
|
|
1514
|
+
if (existsSync(indexPath)) {
|
|
1515
|
+
console.error(`artifact-init: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
|
|
1516
|
+
process.exit(2);
|
|
1517
|
+
}
|
|
1518
|
+
mkdirSync(dir, { recursive: true });
|
|
1519
|
+
writeFileSync(indexPath, blankScaffold(title, existsSync(join(dir, 'data.json'))));
|
|
1520
|
+
ensureGitIgnored(process.cwd(), '.artifacts/');
|
|
1521
|
+
remoteInitCmd(dir, { ...args, key: slug, kind });
|
|
1522
|
+
console.log(`scaffolded ${indexPath}`);
|
|
1523
|
+
console.log(`author the App, then: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
|
|
1524
|
+
}
|
|
1525
|
+
function artifactFromSetCmd(args) {
|
|
1526
|
+
const slugRaw = strArg(args.slug);
|
|
1527
|
+
if (!slugRaw) {
|
|
1528
|
+
console.error('artifact-from-set: --slug <slug> is required');
|
|
1529
|
+
process.exit(2);
|
|
1530
|
+
}
|
|
1531
|
+
const slug = sanitizeSeg(slugRaw, 64);
|
|
1532
|
+
assertValidKey(slug, 'artifact-from-set'); // the slug becomes the set key — fail before any writes
|
|
1533
|
+
const from = resolve(strArg(args.from) || '.screenshots');
|
|
1534
|
+
const dir = resolve('.artifacts', slug);
|
|
1535
|
+
const indexPath = join(dir, 'index.html');
|
|
1536
|
+
if (existsSync(indexPath)) {
|
|
1537
|
+
console.error(`artifact-from-set: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
|
|
1538
|
+
process.exit(2);
|
|
1539
|
+
}
|
|
1540
|
+
mkdirSync(dir, { recursive: true });
|
|
1541
|
+
const res = embedShots(from, {
|
|
1542
|
+
select: strArg(args.select) || undefined,
|
|
1543
|
+
size: numArg(args.size, 720, '--size'),
|
|
1544
|
+
quality: numArg(args.quality, 85, '--quality'),
|
|
1545
|
+
out: join(dir, 'data.json'),
|
|
1546
|
+
});
|
|
1547
|
+
const fromManifest = loadManifest(from);
|
|
1548
|
+
const title = strArg(args.title) || (fromManifest.journey && fromManifest.journey.title) || slug;
|
|
1549
|
+
writeFileSync(indexPath, composedScaffold(title, `${slug}.zip`));
|
|
1550
|
+
ensureGitIgnored(process.cwd(), '.artifacts/');
|
|
1551
|
+
remoteInitCmd(dir, { ...args, key: slug, kind: strArg(args.kind) || 'report' });
|
|
1552
|
+
// Record the source screenshot set (project/branchSlug/key) so push sends ?source= and
|
|
1553
|
+
// vitrinka can cross-link set ↔ artifact both ways.
|
|
1554
|
+
let source = strArg(args.source);
|
|
1555
|
+
if (!source) {
|
|
1556
|
+
if (existsSync(vitrinkaCfgPath(from))) {
|
|
1557
|
+
const c = loadVitrinkaCfg(from);
|
|
1558
|
+
source = `${c.project}/${c.slug}/${c.key}`;
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
console.error(`artifact-from-set: no ${vitrinkaCfgPath(from)} — source set not recorded (pass --source project/branchSlug/key to cross-link)`);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
if (source) {
|
|
1565
|
+
const cfg = loadVitrinkaCfg(dir);
|
|
1566
|
+
cfg.source = source;
|
|
1567
|
+
writeFileSync(vitrinkaCfgPath(dir), JSON.stringify(cfg, null, 2) + '\n');
|
|
1568
|
+
}
|
|
1569
|
+
console.log(`scaffolded ${indexPath} (${res.count} shots embedded${source ? `, source ${source}` : ''})`);
|
|
1570
|
+
console.log(`write the narrative sections, then \`push\`: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
|
|
1571
|
+
}
|
|
1572
|
+
const ROOT_FLAG = { f: '--root', arg: '<dir>', d: 'capture root (default .screenshots)' };
|
|
1573
|
+
const BASE_FLAG = { f: '--base', arg: '<url>', d: 'vitrinka base URL (default $VITRINKA_URL or the mesh host)' };
|
|
1574
|
+
const CTX_FLAGS = [
|
|
1575
|
+
{ f: '--label', arg: '<STAGE>', d: 'journey stage label' },
|
|
1576
|
+
{ f: '--title', arg: '<t>', d: 'short human title' },
|
|
1577
|
+
{ f: '--action', arg: '<a>', d: 'edge label — the action leading to the NEXT shot' },
|
|
1578
|
+
{ f: '--src', arg: '<file>', d: "implementing source file(s) (repeatable or comma-list)" },
|
|
1579
|
+
{ f: '--state', arg: '<s>', d: 'app/auth state note' },
|
|
1580
|
+
{ f: '--device', arg: '<name>', d: 'device name' },
|
|
1581
|
+
{ f: '--viewport', arg: 'WxH[@scale]', d: 'viewport, e.g. 393x852@3' },
|
|
1582
|
+
];
|
|
1583
|
+
export const COMMANDS = [
|
|
1584
|
+
{
|
|
1585
|
+
name: 'add', summary: 'Append a screenshot to the manifest, rebuild index.html',
|
|
1586
|
+
usage: '--root <dir> --file <rel> --surface <ios|android|web|macos> --route <r> --note <n> [ctx flags]',
|
|
1587
|
+
flags: [ROOT_FLAG, { f: '--file', arg: '<rel>', d: 'screenshot path relative to root (required)' },
|
|
1588
|
+
{ f: '--surface', arg: '<ios|android|web|macos>', d: 'platform (default other)' },
|
|
1589
|
+
{ f: '--route', arg: '<r>', d: 'app route' }, { f: '--note', arg: '<n>', d: 'caption' }, ...CTX_FLAGS],
|
|
1590
|
+
examples: ['vitrinka add --root .screenshots --file 2026-07-06/01-web-home.png --surface web --route /home --note "landing"'],
|
|
1591
|
+
},
|
|
1592
|
+
{ name: 'build', summary: 'Rebuild index.html from the manifest', usage: '--root <dir>', flags: [ROOT_FLAG],
|
|
1593
|
+
examples: ['vitrinka build --root .screenshots'] },
|
|
1594
|
+
{
|
|
1595
|
+
name: 'meta', summary: 'Set the journey header (kicker/title/accent/intro/chips)',
|
|
1596
|
+
usage: '--root <dir> [--kicker <k>] [--title <t>] [--accent <substr>] [--intro <p>] [--chip "K=V" ...]',
|
|
1597
|
+
flags: [ROOT_FLAG, { f: '--kicker', arg: '<k>', d: 'eyebrow kicker' }, { f: '--title', arg: '<t>', d: 'display title' },
|
|
1598
|
+
{ f: '--accent', arg: '<substr>', d: 'substring of the title to accent' }, { f: '--intro', arg: '<p>', d: 'intro paragraph' },
|
|
1599
|
+
{ f: '--chip', arg: '"K=V"', d: 'meta chip (repeatable)' }],
|
|
1600
|
+
examples: ['vitrinka meta --root .screenshots --kicker "FLOW" --title "Payout flow" --accent "Payout"'],
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
name: 'remote-init', summary: 'Create a .vitrinka set config for publishing',
|
|
1604
|
+
usage: '--root <dir> [--base <url>] [--project <p>] [--branch <b>] [--kind <k>] [--key <k>] [--issue <ref>]',
|
|
1605
|
+
flags: [ROOT_FLAG, BASE_FLAG, { f: '--project', arg: '<p>', d: 'project slug (default: main worktree name)' },
|
|
1606
|
+
{ f: '--branch', arg: '<b>', d: 'branch (default: current)' }, { f: '--kind', arg: '<k>', d: 'set kind (default screenshots)' },
|
|
1607
|
+
{ f: '--key', arg: '<k>', d: 'set key (default: timestamped)' }, { f: '--issue', arg: '<ref>', d: 'linked issue ref' }],
|
|
1608
|
+
dynamic: 'projects',
|
|
1609
|
+
examples: ['vitrinka remote-init --root .screenshots --project fixit --branch main'],
|
|
1610
|
+
},
|
|
1611
|
+
{
|
|
1612
|
+
name: 'push', summary: 'Publish the set to vitrinka (tar+PUT), refresh the index',
|
|
1613
|
+
usage: '--root <dir> [--title <t>] [--name <slug>] [--source <project/branchSlug/key>]',
|
|
1614
|
+
flags: [ROOT_FLAG, { f: '--title', arg: '<t>', d: 'human title (re-sent every push)' },
|
|
1615
|
+
{ f: '--name', arg: '<slug>', d: 'name the set after publish (sticky)' },
|
|
1616
|
+
{ f: '--source', arg: '<ref>', d: 'cross-link source set project/branchSlug/key' }],
|
|
1617
|
+
examples: ['vitrinka push --root .screenshots --title "Payout flow"'],
|
|
1618
|
+
},
|
|
1619
|
+
{
|
|
1620
|
+
name: 'name', summary: "Set or clear a set's custom URL name",
|
|
1621
|
+
usage: '<project/branchSlug/selector> <new-name> [--base <url>] (or --clear)',
|
|
1622
|
+
positional: '<project/branchSlug/selector>', dynamic: 'projects', write: true,
|
|
1623
|
+
flags: [BASE_FLAG, { f: '--clear', d: 'clear the custom name instead of setting one' }],
|
|
1624
|
+
examples: ['vitrinka name fixit/main/2 payout-flow', 'vitrinka name fixit/main/payout-flow --clear'],
|
|
1625
|
+
},
|
|
1626
|
+
{
|
|
1627
|
+
name: 'operator', summary: 'Show or set the board-attribution operator persona',
|
|
1628
|
+
usage: '[<name>] [--base <url>]', positional: '[<name>]', flags: [BASE_FLAG],
|
|
1629
|
+
examples: ['vitrinka operator', 'vitrinka operator "Lukáš"'],
|
|
1630
|
+
},
|
|
1631
|
+
{
|
|
1632
|
+
name: 'install', summary: 'Install the PATH shim (+ optional shell completion)',
|
|
1633
|
+
usage: '[--no-completion]', flags: [{ f: '--no-completion', d: 'skip appending the shell-completion line' }],
|
|
1634
|
+
examples: ['vitrinka install'],
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
name: 'snap', summary: 'Capture a screenshot → manifest → detached push',
|
|
1638
|
+
usage: '<ios|android|macos|web> [--root <dir>] [--file <path>] --route <r> --note <n> [ctx flags] [--udid <u>] [--region x,y,w,h] [--open <deeplink>] [--settle <seconds>] [--hq]',
|
|
1639
|
+
positional: '<ios|android|macos|web>',
|
|
1640
|
+
flags: [ROOT_FLAG, { f: '--file', arg: '<path>', d: 'adopt an existing image instead of capturing' },
|
|
1641
|
+
{ f: '--route', arg: '<r>', d: 'app route (required)' }, { f: '--note', arg: '<n>', d: 'caption (required)' }, ...CTX_FLAGS,
|
|
1642
|
+
{ f: '--udid', arg: '<u>', d: 'iOS simulator UDID' }, { f: '--region', arg: 'x,y,w,h', d: 'macOS capture region' },
|
|
1643
|
+
{ f: '--open', arg: '<deeplink>', d: 'open a deep link, then settle before capturing' },
|
|
1644
|
+
{ f: '--settle', arg: '<seconds>', d: 'wait after --open (default 4)' },
|
|
1645
|
+
{ f: '--hq', d: 'full-fidelity marketing asset: no lossy re-encode, no resize (lossless WebP only if smaller)' }],
|
|
1646
|
+
examples: ['vitrinka snap ios --route /home --note "landing"', 'vitrinka snap web --route / --note "hero" --hq'],
|
|
1647
|
+
},
|
|
1648
|
+
{
|
|
1649
|
+
name: 'board-from-set', summary: 'Turn the set into an annotation flow board',
|
|
1650
|
+
usage: '[--root <dir>] [--slug <board>] [--btitle <t>] [--actor <name>]',
|
|
1651
|
+
flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
|
|
1652
|
+
{ f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
|
|
1653
|
+
dynamic: 'boards',
|
|
1654
|
+
examples: ['vitrinka board-from-set --root .screenshots'],
|
|
1655
|
+
},
|
|
1656
|
+
{
|
|
1657
|
+
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',
|
|
1658
|
+
usage: '[--board <slug>|--project <p> --branch <b>|--all] [--takeover] [--quiet-grace <sec>] [--once] [--base <url>]',
|
|
1659
|
+
flags: [{ f: '--board', arg: '<slug>', d: 'lease one board (else the repo project+branch is inferred)' },
|
|
1660
|
+
{ f: '--project', arg: '<p>', d: 'override the inferred project scope' },
|
|
1661
|
+
{ f: '--branch', arg: '<b>', d: 'override the inferred branch scope (with --project)' },
|
|
1662
|
+
{ f: '--all', d: 'firehose: observe every board (leases a conflict-free scope, claims nothing)' },
|
|
1663
|
+
{ f: '--takeover', d: 'steal an EXPIRED lease (a dead session); never steals a live one' },
|
|
1664
|
+
{ f: '--quiet-grace', arg: '<sec>', d: 'drain poll interval (default 5)' },
|
|
1665
|
+
{ f: '--once', d: 'single deterministic poll (exit 0 = work, 3 = idle)' }, BASE_FLAG],
|
|
1666
|
+
dynamic: 'boards',
|
|
1667
|
+
examples: ['vitrinka watch', 'vitrinka watch --board payout-flow', 'vitrinka watch --takeover'],
|
|
1668
|
+
},
|
|
1669
|
+
{
|
|
1670
|
+
name: 'index', summary: 'Build + push the file/route autocomplete index',
|
|
1671
|
+
usage: '[--root <dir>] [--base <url>] [--project <p>]',
|
|
1672
|
+
flags: [ROOT_FLAG, BASE_FLAG, { f: '--project', arg: '<p>', d: 'project slug (default: worktree name)' }],
|
|
1673
|
+
dynamic: 'projects',
|
|
1674
|
+
examples: ['vitrinka index --root .screenshots'],
|
|
1675
|
+
},
|
|
1676
|
+
{
|
|
1677
|
+
name: 'embed-shots', summary: 'Embed manifest shots as resized JPEG data URIs → data.json',
|
|
1678
|
+
usage: '[--root <dir>] [--select 1,3-5] [--size 720] [--quality 85] --out <dir>/data.json',
|
|
1679
|
+
flags: [ROOT_FLAG, { f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--size', arg: '<px>', d: 'longest edge (default 720)' },
|
|
1680
|
+
{ f: '--quality', arg: '<q>', d: 'JPEG quality (default 85)' }, { f: '--out', arg: '<file>', d: 'output data.json (required)' }],
|
|
1681
|
+
examples: ['vitrinka embed-shots --root .screenshots --out .artifacts/x/data.json'],
|
|
1682
|
+
},
|
|
1683
|
+
{
|
|
1684
|
+
name: 'artifact-init', summary: 'Scaffold a blank artifact report + set config',
|
|
1685
|
+
usage: '--slug <s> [--kind report] [--title <t>] [--base <url>]',
|
|
1686
|
+
flags: [{ f: '--slug', arg: '<s>', d: 'artifact slug (= set key, required)' }, { f: '--kind', arg: '<k>', d: 'set kind (default report)' },
|
|
1687
|
+
{ f: '--title', arg: '<t>', d: 'artifact title' }, BASE_FLAG],
|
|
1688
|
+
examples: ['vitrinka artifact-init --slug payout-report --title "Payout report"'],
|
|
1689
|
+
},
|
|
1690
|
+
{
|
|
1691
|
+
name: 'artifact-from-set', summary: 'Scaffold an artifact report from a screenshot set',
|
|
1692
|
+
usage: '--slug <s> [--from .screenshots] [--select 1,3-5] [--title <t>] [--kind report] [--source <ref>]',
|
|
1693
|
+
flags: [{ f: '--slug', arg: '<s>', d: 'artifact slug (= set key, required)' }, { f: '--from', arg: '<dir>', d: 'source set root (default .screenshots)' },
|
|
1694
|
+
{ f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--title', arg: '<t>', d: 'artifact title' },
|
|
1695
|
+
{ f: '--kind', arg: '<k>', d: 'set kind (default report)' }, { f: '--source', arg: '<ref>', d: 'source set ref for cross-linking' }, BASE_FLAG],
|
|
1696
|
+
examples: ['vitrinka artifact-from-set --slug payout-report --from .screenshots'],
|
|
1697
|
+
},
|
|
1698
|
+
// Meta commands — documented + completable, kept out of the compact usage grid.
|
|
1699
|
+
{ name: 'help', summary: 'Show help (all commands, or one command in detail)', usage: '[<command>]', positional: '[<command>]', hidden: true,
|
|
1700
|
+
examples: ['vitrinka help', 'vitrinka help snap'] },
|
|
1701
|
+
{ name: 'completion', summary: 'Print a shell-completion script (zsh|bash)', usage: '<zsh|bash>', positional: '<zsh|bash>', hidden: true,
|
|
1702
|
+
examples: ['vitrinka completion zsh >> ~/.zshrc'] },
|
|
1703
|
+
{ name: 'ai', summary: 'Ask claude for a vitrinka command in natural language', usage: '"<ask>" [--yes]', positional: '"<ask>"', hidden: true,
|
|
1704
|
+
flags: [{ f: '--yes', d: 'run the proposed command without a confirm prompt' }],
|
|
1705
|
+
examples: ['vitrinka ai "publish the current screenshots as a flow board"'] },
|
|
1706
|
+
];
|
|
1707
|
+
const COMMAND_MAP = Object.fromEntries(COMMANDS.map((c) => [c.name, c]));
|
|
1708
|
+
export const COMMAND_NAMES = COMMANDS.map((c) => c.name);
|
|
1709
|
+
// ---------- help rendering (man-page-ish; respects NO_COLOR) ----------
|
|
1710
|
+
function useColor() {
|
|
1711
|
+
return !process.env.NO_COLOR && !!process.stdout.isTTY;
|
|
1712
|
+
}
|
|
1713
|
+
function bold(s) { return useColor() ? `\x1b[1m${s}\x1b[0m` : s; }
|
|
1714
|
+
function dim(s) { return useColor() ? `\x1b[2m${s}\x1b[0m` : s; }
|
|
1715
|
+
// renderCompactUsage — the fallback shown on unknown command / bad args.
|
|
1716
|
+
export function renderCompactUsage() {
|
|
1717
|
+
const rows = COMMANDS.filter((c) => !c.hidden).map((c) => ` vitrinka ${c.name} ${c.usage}`);
|
|
1718
|
+
return `Usage:\n${rows.join('\n')}\n\nRun "vitrinka help <command>" for details, or "vitrinka ai \"<ask>\"".`;
|
|
1719
|
+
}
|
|
1720
|
+
// Backwards-compatible binding: dispatch fallback + the gallery.mjs shim test
|
|
1721
|
+
// still reference a `Usage:`-headed string that names every command.
|
|
1722
|
+
const USAGE = renderCompactUsage();
|
|
1723
|
+
export function renderFullHelp() {
|
|
1724
|
+
const lines = [];
|
|
1725
|
+
lines.push(bold('vitrinka') + ' — screenshot sets, artifacts, flow boards, and the work queue.');
|
|
1726
|
+
lines.push('');
|
|
1727
|
+
lines.push(bold('USAGE'));
|
|
1728
|
+
lines.push(' vitrinka <command> [flags]');
|
|
1729
|
+
lines.push('');
|
|
1730
|
+
lines.push(bold('COMMANDS'));
|
|
1731
|
+
const width = Math.max(...COMMANDS.filter((c) => !c.hidden).map((c) => c.name.length));
|
|
1732
|
+
for (const c of COMMANDS.filter((c) => !c.hidden)) {
|
|
1733
|
+
lines.push(` ${c.name.padEnd(width)} ${c.summary}`);
|
|
1734
|
+
}
|
|
1735
|
+
lines.push('');
|
|
1736
|
+
lines.push(bold('MORE'));
|
|
1737
|
+
const meta = COMMANDS.filter((c) => c.hidden);
|
|
1738
|
+
const mw = Math.max(...meta.map((c) => c.name.length));
|
|
1739
|
+
for (const c of meta)
|
|
1740
|
+
lines.push(` ${c.name.padEnd(mw)} ${c.summary}`);
|
|
1741
|
+
lines.push('');
|
|
1742
|
+
lines.push(dim(' Env: VITRINKA_URL, VITRINKA_TOKEN, VITRINKA_OPERATOR, NO_COLOR, VITRINKA_NO_AI=1, CLAUDE_BIN'));
|
|
1743
|
+
return lines.join('\n');
|
|
1744
|
+
}
|
|
1745
|
+
export function renderCmdHelp(name) {
|
|
1746
|
+
const c = COMMAND_MAP[name];
|
|
1747
|
+
if (!c)
|
|
1748
|
+
return renderCompactUsage();
|
|
1749
|
+
const lines = [];
|
|
1750
|
+
lines.push(`${bold('vitrinka ' + c.name)} — ${c.summary}`);
|
|
1751
|
+
lines.push('');
|
|
1752
|
+
lines.push(bold('USAGE'));
|
|
1753
|
+
lines.push(` vitrinka ${c.name} ${c.usage}`);
|
|
1754
|
+
if (c.flags && c.flags.length) {
|
|
1755
|
+
lines.push('');
|
|
1756
|
+
lines.push(bold('FLAGS'));
|
|
1757
|
+
const rows = c.flags.map((f) => ({ left: `${f.f}${f.arg ? ' ' + f.arg : ''}`, d: f.d }));
|
|
1758
|
+
const w = Math.max(...rows.map((r) => r.left.length));
|
|
1759
|
+
for (const r of rows)
|
|
1760
|
+
lines.push(` ${r.left.padEnd(w)} ${r.d}`);
|
|
1761
|
+
}
|
|
1762
|
+
if (c.examples && c.examples.length) {
|
|
1763
|
+
lines.push('');
|
|
1764
|
+
lines.push(bold('EXAMPLES'));
|
|
1765
|
+
for (const e of c.examples)
|
|
1766
|
+
lines.push(` ${e}`);
|
|
1767
|
+
}
|
|
1768
|
+
return lines.join('\n');
|
|
1769
|
+
}
|
|
1770
|
+
// nearestCommand — plain edit-distance "did you mean" (no AI). Returns '' when
|
|
1771
|
+
// nothing is within a small threshold.
|
|
1772
|
+
export function levenshtein(a, b) {
|
|
1773
|
+
const m = a.length, n = b.length;
|
|
1774
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
|
|
1775
|
+
for (let j = 0; j <= n; j++)
|
|
1776
|
+
dp[0][j] = j;
|
|
1777
|
+
for (let i = 1; i <= m; i++) {
|
|
1778
|
+
for (let j = 1; j <= n; j++) {
|
|
1779
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1]
|
|
1780
|
+
: 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
return dp[m][n];
|
|
1784
|
+
}
|
|
1785
|
+
export function nearestCommand(input) {
|
|
1786
|
+
let best = '', bestD = Infinity;
|
|
1787
|
+
for (const name of COMMAND_NAMES) {
|
|
1788
|
+
const d = levenshtein(input, name);
|
|
1789
|
+
if (d < bestD) {
|
|
1790
|
+
bestD = d;
|
|
1791
|
+
best = name;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
// Suggest only a genuinely-close miss (≤ a third of the name length, min 2).
|
|
1795
|
+
return bestD <= Math.max(2, Math.ceil(best.length / 3)) ? best : '';
|
|
1796
|
+
}
|
|
1797
|
+
// ---------- shell completion ----------
|
|
1798
|
+
// completionScript embeds the command names + per-command flags at generation
|
|
1799
|
+
// time; slug-shaped positionals shell out to the hidden `vitrinka __complete`.
|
|
1800
|
+
export function completionScript(shell) {
|
|
1801
|
+
const names = COMMANDS.filter((c) => !c.hidden || c.name === 'help').map((c) => c.name).concat(['help', 'completion', 'ai']);
|
|
1802
|
+
const uniqNames = [...new Set(names)];
|
|
1803
|
+
const flagsFor = (c) => (c.flags || []).map((f) => f.f).join(' ');
|
|
1804
|
+
const dynamicCmds = COMMANDS.filter((c) => c.dynamic).map((c) => c.name);
|
|
1805
|
+
if (shell === 'bash') {
|
|
1806
|
+
const cases = COMMANDS.filter((c) => c.flags && c.flags.length)
|
|
1807
|
+
.map((c) => ` ${c.name}) __vitrinka_flags="${flagsFor(c)}" ;;`).join('\n');
|
|
1808
|
+
return `# vitrinka bash completion — add to ~/.bashrc:
|
|
1809
|
+
# eval "$(vitrinka completion bash)"
|
|
1810
|
+
_vitrinka() {
|
|
1811
|
+
local cur prev cmd
|
|
1812
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
1813
|
+
local cmds="${uniqNames.join(' ')}"
|
|
1814
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
1815
|
+
COMPREPLY=( $(compgen -W "$cmds" -- "$cur") ); return
|
|
1816
|
+
fi
|
|
1817
|
+
cmd="\${COMP_WORDS[1]}"
|
|
1818
|
+
if [[ "$cur" == -* ]]; then
|
|
1819
|
+
local __vitrinka_flags=""
|
|
1820
|
+
case "$cmd" in
|
|
1821
|
+
${cases}
|
|
1822
|
+
esac
|
|
1823
|
+
COMPREPLY=( $(compgen -W "$__vitrinka_flags" -- "$cur") ); return
|
|
1824
|
+
fi
|
|
1825
|
+
case " ${dynamicCmds.join(' ')} " in
|
|
1826
|
+
*" $cmd "*)
|
|
1827
|
+
local dyn; dyn="$(vitrinka __complete "$cmd" "$cur" 2>/dev/null)"
|
|
1828
|
+
COMPREPLY=( $(compgen -W "$dyn" -- "$cur") ) ;;
|
|
1829
|
+
esac
|
|
1830
|
+
}
|
|
1831
|
+
complete -F _vitrinka vitrinka
|
|
1832
|
+
`;
|
|
1833
|
+
}
|
|
1834
|
+
if (shell === 'zsh') {
|
|
1835
|
+
const cases = COMMANDS.filter((c) => c.flags && c.flags.length)
|
|
1836
|
+
.map((c) => ` ${c.name}) flags="${flagsFor(c)}" ;;`).join('\n');
|
|
1837
|
+
return `# vitrinka zsh completion — add to ~/.zshrc:
|
|
1838
|
+
# eval "$(vitrinka completion zsh)"
|
|
1839
|
+
_vitrinka() {
|
|
1840
|
+
local cmds=(${uniqNames.join(' ')})
|
|
1841
|
+
if (( CURRENT == 2 )); then
|
|
1842
|
+
compadd -- $cmds; return
|
|
1843
|
+
fi
|
|
1844
|
+
local cmd="\${words[2]}"
|
|
1845
|
+
local cur="\${words[CURRENT]}"
|
|
1846
|
+
if [[ "$cur" == -* ]]; then
|
|
1847
|
+
local flags=""
|
|
1848
|
+
case "$cmd" in
|
|
1849
|
+
${cases}
|
|
1850
|
+
esac
|
|
1851
|
+
compadd -- \${=flags}; return
|
|
1852
|
+
fi
|
|
1853
|
+
case " ${dynamicCmds.join(' ')} " in
|
|
1854
|
+
*" $cmd "*)
|
|
1855
|
+
local dyn; dyn=("\${(f)$(vitrinka __complete "$cmd" "$cur" 2>/dev/null)}")
|
|
1856
|
+
compadd -- $dyn ;;
|
|
1857
|
+
esac
|
|
1858
|
+
}
|
|
1859
|
+
compdef _vitrinka vitrinka
|
|
1860
|
+
`;
|
|
1861
|
+
}
|
|
1862
|
+
fail(`completion: unsupported shell ${JSON.stringify(shell)} — expected zsh or bash`, 2);
|
|
1863
|
+
}
|
|
1864
|
+
function completionCmd(rest) {
|
|
1865
|
+
const shell = rest.find((a) => !a.startsWith('--')) || '';
|
|
1866
|
+
if (!shell)
|
|
1867
|
+
fail('completion: expected a shell — zsh or bash', 2);
|
|
1868
|
+
process.stdout.write(completionScript(shell));
|
|
1869
|
+
}
|
|
1870
|
+
function resolveBase(args) {
|
|
1871
|
+
return resolveBaseUrl(strArg(args.base));
|
|
1872
|
+
}
|
|
1873
|
+
// fetchCandidates lists slug candidates of `kind` from the server. Silent [] on
|
|
1874
|
+
// any failure — completion and fuzzy resolution must never hang or error.
|
|
1875
|
+
export async function fetchCandidates(base, kind, timeoutMs = 1500) {
|
|
1876
|
+
const url = kind === 'boards' ? `${base}/api/v1/boards` : `${base}/api/v1/projects`;
|
|
1877
|
+
let data;
|
|
1878
|
+
try {
|
|
1879
|
+
data = await watchFetchJson(url, timeoutMs);
|
|
1880
|
+
}
|
|
1881
|
+
catch {
|
|
1882
|
+
return []; // unreachable / non-2xx — completion stays empty (expected, by design)
|
|
1883
|
+
}
|
|
1884
|
+
const rows = kind === 'boards' ? data?.boards : data?.projects;
|
|
1885
|
+
if (!Array.isArray(rows))
|
|
1886
|
+
return [];
|
|
1887
|
+
return rows.map((r) => String(kind === 'boards' ? r?.slug : r?.project)).filter(Boolean);
|
|
1888
|
+
}
|
|
1889
|
+
// __complete — the hidden dynamic-candidate endpoint the shell scripts call.
|
|
1890
|
+
// Prints newline-separated matches; ALWAYS exits 0 with no output on any
|
|
1891
|
+
// failure so a shell completion never blocks.
|
|
1892
|
+
async function completeDynamicCmd(rest) {
|
|
1893
|
+
const cmd = rest[0] || '';
|
|
1894
|
+
const partial = rest[1] || '';
|
|
1895
|
+
const spec = COMMAND_MAP[cmd];
|
|
1896
|
+
if (!spec?.dynamic)
|
|
1897
|
+
return; // nothing dynamic for this command → silent
|
|
1898
|
+
const cands = await fetchCandidates(resolveBase({}), spec.dynamic);
|
|
1899
|
+
for (const c of cands)
|
|
1900
|
+
if (c.startsWith(partial))
|
|
1901
|
+
process.stdout.write(c + '\n');
|
|
1902
|
+
}
|
|
1903
|
+
// ---------- AI assistance (local claude CLI; degrades gracefully) ----------
|
|
1904
|
+
// aiEnabled — the single kill switch for all three AI paths.
|
|
1905
|
+
function aiEnabled() { return process.env.VITRINKA_NO_AI !== '1'; }
|
|
1906
|
+
// askClaude runs the LOCAL claude binary ($CLAUDE_BIN then PATH) in print mode
|
|
1907
|
+
// with --model haiku, feeding `prompt` on stdin. Returns the trimmed stdout on a
|
|
1908
|
+
// clean exit, or null on ANY failure (disabled, no binary, non-zero, timeout) —
|
|
1909
|
+
// every caller degrades to a single clear line, never a crash.
|
|
1910
|
+
export function askClaude(prompt, opts = {}) {
|
|
1911
|
+
if (!aiEnabled())
|
|
1912
|
+
return Promise.resolve(null);
|
|
1913
|
+
const bin = process.env.CLAUDE_BIN || 'claude';
|
|
1914
|
+
return new Promise((resolvePromise) => {
|
|
1915
|
+
let settled = false;
|
|
1916
|
+
const done = (v) => { if (!settled) {
|
|
1917
|
+
settled = true;
|
|
1918
|
+
resolvePromise(v);
|
|
1919
|
+
} };
|
|
1920
|
+
let child;
|
|
1921
|
+
try {
|
|
1922
|
+
child = spawn(bin, ['-p', '--model', 'haiku'], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
1923
|
+
}
|
|
1924
|
+
catch {
|
|
1925
|
+
return done(null); // spawn threw synchronously (e.g. bad bin) — degrade
|
|
1926
|
+
}
|
|
1927
|
+
const timer = setTimeout(() => { try {
|
|
1928
|
+
child.kill('SIGKILL');
|
|
1929
|
+
}
|
|
1930
|
+
catch { /* already gone */ } done(null); }, opts.timeoutMs ?? 6000);
|
|
1931
|
+
let out = '';
|
|
1932
|
+
child.stdout?.setEncoding('utf8');
|
|
1933
|
+
child.stdout?.on('data', (d) => { out += d; });
|
|
1934
|
+
child.on('error', () => { clearTimeout(timer); done(null); }); // ENOENT: no claude on PATH
|
|
1935
|
+
child.on('close', (code) => { clearTimeout(timer); done(code === 0 ? out.trim() : null); });
|
|
1936
|
+
child.stdin?.on('error', () => { });
|
|
1937
|
+
child.stdin?.end(prompt);
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
// extractCommandLine pulls a single proposed command out of claude's reply:
|
|
1941
|
+
// the first non-empty line of the first fenced block, else the first line that
|
|
1942
|
+
// starts with "vitrinka". A leading shell prompt ("$ ") is stripped.
|
|
1943
|
+
export function extractCommandLine(reply) {
|
|
1944
|
+
const fence = /```[a-zA-Z]*\n([\s\S]*?)```/.exec(reply);
|
|
1945
|
+
const scan = (text) => {
|
|
1946
|
+
for (const raw of text.split('\n')) {
|
|
1947
|
+
const line = raw.trim().replace(/^\$\s+/, '');
|
|
1948
|
+
if (line)
|
|
1949
|
+
return line;
|
|
1950
|
+
}
|
|
1951
|
+
return '';
|
|
1952
|
+
};
|
|
1953
|
+
if (fence) {
|
|
1954
|
+
const inside = scan(fence[1]);
|
|
1955
|
+
if (inside)
|
|
1956
|
+
return inside;
|
|
1957
|
+
}
|
|
1958
|
+
for (const raw of reply.split('\n')) {
|
|
1959
|
+
const line = raw.trim().replace(/^\$\s+/, '');
|
|
1960
|
+
if (/^vitrinka\b/.test(line))
|
|
1961
|
+
return line;
|
|
1962
|
+
}
|
|
1963
|
+
return scan(reply);
|
|
1964
|
+
}
|
|
1965
|
+
// SHELL_META rejects any command line carrying shell control operators — the
|
|
1966
|
+
// proposal must be a plain argv, never a pipeline/chain/substitution.
|
|
1967
|
+
const SHELL_META = /[|;&><`]|\$\(|\$\{|\n/;
|
|
1968
|
+
// tokenizeArgv splits a command line into argv honoring single/double quotes but
|
|
1969
|
+
// interpreting NO shell metacharacters. Returns null on an unbalanced quote.
|
|
1970
|
+
export function tokenizeArgv(line) {
|
|
1971
|
+
const out = [];
|
|
1972
|
+
let cur = '', quote = '', started = false;
|
|
1973
|
+
for (const c of line) {
|
|
1974
|
+
if (quote) {
|
|
1975
|
+
if (c === quote)
|
|
1976
|
+
quote = '';
|
|
1977
|
+
else
|
|
1978
|
+
cur += c;
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
if (c === '"' || c === "'") {
|
|
1982
|
+
quote = c;
|
|
1983
|
+
started = true;
|
|
1984
|
+
continue;
|
|
1985
|
+
}
|
|
1986
|
+
if (/\s/.test(c)) {
|
|
1987
|
+
if (started || cur) {
|
|
1988
|
+
out.push(cur);
|
|
1989
|
+
cur = '';
|
|
1990
|
+
started = false;
|
|
1991
|
+
}
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
cur += c;
|
|
1995
|
+
started = true;
|
|
1996
|
+
}
|
|
1997
|
+
if (quote)
|
|
1998
|
+
return null;
|
|
1999
|
+
if (started || cur)
|
|
2000
|
+
out.push(cur);
|
|
2001
|
+
return out;
|
|
2002
|
+
}
|
|
2003
|
+
// validateProposal turns claude's UNTRUSTED command line into a safe, internally
|
|
2004
|
+
// dispatchable argv, or rejects it. The ONLY accepted shape is
|
|
2005
|
+
// `vitrinka <known-subcommand> …` with no shell metacharacters; `ai` is refused
|
|
2006
|
+
// to prevent recursion. The command is never shell-eval'd — the caller dispatches
|
|
2007
|
+
// the returned argv through the same in-process switch.
|
|
2008
|
+
export function validateProposal(line) {
|
|
2009
|
+
if (!line)
|
|
2010
|
+
return { ok: false, reason: 'empty proposal' };
|
|
2011
|
+
if (SHELL_META.test(line))
|
|
2012
|
+
return { ok: false, reason: 'contains shell metacharacters' };
|
|
2013
|
+
const argv = tokenizeArgv(line);
|
|
2014
|
+
if (!argv || argv.length === 0)
|
|
2015
|
+
return { ok: false, reason: 'unparseable command line' };
|
|
2016
|
+
if (argv[0] !== 'vitrinka')
|
|
2017
|
+
return { ok: false, reason: `argv[0] is not "vitrinka" (got ${JSON.stringify(argv[0])})` };
|
|
2018
|
+
const sub = argv[1] || '';
|
|
2019
|
+
if (!COMMAND_NAMES.includes(sub))
|
|
2020
|
+
return { ok: false, reason: `unknown subcommand ${JSON.stringify(sub)}` };
|
|
2021
|
+
if (sub === 'ai')
|
|
2022
|
+
return { ok: false, reason: 'refusing to recurse into `ai`' };
|
|
2023
|
+
// Registry-validate the FLAGS too: a hallucinated flag must be rejected
|
|
2024
|
+
// here, not silently ignored (or misread) by the command at runtime.
|
|
2025
|
+
const spec = COMMANDS.find((c) => c.name === sub);
|
|
2026
|
+
const known = new Set((spec?.flags ?? []).map((fl) => fl.f));
|
|
2027
|
+
for (const tok of argv.slice(2)) {
|
|
2028
|
+
if (!tok.startsWith('--'))
|
|
2029
|
+
continue; // positional / flag value
|
|
2030
|
+
const flag = tok.includes('=') ? tok.slice(0, tok.indexOf('=')) : tok;
|
|
2031
|
+
if (!known.has(flag))
|
|
2032
|
+
return { ok: false, reason: `unknown flag ${JSON.stringify(flag)} for ${sub}` };
|
|
2033
|
+
}
|
|
2034
|
+
return { ok: true, sub, argv };
|
|
2035
|
+
}
|
|
2036
|
+
// confirmYN prompts on stderr and reads one line from stdin. Non-interactive
|
|
2037
|
+
// (no TTY) without an explicit --yes defaults to NO — never auto-runs.
|
|
2038
|
+
async function confirmYN(question) {
|
|
2039
|
+
if (!process.stdin.isTTY)
|
|
2040
|
+
return false;
|
|
2041
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
2042
|
+
const ans = await new Promise((res) => rl.question(`${question} [y/N] `, res));
|
|
2043
|
+
rl.close();
|
|
2044
|
+
return /^y(es)?$/i.test(ans.trim());
|
|
2045
|
+
}
|
|
2046
|
+
// aiCmd: natural-language → ONE proposed vitrinka command → confirm → dispatch
|
|
2047
|
+
// INTERNALLY (never a shell). claude's output is untrusted: parsed, registry-
|
|
2048
|
+
// validated, and only then run through the same in-process switch.
|
|
2049
|
+
async function aiCmd(rest, args) {
|
|
2050
|
+
if (!aiEnabled())
|
|
2051
|
+
fail('ai: disabled (VITRINKA_NO_AI=1)', 1);
|
|
2052
|
+
const ask = positionals(rest).slice(1).join(' ').trim() || strArg(args.ask);
|
|
2053
|
+
if (!ask)
|
|
2054
|
+
fail('ai: expected a natural-language request, e.g. vitrinka ai "publish these as a flow board"', 2);
|
|
2055
|
+
const cwd = process.cwd();
|
|
2056
|
+
const repo = originRepo(cwd) || basename(mainWorktreeTop(cwd) || cwd);
|
|
2057
|
+
const base = resolveBase(args);
|
|
2058
|
+
const operator = readOperator(true) || '(unset)';
|
|
2059
|
+
const prompt = [
|
|
2060
|
+
'You translate a natural-language request into ONE vitrinka CLI command.',
|
|
2061
|
+
'Output ONLY the command, on a single line, inside a ```sh fenced block. No prose, no explanation.',
|
|
2062
|
+
'The command MUST start with "vitrinka " and use only the documented subcommands and flags below.',
|
|
2063
|
+
'',
|
|
2064
|
+
'# Context',
|
|
2065
|
+
`cwd repo: ${repo}`,
|
|
2066
|
+
`base URL: ${base}`,
|
|
2067
|
+
`operator: ${operator}`,
|
|
2068
|
+
'',
|
|
2069
|
+
'# vitrinka help',
|
|
2070
|
+
renderFullHelpPlain(),
|
|
2071
|
+
'',
|
|
2072
|
+
'# Request',
|
|
2073
|
+
ask,
|
|
2074
|
+
].join('\n');
|
|
2075
|
+
const reply = await askClaude(prompt, { timeoutMs: 20_000 });
|
|
2076
|
+
if (reply == null) {
|
|
2077
|
+
fail('ai: claude unavailable (no `claude` on PATH, or non-zero exit). Set $CLAUDE_BIN, or run the command yourself.', 1);
|
|
2078
|
+
}
|
|
2079
|
+
const line = extractCommandLine(reply);
|
|
2080
|
+
const proposal = validateProposal(line);
|
|
2081
|
+
if (!proposal.ok) {
|
|
2082
|
+
console.error(`ai: rejected claude's suggestion (${proposal.reason}). Raw suggestion:`);
|
|
2083
|
+
console.error(` ${line || '(none)'}`);
|
|
2084
|
+
process.exit(1);
|
|
2085
|
+
}
|
|
2086
|
+
console.log(`proposed: ${proposal.argv.join(' ')}`);
|
|
2087
|
+
if (args.yes !== true) {
|
|
2088
|
+
const ok = await confirmYN('run it?');
|
|
2089
|
+
if (!ok) {
|
|
2090
|
+
console.error('ai: cancelled');
|
|
2091
|
+
process.exit(0);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
// Dispatch the vetted argv through the SAME in-process switch (no shell eval).
|
|
2095
|
+
await runSub(proposal.argv.slice(1));
|
|
2096
|
+
}
|
|
2097
|
+
// renderFullHelpPlain — the full help with color forced off (for the AI prompt).
|
|
2098
|
+
function renderFullHelpPlain() {
|
|
2099
|
+
const rows = COMMANDS.map((c) => ` vitrinka ${c.name} ${c.usage}`);
|
|
2100
|
+
const detail = COMMANDS.filter((c) => c.flags && c.flags.length).map((c) => {
|
|
2101
|
+
const fl = (c.flags || []).map((f) => ` ${f.f}${f.arg ? ' ' + f.arg : ''} ${f.d}`).join('\n');
|
|
2102
|
+
return `${c.name}:\n${fl}`;
|
|
2103
|
+
}).join('\n');
|
|
2104
|
+
return `Commands:\n${rows.join('\n')}\n\nFlags:\n${detail}`;
|
|
2105
|
+
}
|
|
2106
|
+
// ---------- AI fuzzy slug resolution (read: auto; write: confirm) ----------
|
|
2107
|
+
// resolveFuzzy maps a possibly-fuzzy slug to a real one of `kind`. Exact match →
|
|
2108
|
+
// returned as-is. Otherwise the live candidate list is fetched and claude picks
|
|
2109
|
+
// the intended slug; for READ commands the resolution is applied automatically
|
|
2110
|
+
// (announced on stderr), for WRITE commands it is confirmed first (unless --yes).
|
|
2111
|
+
// No candidates / ambiguous / AI unavailable → the top candidates are listed and
|
|
2112
|
+
// the process exits 1.
|
|
2113
|
+
export async function resolveFuzzy(input, kind, opts) {
|
|
2114
|
+
const cands = await fetchCandidates(opts.base, kind);
|
|
2115
|
+
if (cands.includes(input))
|
|
2116
|
+
return input;
|
|
2117
|
+
if (cands.length === 0) {
|
|
2118
|
+
fail(`no matching ${kind.replace(/s$/, '')} for ${JSON.stringify(input)} (server returned no candidates)`, 1);
|
|
2119
|
+
}
|
|
2120
|
+
const prompt = [
|
|
2121
|
+
`From this list of ${kind}, pick the ONE that best matches the user's fuzzy input.`,
|
|
2122
|
+
'Output ONLY the exact chosen value on a single line, nothing else. If none is a clear match, output NONE.',
|
|
2123
|
+
'', `Input: ${input}`, '', 'Candidates:', ...cands.map((c) => `- ${c}`),
|
|
2124
|
+
].join('\n');
|
|
2125
|
+
const reply = await askClaude(prompt, { timeoutMs: 8000 });
|
|
2126
|
+
const picked = (reply || '').split('\n').map((s) => s.trim()).find(Boolean) || '';
|
|
2127
|
+
if (!picked || picked === 'NONE' || !cands.includes(picked)) {
|
|
2128
|
+
console.error(`could not confidently resolve ${JSON.stringify(input)} — candidates:`);
|
|
2129
|
+
for (const c of cands.slice(0, 10))
|
|
2130
|
+
console.error(` ${c}`);
|
|
2131
|
+
process.exit(1);
|
|
2132
|
+
}
|
|
2133
|
+
console.error(`resolved "${input}" → ${picked}`);
|
|
2134
|
+
if (opts.write && !opts.yes) {
|
|
2135
|
+
const ok = await confirmYN(`use "${picked}"?`);
|
|
2136
|
+
if (!ok) {
|
|
2137
|
+
console.error('cancelled');
|
|
2138
|
+
process.exit(0);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
return picked;
|
|
2142
|
+
}
|
|
2143
|
+
// emitHint prints ONE claude-authored `hint:` line AFTER a command's own error
|
|
2144
|
+
// output (usage error / 4xx). Capped short; prints nothing on timeout / no
|
|
2145
|
+
// claude / AI disabled — the hint is strictly additive, never blocking.
|
|
2146
|
+
async function emitHint(cmdline, errorText, helpSection) {
|
|
2147
|
+
if (!aiEnabled())
|
|
2148
|
+
return;
|
|
2149
|
+
const prompt = [
|
|
2150
|
+
'A vitrinka CLI command just failed. In ONE short line, suggest the single most likely fix.',
|
|
2151
|
+
'Output ONLY that line, prefixed with "hint: ". No other prose.',
|
|
2152
|
+
'', '# Command', cmdline, '', '# Error', errorText, '', '# Relevant help', helpSection,
|
|
2153
|
+
].join('\n');
|
|
2154
|
+
const reply = await askClaude(prompt, { timeoutMs: 6000 });
|
|
2155
|
+
if (!reply)
|
|
2156
|
+
return;
|
|
2157
|
+
const line = reply.split('\n').map((s) => s.trim()).find(Boolean) || '';
|
|
2158
|
+
if (!line)
|
|
2159
|
+
return;
|
|
2160
|
+
process.stderr.write((line.startsWith('hint:') ? line : `hint: ${line}`) + '\n');
|
|
2161
|
+
}
|
|
2162
|
+
// board-from-set — the flow-visualizer one-shot: turn THIS session's set into a
|
|
2163
|
+
// board where shots are laid out as the user flow (serpentine, action-labeled
|
|
2164
|
+
// arrows). Board slug defaults to the set key; re-running appends the newest
|
|
2165
|
+
// import below the existing content (server behavior).
|
|
2166
|
+
async function boardFromSetCmd(root, args) {
|
|
2167
|
+
if (!existsSync(vitrinkaCfgPath(root))) {
|
|
2168
|
+
console.error(`board-from-set: no ${vitrinkaCfgPath(root)} — run remote-init first`);
|
|
2169
|
+
process.exit(2);
|
|
2170
|
+
}
|
|
2171
|
+
const cfg = loadVitrinkaCfg(root);
|
|
2172
|
+
const slug = (strArg(args.slug) || cfg.key).toLowerCase();
|
|
2173
|
+
const title = strArg(args.btitle) || strArg(args.title) || '';
|
|
2174
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
2175
|
+
const token = readToken();
|
|
2176
|
+
if (token)
|
|
2177
|
+
headers.Authorization = `Bearer ${token}`;
|
|
2178
|
+
// Attribute the board + its imported cards to the operator (or --actor).
|
|
2179
|
+
const actor = actorFor(args);
|
|
2180
|
+
if (actor)
|
|
2181
|
+
headers['X-Board-Actor'] = actor;
|
|
2182
|
+
const create = await fetch(`${cfg.base}/api/v1/boards`, {
|
|
2183
|
+
method: 'POST', headers, body: JSON.stringify({ slug, title }),
|
|
2184
|
+
signal: AbortSignal.timeout(30_000),
|
|
2185
|
+
});
|
|
2186
|
+
if (!create.ok && create.status !== 409) { // 409 = board exists → reuse
|
|
2187
|
+
console.error(`board-from-set: create board failed: ${create.status} ${await create.text()}`);
|
|
2188
|
+
process.exit(1);
|
|
2189
|
+
}
|
|
2190
|
+
const imp = await fetch(`${cfg.base}/api/v1/boards/${encodeURIComponent(slug)}/import-set`, {
|
|
2191
|
+
method: 'POST', headers,
|
|
2192
|
+
body: JSON.stringify({ project: cfg.project, branch: cfg.slug, selector: cfg.key }),
|
|
2193
|
+
signal: AbortSignal.timeout(60_000),
|
|
2194
|
+
});
|
|
2195
|
+
if (!imp.ok) {
|
|
2196
|
+
console.error(`board-from-set: import failed: ${imp.status} ${await imp.text()}`);
|
|
2197
|
+
process.exit(1);
|
|
2198
|
+
}
|
|
2199
|
+
const out = await imp.json();
|
|
2200
|
+
console.log(`flow board: ${cfg.base}/boards/${slug} (${out.cards.length} cards, ${out.edges.length} edges)`);
|
|
2201
|
+
// The board is about to be annotated — make sure its autocomplete index is fresh.
|
|
2202
|
+
await pushProjectIndex(dirname(root), cfg.base, cfg.project);
|
|
2203
|
+
}
|
|
2204
|
+
// announceLine renders the single stdout line for one work item:
|
|
2205
|
+
// №<id> [<intent>] <board>: <prompt, single line, ≤100 chars>
|
|
2206
|
+
export function announceLine(it) {
|
|
2207
|
+
const intent = it.intent === 'other' && it.intentOther ? it.intentOther : (it.intent || 'other');
|
|
2208
|
+
const board = it.board || '?';
|
|
2209
|
+
const prompt = String(it.prompt ?? '').replace(/\s+/g, ' ').trim();
|
|
2210
|
+
const short = prompt.length > 100 ? prompt.slice(0, 100) + '…' : prompt;
|
|
2211
|
+
return `№${it.id} [${intent}] ${board}: ${short}`;
|
|
2212
|
+
}
|
|
2213
|
+
// computeAnnouncements is the watermark/diff core (PURE — unit-tested without
|
|
2214
|
+
// HTTP). Given the previously-announced OPEN items (id → last status/updatedAt)
|
|
2215
|
+
// and the CURRENT open queue, it returns the lines to print for genuinely-new
|
|
2216
|
+
// work plus the next watermark map. An item is "new" when it wasn't announced
|
|
2217
|
+
// before, OR its status/updatedAt changed (re-queue / prompt revision). An item
|
|
2218
|
+
// that LEFT the open queue simply drops from nextMap — so if it reopens later
|
|
2219
|
+
// it is announced again. Feeding the CURRENT open queue every poll makes the
|
|
2220
|
+
// "left then reappeared" case fall out for free (it was absent → present).
|
|
2221
|
+
export function computeAnnouncements(prev, currentQueue) {
|
|
2222
|
+
const nextMap = {};
|
|
2223
|
+
const lines = [];
|
|
2224
|
+
for (const it of currentQueue) {
|
|
2225
|
+
if (it == null || it.id === undefined || it.id === null)
|
|
2226
|
+
continue;
|
|
2227
|
+
const key = String(it.id);
|
|
2228
|
+
const cur = { status: it.status, updatedAt: it.updatedAt };
|
|
2229
|
+
const before = prev[key];
|
|
2230
|
+
if (!before || before.status !== cur.status || before.updatedAt !== cur.updatedAt) {
|
|
2231
|
+
lines.push(announceLine(it));
|
|
2232
|
+
}
|
|
2233
|
+
nextMap[key] = cur;
|
|
2234
|
+
}
|
|
2235
|
+
return { lines, nextMap };
|
|
2236
|
+
}
|
|
2237
|
+
const watchEmit = (line) => { process.stdout.write(line + '\n'); };
|
|
2238
|
+
const watchSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2239
|
+
function watchBaseUrl(args) {
|
|
2240
|
+
return (strArg(args.base) || process.env.VITRINKA_URL || process.env.VITRINKA_BASE_URL
|
|
2241
|
+
|| 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
|
|
2242
|
+
}
|
|
2243
|
+
// deriveWatchScope resolves the listener scope from flags + the repo context
|
|
2244
|
+
// (PURE — unit-tested). Priority: --all (firehose) → --board <slug> → the repo's
|
|
2245
|
+
// project (main-worktree basename, push's derivation) + current git branch.
|
|
2246
|
+
// --project / --branch override the inferred project scope.
|
|
2247
|
+
export function deriveWatchScope(args, ctx) {
|
|
2248
|
+
if (args.all === true)
|
|
2249
|
+
return { kind: 'all', label: 'all boards' };
|
|
2250
|
+
const board = strArg(args.board);
|
|
2251
|
+
if (board)
|
|
2252
|
+
return { kind: 'board', board, label: `board ${board}` };
|
|
2253
|
+
const project = sanitizeSeg(strArg(args.project) || ctx.repoProject || 'project', 64);
|
|
2254
|
+
const branch = sanitizeSeg(strArg(args.branch) || ctx.repoBranch || 'main', 100);
|
|
2255
|
+
return { kind: 'project', project, branch, label: `${project}/${branch}` };
|
|
2256
|
+
}
|
|
2257
|
+
// scopeQuery renders the scope's server query params (shared by the wait lease
|
|
2258
|
+
// and the plain work list).
|
|
2259
|
+
function scopeQuery(scope) {
|
|
2260
|
+
switch (scope.kind) {
|
|
2261
|
+
case 'all': return { all: '1' };
|
|
2262
|
+
case 'board': return { board: scope.board };
|
|
2263
|
+
case 'project': return { project: scope.project, branch: scope.branch };
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
// watchFetchResp — a work-API read that returns status + parsed body (does NOT
|
|
2267
|
+
// throw on non-2xx, so the caller can branch on 409/holder). Throws only on a
|
|
2268
|
+
// transport failure so the resilience layer backs off.
|
|
2269
|
+
async function watchFetchResp(url, timeoutMs) {
|
|
2270
|
+
const headers = {};
|
|
2271
|
+
const token = readToken();
|
|
2272
|
+
if (token)
|
|
2273
|
+
headers.Authorization = `Bearer ${token}`;
|
|
2274
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
2275
|
+
let body = null;
|
|
2276
|
+
try {
|
|
2277
|
+
body = await res.json();
|
|
2278
|
+
}
|
|
2279
|
+
catch { /* empty/non-JSON */ }
|
|
2280
|
+
return { status: res.status, body };
|
|
2281
|
+
}
|
|
2282
|
+
// watchFetchJson — as above but throws on non-2xx (used by the plain list).
|
|
2283
|
+
async function watchFetchJson(url, timeoutMs) {
|
|
2284
|
+
const { status, body } = await watchFetchResp(url, timeoutMs);
|
|
2285
|
+
if (status < 200 || status >= 300)
|
|
2286
|
+
throw new Error(`HTTP ${status}`);
|
|
2287
|
+
return body;
|
|
2288
|
+
}
|
|
2289
|
+
async function watchCmd(args) {
|
|
2290
|
+
const base = watchBaseUrl(args);
|
|
2291
|
+
const graceSec = numArg(args['quiet-grace'], 5, '--quiet-grace');
|
|
2292
|
+
const once = args.once === true;
|
|
2293
|
+
// Repo context for scope inference (main worktree basename = project; current
|
|
2294
|
+
// branch). '.' cwd is where the session runs (the app repo).
|
|
2295
|
+
const cwd = resolve('.');
|
|
2296
|
+
const repoProject = basename(mainWorktreeTop(cwd) || cwd);
|
|
2297
|
+
const repoBranch = git(['branch', '--show-current'], cwd);
|
|
2298
|
+
const scope = deriveWatchScope(args, { repoProject, repoBranch });
|
|
2299
|
+
const sq = scopeQuery(scope);
|
|
2300
|
+
const listUrl = () => {
|
|
2301
|
+
const q = new URLSearchParams({ status: 'open', ...sq });
|
|
2302
|
+
return `${base}/api/v1/work?${q}`;
|
|
2303
|
+
};
|
|
2304
|
+
// --once: a single deterministic probe (tests). Lease-free (no session), so it
|
|
2305
|
+
// never claims scope. Returns exit 0 with announcements, else 3 (idle).
|
|
2306
|
+
if (once) {
|
|
2307
|
+
const waitUrl = `${base}/api/v1/work/wait?${new URLSearchParams({ timeout: '2', ...sq })}`;
|
|
2308
|
+
let waited;
|
|
2309
|
+
try {
|
|
2310
|
+
waited = await watchFetchJson(waitUrl, 12_000);
|
|
2311
|
+
}
|
|
2312
|
+
catch (e) {
|
|
2313
|
+
console.error(`watch --once: vitrinka unreachable: ${e instanceof Error ? e.message : e}`);
|
|
2314
|
+
process.exit(3);
|
|
2315
|
+
}
|
|
2316
|
+
if (!waited || waited.idle || !Array.isArray(waited.work) || waited.work.length === 0) {
|
|
2317
|
+
process.exit(3);
|
|
2318
|
+
}
|
|
2319
|
+
let list;
|
|
2320
|
+
try {
|
|
2321
|
+
list = await watchFetchJson(listUrl(), 30_000);
|
|
2322
|
+
}
|
|
2323
|
+
catch (e) {
|
|
2324
|
+
console.error(`watch --once: list failed: ${e instanceof Error ? e.message : e}`);
|
|
2325
|
+
process.exit(3);
|
|
2326
|
+
}
|
|
2327
|
+
const items = Array.isArray(list?.work) ? list.work : [];
|
|
2328
|
+
const { lines } = computeAnnouncements({}, items);
|
|
2329
|
+
for (const l of lines)
|
|
2330
|
+
watchEmit(l);
|
|
2331
|
+
process.exit(lines.length > 0 ? 0 : 3);
|
|
2332
|
+
}
|
|
2333
|
+
// Persistent leased loop. Identity: operator persona + host:pid — the process
|
|
2334
|
+
// that must stay alive. The long-poll IS the lease heartbeat; on graceful stop
|
|
2335
|
+
// we DELETE the lease so its unfinished work reverts immediately.
|
|
2336
|
+
const actor = readOperator(true);
|
|
2337
|
+
const session = `${hostname()}:${process.pid}`;
|
|
2338
|
+
const takeover = args.takeover === true;
|
|
2339
|
+
const leasedWaitUrl = (secs) => {
|
|
2340
|
+
const q = new URLSearchParams({ timeout: String(secs), session, ...sq });
|
|
2341
|
+
if (actor)
|
|
2342
|
+
q.set('actor', actor);
|
|
2343
|
+
if (takeover)
|
|
2344
|
+
q.set('takeover', '1');
|
|
2345
|
+
return `${base}/api/v1/work/wait?${q}`;
|
|
2346
|
+
};
|
|
2347
|
+
let stopping = false;
|
|
2348
|
+
let releasing = false;
|
|
2349
|
+
let leaseId = null;
|
|
2350
|
+
const shutdown = async (code) => {
|
|
2351
|
+
if (releasing)
|
|
2352
|
+
return;
|
|
2353
|
+
releasing = true;
|
|
2354
|
+
stopping = true;
|
|
2355
|
+
if (leaseId != null) {
|
|
2356
|
+
try {
|
|
2357
|
+
const headers = {};
|
|
2358
|
+
const token = readToken();
|
|
2359
|
+
if (token)
|
|
2360
|
+
headers.Authorization = `Bearer ${token}`;
|
|
2361
|
+
await fetch(`${base}/api/v1/work/listeners/${leaseId}`, { method: 'DELETE', headers, signal: AbortSignal.timeout(5_000) });
|
|
2362
|
+
}
|
|
2363
|
+
catch { /* best effort — the TTL reaps it anyway */ }
|
|
2364
|
+
}
|
|
2365
|
+
process.exit(code);
|
|
2366
|
+
};
|
|
2367
|
+
process.on('SIGINT', () => { void shutdown(0); });
|
|
2368
|
+
process.on('SIGTERM', () => { void shutdown(0); });
|
|
2369
|
+
let announced = {};
|
|
2370
|
+
let downSince = null;
|
|
2371
|
+
let degradedAt = null;
|
|
2372
|
+
let backoff = 1000;
|
|
2373
|
+
const noteSuccess = () => {
|
|
2374
|
+
if (degradedAt !== null)
|
|
2375
|
+
watchEmit('✓ vitrinka reachable again');
|
|
2376
|
+
downSince = null;
|
|
2377
|
+
degradedAt = null;
|
|
2378
|
+
backoff = 1000;
|
|
2379
|
+
};
|
|
2380
|
+
const noteFailure = async () => {
|
|
2381
|
+
const now = Date.now();
|
|
2382
|
+
if (downSince === null)
|
|
2383
|
+
downSince = now;
|
|
2384
|
+
const downMs = now - downSince;
|
|
2385
|
+
if (downMs >= 120_000 && (degradedAt === null || now - degradedAt >= 600_000)) {
|
|
2386
|
+
watchEmit(`⚠ vitrinka unreachable for ${Math.round(downMs / 1000)}s — listener degraded`);
|
|
2387
|
+
degradedAt = now;
|
|
2388
|
+
}
|
|
2389
|
+
const wait = backoff;
|
|
2390
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
2391
|
+
await watchSleep(wait);
|
|
2392
|
+
};
|
|
2393
|
+
while (!stopping) {
|
|
2394
|
+
// Leased long-poll: claims/renews the scope, blocks until work exists. The
|
|
2395
|
+
// instant the queue is non-empty it returns immediately (busy) so graceSec
|
|
2396
|
+
// below throttles the diff loop.
|
|
2397
|
+
let resp;
|
|
2398
|
+
try {
|
|
2399
|
+
resp = await watchFetchResp(leasedWaitUrl(55), 70_000);
|
|
2400
|
+
}
|
|
2401
|
+
catch {
|
|
2402
|
+
await noteFailure();
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
if (resp.status === 409) {
|
|
2406
|
+
// Scope already held by another session. Live → cannot steal; expired →
|
|
2407
|
+
// retry with --takeover. Either way surface it and exit (the skill reads
|
|
2408
|
+
// exit 2 as "another session owns this scope").
|
|
2409
|
+
const h = resp.body?.holder ?? {};
|
|
2410
|
+
const live = resp.body?.live ? 'live' : 'expired';
|
|
2411
|
+
const scopeStr = h.scopeKind === 'project' ? `${h.scope}/${h.branch}` : (h.scope || h.scopeKind || scope.label);
|
|
2412
|
+
const since = h.lastSeen ? ` (since ${h.lastSeen})` : '';
|
|
2413
|
+
watchEmit(`⚠ listener already active (${live}): ${h.actor || '?'}@${h.session || '?'} on ${scopeStr}${since}`);
|
|
2414
|
+
if (!resp.body?.live)
|
|
2415
|
+
watchEmit(' → retry with --takeover to steal the expired lease');
|
|
2416
|
+
process.exit(2);
|
|
2417
|
+
}
|
|
2418
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
2419
|
+
await noteFailure();
|
|
2420
|
+
continue;
|
|
2421
|
+
}
|
|
2422
|
+
noteSuccess();
|
|
2423
|
+
if (typeof resp.body?.listener === 'number')
|
|
2424
|
+
leaseId = resp.body.listener;
|
|
2425
|
+
const waited = resp.body;
|
|
2426
|
+
if (!waited || waited.idle || !Array.isArray(waited.work) || waited.work.length === 0) {
|
|
2427
|
+
announced = {};
|
|
2428
|
+
continue;
|
|
2429
|
+
}
|
|
2430
|
+
// Queue non-empty → diff the full open list and announce new items. The next
|
|
2431
|
+
// loop iteration re-waits (renewing the lease); graceSec throttles.
|
|
2432
|
+
try {
|
|
2433
|
+
const list = await watchFetchJson(listUrl(), 30_000);
|
|
2434
|
+
const items = Array.isArray(list.work) ? list.work : [];
|
|
2435
|
+
const { lines, nextMap } = computeAnnouncements(announced, items);
|
|
2436
|
+
announced = nextMap;
|
|
2437
|
+
for (const l of lines)
|
|
2438
|
+
watchEmit(l);
|
|
2439
|
+
}
|
|
2440
|
+
catch {
|
|
2441
|
+
await noteFailure();
|
|
2442
|
+
continue;
|
|
2443
|
+
}
|
|
2444
|
+
await watchSleep(graceSec * 1000);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
// runSub — the single command switch, reused by the CLI entrypoint AND the `ai`
|
|
2448
|
+
// command's internal dispatch (no shell eval anywhere). `argv` is [command,
|
|
2449
|
+
// ...rest] exactly as process.argv.slice(2). Every case here MUST have a
|
|
2450
|
+
// COMMANDS registry entry (the registry-completeness test enforces it).
|
|
2451
|
+
async function runSub(argv) {
|
|
2452
|
+
const [cmd, ...rest] = argv;
|
|
2453
|
+
const args = parseArgs(rest);
|
|
2454
|
+
const root = resolve(strArg(args.root) || '.screenshots');
|
|
2455
|
+
if (cmd === 'add')
|
|
2456
|
+
addCmd(root, args);
|
|
2457
|
+
else if (cmd === 'build') {
|
|
2458
|
+
const { count, htmlPath } = buildIndex(root);
|
|
2459
|
+
console.log(`built ${htmlPath} (${count} shots)`);
|
|
2460
|
+
}
|
|
2461
|
+
else if (cmd === 'meta')
|
|
2462
|
+
metaCmd(root, args);
|
|
2463
|
+
else if (cmd === 'remote-init')
|
|
2464
|
+
remoteInitCmd(root, args);
|
|
2465
|
+
else if (cmd === 'push')
|
|
2466
|
+
await pushCmd(root, args);
|
|
2467
|
+
else if (cmd === 'name')
|
|
2468
|
+
await nameCmd(argv, args);
|
|
2469
|
+
else if (cmd === 'operator')
|
|
2470
|
+
await operatorCmd(argv, args);
|
|
2471
|
+
else if (cmd === 'install')
|
|
2472
|
+
installCmd(args);
|
|
2473
|
+
else if (cmd === 'snap')
|
|
2474
|
+
await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args);
|
|
2475
|
+
else if (cmd === 'board-from-set')
|
|
2476
|
+
await boardFromSetCmd(root, args);
|
|
2477
|
+
else if (cmd === 'watch')
|
|
2478
|
+
await watchCmd(args);
|
|
2479
|
+
else if (cmd === 'index')
|
|
2480
|
+
await indexCmd(root, args);
|
|
2481
|
+
else if (cmd === 'embed-shots')
|
|
2482
|
+
embedShotsCmd(root, args);
|
|
2483
|
+
else if (cmd === 'artifact-init')
|
|
2484
|
+
artifactInitCmd(args);
|
|
2485
|
+
else if (cmd === 'artifact-from-set')
|
|
2486
|
+
artifactFromSetCmd(args);
|
|
2487
|
+
else
|
|
2488
|
+
throw new Error(`runSub: unhandled command ${JSON.stringify(cmd)}`); // guarded by the caller
|
|
2489
|
+
}
|
|
2490
|
+
// IS_MAIN self-run: fires only when THIS file is the process entry (direct
|
|
2491
|
+
// `node cli.ts`, or the dist bin). `main` is a hoisted function declaration, so
|
|
2492
|
+
// calling it here — before its definition below — is fine, and keeping the
|
|
2493
|
+
// entry block adjacent to runSub keeps the help/completion cases out of the
|
|
2494
|
+
// runSub-vs-registry source scan in dx.test.ts.
|
|
2495
|
+
const IS_MAIN = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href === import.meta.url : false;
|
|
2496
|
+
if (IS_MAIN) {
|
|
2497
|
+
await main(process.argv.slice(2));
|
|
2498
|
+
}
|
|
2499
|
+
// main is the CLI entry, exported so the thin skills/vitrinka/cli.ts delegator
|
|
2500
|
+
// (kept for backward-compat with already-armed Monitors + the PATH shim) can
|
|
2501
|
+
// run the unified CLI by importing it.
|
|
2502
|
+
export async function main(argv) {
|
|
2503
|
+
const [cmd, ...rest] = argv;
|
|
2504
|
+
const args = parseArgs(rest);
|
|
2505
|
+
const wantsHelp = args.help === true || rest.includes('-h') || rest.includes('--help');
|
|
2506
|
+
if (cmd === undefined) {
|
|
2507
|
+
// Bare invocation — the compact usage grid (exit 2, like the gallery shim).
|
|
2508
|
+
console.error(USAGE);
|
|
2509
|
+
process.exit(2);
|
|
2510
|
+
}
|
|
2511
|
+
else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
2512
|
+
const topic = argv.slice(1).find((a) => !a.startsWith('-')) || '';
|
|
2513
|
+
console.log(topic && COMMAND_MAP[topic] ? renderCmdHelp(topic) : renderFullHelp());
|
|
2514
|
+
process.exit(0);
|
|
2515
|
+
}
|
|
2516
|
+
else if (COMMAND_MAP[cmd] && wantsHelp) {
|
|
2517
|
+
// `vitrinka <cmd> --help` / `-h` → that command's detail page.
|
|
2518
|
+
console.log(renderCmdHelp(cmd));
|
|
2519
|
+
process.exit(0);
|
|
2520
|
+
}
|
|
2521
|
+
else if (cmd === 'completion') {
|
|
2522
|
+
completionCmd(rest);
|
|
2523
|
+
}
|
|
2524
|
+
else if (cmd === '__complete') {
|
|
2525
|
+
await completeDynamicCmd(rest);
|
|
2526
|
+
}
|
|
2527
|
+
else if (cmd === 'ai') {
|
|
2528
|
+
await aiCmd(argv, args);
|
|
2529
|
+
}
|
|
2530
|
+
else if (!COMMAND_MAP[cmd]) {
|
|
2531
|
+
// Unknown command → "did you mean" (plain edit-distance, no AI) + usage,
|
|
2532
|
+
// then one additive AI hint. Exit 2.
|
|
2533
|
+
const near = nearestCommand(cmd);
|
|
2534
|
+
console.error(`unknown command ${JSON.stringify(cmd)}${near ? ` — did you mean "${near}"?` : ''}`);
|
|
2535
|
+
console.error(USAGE);
|
|
2536
|
+
await emitHint(`vitrinka ${argv.join(' ')}`, `unknown command ${JSON.stringify(cmd)}`, USAGE);
|
|
2537
|
+
process.exit(2);
|
|
2538
|
+
}
|
|
2539
|
+
else {
|
|
2540
|
+
await runSub(argv);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
//# sourceMappingURL=cli.js.map
|