@lovinka/vitrinka 1.21.0 → 1.22.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 +74 -0
- package/dist/cli.js +1249 -71
- package/dist/cli.js.map +1 -1
- package/dist/lib/tui.js +30 -0
- package/dist/lib/tui.js.map +1 -1
- package/dist/mcp.js +150 -23
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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
13
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, renameSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, openSync, readSync, closeSync, realpathSync, openAsBlob, lstatSync, constants as fsConstants, } from 'node:fs';
|
|
14
|
-
import { join, resolve, basename, dirname, extname, relative, sep, isAbsolute } from 'node:path';
|
|
14
|
+
import { join, resolve, basename, dirname, extname, relative, sep, isAbsolute, delimiter } from 'node:path';
|
|
15
15
|
import { tmpdir, hostname } from 'node:os';
|
|
16
16
|
import { spawnSync, spawn } from 'node:child_process';
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
@@ -1597,9 +1597,91 @@ async function doctorCmd(args) {
|
|
|
1597
1597
|
}
|
|
1598
1598
|
const appPath = refreshDesktopAppFlag();
|
|
1599
1599
|
console.log(appPath ? `✓ app ${appPath} (flag: ${desktopAppFlagPath()})` : `- app Vitrinka.app not installed (no flag)`);
|
|
1600
|
+
if (reachable && token)
|
|
1601
|
+
await doctorListenersRow(base, token, fix);
|
|
1600
1602
|
if (failed)
|
|
1601
1603
|
process.exit(1);
|
|
1602
1604
|
}
|
|
1605
|
+
// staleLocalLeases splits this host's leases into live and stale (PURE apart
|
|
1606
|
+
// from the liveness probe, which is injected — unit-tested). A lease is stale
|
|
1607
|
+
// when its owning pid is gone from THIS machine: the watch process died without
|
|
1608
|
+
// releasing (SIGKILL, panic, an OS restart that outlived the server's memory).
|
|
1609
|
+
// Leases from other hosts are never judged — we cannot see their process table.
|
|
1610
|
+
export function staleLocalLeases(leases, host, pidAlive) {
|
|
1611
|
+
const stale = [];
|
|
1612
|
+
let live = 0;
|
|
1613
|
+
for (const l of leases) {
|
|
1614
|
+
const session = String(l.session ?? '');
|
|
1615
|
+
const cut = session.lastIndexOf(':');
|
|
1616
|
+
if (cut < 0 || session.slice(0, cut) !== host)
|
|
1617
|
+
continue; // another machine
|
|
1618
|
+
const pid = Number(session.slice(cut + 1));
|
|
1619
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
1620
|
+
continue; // unparseable — leave it alone
|
|
1621
|
+
if (pidAlive(pid))
|
|
1622
|
+
live += 1;
|
|
1623
|
+
else
|
|
1624
|
+
stale.push(l);
|
|
1625
|
+
}
|
|
1626
|
+
return { stale, live };
|
|
1627
|
+
}
|
|
1628
|
+
// pidAlive: signal 0 probes existence without delivering anything. EPERM means
|
|
1629
|
+
// the pid exists but belongs to another user — alive, just not ours.
|
|
1630
|
+
function pidAlive(pid) {
|
|
1631
|
+
try {
|
|
1632
|
+
process.kill(pid, 0);
|
|
1633
|
+
return true;
|
|
1634
|
+
}
|
|
1635
|
+
catch (e) {
|
|
1636
|
+
return e.code === 'EPERM';
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
// doctorListenersRow reports this machine's work-queue leases and, with --fix,
|
|
1640
|
+
// releases the ones whose watch process is gone. A stale lease is not cosmetic:
|
|
1641
|
+
// it holds the board's scope, so the next session to arm a listener from
|
|
1642
|
+
// ANOTHER machine is refused (409 "live, another machine") until it clears.
|
|
1643
|
+
async function doctorListenersRow(base, token, fix) {
|
|
1644
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
1645
|
+
let leases;
|
|
1646
|
+
try {
|
|
1647
|
+
const res = await fetch(`${base}/api/v1/work/listeners`, { headers, signal: AbortSignal.timeout(10_000) });
|
|
1648
|
+
if (!res.ok) {
|
|
1649
|
+
console.log(`? listeners check failed (HTTP ${res.status})`);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
leases = (await res.json())?.listeners ?? [];
|
|
1653
|
+
}
|
|
1654
|
+
catch (e) {
|
|
1655
|
+
console.log(`? listeners check failed (${e instanceof Error ? e.message : String(e)})`);
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const { stale, live } = staleLocalLeases(leases, hostname(), pidAlive);
|
|
1659
|
+
if (stale.length === 0) {
|
|
1660
|
+
console.log(`✓ listeners ${live} live on this machine, no stale leases`);
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
const label = (l) => l.scope || l.scopeKind || '?';
|
|
1664
|
+
if (!fix) {
|
|
1665
|
+
console.log(`✗ listeners ${stale.length} stale lease${stale.length > 1 ? 's' : ''} held by dead watch processes — repair: vitrinka doctor --fix`);
|
|
1666
|
+
for (const l of stale)
|
|
1667
|
+
console.log(` ${label(l)} (session ${l.session})`);
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
let released = 0;
|
|
1671
|
+
for (const l of stale) {
|
|
1672
|
+
try {
|
|
1673
|
+
const res = await fetch(`${base}/api/v1/work/listeners/${l.id}`, { method: 'DELETE', headers, signal: AbortSignal.timeout(10_000) });
|
|
1674
|
+
if (res.ok || res.status === 404)
|
|
1675
|
+
released += 1;
|
|
1676
|
+
else
|
|
1677
|
+
console.log(` ${label(l)}: release failed (HTTP ${res.status})`);
|
|
1678
|
+
}
|
|
1679
|
+
catch (e) {
|
|
1680
|
+
console.log(` ${label(l)}: release failed (${e instanceof Error ? e.message : String(e)})`);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
console.log(`✓ listeners released ${released}/${stale.length} stale lease${stale.length > 1 ? 's' : ''} (${live} live)`);
|
|
1684
|
+
}
|
|
1603
1685
|
// ---------- install: PATH shim ----------
|
|
1604
1686
|
// ---------- plugin distribution (skills ship via the runtimes' plugin systems) ----------
|
|
1605
1687
|
// The skills bundle used to be copied into ~/.claude/skills (install-skills,
|
|
@@ -1654,13 +1736,9 @@ function installPluginInto(rt) {
|
|
|
1654
1736
|
}
|
|
1655
1737
|
return true;
|
|
1656
1738
|
}
|
|
1657
|
-
//
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
? ['claude', 'plugin', 'update', 'vitrinka@lovinka']
|
|
1661
|
-
: ['codex', 'plugin', 'marketplace', 'upgrade'];
|
|
1662
|
-
return spawnSync(cmd[0], cmd.slice(1), { stdio: 'inherit' }).status === 0;
|
|
1663
|
-
}
|
|
1739
|
+
// The plugin REFRESH command per runtime lives in updateCmd, which runs it
|
|
1740
|
+
// through the tui task plumbing (async + captured output) rather than a bare
|
|
1741
|
+
// inherited spawnSync.
|
|
1664
1742
|
// ---------- setup state probing (shared by install / doctor / uninstall) ----------
|
|
1665
1743
|
const SHIM_CONTENT = `#!/bin/sh\nexec node "${CLI_PATH}" "$@"\n`;
|
|
1666
1744
|
function shimPathOf(home) { return join(home, '.local', 'bin', 'vitrinka'); }
|
|
@@ -1835,6 +1913,13 @@ async function installCmd(args) {
|
|
|
1835
1913
|
row(tokenSrc !== 'missing', 'token', tokenSrc === 'missing' ? 'missing — writes will 401' : `present (${tokenSrc === 'env' ? '$VITRINKA_TOKEN' : tokenPath()})`);
|
|
1836
1914
|
row(!!op, 'operator', op || 'unset — board writes stay anonymous');
|
|
1837
1915
|
row(uiOk, 'claude ui', uiOk ? 'footer badge wired' : 'not wired');
|
|
1916
|
+
// The journey-recorder extension is offered, not assumed — and only where a
|
|
1917
|
+
// Chromium browser actually exists to load it into.
|
|
1918
|
+
const extFound = extBrowsers().filter((b) => extBrowserFound(b) !== false).map((b) => b.name);
|
|
1919
|
+
const extHave = extInstalledVersion(extDefaultDir());
|
|
1920
|
+
row(!!extHave, 'extension', extHave ? `recorder ${extHave} (${extDefaultDir()})`
|
|
1921
|
+
: extFound.length ? `will offer — found ${extFound.join(', ')}`
|
|
1922
|
+
: 'no Chromium browser found');
|
|
1838
1923
|
console.log('');
|
|
1839
1924
|
// ---- silent steps: plugins, shim, PATH, completion (reversible, own dirs) ----
|
|
1840
1925
|
for (const rt of runtimes) {
|
|
@@ -1935,6 +2020,34 @@ async function installCmd(args) {
|
|
|
1935
2020
|
else {
|
|
1936
2021
|
console.log('claude ui: skipped (re-run any time: vitrinka install-claude)');
|
|
1937
2022
|
}
|
|
2023
|
+
// 5. Journey-recorder extension. This is also the backfill path: a machine
|
|
2024
|
+
// that ran `install` before the extension existed gets offered it on the next
|
|
2025
|
+
// run, and an already-installed one has its update host re-registered so a
|
|
2026
|
+
// browser added since (or a CLI that moved) starts working again.
|
|
2027
|
+
if (args['no-extension'] === true) {
|
|
2028
|
+
console.log('extension: skipped (--no-extension)');
|
|
2029
|
+
}
|
|
2030
|
+
else if (extHave) {
|
|
2031
|
+
const reg = extRegisterOutcome(registerExtHost().wrote);
|
|
2032
|
+
if (reg.ok)
|
|
2033
|
+
console.log(`✓ extension ${extHave} — update host registered for ${reg.who}`);
|
|
2034
|
+
else
|
|
2035
|
+
console.error(`⚠ extension ${extHave} — update host registered for ${reg.who} (see errors above); in-place updates will not work: vitrinka extension doctor`);
|
|
2036
|
+
}
|
|
2037
|
+
else if (!extFound.length && args.extension !== true) {
|
|
2038
|
+
console.log('extension: skipped (no Chromium browser found — install one, then: vitrinka extension setup)');
|
|
2039
|
+
}
|
|
2040
|
+
else if (args.extension === true
|
|
2041
|
+
|| (!noPrompt && promptYesNo(`Install the journey-recorder extension for ${extFound.join(' / ')}? [Y/n] `))) {
|
|
2042
|
+
// soft: an unreachable shelf reports itself and setup continues.
|
|
2043
|
+
await extensionSetupCmd({}, true);
|
|
2044
|
+
}
|
|
2045
|
+
else if (noPrompt) {
|
|
2046
|
+
console.log('extension: skipped (non-interactive — run `vitrinka extension setup`, or pass --extension)');
|
|
2047
|
+
}
|
|
2048
|
+
else {
|
|
2049
|
+
console.log('extension: skipped (re-run any time: vitrinka extension setup)');
|
|
2050
|
+
}
|
|
1938
2051
|
// ---- environment checks (non-fatal) ----
|
|
1939
2052
|
if (spawnSync('cwebp', ['-version'], { stdio: 'ignore' }).error) {
|
|
1940
2053
|
console.error('⚠ cwebp missing (brew install webp) — snap falls back to PNG');
|
|
@@ -2783,89 +2896,216 @@ function pkgVersion() {
|
|
|
2783
2896
|
return ''; // no package.json next to the CLI (odd layout) — version is cosmetic here
|
|
2784
2897
|
}
|
|
2785
2898
|
}
|
|
2786
|
-
//
|
|
2787
|
-
//
|
|
2788
|
-
//
|
|
2789
|
-
//
|
|
2790
|
-
//
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2899
|
+
// installKind — the human name for how the running CLI got onto this machine.
|
|
2900
|
+
// It decides BOTH what `update` does (git pull vs package-manager install) and
|
|
2901
|
+
// which package manager owns the global copy: an `npm i -g` from a
|
|
2902
|
+
// bun/pnpm-installed CLI updates a SHADOWED prefix and the running copy stays
|
|
2903
|
+
// stale forever (repeat-update loop, 2026-07-18).
|
|
2904
|
+
export function installKind(cliPath, isRepo) {
|
|
2905
|
+
if (isRepo)
|
|
2906
|
+
return 'repo';
|
|
2907
|
+
if (cliPath.includes('/.bun/'))
|
|
2908
|
+
return 'bun';
|
|
2909
|
+
if (cliPath.includes('/pnpm/'))
|
|
2910
|
+
return 'pnpm';
|
|
2911
|
+
return 'npm';
|
|
2912
|
+
}
|
|
2913
|
+
// clip — one terminal row's worth of a line, ellipsised. Spinner titles must
|
|
2914
|
+
// never wrap: a wrapped title leaves orphaned rows behind on every repaint.
|
|
2915
|
+
function clip(s, width) {
|
|
2916
|
+
return s.length > width ? `${s.slice(0, width - 1)}…` : s;
|
|
2917
|
+
}
|
|
2918
|
+
// lastLine — the newest meaningful line of a child's output, sanitized (child
|
|
2919
|
+
// tools emit their own spinners and colors) and clipped to fit beside ours.
|
|
2920
|
+
export function lastLine(buf, width = 56) {
|
|
2921
|
+
const lines = buf.split(/\r?\n|\r/).map(stripControl).filter(Boolean);
|
|
2922
|
+
return clip(lines[lines.length - 1] || '', width);
|
|
2923
|
+
}
|
|
2924
|
+
// runStep — spawn a child under a running tui task, retitling the task with the
|
|
2925
|
+
// child's newest output line so the user sees WHAT the tool is doing rather
|
|
2926
|
+
// than just that something is. Two rules make this the only way `update` runs
|
|
2927
|
+
// a subprocess:
|
|
2928
|
+
// * ASYNC, never spawnSync — a synchronous child blocks the event loop, which
|
|
2929
|
+
// freezes the spinner's clock and reproduces the exact "terminal is stuck"
|
|
2930
|
+
// symptom we are removing.
|
|
2931
|
+
// * CAPTURED, not inherited — a child writing to the same TTY scribbles over
|
|
2932
|
+
// the spinner. The transcript is replayed only where it earns its space: on
|
|
2933
|
+
// failure (there it IS the diagnosis) and in --verbose (no spinner to protect).
|
|
2934
|
+
function runStep(t, label, bin, argv, plain) {
|
|
2935
|
+
return new Promise((res) => {
|
|
2936
|
+
let out = '';
|
|
2937
|
+
let stdout = '';
|
|
2938
|
+
const child = spawn(bin, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
2939
|
+
const feed = (isOut) => (b) => {
|
|
2940
|
+
const s = b.toString();
|
|
2941
|
+
out += s;
|
|
2942
|
+
if (isOut)
|
|
2943
|
+
stdout += s;
|
|
2944
|
+
if (plain)
|
|
2945
|
+
(isOut ? process.stdout : process.stderr).write(s);
|
|
2946
|
+
else {
|
|
2947
|
+
// Budget the child's line against the REAL terminal width: clack's
|
|
2948
|
+
// gutter + our label + the `[12s]` timer all share the row, and a
|
|
2949
|
+
// title that wraps leaves an orphan row behind on every repaint.
|
|
2950
|
+
const room = (process.stdout.columns || 80) - label.length - 14;
|
|
2951
|
+
const l = room > 12 ? lastLine(out, room) : '';
|
|
2952
|
+
if (l)
|
|
2953
|
+
t.message(`${label} ${tui.pc.dim(`· ${l}`)}`);
|
|
2954
|
+
}
|
|
2955
|
+
};
|
|
2956
|
+
child.stdout.on('data', feed(true));
|
|
2957
|
+
child.stderr.on('data', feed(false));
|
|
2958
|
+
child.on('error', (e) => res({ ok: false, out: `${out}${e.message}\n`, stdout }));
|
|
2959
|
+
child.on('close', (code) => res({ ok: code === 0, out, stdout }));
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
// pluginOutcome — condense a plugin manager's chatter into ONE result phrase.
|
|
2963
|
+
// Claude prints `✔ Plugin "vitrinka" updated from <sha> to <sha> for scope
|
|
2964
|
+
// user. Restart to apply changes.`; codex words it differently. Unrecognized
|
|
2965
|
+
// (or future) formats fall back to the last meaningful line, so the step still
|
|
2966
|
+
// reports something TRUE instead of a generic "done".
|
|
2967
|
+
export function pluginOutcome(out) {
|
|
2968
|
+
const upd = /updated from ([0-9a-f]{7,40}) to ([0-9a-f]{7,40})/i.exec(out);
|
|
2969
|
+
if (upd)
|
|
2970
|
+
return `updated ${upd[1].slice(0, 7)} → ${upd[2].slice(0, 7)}`;
|
|
2971
|
+
const at = /already at the latest version \(([0-9a-f]{7,40})\)/i.exec(out);
|
|
2972
|
+
if (at)
|
|
2973
|
+
return `already at ${at[1].slice(0, 7)}`;
|
|
2974
|
+
if (/already (?:at the latest|up[\s-]?to[\s-]?date)|no updates? available|nothing to update/i.test(out)) {
|
|
2975
|
+
return 'already up to date';
|
|
2976
|
+
}
|
|
2977
|
+
// The task line already carries our own ✓ — drop the child's result glyph so
|
|
2978
|
+
// the fallback doesn't render a stuttering "✓ ✔ …".
|
|
2979
|
+
return lastLine(out, 64).replace(/^[✔✓✗✖•*-]\s*/, '') || 'refreshed';
|
|
2980
|
+
}
|
|
2981
|
+
// updateCmd updates the CLI source (git pull in a repo checkout, a global
|
|
2982
|
+
// install through the owning package manager otherwise — detected from where
|
|
2983
|
+
// the running file lives), then refreshes every surface a previous install put
|
|
2984
|
+
// on this machine: the plugins in each agent runtime and, ONLY where it was
|
|
2985
|
+
// consented to before, the Claude Code UI wiring. It never asks new consent
|
|
2986
|
+
// questions — that stays `vitrinka install`'s job. The refresh steps re-exec
|
|
2987
|
+
// the (possibly just replaced) CLI file so they run the NEW code, not this
|
|
2988
|
+
// stale process.
|
|
2989
|
+
//
|
|
2990
|
+
// Every step runs inside a tui task with a live elapsed timer, because the slow
|
|
2991
|
+
// legs here (registry read, global install, `claude plugin update` at ~10s) are
|
|
2992
|
+
// silent third-party subprocesses — without the ticking clock and their piped
|
|
2993
|
+
// output the terminal reads as hung. `--verbose` drops the spinners and hands
|
|
2994
|
+
// the terminal to the children instead.
|
|
2995
|
+
async function updateCmd(args) {
|
|
2996
|
+
const plain = args.verbose === true;
|
|
2997
|
+
const task = (title) => tui.task(title, plain);
|
|
2794
2998
|
const repoTop = git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH));
|
|
2999
|
+
const kind = installKind(CLI_PATH, !!repoTop);
|
|
3000
|
+
const current = pkgVersion();
|
|
3001
|
+
tui.intro(`vitrinka update · ${current || '?'} · ${kind === 'repo' ? 'repo checkout' : `${kind} global install`}`);
|
|
3002
|
+
let moved = ''; // what the version leg actually changed (drives the outro)
|
|
3003
|
+
let news = ''; // changelog / commit log, shown once the CLI is current
|
|
2795
3004
|
if (repoTop) {
|
|
2796
3005
|
// Repo checkout — the shim/MCP run the source directly, so a pull IS the update.
|
|
2797
|
-
const before = git(['rev-parse', '--short', 'HEAD'], repoTop);
|
|
2798
|
-
|
|
2799
|
-
const pull =
|
|
2800
|
-
if (pull.
|
|
2801
|
-
fail(
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
3006
|
+
const before = git(['rev-parse', '--short', 'HEAD'], repoTop) || '?';
|
|
3007
|
+
const t = task(`pulling ${clip(repoTop, 48)}`);
|
|
3008
|
+
const pull = await runStep(t, 'git pull --ff-only', 'git', ['-C', repoTop, 'pull', '--ff-only'], plain);
|
|
3009
|
+
if (!pull.ok) {
|
|
3010
|
+
t.fail(`source ✗ git pull --ff-only failed in ${clip(repoTop, 48)}`);
|
|
3011
|
+
if (pull.out.trim())
|
|
3012
|
+
tui.note(pull.out.trim().slice(-1200), 'git says');
|
|
3013
|
+
tui.outro('update aborted — fix the repo state (dirty tree / diverged branch) and re-run');
|
|
3014
|
+
process.exit(1);
|
|
3015
|
+
}
|
|
3016
|
+
const after = git(['rev-parse', '--short', 'HEAD'], repoTop) || '?';
|
|
3017
|
+
if (after === before) {
|
|
3018
|
+
t.done(`source ✓ already up to date (${after})`);
|
|
3019
|
+
}
|
|
2805
3020
|
else {
|
|
2806
|
-
|
|
3021
|
+
t.done(`source ✓ ${before} → ${after}`);
|
|
3022
|
+
moved = `${before} → ${after}`;
|
|
2807
3023
|
// What's new — the pulled commits, capped so a long-idle machine doesn't scroll.
|
|
2808
3024
|
const log = git(['log', '--oneline', '--no-decorate', `${before}..${after}`], repoTop);
|
|
2809
3025
|
if (log) {
|
|
2810
3026
|
const lines = log.split('\n');
|
|
2811
|
-
|
|
2812
|
-
console.log(` ${l}`);
|
|
2813
|
-
if (lines.length > 30)
|
|
2814
|
-
console.log(` … ${lines.length - 30} more`);
|
|
3027
|
+
news = lines.slice(0, 30).join('\n') + (lines.length > 30 ? `\n… ${lines.length - 30} more` : '');
|
|
2815
3028
|
}
|
|
2816
3029
|
}
|
|
2817
3030
|
}
|
|
2818
3031
|
else {
|
|
2819
|
-
// Global
|
|
2820
|
-
const
|
|
2821
|
-
const view =
|
|
2822
|
-
const latest = view.
|
|
3032
|
+
// Global install — compare against the registry, install when behind.
|
|
3033
|
+
const t = task('checking the registry for a newer release');
|
|
3034
|
+
const view = await runStep(t, 'npm view', 'npm', ['view', '@lovinka/vitrinka', 'version'], plain);
|
|
3035
|
+
const latest = view.ok ? view.stdout.trim() : '';
|
|
2823
3036
|
if (!latest) {
|
|
2824
|
-
fail(
|
|
3037
|
+
t.fail('registry ✗ could not read the latest version');
|
|
3038
|
+
if (view.out.trim())
|
|
3039
|
+
tui.note(view.out.trim().slice(-800), 'npm view @lovinka/vitrinka version');
|
|
3040
|
+
tui.outro('update aborted — the npm registry was unreachable; check the network and re-run');
|
|
3041
|
+
process.exit(1);
|
|
2825
3042
|
}
|
|
2826
3043
|
if (latest === current) {
|
|
2827
|
-
|
|
3044
|
+
t.done(`registry ✓ ${current} is the latest release`);
|
|
2828
3045
|
}
|
|
2829
3046
|
else {
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
3047
|
+
t.done(`registry ✓ ${current || '?'} → ${latest} available`);
|
|
3048
|
+
const argvOf = {
|
|
3049
|
+
bun: ['add', '-g', '@lovinka/vitrinka@latest'],
|
|
3050
|
+
pnpm: ['add', '-g', '@lovinka/vitrinka@latest'],
|
|
3051
|
+
npm: ['install', '-g', '@lovinka/vitrinka@latest'],
|
|
3052
|
+
};
|
|
3053
|
+
const mgr = kind === 'repo' ? 'npm' : kind;
|
|
3054
|
+
const t2 = task(`installing ${latest} with ${mgr}`);
|
|
3055
|
+
const inst = await runStep(t2, mgr, mgr, argvOf[mgr], plain);
|
|
3056
|
+
if (!inst.ok) {
|
|
3057
|
+
t2.fail(`cli ✗ ${mgr} ${argvOf[mgr].join(' ')} failed`);
|
|
3058
|
+
if (inst.out.trim())
|
|
3059
|
+
tui.note(inst.out.trim().slice(-1200), `${mgr} says`);
|
|
3060
|
+
tui.outro('update aborted — the install failed; nothing else was touched');
|
|
3061
|
+
process.exit(1);
|
|
3062
|
+
}
|
|
2842
3063
|
// Verify the RUNNING copy actually changed on disk — if not, another
|
|
2843
3064
|
// install is shadowing it on PATH; say so instead of claiming success.
|
|
2844
3065
|
const after = pkgVersion();
|
|
2845
3066
|
if (after === current) {
|
|
2846
|
-
|
|
3067
|
+
t2.fail(`cli ⚠ installed ${latest}, but ${CLI_PATH} is still ${current || '?'}`);
|
|
3068
|
+
tui.warn('another install shadows it on PATH — inspect: which -a vitrinka');
|
|
2847
3069
|
}
|
|
2848
3070
|
else {
|
|
2849
|
-
|
|
3071
|
+
t2.done(`cli ✓ @lovinka/vitrinka ${after || latest}`);
|
|
3072
|
+
moved = `${current || '?'} → ${after || latest}`;
|
|
2850
3073
|
}
|
|
2851
|
-
//
|
|
2852
|
-
//
|
|
3074
|
+
// The package files were replaced in place, so the CHANGELOG next to the
|
|
3075
|
+
// CLI is the NEW one; show its sections newer than the version we had.
|
|
2853
3076
|
try {
|
|
2854
|
-
|
|
2855
|
-
if (news)
|
|
2856
|
-
console.log(`\n${news}\n`);
|
|
3077
|
+
news = changelogSince(readFileSync(join(dirname(CLI_PATH), '..', 'CHANGELOG.md'), 'utf8'), current);
|
|
2857
3078
|
}
|
|
2858
3079
|
catch {
|
|
2859
3080
|
// Older packages ship no CHANGELOG.md — nothing to show.
|
|
2860
3081
|
}
|
|
2861
3082
|
}
|
|
2862
3083
|
}
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
3084
|
+
if (news)
|
|
3085
|
+
tui.note(news, "what's new");
|
|
3086
|
+
// ---- installed surfaces: plugins where installed, Claude wiring only where
|
|
3087
|
+
// the footer badge shows prior consent. Refreshed from the NEW code. ----
|
|
3088
|
+
const probe = task('checking installed surfaces');
|
|
3089
|
+
const runtimes = pluginRuntimes();
|
|
3090
|
+
probe.done(`surfaces ✓ ${runtimes.map((r) => `${r.name} ${r.installed ? 'plugin' : r.present ? '(no plugin)' : '(absent)'}`).join(' · ')}`);
|
|
3091
|
+
const problems = [];
|
|
3092
|
+
let restartHint = false;
|
|
3093
|
+
for (const rt of runtimes.filter((r) => r.installed)) {
|
|
3094
|
+
const cmd = rt.name === 'claude'
|
|
3095
|
+
? ['claude', 'plugin', 'update', 'vitrinka@lovinka']
|
|
3096
|
+
: ['codex', 'plugin', 'marketplace', 'upgrade'];
|
|
3097
|
+
const t = task(`refreshing the ${rt.name} plugin (skills + commands)`);
|
|
3098
|
+
const r = await runStep(t, `${rt.name} plugin`, cmd[0], cmd.slice(1), plain);
|
|
3099
|
+
if (!r.ok) {
|
|
3100
|
+
t.fail(`${rt.name} plugin ✗ refresh failed`);
|
|
3101
|
+
if (r.out.trim())
|
|
3102
|
+
tui.note(r.out.trim().slice(-800), `${rt.name} says`);
|
|
3103
|
+
problems.push(`${rt.name} plugin — re-run: ${cmd.join(' ')}`);
|
|
3104
|
+
}
|
|
3105
|
+
else {
|
|
3106
|
+
t.done(`${rt.name} plugin ✓ ${pluginOutcome(r.out)}`);
|
|
3107
|
+
if (/restart/i.test(r.out))
|
|
3108
|
+
restartHint = true;
|
|
2869
3109
|
}
|
|
2870
3110
|
}
|
|
2871
3111
|
// Remove the retired statusline extension from existing installs, then refresh
|
|
@@ -2880,10 +3120,691 @@ async function updateCmd(_args) {
|
|
|
2880
3120
|
}
|
|
2881
3121
|
catch { /* malformed settings.json — treat as not-wired; install-claude reports it */ }
|
|
2882
3122
|
}
|
|
2883
|
-
if (consented
|
|
2884
|
-
|
|
3123
|
+
if (consented) {
|
|
3124
|
+
const t = task('refreshing the Claude Code UI wiring');
|
|
3125
|
+
const r = await runStep(t, 'install-claude', process.execPath, [CLI_PATH, 'install-claude'], plain);
|
|
3126
|
+
if (r.ok) {
|
|
3127
|
+
t.done('claude ui ✓ board footer badges current');
|
|
3128
|
+
}
|
|
3129
|
+
else {
|
|
3130
|
+
t.fail('claude ui ✗ refresh failed');
|
|
3131
|
+
if (r.out.trim())
|
|
3132
|
+
tui.note(r.out.trim().slice(-800), 'install-claude says');
|
|
3133
|
+
problems.push('claude ui — re-run: vitrinka install-claude');
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
// The recorder extension is a surface too: it self-updates hourly through the
|
|
3137
|
+
// native host, but a CLI update is a good moment to catch a machine whose
|
|
3138
|
+
// browser has been closed for a week. Silent when it isn't installed.
|
|
3139
|
+
const extHave = extInstalledVersion(extDefaultDir());
|
|
3140
|
+
if (extHave) {
|
|
3141
|
+
const t = task(`checking the recorder extension (${extHave})`);
|
|
3142
|
+
try {
|
|
3143
|
+
const rel = await extLatestRelease();
|
|
3144
|
+
const moved = compareVersions(rel.version, extHave) > 0;
|
|
3145
|
+
if (moved)
|
|
3146
|
+
await extInstallLocked(rel, extDefaultDir());
|
|
3147
|
+
const reg = extRegisterOutcome(registerExtHost().wrote);
|
|
3148
|
+
const where = moved ? `${extHave} → ${rel.version} (reloads itself within the hour)` : `${extHave} is current`;
|
|
3149
|
+
if (reg.ok) {
|
|
3150
|
+
t.done(`extension ✓ ${where}`);
|
|
3151
|
+
}
|
|
3152
|
+
else {
|
|
3153
|
+
// Registering nothing is not "current" — say so instead of a ✓ over a
|
|
3154
|
+
// host chain that cannot answer the popup.
|
|
3155
|
+
t.fail(`extension ⚠ ${where}, but the update host is registered for ${reg.who}`);
|
|
3156
|
+
problems.push('extension — no update host registered: vitrinka extension doctor --fix');
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
catch (e) {
|
|
3160
|
+
// Never fail a CLI update over the extension — it keeps recording either
|
|
3161
|
+
// way, and the popup can install the release itself later.
|
|
3162
|
+
t.fail(`extension ⚠ ${String(e.message || e)}`);
|
|
3163
|
+
problems.push('extension — re-run: vitrinka extension update');
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
if (problems.length) {
|
|
3167
|
+
tui.warn(`${problems.length} step(s) need a hand:\n${problems.map((p) => ` · ${p}`).join('\n')}`);
|
|
3168
|
+
}
|
|
3169
|
+
const tail = restartHint ? ' · restart Claude Code sessions to load it' : '';
|
|
3170
|
+
tui.outro(moved
|
|
3171
|
+
? `updated ${moved}${tail} — try: vitrinka --help`
|
|
3172
|
+
: `already up to date${tail} — try: vitrinka --help`);
|
|
3173
|
+
}
|
|
3174
|
+
// ---------- extension: the journey-recorder browser extension ----------
|
|
3175
|
+
//
|
|
3176
|
+
// The recorder ships UNPACKED (mesh-only tool, no Web Store), and Chrome never
|
|
3177
|
+
// auto-updates an unpacked extension. What Chrome DOES do is re-read the whole
|
|
3178
|
+
// folder from disk on `chrome.runtime.reload()` — so an update is really "swap
|
|
3179
|
+
// the files, then reload". An extension cannot write its own folder, so this
|
|
3180
|
+
// CLI is the writer and Chrome reaches it over a native-messaging host:
|
|
3181
|
+
//
|
|
3182
|
+
// setup unpack the marketplace zip into the canonical dir + register the host
|
|
3183
|
+
// update swap the dir to the newest release (the extension reloads itself)
|
|
3184
|
+
// doctor report every link in that chain; --fix re-registers what it can
|
|
3185
|
+
// host the stdio protocol Chrome speaks (hidden — Chrome spawns it)
|
|
3186
|
+
//
|
|
3187
|
+
// The extension's id is pinned by a fixed `key` in its manifest because
|
|
3188
|
+
// allowed_origins has to name ONE id, and an unpacked extension's id is
|
|
3189
|
+
// otherwise a hash of wherever the folder happens to live.
|
|
3190
|
+
const EXT_ID = 'lidjccailicbgjdfaplpgmbmmecehlfo';
|
|
3191
|
+
const EXT_HOST_NAME = 'in.vitrinka.updater';
|
|
3192
|
+
const EXT_APP = 'vitrinka-recorder';
|
|
3193
|
+
const EXT_MARKET_DEFAULT = 'https://apps.fixit.app';
|
|
3194
|
+
// $VITRINKA_EXT_MARKET repoints the release feed — the seam the tests use, and
|
|
3195
|
+
// what a self-hosted pultik shelf would set.
|
|
3196
|
+
function extMarket() {
|
|
3197
|
+
return (process.env.VITRINKA_EXT_MARKET || EXT_MARKET_DEFAULT).replace(/\/+$/, '');
|
|
3198
|
+
}
|
|
3199
|
+
// A native-messaging frame is capped at 1 MB by Chrome in both directions; ours
|
|
3200
|
+
// are tiny, so anything near the cap is a desync, not a big message.
|
|
3201
|
+
const EXT_FRAME_MAX = 1024 * 1024;
|
|
3202
|
+
function extConfigHome() { return join(process.env.HOME || '', '.config', 'vitrinka'); }
|
|
3203
|
+
// ONE canonical folder, deliberately not overridable: the host Chrome spawns
|
|
3204
|
+
// gets no arguments, so a per-user location would have to be persisted and kept
|
|
3205
|
+
// in sync, and every "update" from the popup could then target the wrong copy.
|
|
3206
|
+
// $HOME is the only seam (which is also how the tests isolate).
|
|
3207
|
+
function extDefaultDir() { return join(extConfigHome(), 'extension'); }
|
|
3208
|
+
function extShimPath() { return join(extConfigHome(), 'extension-host.sh'); }
|
|
3209
|
+
// compareVersions — dotted numeric compare, exported for the popup-parity test.
|
|
3210
|
+
// Returns >0 when a outranks b. Non-numeric junk sorts as 0 rather than NaN, so
|
|
3211
|
+
// a malformed feed can never claim to be newer.
|
|
3212
|
+
export function compareVersions(a, b) {
|
|
3213
|
+
const pa = String(a).split('.'), pb = String(b).split('.');
|
|
3214
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
3215
|
+
const na = Number(pa[i]) || 0, nb = Number(pb[i]) || 0;
|
|
3216
|
+
if (na !== nb)
|
|
3217
|
+
return na - nb;
|
|
3218
|
+
}
|
|
3219
|
+
return 0;
|
|
3220
|
+
}
|
|
3221
|
+
export function extBrowsers(platform = process.platform, home = process.env.HOME || '') {
|
|
3222
|
+
const mac = [
|
|
3223
|
+
['chrome', 'Google/Chrome', 'Google Chrome.app'],
|
|
3224
|
+
['brave', 'BraveSoftware/Brave-Browser', 'Brave Browser.app'],
|
|
3225
|
+
['edge', 'Microsoft Edge', 'Microsoft Edge.app'],
|
|
3226
|
+
['chromium', 'Chromium', 'Chromium.app'],
|
|
3227
|
+
];
|
|
3228
|
+
const linux = [
|
|
3229
|
+
['chrome', 'google-chrome', ['google-chrome', 'google-chrome-stable']],
|
|
3230
|
+
['brave', 'BraveSoftware/Brave-Browser', ['brave-browser', 'brave']],
|
|
3231
|
+
['edge', 'microsoft-edge', ['microsoft-edge', 'microsoft-edge-stable']],
|
|
3232
|
+
['chromium', 'chromium', ['chromium', 'chromium-browser']],
|
|
3233
|
+
];
|
|
3234
|
+
if (platform === 'darwin') {
|
|
3235
|
+
return mac.map(([name, rel, app]) => {
|
|
3236
|
+
const profile = join(home, 'Library', 'Application Support', ...rel.split('/'));
|
|
3237
|
+
return {
|
|
3238
|
+
name, profile, bins: [],
|
|
3239
|
+
dir: join(profile, 'NativeMessagingHosts'),
|
|
3240
|
+
// Per-user installs land in ~/Applications, not /Applications.
|
|
3241
|
+
apps: [join('/Applications', app), join(home, 'Applications', app)],
|
|
3242
|
+
};
|
|
3243
|
+
});
|
|
3244
|
+
}
|
|
3245
|
+
return linux.map(([name, rel, bins]) => {
|
|
3246
|
+
const profile = join(home, '.config', ...rel.split('/'));
|
|
3247
|
+
return { name, profile, bins, apps: [], dir: join(profile, 'NativeMessagingHosts') };
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
// An executable of that name on PATH — `delimiter` because the separator is
|
|
3251
|
+
// platform-specific, and the mode bit because a plain file (or a directory)
|
|
3252
|
+
// called `google-chrome` is not a browser.
|
|
3253
|
+
function onPath(bin) {
|
|
3254
|
+
return (process.env.PATH || '').split(delimiter).some((d) => {
|
|
3255
|
+
if (!d)
|
|
3256
|
+
return false;
|
|
3257
|
+
const p = join(d, bin);
|
|
3258
|
+
try {
|
|
3259
|
+
const st = statSync(p);
|
|
3260
|
+
return st.isFile() && (st.mode & 0o111) !== 0;
|
|
3261
|
+
}
|
|
3262
|
+
catch {
|
|
3263
|
+
return false; // not there (ENOENT) — the ordinary case for most PATH dirs
|
|
3264
|
+
}
|
|
3265
|
+
});
|
|
3266
|
+
}
|
|
3267
|
+
export function extBrowserFound(b) {
|
|
3268
|
+
if (b.apps.some((p) => existsSync(p)))
|
|
3269
|
+
return 'app';
|
|
3270
|
+
if (b.bins.some(onPath))
|
|
3271
|
+
return 'path';
|
|
3272
|
+
return existsSync(join(b.profile, 'Local State')) ? 'profile' : false;
|
|
3273
|
+
}
|
|
3274
|
+
// extBrowserInstalled — the strong reading, for anything that ASSERTS the
|
|
3275
|
+
// browser exists (doctor's "installed, host NOT registered" alarm).
|
|
3276
|
+
export function extBrowserInstalled(b) {
|
|
3277
|
+
const e = extBrowserFound(b);
|
|
3278
|
+
return e === 'app' || e === 'path';
|
|
3279
|
+
}
|
|
3280
|
+
export function extHostManifest(shim) {
|
|
3281
|
+
return JSON.stringify({
|
|
3282
|
+
name: EXT_HOST_NAME,
|
|
3283
|
+
description: 'Vitrinka journey-recorder updater',
|
|
3284
|
+
path: shim,
|
|
3285
|
+
type: 'stdio',
|
|
3286
|
+
allowed_origins: [`chrome-extension://${EXT_ID}/`],
|
|
3287
|
+
}, null, 2) + '\n';
|
|
3288
|
+
}
|
|
3289
|
+
// extShim — Chrome execs this file, so it must be executable and speak the
|
|
3290
|
+
// framed protocol on stdout and NOTHING else. node + the CLI path are baked in
|
|
3291
|
+
// at setup time; `extension doctor` catches a stale pair after a reinstall.
|
|
3292
|
+
export function extShim(nodeBin, cliPath) {
|
|
3293
|
+
return [
|
|
3294
|
+
'#!/bin/sh',
|
|
3295
|
+
'# vitrinka native-messaging host — generated by `vitrinka extension setup`.',
|
|
3296
|
+
'# Chrome speaks a 4-byte-length + JSON protocol on stdin/stdout; anything',
|
|
3297
|
+
'# else printed to stdout corrupts the stream, so keep this a bare exec.',
|
|
3298
|
+
`exec ${JSON.stringify(nodeBin)} ${JSON.stringify(cliPath)} extension host`,
|
|
3299
|
+
'',
|
|
3300
|
+
].join('\n');
|
|
3301
|
+
}
|
|
3302
|
+
// extTargetBrowsers — who gets a host manifest: the browsers we found any
|
|
3303
|
+
// evidence for (including a leftover profile — a spare manifest is harmless and
|
|
3304
|
+
// the browser may come back). A machine where we detect NONE (a fresh box, or a
|
|
3305
|
+
// Chromium fork we don't know) falls back to all of them, so setup still leaves
|
|
3306
|
+
// something that works. `browsers` is injectable so both branches are testable
|
|
3307
|
+
// without depending on what happens to be installed on the test machine.
|
|
3308
|
+
export function extTargetBrowsers(browsers = extBrowsers()) {
|
|
3309
|
+
const found = browsers.filter((b) => extBrowserFound(b) !== false);
|
|
3310
|
+
return { targets: found.length ? found : browsers, found, all: found.length === 0 };
|
|
3311
|
+
}
|
|
3312
|
+
// extRegisterOutcome — how `install` and `update` report a re-registration.
|
|
3313
|
+
// Shared so the "wrote nothing" case is ONE branch with one test rather than
|
|
3314
|
+
// two hand-written checks: an empty list is exactly the broken host that
|
|
3315
|
+
// re-registration exists to repair, so it must never read as success.
|
|
3316
|
+
export function extRegisterOutcome(wrote) {
|
|
3317
|
+
return wrote.length
|
|
3318
|
+
? { ok: true, who: wrote.join(' · ') }
|
|
3319
|
+
: { ok: false, who: 'NO browser' };
|
|
3320
|
+
}
|
|
3321
|
+
export function extHostVerdict(name, evidence, manifest) {
|
|
3322
|
+
const installed = evidence === 'app' || evidence === 'path';
|
|
3323
|
+
if (manifest === 'stale')
|
|
3324
|
+
return { level: 'bad', text: `${name}: host manifest is stale` };
|
|
3325
|
+
if (manifest === 'missing') {
|
|
3326
|
+
if (installed)
|
|
3327
|
+
return { level: 'bad', text: `${name}: installed, host NOT registered` };
|
|
3328
|
+
if (evidence === 'profile')
|
|
3329
|
+
return { level: 'note', text: `${name}: profile present but the browser looks uninstalled — not registered` };
|
|
3330
|
+
return { level: 'note', text: `${name}: not installed` };
|
|
3331
|
+
}
|
|
3332
|
+
const note = installed ? ''
|
|
3333
|
+
: evidence === 'profile' ? ' (profile only — browser looks uninstalled)'
|
|
3334
|
+
: ' (browser not installed — harmless)';
|
|
3335
|
+
return { level: 'ok', text: `${name}: host registered${note}` };
|
|
3336
|
+
}
|
|
3337
|
+
// registerExtHost writes the shim + one manifest per target browser.
|
|
3338
|
+
function registerExtHost(targets) {
|
|
3339
|
+
const shim = extShimPath();
|
|
3340
|
+
mkdirSync(dirname(shim), { recursive: true });
|
|
3341
|
+
writeFileSync(shim, extShim(process.execPath, CLI_PATH));
|
|
3342
|
+
chmodSync(shim, 0o755);
|
|
3343
|
+
const body = extHostManifest(shim);
|
|
3344
|
+
const wrote = [];
|
|
3345
|
+
for (const { name, dir } of targets ?? extTargetBrowsers().targets) {
|
|
3346
|
+
try {
|
|
3347
|
+
mkdirSync(dir, { recursive: true });
|
|
3348
|
+
writeFileSync(join(dir, `${EXT_HOST_NAME}.json`), body);
|
|
3349
|
+
wrote.push(name);
|
|
3350
|
+
}
|
|
3351
|
+
catch (e) {
|
|
3352
|
+
// A browser we can't write to (locked-down profile) must not sink setup —
|
|
3353
|
+
// the others still work, and doctor reports the gap.
|
|
3354
|
+
console.error(`vitrinka: could not register the host for ${name}: ${String(e.message || e)}`);
|
|
3355
|
+
}
|
|
2885
3356
|
}
|
|
2886
|
-
|
|
3357
|
+
return { shim, wrote };
|
|
3358
|
+
}
|
|
3359
|
+
function extInstalledVersion(dir) {
|
|
3360
|
+
try {
|
|
3361
|
+
return String(JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')).version || '');
|
|
3362
|
+
}
|
|
3363
|
+
catch {
|
|
3364
|
+
return ''; // not installed (or mid-swap) — callers treat '' as "absent"
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
const EXT_SHA256_RE = /^[0-9a-f]{64}$/;
|
|
3368
|
+
// Hard ceiling on a release artifact, independent of what the feed claims. The
|
|
3369
|
+
// real one is ~64 KB; this is headroom, not a target.
|
|
3370
|
+
const EXT_ZIP_MAX = 32 * 1024 * 1024;
|
|
3371
|
+
async function extLatestRelease() {
|
|
3372
|
+
const url = `${extMarket()}/api/releases/${EXT_APP}/latest`;
|
|
3373
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
3374
|
+
if (!r.ok)
|
|
3375
|
+
throw new Error(`${url} → HTTP ${r.status}`);
|
|
3376
|
+
const j = await r.json();
|
|
3377
|
+
if (!j.version || !j.artifact)
|
|
3378
|
+
throw new Error(`${url} returned no version/artifact`);
|
|
3379
|
+
const sha256 = String(j.sha256 ?? '').trim().toLowerCase();
|
|
3380
|
+
if (!EXT_SHA256_RE.test(sha256)) {
|
|
3381
|
+
throw new Error(`${url} has no usable sha256 (${JSON.stringify(j.sha256 ?? null)}) — refusing an unverifiable release`);
|
|
3382
|
+
}
|
|
3383
|
+
return { version: j.version, artifact: j.artifact, sha256, size: j.size };
|
|
3384
|
+
}
|
|
3385
|
+
// extDownload buffers a release under a hard byte cap. The cap matters because
|
|
3386
|
+
// the caller holds the whole artifact in memory to hash it: without one, a
|
|
3387
|
+
// shelf lying about its size (or anything that can answer for extMarket()) can
|
|
3388
|
+
// make every popup update balloon the host until it dies. The advertised size
|
|
3389
|
+
// is the expectation when present; EXT_ZIP_MAX is the backstop when it is not.
|
|
3390
|
+
async function extDownload(url, advertised) {
|
|
3391
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
|
3392
|
+
if (!res.ok)
|
|
3393
|
+
throw new Error(`${url} → HTTP ${res.status}`);
|
|
3394
|
+
const cap = advertised > 0 ? Math.min(advertised, EXT_ZIP_MAX) : EXT_ZIP_MAX;
|
|
3395
|
+
// EVERY rejection past this point goes through here: an unconsumed undici body
|
|
3396
|
+
// keeps its connection (and the server's ability to keep streaming) alive, so
|
|
3397
|
+
// "throw immediately" without a cancel is not actually immediate.
|
|
3398
|
+
const reject = async (msg) => {
|
|
3399
|
+
await res.body?.cancel().catch(() => { });
|
|
3400
|
+
throw new Error(msg);
|
|
3401
|
+
};
|
|
3402
|
+
const declared = Number(res.headers.get('content-length') || 0);
|
|
3403
|
+
if (declared > cap) {
|
|
3404
|
+
return reject(`${url} declares ${declared} bytes — too large, the feed advertised size ${cap}`);
|
|
3405
|
+
}
|
|
3406
|
+
if (!res.body)
|
|
3407
|
+
throw new Error(`${url} returned no body`);
|
|
3408
|
+
const chunks = [];
|
|
3409
|
+
let total = 0;
|
|
3410
|
+
for await (const chunk of res.body) {
|
|
3411
|
+
total += chunk.length;
|
|
3412
|
+
if (total > cap) {
|
|
3413
|
+
// Stop reading rather than finish and check afterwards — the point is to
|
|
3414
|
+
// never hold the oversized body at all.
|
|
3415
|
+
return reject(`${url} is too large — exceeded ${cap} bytes (advertised size ${advertised || 'none'})`);
|
|
3416
|
+
}
|
|
3417
|
+
chunks.push(Buffer.from(chunk));
|
|
3418
|
+
}
|
|
3419
|
+
return Buffer.concat(chunks, total);
|
|
3420
|
+
}
|
|
3421
|
+
// extInstallRelease downloads a release and swaps it into `dir`.
|
|
3422
|
+
//
|
|
3423
|
+
// Everything stages inside dir's PARENT so each rename stays on one filesystem
|
|
3424
|
+
// (a tmpdir on another volume would make renameSync throw EXDEV). The previous
|
|
3425
|
+
// generation is cleared at the START of the next update, never at the end of
|
|
3426
|
+
// this one: between the swap and the extension's reload, a running extension
|
|
3427
|
+
// still resolves its files through the old inode.
|
|
3428
|
+
async function extInstallRelease(rel, dir) {
|
|
3429
|
+
const stage = `${dir}.stage`;
|
|
3430
|
+
const prev = `${dir}.old`;
|
|
3431
|
+
rmSync(stage, { recursive: true, force: true });
|
|
3432
|
+
rmSync(prev, { recursive: true, force: true });
|
|
3433
|
+
mkdirSync(stage, { recursive: true });
|
|
3434
|
+
const url = `${extMarket()}/releases/${EXT_APP}/${rel.version}/${rel.artifact}`;
|
|
3435
|
+
let zipBytes;
|
|
3436
|
+
try {
|
|
3437
|
+
zipBytes = await extDownload(url, Number(rel.size) || 0);
|
|
3438
|
+
}
|
|
3439
|
+
catch (e) {
|
|
3440
|
+
rmSync(stage, { recursive: true, force: true });
|
|
3441
|
+
throw e;
|
|
3442
|
+
}
|
|
3443
|
+
// Unconditional: extLatestRelease already refused a release without a usable
|
|
3444
|
+
// digest, so there is no "no checksum to check" branch left to fall through.
|
|
3445
|
+
const got = createHash('sha256').update(zipBytes).digest('hex');
|
|
3446
|
+
if (got !== rel.sha256) {
|
|
3447
|
+
rmSync(stage, { recursive: true, force: true });
|
|
3448
|
+
throw new Error(`checksum mismatch for ${rel.artifact} (feed ${rel.sha256.slice(0, 12)}…, got ${got.slice(0, 12)}…) — refusing to install`);
|
|
3449
|
+
}
|
|
3450
|
+
const zipPath = join(stage, 'release.zip');
|
|
3451
|
+
writeFileSync(zipPath, zipBytes);
|
|
3452
|
+
const unzip = spawnSync('unzip', ['-q', '-o', zipPath, '-d', stage], { encoding: 'utf8' });
|
|
3453
|
+
if (unzip.error || unzip.status !== 0) {
|
|
3454
|
+
rmSync(stage, { recursive: true, force: true });
|
|
3455
|
+
throw new Error(`unzip failed (${unzip.error ? unzip.error.message : `exit ${unzip.status}`}${(unzip.stderr || '').trim() ? `: ${unzip.stderr.trim()}` : ''}) — install unzip and re-run`);
|
|
3456
|
+
}
|
|
3457
|
+
rmSync(zipPath, { force: true });
|
|
3458
|
+
// The zip unpacks to a single vitrinka-recorder/ folder; tolerate a flat one.
|
|
3459
|
+
let root = stage;
|
|
3460
|
+
if (!existsSync(join(root, 'manifest.json'))) {
|
|
3461
|
+
const subs = readdirSync(stage, { withFileTypes: true })
|
|
3462
|
+
.filter((d) => d.isDirectory() && existsSync(join(stage, d.name, 'manifest.json')));
|
|
3463
|
+
if (subs.length !== 1) {
|
|
3464
|
+
rmSync(stage, { recursive: true, force: true });
|
|
3465
|
+
throw new Error(`${rel.artifact} has no single folder containing manifest.json — bad release artifact`);
|
|
3466
|
+
}
|
|
3467
|
+
root = join(stage, subs[0].name);
|
|
3468
|
+
}
|
|
3469
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
3470
|
+
if (existsSync(dir))
|
|
3471
|
+
renameSync(dir, prev);
|
|
3472
|
+
renameSync(root, dir);
|
|
3473
|
+
rmSync(stage, { recursive: true, force: true });
|
|
3474
|
+
}
|
|
3475
|
+
// extInstallLocked — one updater at a time, then install. Two Chrome ports mean
|
|
3476
|
+
// two host processes, and two concurrent swaps of the same dir corrupt it.
|
|
3477
|
+
// mkdir is the atomic primitive; a lock older than the download timeout is
|
|
3478
|
+
// treated as abandoned by whatever died holding it.
|
|
3479
|
+
async function extInstallLocked(rel, dir) {
|
|
3480
|
+
const lock = `${dir}.lock`;
|
|
3481
|
+
try {
|
|
3482
|
+
const age = Date.now() - statSync(lock).mtimeMs;
|
|
3483
|
+
if (age > 150_000)
|
|
3484
|
+
rmSync(lock, { recursive: true, force: true });
|
|
3485
|
+
}
|
|
3486
|
+
catch { /* nothing holds it (ENOENT) — the mkdir below claims it */ }
|
|
3487
|
+
mkdirSync(dirname(lock), { recursive: true });
|
|
3488
|
+
try {
|
|
3489
|
+
mkdirSync(lock, { recursive: false });
|
|
3490
|
+
}
|
|
3491
|
+
catch (e) {
|
|
3492
|
+
// EEXIST is the lock doing its job. Anything else (EACCES, ENOSPC) is a
|
|
3493
|
+
// real fault and must not be reported as "someone else is installing".
|
|
3494
|
+
if (e.code !== 'EEXIST')
|
|
3495
|
+
throw e;
|
|
3496
|
+
throw new Error(`another vitrinka updater holds ${lock} — retry in a moment, or remove it if nothing is running`);
|
|
3497
|
+
}
|
|
3498
|
+
try {
|
|
3499
|
+
await extInstallRelease(rel, dir);
|
|
3500
|
+
}
|
|
3501
|
+
finally {
|
|
3502
|
+
rmSync(lock, { recursive: true, force: true });
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
async function extensionCmd(argv, args) {
|
|
3506
|
+
const sub = argv[0] && !argv[0].startsWith('--') ? argv[0] : '';
|
|
3507
|
+
if (sub === 'setup')
|
|
3508
|
+
return extensionSetupCmd(args);
|
|
3509
|
+
if (sub === 'update')
|
|
3510
|
+
return extensionUpdateCmd(args);
|
|
3511
|
+
if (sub === 'doctor')
|
|
3512
|
+
return extensionDoctorCmd(args);
|
|
3513
|
+
if (sub === 'host')
|
|
3514
|
+
return extensionHostCmd();
|
|
3515
|
+
console.error(sub
|
|
3516
|
+
? `extension: unknown subcommand ${JSON.stringify(sub)}`
|
|
3517
|
+
: 'extension: needs a subcommand');
|
|
3518
|
+
console.error('\n' + renderCmdHelp('extension'));
|
|
3519
|
+
process.exit(2);
|
|
3520
|
+
}
|
|
3521
|
+
// soft: called from `vitrinka install` as an optional extra, where a shelf
|
|
3522
|
+
// outage must report itself and let machine setup finish rather than exit(1)
|
|
3523
|
+
// out of the middle of someone's onboarding.
|
|
3524
|
+
async function extensionSetupCmd(args, soft = false) {
|
|
3525
|
+
const plain = args.verbose === true;
|
|
3526
|
+
const task = (title) => tui.task(title, plain);
|
|
3527
|
+
const dir = extDefaultDir();
|
|
3528
|
+
const have = extInstalledVersion(dir);
|
|
3529
|
+
tui.intro(`vitrinka extension setup · ${clip(dir, 52)}`);
|
|
3530
|
+
const t = task('checking the marketplace for the current release');
|
|
3531
|
+
let rel;
|
|
3532
|
+
try {
|
|
3533
|
+
rel = await extLatestRelease();
|
|
3534
|
+
t.done(`release ✓ ${EXT_APP} ${rel.version}${have ? ` (installed: ${have})` : ''}`);
|
|
3535
|
+
}
|
|
3536
|
+
catch (e) {
|
|
3537
|
+
t.fail(`release ✗ ${String(e.message || e)}`);
|
|
3538
|
+
tui.outro(`extension setup ${soft ? 'skipped' : 'aborted'} — the marketplace was unreachable. On the mesh? ${extMarket()} must resolve.`);
|
|
3539
|
+
if (soft)
|
|
3540
|
+
return;
|
|
3541
|
+
process.exit(1);
|
|
3542
|
+
}
|
|
3543
|
+
if (have === rel.version && args.force !== true) {
|
|
3544
|
+
tui.info(`files ✓ ${dir} already holds ${have} (re-download with --force)`);
|
|
3545
|
+
}
|
|
3546
|
+
else {
|
|
3547
|
+
const t2 = task(`downloading ${rel.artifact}${rel.size ? ` (${Math.round(rel.size / 1024)} KB)` : ''}`);
|
|
3548
|
+
try {
|
|
3549
|
+
await extInstallLocked(rel, dir);
|
|
3550
|
+
t2.done(`files ✓ ${have ? `${have} → ` : ''}${rel.version} unpacked into ${clip(dir, 40)}`);
|
|
3551
|
+
}
|
|
3552
|
+
catch (e) {
|
|
3553
|
+
t2.fail(`files ✗ ${String(e.message || e)}`);
|
|
3554
|
+
tui.outro(`extension setup ${soft ? 'skipped' : 'aborted'} — nothing was swapped in; the previous folder is untouched`);
|
|
3555
|
+
if (soft)
|
|
3556
|
+
return;
|
|
3557
|
+
process.exit(1);
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
const t3 = task('looking for browsers to register the update host with');
|
|
3561
|
+
const { targets, all } = extTargetBrowsers();
|
|
3562
|
+
const { wrote } = registerExtHost(targets);
|
|
3563
|
+
if (!wrote.length) {
|
|
3564
|
+
t3.fail('host ✗ could not write a manifest for any browser');
|
|
3565
|
+
tui.outro('extension setup incomplete — see the errors above; it still records, just without in-place updates');
|
|
3566
|
+
if (soft)
|
|
3567
|
+
return;
|
|
3568
|
+
process.exit(1);
|
|
3569
|
+
}
|
|
3570
|
+
const missing = extBrowsers().filter((b) => !wrote.includes(b.name)).map((b) => b.name);
|
|
3571
|
+
t3.done(all
|
|
3572
|
+
? `host ✓ registered for ${wrote.join(' · ')} — no browser detected, so all of them are covered`
|
|
3573
|
+
: `host ✓ registered for ${wrote.join(' · ')}${missing.length ? ` (${missing.join(', ')} not installed)` : ''}`);
|
|
3574
|
+
if (!all && missing.length) {
|
|
3575
|
+
tui.info(`Install ${missing.length > 1 ? 'one of those' : missing[0]} later? Run \`vitrinka extension doctor --fix\` to register it too.`);
|
|
3576
|
+
}
|
|
3577
|
+
tui.note([
|
|
3578
|
+
`1. open chrome://extensions (brave:// · edge:// all work)`,
|
|
3579
|
+
'2. turn on Developer mode, click "Load unpacked"',
|
|
3580
|
+
`3. pick ${dir}`,
|
|
3581
|
+
'',
|
|
3582
|
+
`The id must read ${EXT_ID}`,
|
|
3583
|
+
'If you already had the recorder loaded from another folder, REMOVE that',
|
|
3584
|
+
'card — the pinned id makes this a different extension, so the two would',
|
|
3585
|
+
'both answer the shortcuts.',
|
|
3586
|
+
].join('\n'), 'load it once');
|
|
3587
|
+
const tok = tokenSource();
|
|
3588
|
+
tui.note(tok === 'missing'
|
|
3589
|
+
? `No token on this machine yet — run \`vitrinka login\`, then use "Fill from the\nvitrinka CLI" on the extension's options page (or just reload the extension).`
|
|
3590
|
+
: `Base URL ${resolveBaseUrl()} and your ${tok === 'env' ? '$VITRINKA_TOKEN' : 'saved'} token are handed to\nthe extension when it first loads — the options page is an override, not a step.`, 'settings');
|
|
3591
|
+
tui.outro(`recorder ${rel.version} ready — future releases install from the popup (or \`vitrinka extension update\`)`);
|
|
3592
|
+
}
|
|
3593
|
+
async function extensionUpdateCmd(args) {
|
|
3594
|
+
const plain = args.verbose === true;
|
|
3595
|
+
const task = (title) => tui.task(title, plain);
|
|
3596
|
+
const dir = extDefaultDir();
|
|
3597
|
+
const have = extInstalledVersion(dir);
|
|
3598
|
+
if (!have) {
|
|
3599
|
+
console.error(`extension update: nothing installed at ${dir} — run \`vitrinka extension setup\` first`);
|
|
3600
|
+
process.exit(1);
|
|
3601
|
+
}
|
|
3602
|
+
tui.intro(`vitrinka extension update · ${have} · ${clip(dir, 46)}`);
|
|
3603
|
+
const t = task('checking the marketplace');
|
|
3604
|
+
let rel;
|
|
3605
|
+
try {
|
|
3606
|
+
rel = await extLatestRelease();
|
|
3607
|
+
}
|
|
3608
|
+
catch (e) {
|
|
3609
|
+
t.fail(`release ✗ ${String(e.message || e)}`);
|
|
3610
|
+
tui.outro('update aborted — the marketplace was unreachable; nothing was touched');
|
|
3611
|
+
process.exit(1);
|
|
3612
|
+
}
|
|
3613
|
+
const behind = compareVersions(rel.version, have) > 0;
|
|
3614
|
+
t.done(behind ? `release ✓ ${have} → ${rel.version} available` : `release ✓ ${have} is current`);
|
|
3615
|
+
if (!behind && args.force !== true) {
|
|
3616
|
+
tui.outro('already up to date — nothing to do');
|
|
3617
|
+
return;
|
|
3618
|
+
}
|
|
3619
|
+
if (args.check === true) {
|
|
3620
|
+
tui.outro(`update available: ${rel.version} — run \`vitrinka extension update\` to install it`);
|
|
3621
|
+
return;
|
|
3622
|
+
}
|
|
3623
|
+
const t2 = task(`installing ${rel.version}`);
|
|
3624
|
+
try {
|
|
3625
|
+
await extInstallLocked(rel, dir);
|
|
3626
|
+
t2.done(`files ✓ ${have} → ${rel.version}`);
|
|
3627
|
+
}
|
|
3628
|
+
catch (e) {
|
|
3629
|
+
t2.fail(`files ✗ ${String(e.message || e)}`);
|
|
3630
|
+
tui.outro('update aborted — nothing was swapped in; the running extension is untouched');
|
|
3631
|
+
process.exit(1);
|
|
3632
|
+
}
|
|
3633
|
+
tui.outro(`installed ${rel.version} — the extension picks it up within the hour, or instantly from the popup (↻ in chrome://extensions also works)`);
|
|
3634
|
+
}
|
|
3635
|
+
async function extensionDoctorCmd(args) {
|
|
3636
|
+
const fix = args.fix === true;
|
|
3637
|
+
const dir = extDefaultDir();
|
|
3638
|
+
const lines = [];
|
|
3639
|
+
let bad = 0;
|
|
3640
|
+
const ok = (m) => { lines.push(` ✓ ${m}`); };
|
|
3641
|
+
const no = (m) => { lines.push(` ✗ ${m}`); bad++; };
|
|
3642
|
+
tui.intro(`vitrinka extension doctor${fix ? ' --fix' : ''}`);
|
|
3643
|
+
const have = extInstalledVersion(dir);
|
|
3644
|
+
if (have)
|
|
3645
|
+
ok(`files ${have} at ${dir}`);
|
|
3646
|
+
else
|
|
3647
|
+
no(`no manifest.json at ${dir} — run: vitrinka extension setup`);
|
|
3648
|
+
// Registering is idempotent and useful even with no extension unpacked yet,
|
|
3649
|
+
// so --fix is not gated on the folder existing (otherwise "shim missing →
|
|
3650
|
+
// run --fix" would loop on a machine that has lost the folder).
|
|
3651
|
+
if (fix)
|
|
3652
|
+
registerExtHost();
|
|
3653
|
+
const shim = extShimPath();
|
|
3654
|
+
if (!existsSync(shim)) {
|
|
3655
|
+
no(`host shim missing (${shim}) — run: vitrinka extension doctor --fix`);
|
|
3656
|
+
}
|
|
3657
|
+
else {
|
|
3658
|
+
const body = readFileSync(shim, 'utf8');
|
|
3659
|
+
const exec = (statSync(shim).mode & 0o111) !== 0;
|
|
3660
|
+
// The shim bakes in node + the CLI path; a global reinstall can move either.
|
|
3661
|
+
const stale = !body.includes(JSON.stringify(CLI_PATH)) || !body.includes(JSON.stringify(process.execPath));
|
|
3662
|
+
if (!exec)
|
|
3663
|
+
no(`host shim is not executable (${shim}) — run: vitrinka extension doctor --fix`);
|
|
3664
|
+
else if (stale)
|
|
3665
|
+
no(`host shim points at a different node/CLI than this one — run: vitrinka extension doctor --fix`);
|
|
3666
|
+
else
|
|
3667
|
+
ok(`host shim ${shim}`);
|
|
3668
|
+
}
|
|
3669
|
+
// Registration only matters for browsers that exist: a missing manifest for
|
|
3670
|
+
// an uninstalled Edge is not a problem, but a missing one for the browser
|
|
3671
|
+
// you actually use is the whole feature silently not working.
|
|
3672
|
+
const want = extHostManifest(shim);
|
|
3673
|
+
for (const b of extBrowsers()) {
|
|
3674
|
+
const p = join(b.dir, `${EXT_HOST_NAME}.json`);
|
|
3675
|
+
const state = !existsSync(p) ? 'missing' : readFileSync(p, 'utf8') === want ? 'current' : 'stale';
|
|
3676
|
+
const v = extHostVerdict(b.name, extBrowserFound(b), state);
|
|
3677
|
+
const fixHint = ' — run: vitrinka extension doctor --fix';
|
|
3678
|
+
if (v.level === 'ok')
|
|
3679
|
+
ok(v.text);
|
|
3680
|
+
else if (v.level === 'bad')
|
|
3681
|
+
no(`${v.text}${state === 'stale' ? ` (${p})` : ''}${fixHint}`);
|
|
3682
|
+
else
|
|
3683
|
+
lines.push(` · ${v.text}`);
|
|
3684
|
+
}
|
|
3685
|
+
if (spawnSync('unzip', ['-v'], { stdio: 'ignore' }).error) {
|
|
3686
|
+
no('unzip not found on PATH — in-place updates cannot unpack a release');
|
|
3687
|
+
}
|
|
3688
|
+
else {
|
|
3689
|
+
ok('unzip available');
|
|
3690
|
+
}
|
|
3691
|
+
const tok = tokenSource();
|
|
3692
|
+
if (tok === 'missing')
|
|
3693
|
+
no(`no write token (${tokenPath()}) — run: vitrinka login`);
|
|
3694
|
+
else
|
|
3695
|
+
ok(`token from ${tok === 'env' ? '$VITRINKA_TOKEN' : tokenPath()}`);
|
|
3696
|
+
ok(`base URL ${resolveBaseUrl(strArg(args.base))}`);
|
|
3697
|
+
let latest = '';
|
|
3698
|
+
try {
|
|
3699
|
+
latest = (await extLatestRelease()).version;
|
|
3700
|
+
if (have && compareVersions(latest, have) > 0) {
|
|
3701
|
+
lines.push(` ↑ ${latest} available — run: vitrinka extension update`);
|
|
3702
|
+
}
|
|
3703
|
+
else if (have) {
|
|
3704
|
+
ok(`marketplace: ${have} is current`);
|
|
3705
|
+
}
|
|
3706
|
+
else {
|
|
3707
|
+
ok(`marketplace: ${latest} is the current release`);
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
catch (e) {
|
|
3711
|
+
// Not always "unreachable" any more: a feed that answers without a usable
|
|
3712
|
+
// sha256 is refused here too, and saying "unreachable" would misdirect.
|
|
3713
|
+
no(`marketplace: ${String(e.message || e)}`);
|
|
3714
|
+
}
|
|
3715
|
+
tui.note(lines.join('\n'), `extension · id ${EXT_ID}`);
|
|
3716
|
+
tui.outro(bad
|
|
3717
|
+
? `${bad} problem(s) — the recorder still records, but in-place updates need the host chain intact`
|
|
3718
|
+
: 'all good — updates install from the popup');
|
|
3719
|
+
if (bad)
|
|
3720
|
+
process.exit(1);
|
|
3721
|
+
}
|
|
3722
|
+
// extensionHostCmd — the native-messaging endpoint Chrome spawns via the shim.
|
|
3723
|
+
//
|
|
3724
|
+
// Protocol: 4-byte little-endian length + JSON payload, both directions.
|
|
3725
|
+
// stdout IS the wire, so every diagnostic goes to stderr (Chrome logs it).
|
|
3726
|
+
//
|
|
3727
|
+
// The `config` reply hands the extension this machine's write token. That is
|
|
3728
|
+
// not a new exposure: Chrome only lets the allowed_origins extension connect,
|
|
3729
|
+
// and any local process could already read ~/.config/vitrinka/token directly.
|
|
3730
|
+
async function extensionHostCmd() {
|
|
3731
|
+
const dir = extDefaultDir();
|
|
3732
|
+
const send = (obj) => {
|
|
3733
|
+
const body = Buffer.from(JSON.stringify(obj), 'utf8');
|
|
3734
|
+
const len = Buffer.alloc(4);
|
|
3735
|
+
len.writeUInt32LE(body.length, 0);
|
|
3736
|
+
process.stdout.write(Buffer.concat([len, body]));
|
|
3737
|
+
};
|
|
3738
|
+
const handle = async (msg) => {
|
|
3739
|
+
switch (msg.cmd) {
|
|
3740
|
+
case 'config':
|
|
3741
|
+
return { ok: true, base: resolveBaseUrl(), token: readToken(), tokenSource: tokenSource(), cli: pkgVersion() };
|
|
3742
|
+
case 'check': {
|
|
3743
|
+
const disk = extInstalledVersion(dir);
|
|
3744
|
+
let latest = '';
|
|
3745
|
+
let error = '';
|
|
3746
|
+
try {
|
|
3747
|
+
latest = (await extLatestRelease()).version;
|
|
3748
|
+
}
|
|
3749
|
+
catch (e) {
|
|
3750
|
+
error = String(e.message || e);
|
|
3751
|
+
}
|
|
3752
|
+
return { ok: true, dir, disk, latest, error, cli: pkgVersion() };
|
|
3753
|
+
}
|
|
3754
|
+
case 'update': {
|
|
3755
|
+
const from = extInstalledVersion(dir);
|
|
3756
|
+
if (!from)
|
|
3757
|
+
return { ok: false, error: `nothing installed at ${dir} — run \`vitrinka extension setup\`` };
|
|
3758
|
+
const rel = await extLatestRelease();
|
|
3759
|
+
if (compareVersions(rel.version, from) <= 0 && msg.force !== true) {
|
|
3760
|
+
return { ok: true, changed: false, from, to: from };
|
|
3761
|
+
}
|
|
3762
|
+
await extInstallLocked(rel, dir);
|
|
3763
|
+
return { ok: true, changed: true, from, to: rel.version };
|
|
3764
|
+
}
|
|
3765
|
+
default:
|
|
3766
|
+
return { ok: false, error: `unknown cmd ${JSON.stringify(msg.cmd ?? null)}` };
|
|
3767
|
+
}
|
|
3768
|
+
};
|
|
3769
|
+
let buf = Buffer.alloc(0);
|
|
3770
|
+
// Frames are handled one at a time: a second `update` must queue behind the
|
|
3771
|
+
// first, never interleave with its swap.
|
|
3772
|
+
let chain = Promise.resolve();
|
|
3773
|
+
process.stdin.on('data', (chunk) => {
|
|
3774
|
+
buf = Buffer.concat([buf, chunk]);
|
|
3775
|
+
for (;;) {
|
|
3776
|
+
if (buf.length < 4)
|
|
3777
|
+
return;
|
|
3778
|
+
const len = buf.readUInt32LE(0);
|
|
3779
|
+
if (len > EXT_FRAME_MAX) {
|
|
3780
|
+
console.error(`vitrinka extension host: ${len}-byte frame exceeds the 1 MB cap — stream desynced, exiting`);
|
|
3781
|
+
process.exit(1);
|
|
3782
|
+
}
|
|
3783
|
+
if (buf.length < 4 + len)
|
|
3784
|
+
return;
|
|
3785
|
+
const body = buf.subarray(4, 4 + len).toString('utf8');
|
|
3786
|
+
buf = buf.subarray(4 + len);
|
|
3787
|
+
let msg;
|
|
3788
|
+
try {
|
|
3789
|
+
msg = JSON.parse(body);
|
|
3790
|
+
}
|
|
3791
|
+
catch (e) {
|
|
3792
|
+
send({ ok: false, error: `bad JSON frame: ${String(e.message || e)}` });
|
|
3793
|
+
continue;
|
|
3794
|
+
}
|
|
3795
|
+
chain = chain.then(async () => {
|
|
3796
|
+
try {
|
|
3797
|
+
send(await handle(msg));
|
|
3798
|
+
}
|
|
3799
|
+
catch (e) {
|
|
3800
|
+
send({ ok: false, error: String(e.message || e) });
|
|
3801
|
+
}
|
|
3802
|
+
});
|
|
3803
|
+
}
|
|
3804
|
+
});
|
|
3805
|
+
// Chrome closes stdin when the port goes away; that is the host's exit signal.
|
|
3806
|
+
await new Promise((res) => { process.stdin.on('end', () => res()); });
|
|
3807
|
+
await chain;
|
|
2887
3808
|
}
|
|
2888
3809
|
// ---------- snap: one-shot capture → manifest → detached push ----------
|
|
2889
3810
|
// Filename slug: diacritics folded (Přihlášení → prihlaseni), non-alnum → '-', capped.
|
|
@@ -3597,9 +4518,9 @@ export const COMMANDS = [
|
|
|
3597
4518
|
examples: ['vitrinka login', 'vitrinka login --base https://vitrinka.in'],
|
|
3598
4519
|
},
|
|
3599
4520
|
{
|
|
3600
|
-
name: 'doctor', summary: 'Check this machine\'s setup (server, token, operator, skills, shim, MCP); --fix repairs it',
|
|
4521
|
+
name: 'doctor', summary: 'Check this machine\'s setup (server, token, operator, skills, shim, MCP, stale listener leases); --fix repairs it',
|
|
3601
4522
|
usage: '[--fix] [--base <url>]',
|
|
3602
|
-
flags: [{ f: '--fix', d: 'repair everything repairable: refresh skills, rewrite the shim, re-register the MCP, refresh the Claude UI wiring' }, BASE_FLAG],
|
|
4523
|
+
flags: [{ f: '--fix', d: 'repair everything repairable: refresh skills, rewrite the shim, re-register the MCP, refresh the Claude UI wiring, release stale listener leases' }, BASE_FLAG],
|
|
3603
4524
|
examples: ['vitrinka doctor', 'vitrinka doctor --fix'],
|
|
3604
4525
|
},
|
|
3605
4526
|
{
|
|
@@ -3610,14 +4531,16 @@ export const COMMANDS = [
|
|
|
3610
4531
|
examples: ['vitrinka tidy', 'vitrinka tidy --yes', 'vitrinka tidy --yes --all'],
|
|
3611
4532
|
},
|
|
3612
4533
|
{
|
|
3613
|
-
name: 'install', summary: 'Onboard this machine: status table, then skills + shim + completion + MCP + token + operator + Claude UI',
|
|
3614
|
-
usage: '[--local] [--operator <name>] [--mcp|--no-mcp] [--claude|--no-claude] [--no-completion]',
|
|
4534
|
+
name: 'install', summary: 'Onboard this machine: status table, then skills + shim + completion + MCP + token + operator + Claude UI + recorder extension',
|
|
4535
|
+
usage: '[--local] [--operator <name>] [--mcp|--no-mcp] [--claude|--no-claude] [--extension|--no-extension] [--no-completion]',
|
|
3615
4536
|
flags: [{ f: '--local', d: 'project scope: skills → ./.claude, MCP --scope project; skips shim/completion' },
|
|
3616
4537
|
{ f: '--operator', arg: '<name>', d: 'set the board-attribution persona without prompting' },
|
|
3617
4538
|
{ f: '--mcp', d: 'register the MCP without asking' },
|
|
3618
4539
|
{ f: '--no-mcp', d: 'skip the MCP registration' },
|
|
3619
4540
|
{ f: '--claude', d: 'wire the Claude Code UI without asking' },
|
|
3620
4541
|
{ f: '--no-claude', d: 'skip the Claude Code UI wiring' },
|
|
4542
|
+
{ f: '--extension', d: 'install the journey-recorder browser extension without asking (even with no browser detected)' },
|
|
4543
|
+
{ f: '--no-extension', d: 'skip the journey-recorder browser extension' },
|
|
3621
4544
|
{ f: '--no-completion', d: 'skip appending the shell-completion line' },
|
|
3622
4545
|
{ f: '--yes', d: 'non-interactive: skip every prompt (config-touchers print their manual command)' }],
|
|
3623
4546
|
examples: ['vitrinka install', 'vitrinka install --local', 'vitrinka install --operator "Lukáš" --mcp --claude'],
|
|
@@ -3645,10 +4568,30 @@ export const COMMANDS = [
|
|
|
3645
4568
|
examples: ['vitrinka install-claude'],
|
|
3646
4569
|
},
|
|
3647
4570
|
{
|
|
3648
|
-
name: 'update', summary: 'Update the CLI (git pull /
|
|
3649
|
-
usage: '[--
|
|
3650
|
-
|
|
3651
|
-
|
|
4571
|
+
name: 'update', summary: 'Update the CLI (git pull / global install), show what\'s new, refresh the installed plugins + Claude wiring + recorder extension',
|
|
4572
|
+
usage: '[--verbose]',
|
|
4573
|
+
// --local is GONE: it refreshed ~/.claude/skills, and skills have shipped
|
|
4574
|
+
// exclusively as the marketplace plugin since 2026-07-11. It had been a
|
|
4575
|
+
// no-op flag in the help ever since.
|
|
4576
|
+
flags: [{ f: '--verbose', d: 'stream each subprocess\'s full output instead of the live step spinners' }],
|
|
4577
|
+
examples: ['vitrinka update', 'vitrinka update --verbose'],
|
|
4578
|
+
},
|
|
4579
|
+
{
|
|
4580
|
+
name: 'extension', summary: 'Install, update and diagnose the journey-recorder browser extension (in-place updates from the popup)',
|
|
4581
|
+
usage: '<setup|update|doctor> [--check] [--fix] [--force] [--verbose]',
|
|
4582
|
+
positional: '<setup|update|doctor>',
|
|
4583
|
+
flags: [
|
|
4584
|
+
{ f: '--check', d: 'update: report whether a release is newer, install nothing' },
|
|
4585
|
+
{ f: '--fix', d: 'doctor: re-register the native-messaging host + rewrite the shim' },
|
|
4586
|
+
{ f: '--force', d: 'setup/update: re-download even when the installed version is current' },
|
|
4587
|
+
{ f: '--verbose', d: 'stream each subprocess\'s full output instead of the live step spinners' },
|
|
4588
|
+
],
|
|
4589
|
+
examples: [
|
|
4590
|
+
'vitrinka extension setup',
|
|
4591
|
+
'vitrinka extension update',
|
|
4592
|
+
'vitrinka extension update --check',
|
|
4593
|
+
'vitrinka extension doctor --fix',
|
|
4594
|
+
],
|
|
3652
4595
|
},
|
|
3653
4596
|
{
|
|
3654
4597
|
name: 'version', summary: 'Print the installed version, the install kind, and the last known update state (`--version`/`-V` print the bare version)',
|
|
@@ -3704,6 +4647,23 @@ export const COMMANDS = [
|
|
|
3704
4647
|
examples: ['vitrinka upload report.pdf --board pr-105-vitrinka --section "Evidence"',
|
|
3705
4648
|
'vitrinka upload spec.docx shots/*.png --board payout-flow'],
|
|
3706
4649
|
},
|
|
4650
|
+
{
|
|
4651
|
+
name: 'tag', summary: 'Read and manage the tag vocabulary — the third grouping axis beside project and subgroup',
|
|
4652
|
+
usage: 'ls [--entity board|card|session] | add <board> <tags…> | rm <board> <tags…> | set <board> <tags…> | rename <old> <new> | merge <from> <into> | edit <tag> [--color c] [--icon i] [--desc "…"] | drop <tag>',
|
|
4653
|
+
positional: '<subcommand>',
|
|
4654
|
+
flags: [{ f: '--entity', arg: '<board|card|session>', d: 'ls: only tags carried by that entity type (default: the whole vocabulary)' },
|
|
4655
|
+
{ f: '--color', arg: '<name>', d: 'edit: accent colour token' },
|
|
4656
|
+
{ f: '--icon', arg: '<name>', d: 'edit: Lucide icon name' },
|
|
4657
|
+
{ f: '--desc', arg: '<text>', d: 'edit: what this tag MEANS — returned by list_tags, so agents read it' },
|
|
4658
|
+
{ f: '--label', arg: '<text>', d: 'edit: display label (the slug stays the identity)' },
|
|
4659
|
+
{ f: '--json', arg: '', d: 'raw JSON instead of the table' },
|
|
4660
|
+
{ f: '--yes', arg: '', d: 'confirm merge / drop up front — REQUIRED for them outside a TTY (scripts, CI)' },
|
|
4661
|
+
{ f: '--actor', arg: '<name>', d: 'override the operator attribution' }, BASE_FLAG],
|
|
4662
|
+
examples: ['vitrinka tag ls --entity board',
|
|
4663
|
+
'vitrinka tag add pr-105-vitrinka auth "Q3 Launch"',
|
|
4664
|
+
'vitrinka tag merge onbaording onboarding',
|
|
4665
|
+
'vitrinka tag edit auth --color accent --desc "boards covering the auth/login journey"'],
|
|
4666
|
+
},
|
|
3707
4667
|
{
|
|
3708
4668
|
name: 'import', summary: 'Parse a source file (OpenAPI / SQL DDL / docker-compose) into a refreshable diagram card',
|
|
3709
4669
|
usage: '<file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"] [--section "Name"] [--rev <sha>] [--actor <name>] [--base <url>]',
|
|
@@ -4457,6 +5417,184 @@ async function postImport(board, body, args) {
|
|
|
4457
5417
|
}
|
|
4458
5418
|
console.log(text);
|
|
4459
5419
|
}
|
|
5420
|
+
// ---------- tags (tags+spotlight decisions #12) ----------
|
|
5421
|
+
// tagApi is the one HTTP seam every `vitrinka tag` subcommand goes through.
|
|
5422
|
+
// Returns the parsed body so callers can print a table instead of raw JSON.
|
|
5423
|
+
async function tagApi(method, path, args, body) {
|
|
5424
|
+
const base = resolveBaseUrl(strArg(args.base));
|
|
5425
|
+
const headers = {};
|
|
5426
|
+
if (body !== undefined)
|
|
5427
|
+
headers['Content-Type'] = 'application/json';
|
|
5428
|
+
const token = readToken();
|
|
5429
|
+
if (token)
|
|
5430
|
+
headers.Authorization = `Bearer ${token}`;
|
|
5431
|
+
const actor = actorFor(args);
|
|
5432
|
+
if (actor)
|
|
5433
|
+
headers['X-Board-Actor'] = actor;
|
|
5434
|
+
const res = await fetch(`${base}${path}`, {
|
|
5435
|
+
method, headers,
|
|
5436
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
5437
|
+
signal: AbortSignal.timeout(30_000),
|
|
5438
|
+
});
|
|
5439
|
+
const text = await res.text();
|
|
5440
|
+
if (!res.ok) {
|
|
5441
|
+
console.error(`tag: ${res.status} ${text}`);
|
|
5442
|
+
process.exit(1);
|
|
5443
|
+
}
|
|
5444
|
+
if (!text)
|
|
5445
|
+
return null;
|
|
5446
|
+
try {
|
|
5447
|
+
return JSON.parse(text);
|
|
5448
|
+
}
|
|
5449
|
+
catch {
|
|
5450
|
+
// A non-JSON 2xx means we are talking to something that is not the API
|
|
5451
|
+
// (a proxy error page, a login wall) — say so rather than crashing on undefined.
|
|
5452
|
+
console.error(`tag: unexpected non-JSON response: ${text.slice(0, 200)}`);
|
|
5453
|
+
process.exit(1);
|
|
5454
|
+
}
|
|
5455
|
+
}
|
|
5456
|
+
// printTagTable renders the vocabulary as aligned columns: slug, per-entity
|
|
5457
|
+
// counts, and the description that tells a reader what the tag MEANS.
|
|
5458
|
+
function printTagTable(tags) {
|
|
5459
|
+
if (!tags.length) {
|
|
5460
|
+
console.log('no tags yet — they autocreate the first time you attach one');
|
|
5461
|
+
return;
|
|
5462
|
+
}
|
|
5463
|
+
const rows = tags.map((t) => {
|
|
5464
|
+
const c = t.counts || {};
|
|
5465
|
+
const counts = ['board', 'card', 'session']
|
|
5466
|
+
.map((k) => (c[k] ? `${c[k]}${k[0]}` : ''))
|
|
5467
|
+
.filter(Boolean).join(' ') || '—';
|
|
5468
|
+
return { slug: t.slug, display: t.display || t.slug, counts, desc: t.description || '' };
|
|
5469
|
+
});
|
|
5470
|
+
const w = (k) => Math.max(...rows.map((r) => r[k].length));
|
|
5471
|
+
const ws = w('slug'), wc = w('counts');
|
|
5472
|
+
for (const r of rows) {
|
|
5473
|
+
const label = r.display !== r.slug ? dim(` ${r.display}`) : '';
|
|
5474
|
+
console.log(`${r.slug.padEnd(ws)} ${dim(r.counts.padEnd(wc))}${label}${r.desc ? ` ${dim(r.desc)}` : ''}`);
|
|
5475
|
+
}
|
|
5476
|
+
console.log(dim(`\n${rows.length} tag${rows.length === 1 ? '' : 's'} · b=boards c=cards s=sessions`));
|
|
5477
|
+
}
|
|
5478
|
+
// reportCreated surfaces the autocreate receipt (decisions #5) — the operator
|
|
5479
|
+
// should always see when a command minted a new tag, so a typo is caught at the
|
|
5480
|
+
// moment it happens rather than weeks later on the /tags page.
|
|
5481
|
+
function reportCreated(created) {
|
|
5482
|
+
const list = Array.isArray(created) ? created.filter((s) => typeof s === 'string') : [];
|
|
5483
|
+
for (const slug of list)
|
|
5484
|
+
console.log(`✦ created new tag: ${slug}`);
|
|
5485
|
+
}
|
|
5486
|
+
async function tagCmd(rest, args) {
|
|
5487
|
+
// positionals() applies the same greedy rule parseArgs used, so an option
|
|
5488
|
+
// VALUE can never be mistaken for a tag name (tags autocreate — a stray
|
|
5489
|
+
// `--base` URL was being minted as a tag) and a flag placed before the verb
|
|
5490
|
+
// cannot silently select the `ls` default. The hand-rolled
|
|
5491
|
+
// `filter(a => !a.startsWith('--'))` this replaces did both.
|
|
5492
|
+
const argv = positionals(rest);
|
|
5493
|
+
const sub = argv[0] || '';
|
|
5494
|
+
const pos = argv.slice(1);
|
|
5495
|
+
const json = args.json === true;
|
|
5496
|
+
const need = (n, usage) => {
|
|
5497
|
+
if (pos.length < n) {
|
|
5498
|
+
console.error(`usage: vitrinka tag ${usage}`);
|
|
5499
|
+
process.exit(2);
|
|
5500
|
+
}
|
|
5501
|
+
return pos;
|
|
5502
|
+
};
|
|
5503
|
+
// Destructive verbs confirm unless --yes; merge and drop both change what
|
|
5504
|
+
// other boards are tagged with, so they are never silent.
|
|
5505
|
+
//
|
|
5506
|
+
// Fails CLOSED without a TTY: treating "cannot ask" as "yes" meant `drop`
|
|
5507
|
+
// (which detaches the tag from every board, card and session) ran unconfirmed
|
|
5508
|
+
// in CI, scripts, and any redirected shell — the opposite of what --yes
|
|
5509
|
+
// documents, and of what uninstallCmd does in the same file.
|
|
5510
|
+
const confirm = async (what) => {
|
|
5511
|
+
if (args.yes === true)
|
|
5512
|
+
return;
|
|
5513
|
+
if (!process.stdin.isTTY) {
|
|
5514
|
+
console.error(`tag: refusing to ${what} without a TTY to confirm on — re-run with --yes`);
|
|
5515
|
+
process.exit(2);
|
|
5516
|
+
}
|
|
5517
|
+
const ok = await confirmYN(`${what} — continue?`);
|
|
5518
|
+
if (!ok) {
|
|
5519
|
+
console.log('aborted');
|
|
5520
|
+
process.exit(0);
|
|
5521
|
+
}
|
|
5522
|
+
};
|
|
5523
|
+
switch (sub) {
|
|
5524
|
+
case 'ls':
|
|
5525
|
+
case '': {
|
|
5526
|
+
const entity = strArg(args.entity);
|
|
5527
|
+
const q = entity ? `?entity=${encodeURIComponent(entity)}` : '';
|
|
5528
|
+
const data = await tagApi('GET', `/api/v1/tags${q}`, args);
|
|
5529
|
+
if (json) {
|
|
5530
|
+
console.log(JSON.stringify(data, null, 2));
|
|
5531
|
+
return;
|
|
5532
|
+
}
|
|
5533
|
+
printTagTable(data?.tags || []);
|
|
5534
|
+
return;
|
|
5535
|
+
}
|
|
5536
|
+
case 'add':
|
|
5537
|
+
case 'rm':
|
|
5538
|
+
case 'set': {
|
|
5539
|
+
const [board, ...names] = need(2, `${sub} <board> <tags…>`);
|
|
5540
|
+
if (sub === 'rm') {
|
|
5541
|
+
const data = await tagApi('DELETE', `/api/v1/boards/${encodeURIComponent(board)}/tags?tags=${encodeURIComponent(names.join(','))}`, args);
|
|
5542
|
+
console.log(json ? JSON.stringify(data, null, 2)
|
|
5543
|
+
: `${board}: ${(data?.tags || []).map((t) => t.slug).join(' ') || '(no tags)'}`);
|
|
5544
|
+
return;
|
|
5545
|
+
}
|
|
5546
|
+
const data = await tagApi('POST', `/api/v1/boards/${encodeURIComponent(board)}/tags`, args, { tags: names, ...(sub === 'set' ? { mode: 'set' } : {}) });
|
|
5547
|
+
if (json) {
|
|
5548
|
+
console.log(JSON.stringify(data, null, 2));
|
|
5549
|
+
return;
|
|
5550
|
+
}
|
|
5551
|
+
reportCreated(data?.created);
|
|
5552
|
+
console.log(`${board}: ${(data?.tags || []).map((t) => t.slug).join(' ') || '(no tags)'}`);
|
|
5553
|
+
return;
|
|
5554
|
+
}
|
|
5555
|
+
case 'rename': {
|
|
5556
|
+
const [from, to] = need(2, 'rename <old> <new>');
|
|
5557
|
+
const data = await tagApi('PATCH', `/api/v1/tags/${encodeURIComponent(from)}`, args, { name: to });
|
|
5558
|
+
console.log(json ? JSON.stringify(data, null, 2) : `${from} → ${data?.tag?.slug}`);
|
|
5559
|
+
return;
|
|
5560
|
+
}
|
|
5561
|
+
case 'merge': {
|
|
5562
|
+
const [from, into] = need(2, 'merge <from> <into>');
|
|
5563
|
+
await confirm(`merge "${from}" into "${into}" (every board tagged ${from} moves, ${from} is deleted)`);
|
|
5564
|
+
const data = await tagApi('POST', `/api/v1/tags/${encodeURIComponent(from)}/merge`, args, { into });
|
|
5565
|
+
console.log(json ? JSON.stringify(data, null, 2)
|
|
5566
|
+
: `merged ${from} → ${data?.tag?.slug} (${data?.moved ?? 0} attachment${data?.moved === 1 ? '' : 's'} moved)`);
|
|
5567
|
+
return;
|
|
5568
|
+
}
|
|
5569
|
+
case 'edit': {
|
|
5570
|
+
const [slug] = need(1, 'edit <tag> [--label "…"] [--color c] [--icon i] [--desc "…"]');
|
|
5571
|
+
const patch = {};
|
|
5572
|
+
for (const [flag, key] of [['label', 'label'], ['color', 'color'], ['icon', 'icon'], ['desc', 'description']]) {
|
|
5573
|
+
const v = args[flag];
|
|
5574
|
+
if (v !== undefined)
|
|
5575
|
+
patch[key] = typeof v === 'string' ? v : String(v);
|
|
5576
|
+
}
|
|
5577
|
+
if (!Object.keys(patch).length) {
|
|
5578
|
+
console.error('usage: vitrinka tag edit <tag> [--label "…"] [--color c] [--icon i] [--desc "…"]');
|
|
5579
|
+
process.exit(2);
|
|
5580
|
+
}
|
|
5581
|
+
const data = await tagApi('PATCH', `/api/v1/tags/${encodeURIComponent(slug)}`, args, patch);
|
|
5582
|
+
console.log(json ? JSON.stringify(data, null, 2) : `${data?.tag?.slug} updated`);
|
|
5583
|
+
return;
|
|
5584
|
+
}
|
|
5585
|
+
case 'drop': {
|
|
5586
|
+
const [slug] = need(1, 'drop <tag>');
|
|
5587
|
+
await confirm(`delete "${slug}" (it is detached from every board, card and session carrying it)`);
|
|
5588
|
+
await tagApi('DELETE', `/api/v1/tags/${encodeURIComponent(slug)}`, args);
|
|
5589
|
+
console.log(`dropped ${slug}`);
|
|
5590
|
+
return;
|
|
5591
|
+
}
|
|
5592
|
+
default:
|
|
5593
|
+
console.error(`tag: unknown subcommand ${JSON.stringify(sub)}\n`
|
|
5594
|
+
+ 'usage: vitrinka tag ls | add | rm | set | rename | merge | edit | drop');
|
|
5595
|
+
process.exit(2);
|
|
5596
|
+
}
|
|
5597
|
+
}
|
|
4460
5598
|
async function importCmd(rest, args) {
|
|
4461
5599
|
const file = rest.find((a) => !a.startsWith('--'));
|
|
4462
5600
|
const board = strArg(args.board);
|
|
@@ -4665,6 +5803,28 @@ export function computeChoiceAnnouncements(prev, choices) {
|
|
|
4665
5803
|
export function shouldKeepalive(nowMs, lastWakeMs, keepaliveSec, viewerPresent) {
|
|
4666
5804
|
return keepaliveSec > 0 && viewerPresent && nowMs - lastWakeMs >= keepaliveSec * 1000;
|
|
4667
5805
|
}
|
|
5806
|
+
// isOrphaned is the parent-death guard (PURE — unit-tested). The watch leases
|
|
5807
|
+
// its scope through the long-poll, so a watch that outlives its agent session
|
|
5808
|
+
// keeps the lease ALIVE forever: the TTL only reaps leases that stop
|
|
5809
|
+
// heartbeating, and an orphan never stops. The scope then belongs to a process
|
|
5810
|
+
// whose stdout nobody reads and dispatched work vanishes into it.
|
|
5811
|
+
//
|
|
5812
|
+
// A signal can't be relied on to prevent that. Claude Code SIGTERMs the shell
|
|
5813
|
+
// it spawned, so `exec vitrinka watch` (what the listen skill arms) does get
|
|
5814
|
+
// the signal — but a SIGKILLed / crashed / terminal-closed agent never sends
|
|
5815
|
+
// one, and a non-exec wrapper eats it. The universal tell is REPARENTING: any
|
|
5816
|
+
// change to our ppid means the process that started us is gone (adopted by
|
|
5817
|
+
// init, or by a subreaper). We were born attached; we are now not.
|
|
5818
|
+
//
|
|
5819
|
+
// A watch deliberately started detached (nohup, launchd, systemd, a container
|
|
5820
|
+
// entrypoint) is born with a stable ppid and never sees it change — so it is
|
|
5821
|
+
// never touched by this guard.
|
|
5822
|
+
export function isOrphaned(bornPpid, currentPpid) {
|
|
5823
|
+
return bornPpid > 1 && currentPpid !== bornPpid;
|
|
5824
|
+
}
|
|
5825
|
+
// How often the persistent watch checks for reparenting. `process.ppid` is a
|
|
5826
|
+
// live syscall (uv_os_getppid), not a cached boot value.
|
|
5827
|
+
const WATCH_ORPHAN_POLL_MS = 5_000;
|
|
4668
5828
|
const watchEmit = (line) => { process.stdout.write(line + '\n'); };
|
|
4669
5829
|
const watchSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4670
5830
|
function watchBaseUrl(args) {
|
|
@@ -4807,8 +5967,22 @@ async function watchCmd(args) {
|
|
|
4807
5967
|
}
|
|
4808
5968
|
process.exit(code);
|
|
4809
5969
|
};
|
|
5970
|
+
// SIGHUP too: closing the terminal that hosts the agent hangs us up, and a
|
|
5971
|
+
// hangup must release the lease exactly like an interrupt.
|
|
4810
5972
|
process.on('SIGINT', () => { void shutdown(0); });
|
|
4811
5973
|
process.on('SIGTERM', () => { void shutdown(0); });
|
|
5974
|
+
process.on('SIGHUP', () => { void shutdown(0); });
|
|
5975
|
+
// Parent-death guard — the backstop for every exit that delivers no signal
|
|
5976
|
+
// (agent SIGKILLed, crashed, or its wrapper shell absorbed the SIGTERM).
|
|
5977
|
+
// unref'd so it never keeps the process alive on its own.
|
|
5978
|
+
const bornPpid = process.ppid;
|
|
5979
|
+
setInterval(() => {
|
|
5980
|
+
if (!isOrphaned(bornPpid, process.ppid))
|
|
5981
|
+
return;
|
|
5982
|
+
// stderr: the Monitor records it without waking a session that is already gone.
|
|
5983
|
+
process.stderr.write(`vitrinka watch: parent ${bornPpid} exited — releasing ${scope.label} and stopping\n`);
|
|
5984
|
+
void shutdown(0);
|
|
5985
|
+
}, WATCH_ORPHAN_POLL_MS).unref();
|
|
4812
5986
|
let announced = {};
|
|
4813
5987
|
let announcedChoices = {};
|
|
4814
5988
|
let downSince = null;
|
|
@@ -4970,6 +6144,8 @@ async function runSub(argv) {
|
|
|
4970
6144
|
uninstallCmd(args);
|
|
4971
6145
|
else if (cmd === 'update')
|
|
4972
6146
|
await updateCmd(args);
|
|
6147
|
+
else if (cmd === 'extension')
|
|
6148
|
+
await extensionCmd(rest, args);
|
|
4973
6149
|
else if (cmd === 'version')
|
|
4974
6150
|
versionCmd(false);
|
|
4975
6151
|
else if (cmd === 'open')
|
|
@@ -4984,6 +6160,8 @@ async function runSub(argv) {
|
|
|
4984
6160
|
await uploadCmd(rest, args);
|
|
4985
6161
|
else if (cmd === 'import')
|
|
4986
6162
|
await importCmd(rest, args);
|
|
6163
|
+
else if (cmd === 'tag')
|
|
6164
|
+
await tagCmd(rest, args);
|
|
4987
6165
|
else if (cmd === 'schema')
|
|
4988
6166
|
await schemaCmd(rest, args);
|
|
4989
6167
|
else if (cmd === 'board-from-set')
|