@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 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
- async function boardFromSetCmd(root, args) {
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({ project: cfg.project, branch: cfg.slug, selector: cfg.key }),
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')