@lovinka/vitrinka 1.10.2 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/cli.js +175 -10
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +18 -3
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
`vitrinka update` prints the sections newer than your previous version after
|
|
4
4
|
updating — keep entries short and user-facing.
|
|
5
5
|
|
|
6
|
+
## 1.11.0 — 2026-07-15
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- `vitrinka upload <files…> --board <slug>` — put PDFs, documents, and images
|
|
10
|
+
on a board as cards straight from disk (the bytes never pass through an AI
|
|
11
|
+
context). PDFs land as live embedded document cards, other files as download
|
|
12
|
+
chips. Placement is intent-based: `--section "Name"` lands inside a journey
|
|
13
|
+
frame (grows to fit), `--anchor <cardId> [--side right|below]` docks beside a
|
|
14
|
+
card, and no flag = server free placement (never a 0,0 pile).
|
|
15
|
+
|
|
6
16
|
## 1.10.1 — 2026-07-13
|
|
7
17
|
|
|
8
18
|
### Changed
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// skills/screenshot/gallery.mjs (offline marker, COPYFILE_DISABLE, control-file excludes,
|
|
11
11
|
// `--key`, repeatable `--chip`). gallery.mjs is now a passthrough shim to this file.
|
|
12
12
|
// Design: vitrinka/docs/plans/2026-07-03-hand-in-hand-tooling-design.md
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, openSync, readSync, closeSync, realpathSync, } from 'node:fs';
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, openSync, readSync, closeSync, realpathSync, openAsBlob, } from 'node:fs';
|
|
14
14
|
import { join, resolve, basename, dirname, extname, relative, sep } from 'node:path';
|
|
15
15
|
import { tmpdir, hostname } from 'node:os';
|
|
16
16
|
import { spawnSync, spawn } from 'node:child_process';
|
|
@@ -82,6 +82,55 @@ export function positionals(argv) {
|
|
|
82
82
|
}
|
|
83
83
|
return out;
|
|
84
84
|
}
|
|
85
|
+
// parseBranches extracts journey branch groups from the RAW argv (the generic
|
|
86
|
+
// parser accumulates repeated flags but loses which --target/--action belongs
|
|
87
|
+
// to which --next). Grammar: each `--next <label>` opens a group; a following
|
|
88
|
+
// `--target <json>` / `--action <text>` belongs to it until the next `--next`.
|
|
89
|
+
// An `--action` BEFORE the first `--next` stays the shot's own linear action.
|
|
90
|
+
export function parseBranches(argv) {
|
|
91
|
+
let branches;
|
|
92
|
+
let linearAction;
|
|
93
|
+
let cur;
|
|
94
|
+
for (let i = 0; i < argv.length; i++) {
|
|
95
|
+
const a = argv[i];
|
|
96
|
+
if (!a.startsWith('--'))
|
|
97
|
+
continue;
|
|
98
|
+
const next = argv[i + 1];
|
|
99
|
+
const val = next === undefined || next.startsWith('--') ? undefined : argv[++i];
|
|
100
|
+
if (a === '--next') {
|
|
101
|
+
if (!val)
|
|
102
|
+
fail('--next needs the target shot\'s label', 2);
|
|
103
|
+
cur = { to: val };
|
|
104
|
+
(branches ??= []).push(cur);
|
|
105
|
+
}
|
|
106
|
+
else if (a === '--target') {
|
|
107
|
+
if (!cur)
|
|
108
|
+
fail('--target must follow a --next (it belongs to that branch)', 2);
|
|
109
|
+
if (!val)
|
|
110
|
+
fail('--target needs a {x,y,w,h} JSON bbox', 2);
|
|
111
|
+
let t;
|
|
112
|
+
try {
|
|
113
|
+
t = JSON.parse(val);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
fail(`--target: malformed JSON ${JSON.stringify(val)}`, 2);
|
|
117
|
+
}
|
|
118
|
+
const r = t;
|
|
119
|
+
const nums = [r.x, r.y, r.w, r.h].map(Number);
|
|
120
|
+
if (nums.some((n) => !Number.isFinite(n)) || nums[2] <= 0 || nums[3] <= 0) {
|
|
121
|
+
fail('--target: need {x,y,w,h} numbers with positive w/h (image px)', 2);
|
|
122
|
+
}
|
|
123
|
+
cur.target = { x: nums[0], y: nums[1], w: nums[2], h: nums[3] };
|
|
124
|
+
}
|
|
125
|
+
else if (a === '--action' && val !== undefined) {
|
|
126
|
+
if (cur)
|
|
127
|
+
cur.action = val;
|
|
128
|
+
else
|
|
129
|
+
linearAction = val;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { branches, linearAction };
|
|
133
|
+
}
|
|
85
134
|
function manifestPathOf(root) { return join(root, 'manifest.json'); }
|
|
86
135
|
function htmlPathOf(root) { return join(root, 'index.html'); }
|
|
87
136
|
function loadManifest(root) {
|
|
@@ -189,12 +238,15 @@ function appendShot(root, shot) {
|
|
|
189
238
|
writeFileSync(htmlPath, renderHtml(m.shots, project));
|
|
190
239
|
return { count: m.shots.length, htmlPath };
|
|
191
240
|
}
|
|
192
|
-
function addCmd(root, args) {
|
|
241
|
+
function addCmd(root, args, rawArgv = []) {
|
|
193
242
|
const file = args.file;
|
|
194
243
|
if (!file || file === true) {
|
|
195
244
|
console.error('cli.ts add: --file <rel-path> is required');
|
|
196
245
|
process.exit(2);
|
|
197
246
|
}
|
|
247
|
+
// Branch groups need the raw argv (which --target belongs to which --next);
|
|
248
|
+
// with branches present the grouped parse also owns the action flags.
|
|
249
|
+
const { branches, linearAction } = parseBranches(rawArgv);
|
|
198
250
|
const shot = {
|
|
199
251
|
file: String(file),
|
|
200
252
|
surface: args.surface && args.surface !== true ? String(args.surface) : 'other',
|
|
@@ -203,7 +255,8 @@ function addCmd(root, args) {
|
|
|
203
255
|
// journey fields (vitrinka editorial viewer): stage label + short human title
|
|
204
256
|
label: args.label && args.label !== true ? String(args.label) : undefined,
|
|
205
257
|
title: args.title && args.title !== true ? String(args.title) : undefined,
|
|
206
|
-
action: args.action && args.action !== true ? String(args.action) : undefined,
|
|
258
|
+
action: branches ? linearAction : (args.action && args.action !== true ? String(args.action) : undefined),
|
|
259
|
+
branches,
|
|
207
260
|
ts: new Date().toISOString(),
|
|
208
261
|
...shotContext(args),
|
|
209
262
|
};
|
|
@@ -2452,7 +2505,7 @@ export function chooseEncoding(i) {
|
|
|
2452
2505
|
}
|
|
2453
2506
|
return 'original';
|
|
2454
2507
|
}
|
|
2455
|
-
async function snapCmd(surface, args) {
|
|
2508
|
+
async function snapCmd(surface, args, rawArgv = []) {
|
|
2456
2509
|
if (!['ios', 'android', 'macos', 'web'].includes(surface)) {
|
|
2457
2510
|
console.error(`snap: surface must be ios|android|macos|web, got ${JSON.stringify(surface)}`);
|
|
2458
2511
|
process.exit(2);
|
|
@@ -2607,6 +2660,9 @@ async function snapCmd(surface, args) {
|
|
|
2607
2660
|
: surface === 'android' ? 'adb-screencap'
|
|
2608
2661
|
: 'screencapture';
|
|
2609
2662
|
const url = strArg(args.url) || openUrl;
|
|
2663
|
+
// Branch groups (journey signposts) parse from the raw argv — with --next
|
|
2664
|
+
// present, the grouped parse owns the --action flags too.
|
|
2665
|
+
const { branches, linearAction } = parseBranches(rawArgv);
|
|
2610
2666
|
const { count } = appendShot(root, {
|
|
2611
2667
|
file: `${day}/${name}`,
|
|
2612
2668
|
surface,
|
|
@@ -2614,7 +2670,8 @@ async function snapCmd(surface, args) {
|
|
|
2614
2670
|
note,
|
|
2615
2671
|
label: strArg(args.label) || undefined,
|
|
2616
2672
|
title: strArg(args.title) || undefined,
|
|
2617
|
-
action: strArg(args.action) || undefined,
|
|
2673
|
+
action: branches ? linearAction : (strArg(args.action) || undefined),
|
|
2674
|
+
branches,
|
|
2618
2675
|
hq: hq || undefined,
|
|
2619
2676
|
ts: new Date().toISOString(),
|
|
2620
2677
|
tool,
|
|
@@ -2926,7 +2983,9 @@ const BASE_FLAG = { f: '--base', arg: '<url>', d: 'vitrinka base URL (default $V
|
|
|
2926
2983
|
const CTX_FLAGS = [
|
|
2927
2984
|
{ f: '--label', arg: '<STAGE>', d: 'journey stage label' },
|
|
2928
2985
|
{ f: '--title', arg: '<t>', d: 'short human title' },
|
|
2929
|
-
{ f: '--action', arg: '<a>', d: 'edge label — the action leading to the NEXT shot' },
|
|
2986
|
+
{ f: '--action', arg: '<a>', d: 'edge label — the action leading to the NEXT shot (after a --next: that branch\'s wire label)' },
|
|
2987
|
+
{ f: '--next', arg: '<LABEL>', d: 'journey branch: this screen leads to the shot labeled <LABEL> (repeatable — 2+ make the screen a signpost; each opens a group for --target/--action)' },
|
|
2988
|
+
{ f: '--target', arg: '<json>', d: 'the clicked element\'s {x,y,w,h} bbox in IMAGE px (getBoundingClientRect × devicePixelRatio) — belongs to the preceding --next; the wire leaves from it' },
|
|
2930
2989
|
{ f: '--src', arg: '<file>', d: "implementing source file(s) (repeatable or comma-list)" },
|
|
2931
2990
|
{ f: '--state', arg: '<s>', d: 'app/auth state note' },
|
|
2932
2991
|
{ f: '--device', arg: '<name>', d: 'device name' },
|
|
@@ -3051,6 +3110,18 @@ export const COMMANDS = [
|
|
|
3051
3110
|
{ f: '--hq', d: 'full-fidelity marketing asset: no lossy re-encode, no resize (lossless WebP only if smaller)' }],
|
|
3052
3111
|
examples: ['vitrinka snap ios --route /home --note "landing"', 'vitrinka snap web --route / --note "hero" --hq'],
|
|
3053
3112
|
},
|
|
3113
|
+
{
|
|
3114
|
+
name: 'upload', summary: 'Upload files (PDF/docs/images) onto a board as cards — bytes stream from disk, never through an AI context',
|
|
3115
|
+
usage: '<files…> --board <slug> [--section "Name"] [--anchor <cardId> --side right|below] [--actor <name>] [--base <url>]',
|
|
3116
|
+
flags: [{ f: '--board', arg: '<slug>', d: 'target board (required)' },
|
|
3117
|
+
{ f: '--section', arg: '<name>', d: 'land inside the named journey/take frame (grows to fit)' },
|
|
3118
|
+
{ f: '--anchor', arg: '<cardId>', d: 'dock beside an existing card' },
|
|
3119
|
+
{ f: '--side', arg: '<right|below>', d: 'which side of the anchor (default right)' },
|
|
3120
|
+
{ f: '--actor', arg: '<name>', d: 'override the operator attribution' }, BASE_FLAG],
|
|
3121
|
+
dynamic: 'boards',
|
|
3122
|
+
examples: ['vitrinka upload report.pdf --board pr-105-vitrinka --section "Evidence"',
|
|
3123
|
+
'vitrinka upload spec.docx shots/*.png --board payout-flow'],
|
|
3124
|
+
},
|
|
3054
3125
|
{
|
|
3055
3126
|
name: 'board-from-set', summary: 'Turn the set into an annotation flow board',
|
|
3056
3127
|
usage: '[--root <dir>] [--slug <board>] [--btitle <t>] [--actor <name>]',
|
|
@@ -3059,6 +3130,14 @@ export const COMMANDS = [
|
|
|
3059
3130
|
dynamic: 'boards',
|
|
3060
3131
|
examples: ['vitrinka board-from-set --root .screenshots'],
|
|
3061
3132
|
},
|
|
3133
|
+
{
|
|
3134
|
+
name: 'journey-from-set', summary: 'Turn the set into a journey TREE board — signpost screens fan their branches, wires leave from the clicked element (snap --next/--target)',
|
|
3135
|
+
usage: '[--root <dir>] [--slug <board>] [--btitle <t>] [--actor <name>]',
|
|
3136
|
+
flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
|
|
3137
|
+
{ f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
|
|
3138
|
+
dynamic: 'boards',
|
|
3139
|
+
examples: ['vitrinka journey-from-set --root .screenshots'],
|
|
3140
|
+
},
|
|
3062
3141
|
{
|
|
3063
3142
|
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',
|
|
3064
3143
|
usage: '[--board <slug>|--project <p> --branch <b>|--all] [--takeover] [--quiet-grace <sec>] [--once] [--base <url>]',
|
|
@@ -3583,7 +3662,10 @@ async function emitHint(cmdline, errorText, helpSection) {
|
|
|
3583
3662
|
// board where shots are laid out as the user flow (serpentine, action-labeled
|
|
3584
3663
|
// arrows). Board slug defaults to the set key; re-running appends the newest
|
|
3585
3664
|
// import below the existing content (server behavior).
|
|
3586
|
-
|
|
3665
|
+
// journey=true (the `journey-from-set` verb, or shots carrying --next
|
|
3666
|
+
// branches — the server auto-detects those) lays the flow out as a TREE:
|
|
3667
|
+
// signpost screens fan their branches, wires leave from the clicked element.
|
|
3668
|
+
async function boardFromSetCmd(root, args, journey = false) {
|
|
3587
3669
|
if (!existsSync(vitrinkaCfgPath(root))) {
|
|
3588
3670
|
console.error(`board-from-set: no ${vitrinkaCfgPath(root)} — run remote-init first`);
|
|
3589
3671
|
process.exit(2);
|
|
@@ -3611,7 +3693,10 @@ async function boardFromSetCmd(root, args) {
|
|
|
3611
3693
|
}
|
|
3612
3694
|
const imp = await fetch(`${cfg.base}/api/v1/boards/${encodeURIComponent(slug)}/import-set`, {
|
|
3613
3695
|
method: 'POST', headers,
|
|
3614
|
-
body: JSON.stringify({
|
|
3696
|
+
body: JSON.stringify({
|
|
3697
|
+
project: cfg.project, branch: cfg.slug, selector: cfg.key,
|
|
3698
|
+
...(journey ? { mode: 'journey' } : {}),
|
|
3699
|
+
}),
|
|
3615
3700
|
signal: AbortSignal.timeout(60_000),
|
|
3616
3701
|
});
|
|
3617
3702
|
if (!imp.ok) {
|
|
@@ -3619,6 +3704,11 @@ async function boardFromSetCmd(root, args) {
|
|
|
3619
3704
|
process.exit(1);
|
|
3620
3705
|
}
|
|
3621
3706
|
const out = await imp.json();
|
|
3707
|
+
// A declared path whose target label matched no shot — surface it loudly so
|
|
3708
|
+
// the walker fixes the label (or captures the missing screen) and re-imports.
|
|
3709
|
+
for (const u of out.unresolvedBranches || []) {
|
|
3710
|
+
console.error(`⚠ branch dropped: ${u.from} → ${u.to} (no shot labeled ${JSON.stringify(u.to)} in this set)`);
|
|
3711
|
+
}
|
|
3622
3712
|
// Prefer the server-authoritative board url (carries /w/<workspace>); compose
|
|
3623
3713
|
// the workspace-prefixed path only as a fallback for older servers.
|
|
3624
3714
|
const ws = cfg.workspace ? `/w/${cfg.workspace}` : '';
|
|
@@ -3627,6 +3717,77 @@ async function boardFromSetCmd(root, args) {
|
|
|
3627
3717
|
// The board is about to be annotated — make sure its autocomplete index is fresh.
|
|
3628
3718
|
await pushProjectIndex(dirname(root), cfg.base, cfg.project);
|
|
3629
3719
|
}
|
|
3720
|
+
// ---------- upload: files → board cards (mcp-file-upload 2026-07-15) ----------
|
|
3721
|
+
//
|
|
3722
|
+
// `vitrinka upload <files…> --board <slug>` streams local files to the board's
|
|
3723
|
+
// multipart /upload endpoint — PDFs land as live embedded document cards,
|
|
3724
|
+
// other files as download chips, images as media cards. Built for agents: the
|
|
3725
|
+
// bytes never pass through the model's context (the CLI reads disk), and
|
|
3726
|
+
// placement is INTENT — --section lands inside a journey frame, --anchor
|
|
3727
|
+
// [--side] docks beside a card, neither = server free placement (never 0,0).
|
|
3728
|
+
async function uploadCmd(rest, args) {
|
|
3729
|
+
// Positionals = tokens that are neither flags nor a flag's consumed value.
|
|
3730
|
+
const valueFlags = new Set(['--board', '--section', '--anchor', '--side', '--base', '--actor', '--root']);
|
|
3731
|
+
const files = [];
|
|
3732
|
+
for (let i = 0; i < rest.length; i++) {
|
|
3733
|
+
const a = rest[i];
|
|
3734
|
+
if (a.startsWith('--')) {
|
|
3735
|
+
if (valueFlags.has(a))
|
|
3736
|
+
i++; // skip its value
|
|
3737
|
+
continue;
|
|
3738
|
+
}
|
|
3739
|
+
files.push(a);
|
|
3740
|
+
}
|
|
3741
|
+
const board = strArg(args.board);
|
|
3742
|
+
if (!board || !files.length) {
|
|
3743
|
+
console.error('usage: vitrinka upload <files…> --board <slug> [--section "Name"] [--anchor <cardId> --side right|below]');
|
|
3744
|
+
process.exit(2);
|
|
3745
|
+
}
|
|
3746
|
+
const missing = files.filter((f) => !existsSync(f));
|
|
3747
|
+
if (missing.length) {
|
|
3748
|
+
console.error(`upload: no such file: ${missing.join(', ')}`);
|
|
3749
|
+
process.exit(2);
|
|
3750
|
+
}
|
|
3751
|
+
const section = strArg(args.section);
|
|
3752
|
+
const anchor = strArg(args.anchor);
|
|
3753
|
+
const side = strArg(args.side);
|
|
3754
|
+
if (section && anchor) {
|
|
3755
|
+
console.error('upload: --section and --anchor are mutually exclusive');
|
|
3756
|
+
process.exit(2);
|
|
3757
|
+
}
|
|
3758
|
+
const base = resolveBaseUrl(strArg(args.base));
|
|
3759
|
+
const fd = new FormData();
|
|
3760
|
+
for (const f of files) {
|
|
3761
|
+
// openAsBlob is file-backed — fetch streams it, so a batch of 50 MiB
|
|
3762
|
+
// PDFs never materializes in this process's memory.
|
|
3763
|
+
fd.append('files', await openAsBlob(f), basename(f));
|
|
3764
|
+
// The file's mtime is the chronological sort key layouts trust
|
|
3765
|
+
// (sort-cols decisions #4) — same field the browser drop path sends.
|
|
3766
|
+
fd.append('modified', String(Math.round(statSync(f).mtimeMs)));
|
|
3767
|
+
}
|
|
3768
|
+
if (section)
|
|
3769
|
+
fd.append('section', section);
|
|
3770
|
+
if (anchor)
|
|
3771
|
+
fd.append('anchor', anchor);
|
|
3772
|
+
if (side)
|
|
3773
|
+
fd.append('side', side);
|
|
3774
|
+
const headers = {};
|
|
3775
|
+
const token = readToken();
|
|
3776
|
+
if (token)
|
|
3777
|
+
headers.Authorization = `Bearer ${token}`;
|
|
3778
|
+
const actor = actorFor(args);
|
|
3779
|
+
if (actor)
|
|
3780
|
+
headers['X-Board-Actor'] = actor;
|
|
3781
|
+
const res = await fetch(`${base}/api/v1/boards/${encodeURIComponent(board)}/upload`, {
|
|
3782
|
+
method: 'POST', headers, body: fd, signal: AbortSignal.timeout(300_000),
|
|
3783
|
+
});
|
|
3784
|
+
const text = await res.text();
|
|
3785
|
+
if (!res.ok) {
|
|
3786
|
+
console.error(`upload: ${res.status} ${text}`);
|
|
3787
|
+
process.exit(1);
|
|
3788
|
+
}
|
|
3789
|
+
console.log(text);
|
|
3790
|
+
}
|
|
3630
3791
|
// announceLine renders the single stdout line for one work item:
|
|
3631
3792
|
// №<id> [<intent>] <board>: <prompt, single line, ≤100 chars>
|
|
3632
3793
|
export function announceLine(it) {
|
|
@@ -3879,7 +4040,7 @@ async function runSub(argv) {
|
|
|
3879
4040
|
const args = parseArgs(rest);
|
|
3880
4041
|
const root = resolve(strArg(args.root) || '.screenshots');
|
|
3881
4042
|
if (cmd === 'add')
|
|
3882
|
-
addCmd(root, args);
|
|
4043
|
+
addCmd(root, args, rest);
|
|
3883
4044
|
else if (cmd === 'build') {
|
|
3884
4045
|
const { count, htmlPath } = buildIndex(root);
|
|
3885
4046
|
console.log(`built ${htmlPath} (${count} shots)`);
|
|
@@ -3911,9 +4072,13 @@ async function runSub(argv) {
|
|
|
3911
4072
|
else if (cmd === 'status')
|
|
3912
4073
|
await statusCmd(root, args);
|
|
3913
4074
|
else if (cmd === 'snap')
|
|
3914
|
-
await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args);
|
|
4075
|
+
await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args, rest);
|
|
4076
|
+
else if (cmd === 'upload')
|
|
4077
|
+
await uploadCmd(rest, args);
|
|
3915
4078
|
else if (cmd === 'board-from-set')
|
|
3916
4079
|
await boardFromSetCmd(root, args);
|
|
4080
|
+
else if (cmd === 'journey-from-set')
|
|
4081
|
+
await boardFromSetCmd(root, args, true);
|
|
3917
4082
|
else if (cmd === 'watch')
|
|
3918
4083
|
await watchCmd(args);
|
|
3919
4084
|
else if (cmd === 'index')
|