@lovinka/vitrinka 1.6.0 → 1.7.1
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 +157 -15
- package/dist/cli.js.map +1 -1
- package/dist/lib/token.js +9 -11
- package/dist/lib/token.js.map +1 -1
- package/dist/mcp.js +143 -27
- package/dist/mcp.js.map +1 -1
- package/package.json +2 -2
- package/skills/artifact/SKILL.md +1 -1
- package/skills/brainstorming/SKILL.md +3 -3
- package/skills/screenshot/SKILL.md +41 -3
- package/skills/vitrinka/listen/SKILL.md +4 -0
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
|
-
|
|
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
|
|
1632
|
-
// examples in Claude's own docs); without jq it degrades to
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|