flowviant 0.11.0 → 0.13.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 +15 -1
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/fleet.mjs +1 -1
- package/bin/lib/install.mjs +110 -0
- package/bin/lib/live.mjs +39 -5
- package/bin/lib/preflight.mjs +39 -11
- package/bin/lib/preview.mjs +176 -42
- package/bin/lib/update.mjs +21 -1
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -57,6 +57,20 @@ if (process.argv[2] === 'update') {
|
|
|
57
57
|
process.exit(0);
|
|
58
58
|
}
|
|
59
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
|
+
|
|
60
74
|
// `flowviant clean` — reclaim the persistent worktrees (~/.flowviant/worktrees).
|
|
61
75
|
// They're kept across runs so in-flight work survives Ctrl+C; this is the drain.
|
|
62
76
|
// Repos self-heal: the daemon runs `git worktree prune` if a stale registration
|
|
@@ -106,7 +120,7 @@ async function main() {
|
|
|
106
120
|
? '» safe mode: restricted toolset (unset FLOWVIANT_SAFE for full autonomy).'
|
|
107
121
|
: '» unattended mode: permission prompts skipped so the agent runs hands-off.'
|
|
108
122
|
);
|
|
109
|
-
preflight({ needGit: tokens.length > 1 });
|
|
123
|
+
await preflight({ needGit: tokens.length > 1 });
|
|
110
124
|
if (tokens.length === 1) {
|
|
111
125
|
console.log(`» flowviant → ${MCP_URL} (1 worker · token fva_…${tokens[0].slice(-4)})`);
|
|
112
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.13.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.
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -175,7 +175,7 @@ export async function runFleetDaemon() {
|
|
|
175
175
|
info(`base · ${baseRef}`);
|
|
176
176
|
info(`server · ${FLEET_URL}`);
|
|
177
177
|
console.log('');
|
|
178
|
-
preflight({ needGit: true });
|
|
178
|
+
await preflight({ needGit: true });
|
|
179
179
|
|
|
180
180
|
// Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
|
|
181
181
|
// so Ctrl+C mid-task never loses local work. Keyed per repo path; each
|
|
@@ -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
|
@@ -54,6 +54,23 @@ async function registerLiveTarget(intentId, kind, url) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// The preview's tunnel is going down (replaced by another task's, or the daemon
|
|
58
|
+
// is stopping/restarting) — tell Flowviant to drop the link so it doesn't keep
|
|
59
|
+
// offering a dead URL that 530s. Best-effort + short timeout so teardown is snappy.
|
|
60
|
+
const LIVE_TARGET_CLEAR_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target-clear');
|
|
61
|
+
function clearLiveTarget(intentId, kind) {
|
|
62
|
+
return fetch(LIVE_TARGET_CLEAR_URL, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
66
|
+
'User-Agent': USER_AGENT,
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
},
|
|
69
|
+
signal: AbortSignal.timeout(5_000),
|
|
70
|
+
body: JSON.stringify({ intentId, kind }),
|
|
71
|
+
}).catch(() => {});
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
// Safe mode's curated toolset. Bash is scoped to the specific CLIs the agent
|
|
58
75
|
// needs (git/gh/npm/bun) — NOT bare `Bash`, which would auto-approve arbitrary
|
|
59
76
|
// shell (rm -rf, curl|sh, reading ~/.ssh) and defeat the point of safe mode.
|
|
@@ -74,8 +91,11 @@ const SAFE_TOOLS = [
|
|
|
74
91
|
// rides in the seed message below, so this degrades gracefully if the preset
|
|
75
92
|
// shape shifts between SDK versions.
|
|
76
93
|
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
|
-
|
|
94
|
+
channel. START by stating your approach as a SHORT MARKDOWN LIST — one numbered
|
|
95
|
+
line per step, not a dense paragraph — BEFORE you touch any code; the whole team
|
|
96
|
+
watches this channel and may redirect you. Everything you post here renders as
|
|
97
|
+
Markdown for humans, so write for them: short lists, \`code\` for identifiers and
|
|
98
|
+
paths, **bold** for the key point — never a wall of run-on text.
|
|
79
99
|
A human teammate may message you mid-task; treat any injected "The human
|
|
80
100
|
answered…" or teammate line as a new instruction and adapt. There is NO terminal
|
|
81
101
|
and NO interactive prompt — your only channel to a human is the flowviant MCP
|
|
@@ -106,7 +126,7 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
|
106
126
|
? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
|
|
107
127
|
: []),
|
|
108
128
|
``,
|
|
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.`,
|
|
129
|
+
`${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
130
|
].join('\n');
|
|
111
131
|
}
|
|
112
132
|
|
|
@@ -595,6 +615,7 @@ export async function runLiveWorker({
|
|
|
595
615
|
// kept up while it's in review (a gated agent parks, so it lives until review
|
|
596
616
|
// resolves). Replaced when the next task finishes; torn down on shutdown.
|
|
597
617
|
let preview = null;
|
|
618
|
+
let previewTarget = null; // { intentId, kind } of the currently-registered link
|
|
598
619
|
const stopPreview = () => {
|
|
599
620
|
if (preview) {
|
|
600
621
|
try {
|
|
@@ -604,6 +625,11 @@ export async function runLiveWorker({
|
|
|
604
625
|
}
|
|
605
626
|
preview = null;
|
|
606
627
|
}
|
|
628
|
+
// Drop the app-side link so it stops offering a now-dead tunnel (530).
|
|
629
|
+
if (previewTarget) {
|
|
630
|
+
void clearLiveTarget(previewTarget.intentId, previewTarget.kind);
|
|
631
|
+
previewTarget = null;
|
|
632
|
+
}
|
|
607
633
|
// Detached preview children (dev server + tunnel) survive process exit, so
|
|
608
634
|
// the daemon's SIGINT teardown needs a handle to stop them — clear it here
|
|
609
635
|
// once they're down.
|
|
@@ -616,14 +642,21 @@ export async function runLiveWorker({
|
|
|
616
642
|
const entry = kind ? cfg[kind] : null;
|
|
617
643
|
if (!entry || !intentId) {
|
|
618
644
|
// Say WHY there's no preview instead of skipping silently — this was a
|
|
619
|
-
// real "where's my preview?" support case.
|
|
645
|
+
// real "where's my preview?" support case. We search the root, common
|
|
646
|
+
// frontend dirs, and apps/* + packages/*, so if nothing matched either
|
|
647
|
+
// there's no runnable web app or it needs an explicit config.
|
|
620
648
|
info(
|
|
621
649
|
`${label} ${c.dim(
|
|
622
|
-
'no live preview: add .flowviant/preview.json
|
|
650
|
+
'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
651
|
)}`
|
|
624
652
|
);
|
|
625
653
|
return;
|
|
626
654
|
}
|
|
655
|
+
// Zero-config win: when we found the app in a subdir, say where, so it's
|
|
656
|
+
// clear what's being served (and how to pin it if the guess is wrong).
|
|
657
|
+
if (cfg.dir && cfg.dir !== '.') {
|
|
658
|
+
info(`${label} ${c.dim(`live preview: detected a frontend at ${cfg.dir}/ (port ${entry.port})`)}`);
|
|
659
|
+
}
|
|
627
660
|
info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
|
|
628
661
|
preview = await startPreview({
|
|
629
662
|
worktree: cwd,
|
|
@@ -635,6 +668,7 @@ export async function runLiveWorker({
|
|
|
635
668
|
if (preview) {
|
|
636
669
|
onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
|
|
637
670
|
await registerLiveTarget(intentId, kind, preview.url);
|
|
671
|
+
previewTarget = { intentId, kind }; // so teardown can drop the link
|
|
638
672
|
ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
|
|
639
673
|
}
|
|
640
674
|
};
|
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,59 +164,85 @@ 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
|
}
|
|
128
202
|
|
|
129
203
|
const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
204
|
+
// Where a dev server announces it bound — "Local: http://localhost:3001/".
|
|
205
|
+
const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
|
|
130
206
|
|
|
131
207
|
/**
|
|
132
208
|
* Start the dev server + tunnel for one worktree. Resolves { url, kind, stop }
|
|
133
209
|
* once the tunnel URL is captured, or null if it can't come up. stop() kills
|
|
134
210
|
* both the server and the tunnel.
|
|
211
|
+
*
|
|
212
|
+
* The tunnel target is the port the server ACTUALLY bound (read from its
|
|
213
|
+
* output), not the guessed one — vite/vinext/next hop to the next free port when
|
|
214
|
+
* theirs is taken, and tunneling to the guess then 502s. We fall back to the
|
|
215
|
+
* configured port only if the server never announces one.
|
|
135
216
|
*/
|
|
136
|
-
export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
217
|
+
export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs = 180_000 }) {
|
|
137
218
|
const cf = await ensureCloudflared(log);
|
|
138
219
|
if (!cf) return null; // fall back to captured evidence
|
|
139
220
|
return new Promise((resolve) => {
|
|
221
|
+
// We SIGKILL the dev server's whole group on teardown, which skips a tool's
|
|
222
|
+
// graceful cleanup — some dev servers (e.g. vinext) leave a singleton
|
|
223
|
+
// dev-lock behind and then REFUSE to start next time. Disable known locks so
|
|
224
|
+
// a reused/uncleaned worktree still previews. Harmless to tools that ignore
|
|
225
|
+
// these vars; BROWSER=none stops any auto-open.
|
|
226
|
+
const env = { ...process.env, VINEXT_NO_DEV_LOCK: '1', BROWSER: 'none' };
|
|
140
227
|
// detached so each gets its own process group — `bun run dev` via a shell
|
|
141
228
|
// spawns a grandchild dev server that would otherwise SURVIVE a kill of the
|
|
142
|
-
// shell
|
|
143
|
-
//
|
|
229
|
+
// shell. We kill the whole group instead. stdout/stderr piped so we can read
|
|
230
|
+
// the bound port and surface failures.
|
|
144
231
|
const server = spawn(cmd, {
|
|
145
232
|
cwd: worktree,
|
|
146
233
|
shell: true,
|
|
147
|
-
detached: true,
|
|
148
|
-
stdio: ['ignore', 'ignore', 'ignore'],
|
|
149
|
-
});
|
|
150
|
-
const tunnel = spawn(cf, ['tunnel', '--url', `http://localhost:${port}`], {
|
|
151
234
|
detached: true,
|
|
152
235
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
236
|
+
env,
|
|
153
237
|
});
|
|
238
|
+
|
|
154
239
|
let settled = false;
|
|
240
|
+
let tunnel = null;
|
|
241
|
+
let tunnelStarted = false;
|
|
242
|
+
let out = '';
|
|
243
|
+
const tail = () => out.trim().split('\n').slice(-15).join('\n');
|
|
155
244
|
const killGroup = (child) => {
|
|
156
|
-
if (!child
|
|
245
|
+
if (!child?.pid) return;
|
|
157
246
|
try {
|
|
158
247
|
process.kill(-child.pid, 'SIGKILL'); // negative pid = the whole group
|
|
159
248
|
} catch {
|
|
@@ -168,23 +257,68 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
|
168
257
|
killGroup(server);
|
|
169
258
|
killGroup(tunnel);
|
|
170
259
|
};
|
|
260
|
+
let bindTimer;
|
|
261
|
+
let timer;
|
|
171
262
|
const finish = (val) => {
|
|
172
263
|
if (settled) return;
|
|
173
264
|
settled = true;
|
|
174
265
|
clearTimeout(timer);
|
|
266
|
+
clearTimeout(bindTimer);
|
|
175
267
|
if (!val) stop();
|
|
176
268
|
resolve(val);
|
|
177
269
|
};
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
270
|
+
|
|
271
|
+
// Open the tunnel once we know the real port (detected or fallback).
|
|
272
|
+
const openTunnel = (p) => {
|
|
273
|
+
if (tunnelStarted || settled) return;
|
|
274
|
+
tunnelStarted = true;
|
|
275
|
+
clearTimeout(bindTimer);
|
|
276
|
+
log?.(`preview: dev server on :${p} — opening the tunnel…`);
|
|
277
|
+
tunnel = spawn(cf, ['tunnel', '--url', `http://localhost:${p}`], {
|
|
278
|
+
detached: true,
|
|
279
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
280
|
+
});
|
|
281
|
+
const onTunnel = (d) => {
|
|
282
|
+
const m = TUNNEL_RE.exec(d.toString());
|
|
283
|
+
if (m) finish({ url: m[0], kind, stop });
|
|
284
|
+
};
|
|
285
|
+
tunnel.stdout.on('data', onTunnel);
|
|
286
|
+
tunnel.stderr.on('data', onTunnel);
|
|
287
|
+
tunnel.on('error', () => finish(null));
|
|
288
|
+
tunnel.on('close', () => finish(null));
|
|
181
289
|
};
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
290
|
+
|
|
291
|
+
const onServer = (d) => {
|
|
292
|
+
const s = d.toString();
|
|
293
|
+
out = (out + s).slice(-4000);
|
|
294
|
+
if (!tunnelStarted) {
|
|
295
|
+
const m = BIND_RE.exec(s);
|
|
296
|
+
if (m) openTunnel(Number(m[1]));
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
server.stdout.on('data', onServer);
|
|
300
|
+
server.stderr.on('data', onServer);
|
|
301
|
+
// A dev server that exits before it's reachable (crash on boot, a singleton
|
|
302
|
+
// lock refusing to start) is the loud failure mode — surface its output.
|
|
303
|
+
server.on('exit', (code) => {
|
|
304
|
+
if (settled) return;
|
|
305
|
+
log?.(
|
|
306
|
+
`preview dev server exited (code ${code}) before it was reachable — no preview.${
|
|
307
|
+
tail() ? `\n dev server said:\n${tail()}` : ''
|
|
308
|
+
}`,
|
|
309
|
+
);
|
|
310
|
+
finish(null);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// If the server never prints a URL we recognize (quiet server), tunnel to the
|
|
314
|
+
// configured port as a last resort.
|
|
315
|
+
bindTimer = setTimeout(() => openTunnel(port), 30_000);
|
|
316
|
+
timer = setTimeout(() => {
|
|
317
|
+
log?.(
|
|
318
|
+
`preview tunnel did not come up in ${Math.round(timeoutMs / 1000)}s — skipping.${
|
|
319
|
+
tail() ? `\n last dev-server output:\n${tail()}` : ''
|
|
320
|
+
}`,
|
|
321
|
+
);
|
|
188
322
|
finish(null);
|
|
189
323
|
}, timeoutMs);
|
|
190
324
|
});
|
package/bin/lib/update.mjs
CHANGED
|
@@ -110,8 +110,28 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
|
|
|
110
110
|
}
|
|
111
111
|
return false;
|
|
112
112
|
}
|
|
113
|
+
// Loop guard: the server can announce a version before it's published. npm is
|
|
114
|
+
// the source of truth — only install if npm ACTUALLY has something newer than
|
|
115
|
+
// us, else `npm i -g @latest` reinstalls our own version and we'd re-exec
|
|
116
|
+
// forever.
|
|
117
|
+
let published = null;
|
|
113
118
|
try {
|
|
114
|
-
|
|
119
|
+
published = execFileSync('npm', ['view', 'flowviant', 'version'], {
|
|
120
|
+
encoding: 'utf8',
|
|
121
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
122
|
+
}).trim();
|
|
123
|
+
} catch {
|
|
124
|
+
/* offline / npm hiccup — treat as "can't confirm", skip this poll */
|
|
125
|
+
}
|
|
126
|
+
if (!published || cmpVersion(published, cur) <= 0) {
|
|
127
|
+
if (naggedFor !== target) {
|
|
128
|
+
naggedFor = target;
|
|
129
|
+
note(`update ${target} announced but npm still serves ${published ?? '?'} — waiting for the publish.`);
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
note(`flowviant ${cur} → ${published}: self-updating…`);
|
|
115
135
|
installLatest();
|
|
116
136
|
ok('updated — restarting into the new version.');
|
|
117
137
|
reexec(teardown);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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": {
|