flowviant 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +31 -4
- package/bin/lib/config.mjs +6 -1
- package/bin/lib/fleet.mjs +17 -1
- package/bin/lib/install.mjs +110 -0
- package/bin/lib/live.mjs +15 -5
- package/bin/lib/preflight.mjs +39 -11
- package/bin/lib/preview.mjs +136 -28
- package/bin/lib/update.mjs +137 -0
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Three modes, picked by which env var is set:
|
|
6
6
|
*
|
|
7
|
-
* FLOWVIANT_TOKEN=fva_… npx flowviant
|
|
8
|
-
* FLOWVIANT_TOKENS=a,b,c npx flowviant
|
|
9
|
-
* FLOWVIANT_FLEET=fft_… npx flowviant
|
|
7
|
+
* FLOWVIANT_TOKEN=fva_… npx flowviant@latest # 1 worker, current checkout
|
|
8
|
+
* FLOWVIANT_TOKENS=a,b,c npx flowviant@latest # static fleet, 1 worktree each
|
|
9
|
+
* FLOWVIANT_FLEET=fft_… npx flowviant@latest # FLEET DAEMON (recommended)
|
|
10
|
+
*
|
|
11
|
+
* Launch with `@latest` so each start pulls the newest published version (bare
|
|
12
|
+
* `npx flowviant` can reuse a stale cache). A running daemon also self-updates
|
|
13
|
+
* on its own — at startup and when idle — so it stays current without restarts
|
|
14
|
+
* (FLOWVIANT_NO_UPDATE=1 makes it nag-only; `flowviant update` updates now).
|
|
10
15
|
*
|
|
11
16
|
* Fleet daemon: install ONCE with a fleet credential, then manage everything
|
|
12
17
|
* from Flowviant. The daemon polls GET /api/v2/fleet/agents, reconciles one
|
|
@@ -44,6 +49,28 @@ if (process.argv[2] === 'login') {
|
|
|
44
49
|
process.exit(0);
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
// `flowviant update` — install the latest published version now. The daemon also
|
|
53
|
+
// self-updates on its own (at startup + when idle); this is the manual path.
|
|
54
|
+
if (process.argv[2] === 'update') {
|
|
55
|
+
const { runUpdateCommand } = await import('./lib/update.mjs');
|
|
56
|
+
runUpdateCommand();
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// `flowviant gh-auth` — sign in the gh CLI (incl. a copy we bundled into
|
|
61
|
+
// ~/.flowviant/bin), so the isolated install doesn't need gh on your global PATH.
|
|
62
|
+
if (process.argv[2] === 'gh-auth') {
|
|
63
|
+
const { addLocalBinToPath } = await import('./lib/install.mjs');
|
|
64
|
+
const { execFileSync } = await import('node:child_process');
|
|
65
|
+
addLocalBinToPath();
|
|
66
|
+
try {
|
|
67
|
+
execFileSync('gh', ['auth', 'login'], { stdio: 'inherit' });
|
|
68
|
+
} catch {
|
|
69
|
+
console.error('gh not found — run `flowviant` once to install it, or see https://cli.github.com');
|
|
70
|
+
}
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
// `flowviant clean` — reclaim the persistent worktrees (~/.flowviant/worktrees).
|
|
48
75
|
// They're kept across runs so in-flight work survives Ctrl+C; this is the drain.
|
|
49
76
|
// Repos self-heal: the daemon runs `git worktree prune` if a stale registration
|
|
@@ -93,7 +120,7 @@ async function main() {
|
|
|
93
120
|
? '» safe mode: restricted toolset (unset FLOWVIANT_SAFE for full autonomy).'
|
|
94
121
|
: '» unattended mode: permission prompts skipped so the agent runs hands-off.'
|
|
95
122
|
);
|
|
96
|
-
preflight({ needGit: tokens.length > 1 });
|
|
123
|
+
await preflight({ needGit: tokens.length > 1 });
|
|
97
124
|
if (tokens.length === 1) {
|
|
98
125
|
console.log(`» flowviant → ${MCP_URL} (1 worker · token fva_…${tokens[0].slice(-4)})`);
|
|
99
126
|
await runWorker({ token: tokens[0], cwd: process.cwd(), label: '' });
|
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
|
|
7
|
-
export const VERSION = '0.
|
|
7
|
+
export const VERSION = '0.12.0';
|
|
8
8
|
|
|
9
9
|
// Credential stored by `flowviant login` (device auth) — the no-token,
|
|
10
10
|
// no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
|
|
@@ -35,6 +35,11 @@ export const RECONCILE_SECONDS = Number(process.env.RECONCILE_SECONDS || 10);
|
|
|
35
35
|
// so a long-lived daemon never silently 401s on an expired token.
|
|
36
36
|
export const REFRESH_BEFORE_SECONDS = Number(process.env.REFRESH_BEFORE_SECONDS || 3600);
|
|
37
37
|
export const SAFE = process.env.FLOWVIANT_SAFE === '1';
|
|
38
|
+
// Self-update: a running daemon updates itself to the latest published version
|
|
39
|
+
// (at startup + when idle) and re-execs. On by default; FLOWVIANT_NO_UPDATE=1
|
|
40
|
+
// keeps it nag-only (it still tells you to update, never installs). Below the
|
|
41
|
+
// server's MIN version it updates regardless, since live mode won't work.
|
|
42
|
+
export const AUTO_UPDATE = process.env.FLOWVIANT_NO_UPDATE !== '1';
|
|
38
43
|
// Live mode (DEFAULT since 0.8.0): persistent Agent-SDK session per task —
|
|
39
44
|
// streams into the task channel, injectable mid-task, blocker-parks in place,
|
|
40
45
|
// delivery card on complete, branch preview tunnels. The legacy poll/sentinel
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
RECONCILE_SECONDS,
|
|
23
23
|
REFRESH_BEFORE_SECONDS,
|
|
24
24
|
LIVE,
|
|
25
|
+
AUTO_UPDATE,
|
|
25
26
|
} from './config.mjs';
|
|
27
|
+
import { handleVersionSignal } from './update.mjs';
|
|
26
28
|
import {
|
|
27
29
|
git,
|
|
28
30
|
resetWorktree,
|
|
@@ -173,7 +175,7 @@ export async function runFleetDaemon() {
|
|
|
173
175
|
info(`base · ${baseRef}`);
|
|
174
176
|
info(`server · ${FLEET_URL}`);
|
|
175
177
|
console.log('');
|
|
176
|
-
preflight({ needGit: true });
|
|
178
|
+
await preflight({ needGit: true });
|
|
177
179
|
|
|
178
180
|
// Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
|
|
179
181
|
// so Ctrl+C mid-task never loses local work. Keyed per repo path; each
|
|
@@ -411,6 +413,20 @@ export async function runFleetDaemon() {
|
|
|
411
413
|
}
|
|
412
414
|
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
|
413
415
|
if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
|
|
416
|
+
// Keep the daemon current. Safe = no worker mid-task (true at startup, since
|
|
417
|
+
// no workers are spawned yet). If it self-updates it re-execs into the new
|
|
418
|
+
// version and this process becomes a proxy — stop the loop.
|
|
419
|
+
if (roster.daemon) {
|
|
420
|
+
const safeToUpdate = [...workers.values()].every((w) => w.state.child == null);
|
|
421
|
+
const updating = handleVersionSignal({
|
|
422
|
+
latest: roster.daemon.latest,
|
|
423
|
+
min: roster.daemon.min,
|
|
424
|
+
autoUpdate: AUTO_UPDATE,
|
|
425
|
+
safeToUpdate,
|
|
426
|
+
teardown,
|
|
427
|
+
});
|
|
428
|
+
if (updating) return;
|
|
429
|
+
}
|
|
414
430
|
processMergeJobs(roster.mergeJobs);
|
|
415
431
|
processCleanupJobs(roster.cleanupJobs);
|
|
416
432
|
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent-based prerequisite installers. cloudflared is fetched silently (no
|
|
3
|
+
* login, isolated), but claude + gh are auth-bearing CLIs you likely manage
|
|
4
|
+
* yourself — so we DETECT-FIRST and only install on your explicit yes, never
|
|
5
|
+
* silently and never clobbering an existing install. Interactive login stays
|
|
6
|
+
* yours (`claude` sign-in, `flowviant gh-auth`).
|
|
7
|
+
*
|
|
8
|
+
* gh is dropped into ~/.flowviant/bin (like cloudflared) rather than a system
|
|
9
|
+
* path, so there's nothing to conflict with; addLocalBinToPath() puts that dir
|
|
10
|
+
* on PATH so the daemon (and `flowviant gh-auth`) find it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { writeFileSync, existsSync, mkdirSync, chmodSync, rmSync, cpSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { homedir, platform, arch } from 'node:os';
|
|
17
|
+
|
|
18
|
+
export const LOCAL_BIN = join(homedir(), '.flowviant', 'bin');
|
|
19
|
+
|
|
20
|
+
/** Put ~/.flowviant/bin first on PATH so bundled binaries (gh, cloudflared)
|
|
21
|
+
* resolve by bare name for this process and everything it spawns. Idempotent. */
|
|
22
|
+
export function addLocalBinToPath() {
|
|
23
|
+
const sep = platform() === 'win32' ? ';' : ':';
|
|
24
|
+
const parts = (process.env.PATH || '').split(sep);
|
|
25
|
+
if (!parts.includes(LOCAL_BIN)) {
|
|
26
|
+
process.env.PATH = `${LOCAL_BIN}${sep}${process.env.PATH || ''}`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** TTY-guarded y/N. Non-interactive (no TTY) never auto-installs → returns false
|
|
31
|
+
* so a headless/cron run just prints the manual instructions instead. */
|
|
32
|
+
export async function promptYesNo(question, defaultYes) {
|
|
33
|
+
if (!process.stdin.isTTY) return false;
|
|
34
|
+
const { createInterface } = await import('node:readline');
|
|
35
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
36
|
+
const answer = await new Promise((res) =>
|
|
37
|
+
rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `, res),
|
|
38
|
+
);
|
|
39
|
+
rl.close();
|
|
40
|
+
const a = answer.trim().toLowerCase();
|
|
41
|
+
if (!a) return defaultYes;
|
|
42
|
+
return a === 'y' || a === 'yes';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Install Claude Code via its official npm package (npm is present — you got
|
|
46
|
+
* here through node). Lands on PATH; you still sign in by running `claude`. */
|
|
47
|
+
export function installClaude(log) {
|
|
48
|
+
try {
|
|
49
|
+
log?.('installing Claude Code (npm i -g @anthropic-ai/claude-code)…');
|
|
50
|
+
execFileSync('npm', ['install', '-g', '@anthropic-ai/claude-code'], { stdio: 'inherit' });
|
|
51
|
+
return true;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
log?.(`could not install Claude Code automatically (${e?.message ?? e}). Install: https://claude.com/claude-code`);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function latestGhVersion() {
|
|
59
|
+
const res = await fetch('https://api.github.com/repos/cli/cli/releases/latest', {
|
|
60
|
+
headers: { 'User-Agent': 'flowviant', Accept: 'application/vnd.github+json' },
|
|
61
|
+
redirect: 'follow',
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) throw new Error(`gh release lookup failed (http ${res.status})`);
|
|
64
|
+
const tag = (await res.json())?.tag_name;
|
|
65
|
+
if (!tag) throw new Error('no gh release tag');
|
|
66
|
+
return String(tag).replace(/^v/, '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Fetch the gh release archive into ~/.flowviant/bin and extract the binary —
|
|
70
|
+
* the cloudflared pattern: isolated, no sudo, nothing to conflict with. Returns
|
|
71
|
+
* the binary path or null (falls back to manual instructions). */
|
|
72
|
+
export async function installGh(log) {
|
|
73
|
+
const os = platform();
|
|
74
|
+
const a = arch() === 'arm64' ? 'arm64' : 'amd64';
|
|
75
|
+
const osName = os === 'darwin' ? 'macOS' : os === 'win32' ? 'windows' : 'linux';
|
|
76
|
+
const ext = os === 'linux' ? 'tar.gz' : 'zip';
|
|
77
|
+
const binName = os === 'win32' ? 'gh.exe' : 'gh';
|
|
78
|
+
const dest = join(LOCAL_BIN, binName);
|
|
79
|
+
let archive;
|
|
80
|
+
let innerDir;
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(LOCAL_BIN, { recursive: true });
|
|
83
|
+
const ver = await latestGhVersion();
|
|
84
|
+
const stem = `gh_${ver}_${osName}_${a}`;
|
|
85
|
+
const url = `https://github.com/cli/cli/releases/download/v${ver}/${stem}.${ext}`;
|
|
86
|
+
log?.(`fetching gh ${ver} (${osName}-${a})…`);
|
|
87
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
88
|
+
if (!res.ok) throw new Error(`http ${res.status}`);
|
|
89
|
+
archive = join(LOCAL_BIN, `${stem}.${ext}`);
|
|
90
|
+
writeFileSync(archive, Buffer.from(await res.arrayBuffer()));
|
|
91
|
+
// linux ships tar.gz (GNU tar); mac + windows ship zip (bsdtar reads zip).
|
|
92
|
+
execFileSync('tar', [os === 'linux' ? '-xzf' : '-xf', archive, '-C', LOCAL_BIN], {
|
|
93
|
+
stdio: 'ignore',
|
|
94
|
+
});
|
|
95
|
+
innerDir = join(LOCAL_BIN, stem);
|
|
96
|
+
const inner = join(innerDir, 'bin', binName);
|
|
97
|
+
if (!existsSync(inner)) throw new Error('gh binary not found in the archive');
|
|
98
|
+
cpSync(inner, dest);
|
|
99
|
+
if (os !== 'win32') chmodSync(dest, 0o755);
|
|
100
|
+
return dest;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
log?.(
|
|
103
|
+
`could not install gh automatically (${e?.message ?? e}). Install from https://cli.github.com, then run: gh auth login`,
|
|
104
|
+
);
|
|
105
|
+
return null;
|
|
106
|
+
} finally {
|
|
107
|
+
if (archive) rmSync(archive, { force: true });
|
|
108
|
+
if (innerDir) rmSync(innerDir, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
package/bin/lib/live.mjs
CHANGED
|
@@ -74,8 +74,11 @@ const SAFE_TOOLS = [
|
|
|
74
74
|
// rides in the seed message below, so this degrades gracefully if the preset
|
|
75
75
|
// shape shifts between SDK versions.
|
|
76
76
|
const SYSTEM_LIVE = `You are a Flowviant build agent working ONE task inside a live, shared task
|
|
77
|
-
channel. START by stating your approach
|
|
78
|
-
|
|
77
|
+
channel. START by stating your approach as a SHORT MARKDOWN LIST — one numbered
|
|
78
|
+
line per step, not a dense paragraph — BEFORE you touch any code; the whole team
|
|
79
|
+
watches this channel and may redirect you. Everything you post here renders as
|
|
80
|
+
Markdown for humans, so write for them: short lists, \`code\` for identifiers and
|
|
81
|
+
paths, **bold** for the key point — never a wall of run-on text.
|
|
79
82
|
A human teammate may message you mid-task; treat any injected "The human
|
|
80
83
|
answered…" or teammate line as a new instruction and adapt. There is NO terminal
|
|
81
84
|
and NO interactive prompt — your only channel to a human is the flowviant MCP
|
|
@@ -106,7 +109,7 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
|
106
109
|
? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
|
|
107
110
|
: []),
|
|
108
111
|
``,
|
|
109
|
-
`${transcript ? 'Continue' : 'Begin'}. Post a short plan first, then: report_progress as you go; report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
|
|
112
|
+
`${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
|
|
110
113
|
].join('\n');
|
|
111
114
|
}
|
|
112
115
|
|
|
@@ -616,14 +619,21 @@ export async function runLiveWorker({
|
|
|
616
619
|
const entry = kind ? cfg[kind] : null;
|
|
617
620
|
if (!entry || !intentId) {
|
|
618
621
|
// Say WHY there's no preview instead of skipping silently — this was a
|
|
619
|
-
// real "where's my preview?" support case.
|
|
622
|
+
// real "where's my preview?" support case. We search the root, common
|
|
623
|
+
// frontend dirs, and apps/* + packages/*, so if nothing matched either
|
|
624
|
+
// there's no runnable web app or it needs an explicit config.
|
|
620
625
|
info(
|
|
621
626
|
`${label} ${c.dim(
|
|
622
|
-
'no live preview: add .flowviant/preview.json
|
|
627
|
+
'no live preview: no runnable web frontend found (searched the repo root, web/frontend/client/…, and apps/* + packages/*). If your app is elsewhere or not vite/next/astro/etc., add .flowviant/preview.json: {"ui":{"cmd":"cd <dir> && npm install && npm run dev","port":5173}}.'
|
|
623
628
|
)}`
|
|
624
629
|
);
|
|
625
630
|
return;
|
|
626
631
|
}
|
|
632
|
+
// Zero-config win: when we found the app in a subdir, say where, so it's
|
|
633
|
+
// clear what's being served (and how to pin it if the guess is wrong).
|
|
634
|
+
if (cfg.dir && cfg.dir !== '.') {
|
|
635
|
+
info(`${label} ${c.dim(`live preview: detected a frontend at ${cfg.dir}/ (port ${entry.port})`)}`);
|
|
636
|
+
}
|
|
627
637
|
info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
|
|
628
638
|
preview = await startPreview({
|
|
629
639
|
worktree: cwd,
|
package/bin/lib/preflight.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { ok, warn, info, c } from './ui.mjs';
|
|
9
|
+
import { addLocalBinToPath, promptYesNo, installClaude, installGh } from './install.mjs';
|
|
9
10
|
|
|
10
11
|
function present(cmd) {
|
|
11
12
|
try {
|
|
@@ -25,21 +26,48 @@ function ghAuthed() {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
/** Prints a checklist
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
/** Prints a checklist and, when a missing prereq is auto-installable, OFFERS to
|
|
30
|
+
* install it (consent-based, never silent). Returns false only if a *fatal*
|
|
31
|
+
* prereq (claude, or git when worktrees are used) is still missing after. */
|
|
32
|
+
export async function preflight({ needGit = true } = {}) {
|
|
33
|
+
addLocalBinToPath(); // find a gh/cloudflared we bundled on a previous run
|
|
34
|
+
let claude = present('claude');
|
|
35
|
+
let gh = present('gh');
|
|
33
36
|
const node18 = Number(process.versions.node.split('.')[0]) >= 18;
|
|
34
37
|
const git = needGit ? present('git') : true;
|
|
35
38
|
|
|
36
39
|
info('checking your setup (this tool drives these — it never sees their logins):');
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
|
|
41
|
+
// claude — fatal. Offer the official npm install (no-default: it's bigger and
|
|
42
|
+
// account-coupled, so we don't push it).
|
|
43
|
+
if (claude) {
|
|
44
|
+
ok(`claude installed ${c.dim('· must be signed in — run `claude` once if you haven’t')}`);
|
|
45
|
+
} else {
|
|
46
|
+
warn('claude NOT found — Claude Code is required.');
|
|
47
|
+
if (await promptYesNo('Install Claude Code now?', false)) {
|
|
48
|
+
if (installClaude((m) => info(m))) claude = present('claude');
|
|
49
|
+
}
|
|
50
|
+
claude
|
|
51
|
+
? ok('claude installed — run `claude` once to sign in')
|
|
52
|
+
: warn('install Claude Code manually: https://claude.com/claude-code');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// gh — needed to open PRs. Offer to fetch the isolated binary (yes-default:
|
|
56
|
+
// low-risk, no login carried by the install itself).
|
|
57
|
+
if (gh && ghAuthed()) {
|
|
58
|
+
ok('gh authenticated');
|
|
59
|
+
} else if (gh) {
|
|
60
|
+
warn('gh not signed in — run: gh auth login');
|
|
61
|
+
} else {
|
|
62
|
+
warn('gh NOT found — needed to open PRs.');
|
|
63
|
+
if (await promptYesNo('Install GitHub CLI (gh) now?', true)) {
|
|
64
|
+
if (await installGh((m) => info(m))) gh = present('gh');
|
|
65
|
+
}
|
|
66
|
+
gh
|
|
67
|
+
? ok('gh installed to ~/.flowviant/bin — authenticate with: flowviant gh-auth')
|
|
68
|
+
: warn('install gh manually: https://cli.github.com, then run: gh auth login');
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
if (needGit) (git ? ok('git installed') : warn('git NOT found — install git'));
|
|
44
72
|
node18 ? ok(`node ${process.versions.node}`) : warn(`node ${process.versions.node} — need 18+`);
|
|
45
73
|
console.log('');
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { spawn, execFileSync } from 'node:child_process';
|
|
20
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, readdirSync, rmSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { homedir, platform, arch } from 'node:os';
|
|
23
23
|
|
|
@@ -45,17 +45,37 @@ const FRAMEWORK_PORTS = [
|
|
|
45
45
|
{ re: /\bsvelte/, port: 5173 },
|
|
46
46
|
{ re: /\bgatsby\b/, port: 8000 },
|
|
47
47
|
{ re: /\bexpo\b/, port: 8081 },
|
|
48
|
+
{ re: /@angular\/|\bng serve\b/, port: 4200 },
|
|
49
|
+
{ re: /vue-cli-service/, port: 8080 },
|
|
48
50
|
];
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// A repo whose ROOT is a library/monorepo often keeps its web app in a subdir,
|
|
53
|
+
// so the root package.json has no dev server at all. Search these (plus every
|
|
54
|
+
// child of apps/ and packages/) so a nested frontend previews with ZERO config.
|
|
55
|
+
const SUBDIR_CANDIDATES = [
|
|
56
|
+
'web', 'webapp', 'frontend', 'client', 'ui', 'site', 'www', 'app', 'dashboard',
|
|
57
|
+
];
|
|
58
|
+
const SUBDIR_PARENTS = ['apps', 'packages'];
|
|
59
|
+
|
|
60
|
+
function pkgManager(dir) {
|
|
61
|
+
if (existsSync(join(dir, 'bun.lock')) || existsSync(join(dir, 'bun.lockb'))) return 'bun';
|
|
62
|
+
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
63
|
+
if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
|
|
54
64
|
return 'npm';
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
// An explicit port baked into the dev script (PORT=3005 …, -p 3005, --port 3005,
|
|
68
|
+
// --port=3005) overrides the framework default — otherwise the tunnel would
|
|
69
|
+
// target the wrong port and never connect.
|
|
70
|
+
function portFromScript(s) {
|
|
71
|
+
const m = String(s).match(/(?:PORT=|(?:^|\s)-p[=\s]+|--port[=\s]+)(\d{2,5})\b/);
|
|
72
|
+
return m ? Number(m[1]) : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Infer {script, port} from one directory's package.json, or null if it has no
|
|
76
|
+
// dev/start script or no framework we can map to a port.
|
|
77
|
+
function inferFromDir(absDir) {
|
|
78
|
+
const pkgPath = join(absDir, 'package.json');
|
|
59
79
|
if (!existsSync(pkgPath)) return null;
|
|
60
80
|
let pkg;
|
|
61
81
|
try {
|
|
@@ -70,15 +90,58 @@ function inferPreviewConfig(repoRoot) {
|
|
|
70
90
|
const hay = `${scripts[script]} ${Object.keys(deps).join(' ')}`.toLowerCase();
|
|
71
91
|
const fw = FRAMEWORK_PORTS.find((f) => f.re.test(hay));
|
|
72
92
|
if (!fw) return null; // can't safely guess the port
|
|
73
|
-
|
|
93
|
+
return { script, port: portFromScript(scripts[script]) ?? fw.port };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The ordered dirs to probe: root, then the common frontend names, then every
|
|
97
|
+
// child of apps/ and packages/. Relative to repoRoot ('' = root).
|
|
98
|
+
function candidateDirs(repoRoot) {
|
|
99
|
+
const dirs = ['', ...SUBDIR_CANDIDATES];
|
|
100
|
+
for (const parent of SUBDIR_PARENTS) {
|
|
101
|
+
const p = join(repoRoot, parent);
|
|
102
|
+
try {
|
|
103
|
+
for (const e of readdirSync(p, { withFileTypes: true })) {
|
|
104
|
+
if (e.isDirectory()) dirs.push(`${parent}/${e.name}`);
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
/* no such parent dir */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return dirs;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildConfig(repoRoot, rel, hit) {
|
|
114
|
+
// Prefer the app dir's own package manager if it has a lockfile, else the repo
|
|
115
|
+
// root's (monorepos install from the root).
|
|
116
|
+
const abs = rel ? join(repoRoot, rel) : repoRoot;
|
|
117
|
+
const hasOwnLock = ['bun.lock', 'bun.lockb', 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json'].some(
|
|
118
|
+
(f) => existsSync(join(abs, f)),
|
|
119
|
+
);
|
|
120
|
+
const pm = pkgManager(hasOwnLock ? abs : repoRoot);
|
|
74
121
|
const install = pm === 'npm' ? 'npm install' : `${pm} install`;
|
|
75
|
-
const run = pm === 'yarn' ? `yarn ${script}` : `${pm} run ${script}`;
|
|
76
|
-
|
|
122
|
+
const run = pm === 'yarn' ? `yarn ${hit.script}` : `${pm} run ${hit.script}`;
|
|
123
|
+
const inner = `${install} && ${run}`;
|
|
124
|
+
// Subdir apps run from their own folder (shell:true honors the cd prefix).
|
|
125
|
+
const cmd = rel ? `cd ${rel} && ${inner}` : inner;
|
|
126
|
+
return { ui: { cmd, port: hit.port }, dir: rel || '.' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Infer a preview config by probing the root and likely frontend subdirs.
|
|
130
|
+
function inferPreviewConfig(repoRoot) {
|
|
131
|
+
for (const rel of candidateDirs(repoRoot)) {
|
|
132
|
+
const hit = inferFromDir(rel ? join(repoRoot, rel) : repoRoot);
|
|
133
|
+
if (hit) return buildConfig(repoRoot, rel, hit);
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
77
136
|
}
|
|
78
137
|
|
|
79
|
-
/** The preview config for a repo — explicit file wins, else inferred
|
|
138
|
+
/** The preview config for a repo — explicit file wins, else inferred from the
|
|
139
|
+
* root or a nested frontend. Carries `dir` (relative) so callers can say where
|
|
140
|
+
* it found the app. */
|
|
80
141
|
export function loadPreviewConfig(repoRoot) {
|
|
81
|
-
|
|
142
|
+
const explicit = readPreviewConfig(repoRoot);
|
|
143
|
+
if (explicit) return explicit;
|
|
144
|
+
return inferPreviewConfig(repoRoot);
|
|
82
145
|
}
|
|
83
146
|
|
|
84
147
|
// ── cloudflared: use if installed, else auto-fetch ─────────────────────────
|
|
@@ -101,27 +164,38 @@ async function ensureCloudflared(log) {
|
|
|
101
164
|
const dir = join(homedir(), '.flowviant', 'bin');
|
|
102
165
|
const bin = join(dir, os === 'win32' ? 'cloudflared.exe' : 'cloudflared');
|
|
103
166
|
if (existsSync(bin)) return bin;
|
|
104
|
-
// Raw single-file binaries exist for linux + windows; macOS ships a tarball,
|
|
105
|
-
// so point mac users at brew instead of unpacking here.
|
|
106
|
-
if (os === 'darwin') {
|
|
107
|
-
log?.('cloudflared not found — install it (`brew install cloudflared`) to enable live previews.');
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
const osName = os === 'win32' ? 'windows' : 'linux';
|
|
111
167
|
const a = arch() === 'arm64' ? 'arm64' : 'amd64';
|
|
112
|
-
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${osName}-${a}${
|
|
113
|
-
os === 'win32' ? '.exe' : ''
|
|
114
|
-
}`;
|
|
115
|
-
log?.(`fetching cloudflared (${osName}-${a}) to enable live previews…`);
|
|
116
168
|
try {
|
|
169
|
+
mkdirSync(dir, { recursive: true });
|
|
170
|
+
if (os === 'darwin') {
|
|
171
|
+
// macOS ships a .tgz (not a raw binary) — download it and extract the
|
|
172
|
+
// single `cloudflared` executable with the system tar (always on macOS).
|
|
173
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${a}.tgz`;
|
|
174
|
+
log?.(`fetching cloudflared (darwin-${a}) to enable live previews…`);
|
|
175
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
176
|
+
if (!res.ok) throw new Error(`http ${res.status}`);
|
|
177
|
+
const tgz = join(dir, 'cloudflared.tgz');
|
|
178
|
+
writeFileSync(tgz, Buffer.from(await res.arrayBuffer()));
|
|
179
|
+
execFileSync('tar', ['-xzf', tgz, '-C', dir], { stdio: 'ignore' });
|
|
180
|
+
rmSync(tgz, { force: true });
|
|
181
|
+
if (!existsSync(bin)) throw new Error('archive did not contain cloudflared');
|
|
182
|
+
chmodSync(bin, 0o755);
|
|
183
|
+
return bin;
|
|
184
|
+
}
|
|
185
|
+
// linux + windows ship a raw single-file binary.
|
|
186
|
+
const osName = os === 'win32' ? 'windows' : 'linux';
|
|
187
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${osName}-${a}${
|
|
188
|
+
os === 'win32' ? '.exe' : ''
|
|
189
|
+
}`;
|
|
190
|
+
log?.(`fetching cloudflared (${osName}-${a}) to enable live previews…`);
|
|
117
191
|
const res = await fetch(url, { redirect: 'follow' });
|
|
118
192
|
if (!res.ok) throw new Error(`http ${res.status}`);
|
|
119
|
-
mkdirSync(dir, { recursive: true });
|
|
120
193
|
writeFileSync(bin, Buffer.from(await res.arrayBuffer()));
|
|
121
194
|
if (os !== 'win32') chmodSync(bin, 0o755);
|
|
122
195
|
return bin;
|
|
123
196
|
} catch (e) {
|
|
124
|
-
|
|
197
|
+
const hint = os === 'darwin' ? ' (or `brew install cloudflared`)' : '';
|
|
198
|
+
log?.(`could not fetch cloudflared (${e.message}) — install it manually${hint} to enable live previews.`);
|
|
125
199
|
return null;
|
|
126
200
|
}
|
|
127
201
|
}
|
|
@@ -133,20 +207,38 @@ const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
|
133
207
|
* once the tunnel URL is captured, or null if it can't come up. stop() kills
|
|
134
208
|
* both the server and the tunnel.
|
|
135
209
|
*/
|
|
136
|
-
export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
210
|
+
export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs = 180_000 }) {
|
|
137
211
|
const cf = await ensureCloudflared(log);
|
|
138
212
|
if (!cf) return null; // fall back to captured evidence
|
|
139
213
|
return new Promise((resolve) => {
|
|
214
|
+
// We SIGKILL the dev server's whole group on teardown, which skips a tool's
|
|
215
|
+
// graceful cleanup — some dev servers (e.g. vinext) leave a singleton
|
|
216
|
+
// dev-lock behind and then REFUSE to start next time. Disable known locks so
|
|
217
|
+
// a reused/uncleaned worktree still previews. Harmless to tools that ignore
|
|
218
|
+
// these vars; BROWSER=none stops any auto-open.
|
|
219
|
+
const env = { ...process.env, VINEXT_NO_DEV_LOCK: '1', BROWSER: 'none' };
|
|
140
220
|
// detached so each gets its own process group — `bun run dev` via a shell
|
|
141
221
|
// spawns a grandchild dev server that would otherwise SURVIVE a kill of the
|
|
142
222
|
// shell, keep port bound, and get silently re-fronted by the NEXT task's
|
|
143
223
|
// tunnel (reviewer sees the wrong branch). We kill the whole group instead.
|
|
224
|
+
// stdout/stderr are piped (not ignored) so we can show WHY a preview didn't
|
|
225
|
+
// come up — a swallowed "another dev server is already running" was a real
|
|
226
|
+
// dead-end.
|
|
144
227
|
const server = spawn(cmd, {
|
|
145
228
|
cwd: worktree,
|
|
146
229
|
shell: true,
|
|
147
230
|
detached: true,
|
|
148
|
-
stdio: ['ignore', '
|
|
231
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
232
|
+
env,
|
|
149
233
|
});
|
|
234
|
+
// Ring buffer of the dev server's recent output, surfaced on failure.
|
|
235
|
+
let out = '';
|
|
236
|
+
const capture = (d) => {
|
|
237
|
+
out = (out + d.toString()).slice(-4000);
|
|
238
|
+
};
|
|
239
|
+
server.stdout.on('data', capture);
|
|
240
|
+
server.stderr.on('data', capture);
|
|
241
|
+
const tail = () => out.trim().split('\n').slice(-15).join('\n');
|
|
150
242
|
const tunnel = spawn(cf, ['tunnel', '--url', `http://localhost:${port}`], {
|
|
151
243
|
detached: true,
|
|
152
244
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -183,8 +275,24 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
|
183
275
|
tunnel.stderr.on('data', onData);
|
|
184
276
|
tunnel.on('error', () => finish(null));
|
|
185
277
|
tunnel.on('close', () => finish(null));
|
|
278
|
+
// The dev server exiting BEFORE the tunnel came up is the loud failure mode
|
|
279
|
+
// (crash on boot, or a singleton lock refusing to start) — surface its
|
|
280
|
+
// output instead of a silent timeout.
|
|
281
|
+
server.on('exit', (code) => {
|
|
282
|
+
if (settled) return;
|
|
283
|
+
log?.(
|
|
284
|
+
`preview dev server exited (code ${code}) before it was reachable — no preview.${
|
|
285
|
+
tail() ? `\n dev server said:\n${tail()}` : ''
|
|
286
|
+
}`,
|
|
287
|
+
);
|
|
288
|
+
finish(null);
|
|
289
|
+
});
|
|
186
290
|
const timer = setTimeout(() => {
|
|
187
|
-
log?.(
|
|
291
|
+
log?.(
|
|
292
|
+
`preview tunnel did not come up in ${Math.round(timeoutMs / 1000)}s — skipping.${
|
|
293
|
+
tail() ? `\n last dev-server output:\n${tail()}` : ''
|
|
294
|
+
}`,
|
|
295
|
+
);
|
|
188
296
|
finish(null);
|
|
189
297
|
}, timeoutMs);
|
|
190
298
|
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update — keep a long-running daemon current without babysitting it.
|
|
3
|
+
*
|
|
4
|
+
* A daemon runs for hours/days from one launch, so "latest at launch" (even with
|
|
5
|
+
* `npx flowviant@latest`) doesn't help a process that's already up when a new
|
|
6
|
+
* version ships. The server reports {latest, min} on every roster poll; the
|
|
7
|
+
* daemon compares its own VERSION and, at a SAFE boundary (startup or idle —
|
|
8
|
+
* never mid-task), self-updates + re-execs. Below `min` it updates regardless
|
|
9
|
+
* (older protocol is known-broken); otherwise it honors AUTO_UPDATE. When it
|
|
10
|
+
* can't install (npx cache, or auto off) it nags with the exact command.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { VERSION } from './config.mjs';
|
|
16
|
+
import { note, ok, warn } from './ui.mjs';
|
|
17
|
+
|
|
18
|
+
/** Compare x.y.z version strings → -1 | 0 | 1 (missing parts read as 0). */
|
|
19
|
+
export function cmpVersion(a, b) {
|
|
20
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
21
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
22
|
+
for (let i = 0; i < 3; i++) {
|
|
23
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
24
|
+
if (d !== 0) return d > 0 ? 1 : -1;
|
|
25
|
+
}
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* npx runs from a per-invocation cache dir. `npm i -g` would install to a
|
|
31
|
+
* DIFFERENT location than the one executing, so re-execing our own path would
|
|
32
|
+
* loop on the stale cached copy. Detect npx and skip the install (nag instead —
|
|
33
|
+
* relaunching with `npx flowviant@latest` is the npx-native update).
|
|
34
|
+
*/
|
|
35
|
+
export function runningViaNpx() {
|
|
36
|
+
const ua = process.env.npm_config_user_agent || '';
|
|
37
|
+
const argv1 = process.argv[1] || '';
|
|
38
|
+
let self = '';
|
|
39
|
+
try {
|
|
40
|
+
self = fileURLToPath(import.meta.url);
|
|
41
|
+
} catch {
|
|
42
|
+
/* non-file URL — ignore */
|
|
43
|
+
}
|
|
44
|
+
return /\bnpx\b/.test(ua) || /[\\/]_npx[\\/]/.test(argv1) || /[\\/]_npx[\\/]/.test(self);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Replace this process with a fresh one running the just-installed version.
|
|
49
|
+
* `npm i -g` overwrote the global package in place, so re-running argv[1] loads
|
|
50
|
+
* the NEW code. We tear down first (idle-gated, so nothing's mid-task) and keep
|
|
51
|
+
* this process alive only as a thin proxy waiting on the child, so the user's
|
|
52
|
+
* shell stays attached to one foreground process.
|
|
53
|
+
*/
|
|
54
|
+
function reexec(teardown) {
|
|
55
|
+
try {
|
|
56
|
+
teardown?.();
|
|
57
|
+
} catch {
|
|
58
|
+
/* best-effort */
|
|
59
|
+
}
|
|
60
|
+
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
61
|
+
stdio: 'inherit',
|
|
62
|
+
env: process.env,
|
|
63
|
+
});
|
|
64
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Install @latest globally. Throws on failure (EACCES without sudo, offline…). */
|
|
68
|
+
function installLatest() {
|
|
69
|
+
execFileSync('npm', ['install', '-g', 'flowviant@latest'], { stdio: 'inherit' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `flowviant update` — explicit, manual update. Does not re-exec into a daemon
|
|
73
|
+
* (the user ran a one-shot command); it installs and tells them to relaunch. */
|
|
74
|
+
export function runUpdateCommand() {
|
|
75
|
+
if (runningViaNpx()) {
|
|
76
|
+
note('running via npx — just relaunch with `npx flowviant@latest` to get the newest.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
note(`updating flowviant (currently ${VERSION})…`);
|
|
81
|
+
installLatest();
|
|
82
|
+
ok('updated. Relaunch `flowviant` to run the new version.');
|
|
83
|
+
} catch (e) {
|
|
84
|
+
warn(`update failed (${e?.message ?? e}). Try: npm i -g flowviant@latest`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Nag at most once per target version, so a poll every ~10s doesn't spam.
|
|
89
|
+
let naggedFor = null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* React to the server's {latest, min} signal from a roster poll.
|
|
93
|
+
* @returns true if it kicked off a self-update + re-exec (caller must stop).
|
|
94
|
+
*/
|
|
95
|
+
export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, teardown }) {
|
|
96
|
+
const cur = VERSION;
|
|
97
|
+
const belowMin = min && cmpVersion(cur, min) < 0;
|
|
98
|
+
const belowLatest = latest && cmpVersion(cur, latest) < 0;
|
|
99
|
+
if (!belowMin && !belowLatest) return false; // current — nothing to do
|
|
100
|
+
const target = latest || min;
|
|
101
|
+
const npx = runningViaNpx();
|
|
102
|
+
const wantInstall = belowMin || autoUpdate;
|
|
103
|
+
|
|
104
|
+
if (wantInstall && !npx) {
|
|
105
|
+
if (!safeToUpdate) {
|
|
106
|
+
// Outdated but an agent is mid-task — wait for idle. Nag once meanwhile.
|
|
107
|
+
if (naggedFor !== target) {
|
|
108
|
+
naggedFor = target;
|
|
109
|
+
note(`flowviant ${cur} → ${target} available — self-updating once agents go idle.`);
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
note(`flowviant ${cur} → ${target}: self-updating…`);
|
|
115
|
+
installLatest();
|
|
116
|
+
ok('updated — restarting into the new version.');
|
|
117
|
+
reexec(teardown);
|
|
118
|
+
return true;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
warn(`self-update failed (${e?.message ?? e}) — update manually: npm i -g flowviant@latest`);
|
|
121
|
+
naggedFor = target; // don't retry-spam a failing install every poll
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Can't or won't auto-install → nag once per target version.
|
|
127
|
+
if (naggedFor !== target) {
|
|
128
|
+
naggedFor = target;
|
|
129
|
+
const how = npx ? 'relaunch with `npx flowviant@latest`' : 'run `npm i -g flowviant@latest`';
|
|
130
|
+
if (belowMin) {
|
|
131
|
+
warn(`flowviant ${cur} is below the minimum ${min} — live mode may not work. Update: ${how}.`);
|
|
132
|
+
} else {
|
|
133
|
+
note(`flowviant ${cur} → ${latest} available. Update: ${how}.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|