flowviant 0.6.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.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Live preview (live mode). When a task opens its PR and parks for review, the
3
+ * daemon starts the branch's dev server IN THE AGENT'S WORKTREE and opens a
4
+ * cloudflared quick tunnel to it, so the reviewer can drive the real running
5
+ * change in Flowviant (broker-not-host: Flowviant only stores the tunnel URL;
6
+ * the reviewer's browser talks to it directly).
7
+ *
8
+ * Zero-config where possible: the preview config is read from
9
+ * `.flowviant/preview.json` if present, otherwise INFERRED from package.json
10
+ * (framework → port). cloudflared is AUTO-FETCHED if it isn't installed. No
11
+ * cloudflared / no inferable config → no live preview, and review falls back to
12
+ * the captured evidence the agent attached (never a hard failure).
13
+ *
14
+ * Config shape:
15
+ * { "ui": { "cmd": "<start dev server>", "port": 5173 },
16
+ * "api": { "cmd": "<start api>", "port": 8787 } }
17
+ */
18
+
19
+ import { spawn, execFileSync } from 'node:child_process';
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { homedir, platform, arch } from 'node:os';
23
+
24
+ // ── Config: explicit file, else infer from package.json ────────────────────
25
+
26
+ function readPreviewConfig(repoRoot) {
27
+ const p = join(repoRoot, '.flowviant', 'preview.json');
28
+ if (!existsSync(p)) return null;
29
+ try {
30
+ return JSON.parse(readFileSync(p, 'utf8'));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ // Framework → conventional dev-server port. If we can't identify one, we don't
37
+ // guess — an explicit .flowviant/preview.json is the escape hatch.
38
+ const FRAMEWORK_PORTS = [
39
+ { re: /\bvite\b/, port: 5173 },
40
+ { re: /\bnext\b/, port: 3000 },
41
+ { re: /react-scripts/, port: 3000 },
42
+ { re: /\bastro\b/, port: 4321 },
43
+ { re: /\bnuxt\b/, port: 3000 },
44
+ { re: /\bremix\b/, port: 3000 },
45
+ { re: /\bsvelte/, port: 5173 },
46
+ { re: /\bgatsby\b/, port: 8000 },
47
+ { re: /\bexpo\b/, port: 8081 },
48
+ ];
49
+
50
+ function pkgManager(repoRoot) {
51
+ if (existsSync(join(repoRoot, 'bun.lock')) || existsSync(join(repoRoot, 'bun.lockb'))) return 'bun';
52
+ if (existsSync(join(repoRoot, 'pnpm-lock.yaml'))) return 'pnpm';
53
+ if (existsSync(join(repoRoot, 'yarn.lock'))) return 'yarn';
54
+ return 'npm';
55
+ }
56
+
57
+ function inferPreviewConfig(repoRoot) {
58
+ const pkgPath = join(repoRoot, 'package.json');
59
+ if (!existsSync(pkgPath)) return null;
60
+ let pkg;
61
+ try {
62
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
63
+ } catch {
64
+ return null;
65
+ }
66
+ const scripts = pkg.scripts || {};
67
+ const script = scripts.dev ? 'dev' : scripts.start ? 'start' : null;
68
+ if (!script) return null;
69
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
70
+ const hay = `${scripts[script]} ${Object.keys(deps).join(' ')}`.toLowerCase();
71
+ const fw = FRAMEWORK_PORTS.find((f) => f.re.test(hay));
72
+ if (!fw) return null; // can't safely guess the port
73
+ const pm = pkgManager(repoRoot);
74
+ const install = pm === 'npm' ? 'npm install' : `${pm} install`;
75
+ const run = pm === 'yarn' ? `yarn ${script}` : `${pm} run ${script}`;
76
+ return { ui: { cmd: `${install} && ${run}`, port: fw.port } };
77
+ }
78
+
79
+ /** The preview config for a repo — explicit file wins, else inferred. */
80
+ export function loadPreviewConfig(repoRoot) {
81
+ return readPreviewConfig(repoRoot) ?? inferPreviewConfig(repoRoot);
82
+ }
83
+
84
+ // ── cloudflared: use if installed, else auto-fetch ─────────────────────────
85
+
86
+ function onPath() {
87
+ try {
88
+ execFileSync('cloudflared', ['--version'], { stdio: 'ignore' });
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /** Resolve a cloudflared binary: PATH → cached fetch → download. Returns the
96
+ * command/path to run, or null if unavailable (Windows/macOS auto-fetch is
97
+ * skipped — those install cleanly via brew/winget). */
98
+ async function ensureCloudflared(log) {
99
+ if (onPath()) return 'cloudflared';
100
+ const os = platform();
101
+ const dir = join(homedir(), '.flowviant', 'bin');
102
+ const bin = join(dir, os === 'win32' ? 'cloudflared.exe' : 'cloudflared');
103
+ 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
+ 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
+ try {
117
+ const res = await fetch(url, { redirect: 'follow' });
118
+ if (!res.ok) throw new Error(`http ${res.status}`);
119
+ mkdirSync(dir, { recursive: true });
120
+ writeFileSync(bin, Buffer.from(await res.arrayBuffer()));
121
+ if (os !== 'win32') chmodSync(bin, 0o755);
122
+ return bin;
123
+ } catch (e) {
124
+ log?.(`could not fetch cloudflared (${e.message}) — install it manually to enable live previews.`);
125
+ return null;
126
+ }
127
+ }
128
+
129
+ const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
130
+
131
+ /**
132
+ * Start the dev server + tunnel for one worktree. Resolves { url, kind, stop }
133
+ * once the tunnel URL is captured, or null if it can't come up. stop() kills
134
+ * both the server and the tunnel.
135
+ */
136
+ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs = 90_000 }) {
137
+ const cf = await ensureCloudflared(log);
138
+ if (!cf) return null; // fall back to captured evidence
139
+ return new Promise((resolve) => {
140
+ const server = spawn(cmd, { cwd: worktree, shell: true, stdio: ['ignore', 'ignore', 'ignore'] });
141
+ const tunnel = spawn(cf, ['tunnel', '--url', `http://localhost:${port}`], {
142
+ stdio: ['ignore', 'pipe', 'pipe'],
143
+ });
144
+ let settled = false;
145
+ const stop = () => {
146
+ try { server.kill('SIGKILL'); } catch { /* gone */ }
147
+ try { tunnel.kill('SIGKILL'); } catch { /* gone */ }
148
+ };
149
+ const finish = (val) => {
150
+ if (settled) return;
151
+ settled = true;
152
+ clearTimeout(timer);
153
+ if (!val) stop();
154
+ resolve(val);
155
+ };
156
+ const onData = (d) => {
157
+ const m = TUNNEL_RE.exec(d.toString());
158
+ if (m) finish({ url: m[0], kind, stop });
159
+ };
160
+ tunnel.stdout.on('data', onData);
161
+ tunnel.stderr.on('data', onData);
162
+ tunnel.on('error', () => finish(null));
163
+ tunnel.on('close', () => finish(null));
164
+ const timer = setTimeout(() => {
165
+ log?.('preview tunnel did not come up in time — skipping (captured evidence still applies).');
166
+ finish(null);
167
+ }, timeoutMs);
168
+ });
169
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Back-compat single-token + static-fleet modes. A single token drains the whole
3
+ * queue in one continuous Claude session in the current checkout; FLOWVIANT_TOKENS
4
+ * runs one such worker per token, each in its own git worktree.
5
+ */
6
+
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { MCP_URL, POLL_SECONDS, tokens } from './config.mjs';
11
+ import { sleep, mcpConfigFor, runTurn, sawSentinel, blockedId, SYSTEM_MULTI, KICKOFF, RESUME } from './claude.mjs';
12
+ import { git, repoRootOrDie } from './git.mjs';
13
+
14
+ export async function runWorker({ token, cwd, label }) {
15
+ const { dir, path: mcpConfig } = mcpConfigFor(token, MCP_URL);
16
+ try {
17
+ let out = await runTurn({ prompt: KICKOFF, resume: false, system: SYSTEM_MULTI, cwd, mcpConfig, label });
18
+ while (!sawSentinel(out, 'ALL_CLEAR')) {
19
+ if (blockedId(out)) {
20
+ console.log(`${label} » waiting on you in Flowviant — answer the blocker. Re-checking in ${POLL_SECONDS}s…`);
21
+ }
22
+ await sleep(POLL_SECONDS);
23
+ out = await runTurn({ prompt: RESUME, resume: true, system: SYSTEM_MULTI, cwd, mcpConfig, label });
24
+ }
25
+ console.log(`${label} » queue clear.`);
26
+ } finally {
27
+ rmSync(dir, { recursive: true, force: true });
28
+ }
29
+ }
30
+
31
+ export async function runStaticFleet() {
32
+ console.log(`» flowviant fleet → ${tokens.length} workers · ${MCP_URL}`);
33
+ const repoRoot = repoRootOrDie();
34
+ const baseDir = mkdtempSync(join(tmpdir(), 'flowviant-fleet-'));
35
+ const worktrees = [];
36
+ const cleanup = () => {
37
+ for (const wt of worktrees) {
38
+ try {
39
+ git(['worktree', 'remove', '--force', wt], repoRoot);
40
+ } catch {
41
+ /* best-effort */
42
+ }
43
+ }
44
+ try {
45
+ rmSync(baseDir, { recursive: true, force: true });
46
+ } catch {
47
+ /* best-effort */
48
+ }
49
+ };
50
+ process.on('SIGINT', () => {
51
+ cleanup();
52
+ process.exit(130);
53
+ });
54
+
55
+ const jobs = tokens.map((token, i) => {
56
+ const label = `[w${i + 1}]`;
57
+ const wt = join(baseDir, `worker-${i + 1}`);
58
+ git(['worktree', 'add', '--detach', wt, 'HEAD'], repoRoot);
59
+ worktrees.push(wt);
60
+ console.log(`${label} worktree ready (token fva_…${token.slice(-4)})`);
61
+ return runWorker({ token, cwd: wt, label });
62
+ });
63
+ await Promise.allSettled(jobs);
64
+ cleanup();
65
+ console.log('» fleet done — all queues clear.');
66
+ }
package/bin/lib/ui.mjs ADDED
@@ -0,0 +1,26 @@
1
+ /** Zero-dep ANSI output. Respects NO_COLOR and non-TTY pipes. */
2
+
3
+ const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
4
+ const wrap = (open) => (s) => (COLOR ? `\x1b[${open}m${s}\x1b[0m` : `${s}`);
5
+
6
+ export const c = {
7
+ bold: wrap(1), dim: wrap(2),
8
+ red: wrap(31), green: wrap(32), yellow: wrap(33),
9
+ blue: wrap(34), magenta: wrap(35), cyan: wrap(36), gray: wrap(90),
10
+ };
11
+
12
+ // Stable, cycled colours so each agent's label is easy to scan in a fleet.
13
+ export const LABEL_COLORS = [c.cyan, c.magenta, c.blue, c.yellow, c.green, c.red];
14
+
15
+ const stamp = () => {
16
+ const d = new Date();
17
+ const p = (n) => String(n).padStart(2, '0');
18
+ return c.gray(`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`);
19
+ };
20
+ const line = (sym, msg) => console.log(`${stamp()} ${sym} ${msg}`);
21
+
22
+ export const info = (m) => line(c.dim('·'), c.dim(m));
23
+ export const note = (m) => line(c.blue('›'), m);
24
+ export const ok = (m) => line(c.green('✓'), m);
25
+ export const warn = (m) => line(c.yellow('!'), m);
26
+ export const fail = (m) => line(c.red('✗'), m);
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "flowviant",
3
+ "version": "0.6.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "flowviant": "bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "dependencies": {
18
+ "@anthropic-ai/claude-agent-sdk": "^0.3.0"
19
+ },
20
+ "keywords": [
21
+ "flowviant",
22
+ "claude",
23
+ "claude-code",
24
+ "agent",
25
+ "mcp",
26
+ "ai"
27
+ ],
28
+ "homepage": "https://flowviant.com",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/flowviant/cli.git"
32
+ },
33
+ "license": "MIT",
34
+ "bugs": {
35
+ "url": "https://github.com/flowviant/cli/issues"
36
+ }
37
+ }