@lovinka/vitrinka 1.6.0 → 1.7.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/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, cpSync, openSync, readSync, closeSync, } from 'node:fs';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, cpSync, openSync, readSync, closeSync, realpathSync, } 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';
@@ -760,12 +760,45 @@ function loadVitrinkaCfg(root) {
760
760
  }
761
761
  }
762
762
  function shareUrl(cfg) {
763
- return `${cfg.base}/${cfg.project}/${cfg.slug}/${cfg.key}`;
763
+ const ws = cfg.workspace ? `/w/${cfg.workspace}` : '';
764
+ return `${cfg.base}${ws}/${cfg.project}/${cfg.slug}/${cfg.key}`;
765
+ }
766
+ // resolveWorkspace resolves the multitenant workspace slug for DISPLAY URLs.
767
+ // Precedence: --workspace flag > $VITRINKA_WORKSPACE > GET {base}/api/v1/whoami
768
+ // (token-routed, the token IS the workspace) > "" (single-tenant). whoami is a
769
+ // pure display-URL nicety — any failure (offline, older server, non-JSON) yields
770
+ // "" and must never fail the command that called it.
771
+ async function resolveWorkspace(args, base) {
772
+ const flag = strArg(args.workspace);
773
+ if (flag)
774
+ return flag;
775
+ if (process.env.VITRINKA_WORKSPACE)
776
+ return process.env.VITRINKA_WORKSPACE;
777
+ try {
778
+ const headers = {};
779
+ const token = readToken();
780
+ if (token)
781
+ headers.Authorization = `Bearer ${token}`;
782
+ const res = await fetch(`${base}/api/v1/whoami`, { headers, signal: AbortSignal.timeout(15_000) });
783
+ if (!res.ok)
784
+ return '';
785
+ return String((await res.json()).workspace || '');
786
+ }
787
+ catch {
788
+ // Best-effort: unreachable/older server (no whoami) ⇒ single-tenant display URLs.
789
+ return '';
790
+ }
764
791
  }
765
- function remoteInitCmd(root, args) {
792
+ async function remoteInitCmd(root, args) {
766
793
  if (existsSync(vitrinkaCfgPath(root))) {
767
794
  // Sticky per session dir — reuse, so re-activation never forks the set.
768
795
  const cfg = loadVitrinkaCfg(root);
796
+ // Older descriptors predate the workspace field — resolve + persist once so
797
+ // display URLs carry /w/<ws> without a whoami call on every re-activation.
798
+ if (cfg.workspace === undefined) {
799
+ cfg.workspace = await resolveWorkspace(args, cfg.base);
800
+ writeFileSync(vitrinkaCfgPath(root), JSON.stringify(cfg, null, 2) + '\n');
801
+ }
769
802
  console.log(`vitrinka set (existing) → ${shareUrl(cfg)}`);
770
803
  return cfg;
771
804
  }
@@ -795,6 +828,7 @@ function remoteInitCmd(root, args) {
795
828
  kind: args.kind && args.kind !== true ? String(args.kind) : 'screenshots',
796
829
  issue: args.issue && args.issue !== true ? String(args.issue) : '',
797
830
  };
831
+ cfg.workspace = await resolveWorkspace(args, base);
798
832
  mkdirSync(root, { recursive: true });
799
833
  ensureRootSelfIgnored(root);
800
834
  writeFileSync(vitrinkaCfgPath(root), JSON.stringify(cfg, null, 2) + '\n');
@@ -982,6 +1016,86 @@ async function nameCmd(rest, args) {
982
1016
  else
983
1017
  console.log(`named ${segs[0]}/${segs[1]}/${segs[2]} → ${base}/${segs[0]}/${segs[1]}/${encodeURIComponent(newName)}`);
984
1018
  }
1019
+ // ---------- login: the CLI device dance ----------
1020
+ // loginCmd — `vitrinka login`: the multitenant sign-in. Starts a device-code
1021
+ // request, opens the browser approval page (the signed-in user confirms the
1022
+ // code and picks a workspace), polls until the server parks the minted PAT,
1023
+ // and saves it to ~/.config/vitrinka/token. On single-tenant/mesh installs
1024
+ // (no /api/v1/cli/auth route) it explains the shared-token path instead.
1025
+ async function loginCmd(args) {
1026
+ const base = (strArg(args.base) || process.env.VITRINKA_URL || process.env.VITRINKA_BASE_URL
1027
+ || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
1028
+ let start;
1029
+ try {
1030
+ start = await fetch(`${base}/api/v1/cli/auth`, { method: 'POST', signal: AbortSignal.timeout(15_000) });
1031
+ }
1032
+ catch (e) {
1033
+ fail(`login: ${base} unreachable (${e instanceof Error ? e.message : String(e)})`);
1034
+ return;
1035
+ }
1036
+ if (start.status === 404) {
1037
+ fail(`login: ${base} has no CLI sign-in (single-tenant install?) — use the shared token instead:\n\n${tokenHelp()}`);
1038
+ return;
1039
+ }
1040
+ if (!start.ok)
1041
+ fail(`login: ${base} refused (${start.status})`);
1042
+ const s = (await start.json());
1043
+ const verifyUrl = `${base}${s.verify_path}`;
1044
+ console.log('');
1045
+ console.log(` Confirm this code in your browser: ${s.user_code}`);
1046
+ console.log(` ${verifyUrl}`);
1047
+ console.log('');
1048
+ // Best-effort browser launch; the printed URL is the real contract.
1049
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1050
+ spawnSync(opener, [verifyUrl], { stdio: 'ignore' });
1051
+ const intervalMs = Math.max(1, s.interval ?? 2) * 1000;
1052
+ const deadline = Date.now() + (s.expires_in ?? 600) * 1000;
1053
+ process.stdout.write(' waiting for approval …');
1054
+ while (Date.now() < deadline) {
1055
+ await new Promise((f) => setTimeout(f, intervalMs));
1056
+ let res;
1057
+ try {
1058
+ res = await fetch(`${base}/api/v1/cli/auth/claim`, {
1059
+ method: 'POST',
1060
+ headers: { 'Content-Type': 'application/json' },
1061
+ body: JSON.stringify({ device_code: s.device_code }),
1062
+ signal: AbortSignal.timeout(15_000),
1063
+ });
1064
+ }
1065
+ catch {
1066
+ process.stdout.write('.'); // transient network blip — keep polling
1067
+ continue;
1068
+ }
1069
+ if (res.status === 202) {
1070
+ process.stdout.write('.');
1071
+ continue;
1072
+ }
1073
+ if (!res.ok) {
1074
+ console.log('');
1075
+ fail(`login: ${res.status === 404 ? 'code expired — run vitrinka login again' : `claim failed (${res.status})`}`);
1076
+ }
1077
+ const { token } = (await res.json());
1078
+ const p = tokenPath();
1079
+ mkdirSync(dirname(p), { recursive: true });
1080
+ writeFileSync(p, token + '\n', { mode: 0o600 });
1081
+ console.log('\n');
1082
+ // The token IS the workspace — show which one this machine now acts in.
1083
+ try {
1084
+ const who = await fetch(`${base}/api/v1/whoami`, {
1085
+ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000),
1086
+ });
1087
+ const w = (await who.json());
1088
+ console.log(` ✓ signed in — token saved to ${p}${w.workspace ? ` (workspace: ${w.workspace})` : ''}`);
1089
+ }
1090
+ catch {
1091
+ console.log(` ✓ signed in — token saved to ${p}`);
1092
+ }
1093
+ console.log(' next: vitrinka install (skills + MCP + shim, if not done yet)');
1094
+ return;
1095
+ }
1096
+ console.log('');
1097
+ fail('login: approval timed out (10 min) — run vitrinka login again');
1098
+ }
985
1099
  // ---------- operator: the board-attribution persona ----------
986
1100
  // operatorCmd manages the operator persona name — how vitrinka credits this
987
1101
  // machine's writes on boards.
@@ -1628,8 +1742,9 @@ export function hasBoardFooterLink(settings) {
1628
1742
  // original command is recorded on the VITRINKA_ORIG line (single-quoted), both
1629
1743
  // to run it and so a re-run can recover it instead of wrapping the wrapper.
1630
1744
  // Board detection mirrors the CLI's capture-root contract: .screenshots/.vitrinka
1631
- // with {base, key} → ${base}/boards/${key}. Needs jq (like the statusline
1632
- // examples in Claude's own docs); without jq it degrades to pass-through.
1745
+ // with {base, key[, workspace]} → ${base}[/w/${workspace}]/boards/${key}. Needs jq
1746
+ // (like the statusline examples in Claude's own docs); without jq it degrades to
1747
+ // pass-through.
1633
1748
  export function buildStatuslineWrapper(origCmd) {
1634
1749
  const quoted = origCmd.replaceAll("'", `'\\''`);
1635
1750
  return `#!/bin/bash
@@ -1660,7 +1775,9 @@ for VJSON in "$DIR/.screenshots/.vitrinka" "$PROJECT_DIR/.screenshots/.vitrinka"
1660
1775
  [ -f "$VJSON" ] || continue
1661
1776
  VBASE=$(jq -r '.base // empty' "$VJSON" 2>/dev/null)
1662
1777
  VKEY=$(jq -r '.key // empty' "$VJSON" 2>/dev/null)
1778
+ VWS=$(jq -r '.workspace // empty' "$VJSON" 2>/dev/null)
1663
1779
  if [ -n "$VBASE" ] && [ -n "$VKEY" ]; then
1780
+ [ -n "$VWS" ] && VBASE="$VBASE/w/$VWS"
1664
1781
  BOARD=$(printf '\\033[35m🧷\\033[0m \\033[2m%s\\033[0m' "$VBASE/boards/$VKEY")
1665
1782
  fi
1666
1783
  break
@@ -1782,7 +1899,8 @@ function boardUrlOf(root) {
1782
1899
  if (!existsSync(vitrinkaCfgPath(root)))
1783
1900
  return '';
1784
1901
  const cfg = loadVitrinkaCfg(root);
1785
- return `${cfg.base}/boards/${cfg.key}`;
1902
+ const ws = cfg.workspace ? `/w/${cfg.workspace}` : '';
1903
+ return `${cfg.base}${ws}/boards/${cfg.key}`;
1786
1904
  }
1787
1905
  // openCmd opens the thing you're working on: the capture root's live board when
1788
1906
  // one exists, else the vitrinka home. --qr renders the URL as a terminal QR
@@ -2570,7 +2688,7 @@ createRoot(document.getElementById('root')).render(html\`<\${App} />\`);
2570
2688
  </html>
2571
2689
  `;
2572
2690
  }
2573
- function artifactInitCmd(args) {
2691
+ async function artifactInitCmd(args) {
2574
2692
  const slugRaw = strArg(args.slug);
2575
2693
  if (!slugRaw) {
2576
2694
  console.error('artifact-init: --slug <slug> is required');
@@ -2589,11 +2707,11 @@ function artifactInitCmd(args) {
2589
2707
  mkdirSync(dir, { recursive: true });
2590
2708
  writeFileSync(indexPath, blankScaffold(title, existsSync(join(dir, 'data.json'))));
2591
2709
  ensureGitIgnored(process.cwd(), '.artifacts/');
2592
- remoteInitCmd(dir, { ...args, key: slug, kind });
2710
+ await remoteInitCmd(dir, { ...args, key: slug, kind });
2593
2711
  console.log(`scaffolded ${indexPath}`);
2594
2712
  console.log(`author the App, then: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
2595
2713
  }
2596
- function artifactFromSetCmd(args) {
2714
+ async function artifactFromSetCmd(args) {
2597
2715
  const slugRaw = strArg(args.slug);
2598
2716
  if (!slugRaw) {
2599
2717
  console.error('artifact-from-set: --slug <slug> is required');
@@ -2619,7 +2737,7 @@ function artifactFromSetCmd(args) {
2619
2737
  const title = strArg(args.title) || (fromManifest.journey && fromManifest.journey.title) || slug;
2620
2738
  writeFileSync(indexPath, composedScaffold(title, `${slug}.zip`));
2621
2739
  ensureGitIgnored(process.cwd(), '.artifacts/');
2622
- remoteInitCmd(dir, { ...args, key: slug, kind: strArg(args.kind) || 'report' });
2740
+ await remoteInitCmd(dir, { ...args, key: slug, kind: strArg(args.kind) || 'report' });
2623
2741
  // Record the source screenshot set (project/branchSlug/key) so push sends ?source= and
2624
2742
  // vitrinka can cross-link set ↔ artifact both ways.
2625
2743
  let source = strArg(args.source);
@@ -2699,6 +2817,11 @@ export const COMMANDS = [
2699
2817
  usage: '[<name>] [--base <url>]', positional: '[<name>]', flags: [BASE_FLAG],
2700
2818
  examples: ['vitrinka operator', 'vitrinka operator "Lukáš"'],
2701
2819
  },
2820
+ {
2821
+ name: 'login', summary: 'Sign this machine in: browser approval mints a personal, workspace-scoped token',
2822
+ usage: '[--base <url>]', flags: [BASE_FLAG],
2823
+ examples: ['vitrinka login', 'vitrinka login --base https://vitrinka.lovinka.com'],
2824
+ },
2702
2825
  {
2703
2826
  name: 'doctor', summary: 'Check this machine\'s setup (server, token, operator, skills, shim, MCP); --fix repairs it',
2704
2827
  usage: '[--fix] [--base <url>]',
@@ -3335,7 +3458,11 @@ async function boardFromSetCmd(root, args) {
3335
3458
  process.exit(1);
3336
3459
  }
3337
3460
  const out = await imp.json();
3338
- console.log(`flow board: ${cfg.base}/boards/${slug} (${out.cards.length} cards, ${out.edges.length} edges)`);
3461
+ // Prefer the server-authoritative board url (carries /w/<workspace>); compose
3462
+ // the workspace-prefixed path only as a fallback for older servers.
3463
+ const ws = cfg.workspace ? `/w/${cfg.workspace}` : '';
3464
+ const boardUrl = out.board?.url || `${cfg.base}${ws}/boards/${slug}`;
3465
+ console.log(`flow board: ${boardUrl} (${out.cards.length} cards, ${out.edges.length} edges)`);
3339
3466
  // The board is about to be annotated — make sure its autocomplete index is fresh.
3340
3467
  await pushProjectIndex(dirname(root), cfg.base, cfg.project);
3341
3468
  }
@@ -3599,13 +3726,15 @@ async function runSub(argv) {
3599
3726
  else if (cmd === 'meta')
3600
3727
  metaCmd(root, args);
3601
3728
  else if (cmd === 'remote-init')
3602
- remoteInitCmd(root, args);
3729
+ await remoteInitCmd(root, args);
3603
3730
  else if (cmd === 'push')
3604
3731
  await pushCmd(root, args);
3605
3732
  else if (cmd === 'name')
3606
3733
  await nameCmd(argv, args);
3607
3734
  else if (cmd === 'operator')
3608
3735
  await operatorCmd(argv, args);
3736
+ else if (cmd === 'login')
3737
+ await loginCmd(args);
3609
3738
  else if (cmd === 'doctor')
3610
3739
  await doctorCmd(args);
3611
3740
  else if (cmd === 'install')
@@ -3635,9 +3764,9 @@ async function runSub(argv) {
3635
3764
  else if (cmd === 'embed-shots')
3636
3765
  embedShotsCmd(root, args);
3637
3766
  else if (cmd === 'artifact-init')
3638
- artifactInitCmd(args);
3767
+ await artifactInitCmd(args);
3639
3768
  else if (cmd === 'artifact-from-set')
3640
- artifactFromSetCmd(args);
3769
+ await artifactFromSetCmd(args);
3641
3770
  else
3642
3771
  throw new Error(`runSub: unhandled command ${JSON.stringify(cmd)}`); // guarded by the caller
3643
3772
  }
@@ -3646,7 +3775,20 @@ async function runSub(argv) {
3646
3775
  // calling it here — before its definition below — is fine, and keeping the
3647
3776
  // entry block adjacent to runSub keeps the help/completion cases out of the
3648
3777
  // runSub-vs-registry source scan in dx.test.ts.
3649
- const IS_MAIN = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href === import.meta.url : false;
3778
+ // realpathSync resolves the PATH symlink (e.g. ~/.bun/bin/vitrinka
3779
+ // .../dist/cli.js) so the entry path matches import.meta.url — resolve() alone
3780
+ // doesn't follow symlinks, so the bin shim would otherwise never be IS_MAIN and
3781
+ // the CLI would silently exit 0. Fall back to the raw resolve() if the entry
3782
+ // somehow can't be realpath'd (never on a real run, but keep it non-fatal).
3783
+ function entryHref(argvPath) {
3784
+ try {
3785
+ return pathToFileURL(realpathSync(resolve(argvPath))).href;
3786
+ }
3787
+ catch {
3788
+ return pathToFileURL(resolve(argvPath)).href;
3789
+ }
3790
+ }
3791
+ const IS_MAIN = process.argv[1] ? entryHref(process.argv[1]) === import.meta.url : false;
3650
3792
  if (IS_MAIN) {
3651
3793
  await main(process.argv.slice(2));
3652
3794
  }