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,483 @@
1
+ /**
2
+ * Live mode (phase 2, opt-in via FLOWVIANT_LIVE=1). Instead of one-shot
3
+ * `claude -p` turns + sentinels, each task runs a PERSISTENT Agent-SDK session:
4
+ * the daemon claims, seeds the session with the brief, mirrors the model's
5
+ * streamed reply into the task channel (stream_turn), injects human @-messages
6
+ * as new turns, and bridges blockers (the session idle-parks; the daemon polls
7
+ * the human's answer and injects it to resume in place). Same session = the
8
+ * iterating loop, hosted through Flowviant.
9
+ *
10
+ * Auth invariant: the SDK runs the user's own Claude Code (subscription), never
11
+ * the API — we strip ANTHROPIC_API_KEY from the session env so a key in the
12
+ * user's shell can't silently divert to API billing.
13
+ *
14
+ * NOTE: the SDK mechanics here (streaming-input continuity, tool_use visibility,
15
+ * one result per turn) are validated by spikes; the end-to-end task loop needs a
16
+ * live fleet + repo to shake out. Old (poll/sentinel) mode is untouched.
17
+ */
18
+
19
+ import { query } from '@anthropic-ai/claude-agent-sdk';
20
+ import {
21
+ MCP_URL,
22
+ SAFE,
23
+ POLL_SECONDS,
24
+ IDLE_SECONDS,
25
+ PARK_TIMEOUT_SECONDS,
26
+ FLEET_URL,
27
+ FLEET_TOKEN,
28
+ USER_AGENT,
29
+ } from './config.mjs';
30
+ import { c, info, ok, warn } from './ui.mjs';
31
+ import { sleep } from './claude.mjs';
32
+ import { git, resetWorktree } from './git.mjs';
33
+ import { loadPreviewConfig, startPreview } from './preview.mjs';
34
+
35
+ // Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
36
+ // reviewer then drives it via "Open live preview" in the node.
37
+ const LIVE_TARGET_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target');
38
+ async function registerLiveTarget(intentId, kind, url) {
39
+ try {
40
+ await fetch(LIVE_TARGET_URL, {
41
+ method: 'POST',
42
+ headers: {
43
+ Authorization: `Bearer ${FLEET_TOKEN}`,
44
+ 'User-Agent': USER_AGENT,
45
+ 'Content-Type': 'application/json',
46
+ },
47
+ body: JSON.stringify({ intentId, kind, url }),
48
+ });
49
+ } catch {
50
+ /* best-effort — the tunnel still works; it just isn't linked in the app */
51
+ }
52
+ }
53
+
54
+ const SAFE_TOOLS = ['Edit', 'Write', 'Read', 'Grep', 'Glob', 'Bash', 'mcp__flowviant'];
55
+
56
+ // Appended to Claude Code's preset. The reliable copy of the contract also
57
+ // rides in the seed message below, so this degrades gracefully if the preset
58
+ // shape shifts between SDK versions.
59
+ const SYSTEM_LIVE = `You are a Flowviant build agent working ONE task inside a live, shared task
60
+ channel. START by stating your approach in plain language (a short plan) BEFORE
61
+ you touch any code — the whole team watches this channel and may redirect you.
62
+ A human teammate may message you mid-task; treat any injected "The human
63
+ answered…" or teammate line as a new instruction and adapt. There is NO terminal
64
+ and NO interactive prompt — your only channel to a human is the flowviant MCP
65
+ tools. When you hit a decision only a human can make, call report_blocker (with
66
+ options when you can) and then STOP your turn — do not spin or guess; you will be
67
+ resumed with the answer. When the work is done: call attach_evidence for EACH
68
+ acceptance criterion (this is the floor the human reviews against); open ONE draft
69
+ PR (git push + gh pr create --draft), call attach_pr, then call complete. A live
70
+ preview of your branch is started for you automatically for the review — you do
71
+ NOT need to open a tunnel or register a live target. NEVER merge — the human
72
+ reviews and merging is handled separately.`;
73
+
74
+ function seedPrompt(runId, brief, transcript) {
75
+ return [
76
+ `Your run id is ${runId}. Use it for every flowviant MCP tool call.`,
77
+ brief?.branch
78
+ ? `This is a REVISION — your prior branch "${brief.branch}" is checked out; address the review feedback and push to the SAME branch (the PR updates in place).`
79
+ : `Start from the clean base checkout and open a fresh draft PR when done.`,
80
+ ``,
81
+ `Task brief:`,
82
+ JSON.stringify(brief ?? {}, null, 2),
83
+ ...(transcript
84
+ ? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
85
+ : []),
86
+ ``,
87
+ `${transcript ? 'Continue' : 'Begin'}. Post a short plan first, then: report_progress as you go; report_blocker + stop if you hit a human decision; attach_evidence, open a draft PR, attach_pr, then complete when done.`,
88
+ ].join('\n');
89
+ }
90
+
91
+ // ── MCP JSON-RPC client (the daemon's own calls, outside the session) ───────
92
+ // The flowviant MCP endpoint handles tools/call statelessly with a bearer
93
+ // worker token — no handshake — so this is all the daemon needs.
94
+ let rpcId = 0;
95
+ async function mcpCall(mcpUrl, token, name, args) {
96
+ const res = await fetch(mcpUrl, {
97
+ method: 'POST',
98
+ headers: {
99
+ Authorization: `Bearer ${token}`,
100
+ 'Content-Type': 'application/json',
101
+ Accept: 'application/json',
102
+ },
103
+ body: JSON.stringify({
104
+ jsonrpc: '2.0',
105
+ id: ++rpcId,
106
+ method: 'tools/call',
107
+ params: { name, arguments: args },
108
+ }),
109
+ });
110
+ if (!res.ok) throw new Error(`mcp ${name} ${res.status}`);
111
+ const body = await res.json();
112
+ const text = body?.result?.content?.[0]?.text;
113
+ if (typeof text !== 'string') return null;
114
+ try {
115
+ return JSON.parse(text);
116
+ } catch {
117
+ return { raw: text };
118
+ }
119
+ }
120
+
121
+ // Flatten a tool_result's content (string | array of {type:'text',text}) to text.
122
+ function resultText(content) {
123
+ if (typeof content === 'string') return content;
124
+ if (Array.isArray(content))
125
+ return content.map((b) => (b?.type === 'text' ? b.text : '')).join('');
126
+ return '';
127
+ }
128
+ const BLOCKER_ID_RE = /"blockerId"\s*:\s*"([^"]+)"/;
129
+
130
+ // A streaming-input controller: seed message first, then push() more turns as
131
+ // they arrive (human @-messages, injected blocker answers). close() ends it.
132
+ function makeInput(seedText) {
133
+ const q = [{ type: 'user', message: { role: 'user', content: seedText }, parent_tool_use_id: null }];
134
+ let waker = null;
135
+ let closed = false;
136
+ return {
137
+ push(text, priority) {
138
+ q.push({
139
+ type: 'user',
140
+ message: { role: 'user', content: text },
141
+ parent_tool_use_id: null,
142
+ ...(priority ? { priority } : {}),
143
+ });
144
+ if (waker) { waker(); waker = null; }
145
+ },
146
+ close() {
147
+ closed = true;
148
+ if (waker) { waker(); waker = null; }
149
+ },
150
+ async *stream() {
151
+ while (true) {
152
+ if (q.length === 0) {
153
+ if (closed) return;
154
+ await new Promise((r) => (waker = r));
155
+ if (closed && q.length === 0) return;
156
+ }
157
+ while (q.length) yield q.shift();
158
+ }
159
+ },
160
+ };
161
+ }
162
+
163
+ // A stop word from any teammate halts the agent (interrupt at the next boundary,
164
+ // then hold for direction) — the "stop, you're going the wrong way" valve.
165
+ const STOP_RE = /(^|\W)stop(\W|$)/i;
166
+
167
+ // Idle-park on a blocker: the session is idle (zero tokens); poll the human's
168
+ // answer. Bounded by PARK_TIMEOUT — after that we tear the session down (free
169
+ // the Claude process) and resume later, rather than hold it open forever.
170
+ // Returns {status:'resolved',answer} | {status:'timeout'} | {status:'aborted'}.
171
+ async function waitForResolution(mcpUrl, token, blockerId, isAlive) {
172
+ if (!blockerId) return { status: 'aborted' };
173
+ const deadline = Date.now() + PARK_TIMEOUT_SECONDS * 1000;
174
+ while (isAlive()) {
175
+ await sleep(POLL_SECONDS);
176
+ const r = await mcpCall(mcpUrl, token, 'get_blocker_resolution', { blockerId }).catch(() => null);
177
+ if (r?.resolved) return { status: 'resolved', answer: r.resolution ?? {} };
178
+ if (Date.now() >= deadline) return { status: 'timeout' };
179
+ }
180
+ return { status: 'aborted' };
181
+ }
182
+
183
+ // Park awaiting the next human message (used after a stop — no nudging).
184
+ // Returns the message, or null on shutdown/timeout.
185
+ async function waitForMessage(mcpUrl, token, runId, afterId, isAlive) {
186
+ const deadline = Date.now() + PARK_TIMEOUT_SECONDS * 1000;
187
+ while (isAlive()) {
188
+ await sleep(POLL_SECONDS);
189
+ const poll = await mcpCall(mcpUrl, token, 'poll_channel', {
190
+ runId,
191
+ ...(afterId ? { afterId } : {}),
192
+ }).catch(() => null);
193
+ const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
194
+ if (fresh.length) return fresh[fresh.length - 1];
195
+ if (Date.now() >= deadline) return null;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ // One task: claim → seed → stream/mirror/inject/park → complete. Returns
201
+ // { outcome: 'nothing' | 'done' | 'blocked' | 'stalled' | 'error' }.
202
+ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive }) {
203
+ const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
204
+ if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
205
+ const { runId, intentId } = claim;
206
+ const brief = claim.brief ?? {};
207
+ const title = brief.title ?? 'a task';
208
+
209
+ // Revision resumes its PR branch; otherwise a clean base checkout.
210
+ if (brief.branch) {
211
+ try {
212
+ git(['fetch', 'origin', '--quiet'], cwd);
213
+ git(['checkout', brief.branch], cwd);
214
+ } catch {
215
+ resetWorktree(cwd, baseRef);
216
+ }
217
+ } else {
218
+ resetWorktree(cwd, baseRef);
219
+ }
220
+
221
+ const env = { ...process.env };
222
+ delete env.ANTHROPIC_API_KEY; // force the user's Claude Code subscription
223
+
224
+ // Prior channel transcript — present when resuming a parked/re-claimed task;
225
+ // seed it so a fresh session picks up where the conversation left off. afterId
226
+ // starts at the last existing message so we never re-inject old ones as "new".
227
+ const prior = await mcpCall(mcpUrl, token, 'poll_channel', { runId }).catch(() => null);
228
+ const priorMsgs = prior?.messages ?? [];
229
+ const transcript = priorMsgs
230
+ .map((m) => `${m.authorName || m.role}: ${m.content}`)
231
+ .join('\n');
232
+ let afterId = priorMsgs.length ? priorMsgs[priorMsgs.length - 1].id : null;
233
+
234
+ const input = makeInput(seedPrompt(runId, brief, transcript));
235
+ const session = query({
236
+ prompt: input.stream(),
237
+ options: {
238
+ cwd,
239
+ env,
240
+ permissionMode: SAFE ? 'default' : 'bypassPermissions',
241
+ ...(SAFE ? { allowedTools: SAFE_TOOLS } : {}),
242
+ systemPrompt: { type: 'preset', preset: 'claude_code', append: SYSTEM_LIVE },
243
+ mcpServers: {
244
+ flowviant: { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${token}` } },
245
+ },
246
+ },
247
+ });
248
+
249
+ let turnId = null;
250
+ let turnText = '';
251
+ let turnAt = null;
252
+ let completed = false;
253
+ let sawBlocker = false;
254
+ let blockerId = null;
255
+ let nudges = 0;
256
+ let held = false; // asked to stop — park for direction, don't nudge
257
+
258
+ const flush = async () => {
259
+ if (turnId && turnText.trim())
260
+ await mcpCall(mcpUrl, token, 'stream_turn', {
261
+ runId,
262
+ turnId,
263
+ text: turnText.trim(),
264
+ createdAt: turnAt,
265
+ }).catch(() => {});
266
+ };
267
+ const inject = (msgs) => {
268
+ afterId = msgs[msgs.length - 1].id;
269
+ input.push(msgs.map((f) => (f.authorName ? `${f.authorName}: ` : '') + f.content).join('\n'));
270
+ };
271
+
272
+ try {
273
+ for await (const m of session) {
274
+ if (!isAlive()) return { outcome: 'blocked', title, intentId };
275
+
276
+ if (m.type === 'assistant') {
277
+ if (!turnId) {
278
+ turnId = `t-${runId}-${Date.now()}`;
279
+ turnAt = new Date().toISOString();
280
+ turnText = '';
281
+ }
282
+ for (const b of m.message?.content ?? []) {
283
+ if (b.type === 'text' && b.text) turnText += b.text;
284
+ else if (b.type === 'tool_use') {
285
+ const n = String(b.name ?? '');
286
+ if (n.endsWith('complete')) completed = true;
287
+ else if (n.endsWith('report_blocker')) sawBlocker = true;
288
+ }
289
+ }
290
+ await flush();
291
+ } else if (m.type === 'user') {
292
+ // tool_result echoes — capture the blockerId report_blocker returned.
293
+ for (const b of m.message?.content ?? []) {
294
+ if (b?.type === 'tool_result') {
295
+ const hit = BLOCKER_ID_RE.exec(resultText(b.content));
296
+ if (hit) blockerId = hit[1];
297
+ }
298
+ }
299
+ } else if (m.type === 'result') {
300
+ await flush();
301
+ turnId = null;
302
+
303
+ if (completed) return { outcome: 'done', title, intentId };
304
+
305
+ if (sawBlocker) {
306
+ const res = await waitForResolution(mcpUrl, token, blockerId, isAlive);
307
+ if (res.status === 'resolved') {
308
+ input.push(`The human answered your blocker: ${JSON.stringify(res.answer)}\nApply it and continue.`);
309
+ sawBlocker = false;
310
+ blockerId = null;
311
+ nudges = 0;
312
+ continue;
313
+ }
314
+ if (res.status === 'timeout') return { outcome: 'parked', title, intentId };
315
+ return { outcome: 'blocked', title, intentId }; // aborted (shutdown)
316
+ }
317
+
318
+ // Pick up new human @-messages (this is also where a stop lands — the
319
+ // checkpoint model: halt at the boundary, not a hard mid-tool kill).
320
+ const poll = await mcpCall(mcpUrl, token, 'poll_channel', {
321
+ runId,
322
+ ...(afterId ? { afterId } : {}),
323
+ }).catch(() => null);
324
+ const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
325
+
326
+ if (fresh.some((f) => STOP_RE.test(f.content))) {
327
+ if (fresh.length) afterId = fresh[fresh.length - 1].id;
328
+ held = true;
329
+ input.push('A teammate asked you to STOP. Halt, summarize where you are in one line, and wait for direction — do not continue until told.');
330
+ continue;
331
+ }
332
+ if (fresh.length) {
333
+ inject(fresh);
334
+ nudges = 0;
335
+ held = false;
336
+ continue;
337
+ }
338
+
339
+ // Held after a stop — park for the next human message; never nudge.
340
+ if (held) {
341
+ const next = await waitForMessage(mcpUrl, token, runId, afterId, isAlive);
342
+ if (!next) return { outcome: 'parked', title, intentId };
343
+ held = false;
344
+ nudges = 0;
345
+ inject([next]);
346
+ continue;
347
+ }
348
+
349
+ // Idle turn with no completion — nudge a couple of times, then stop.
350
+ if (nudges < 2) {
351
+ nudges++;
352
+ input.push('Continue until the task is complete: open a draft PR and call complete, or report a blocker.');
353
+ continue;
354
+ }
355
+ return { outcome: 'stalled', title, intentId };
356
+ }
357
+ }
358
+ return { outcome: completed ? 'done' : 'stalled', title, intentId };
359
+ } catch (e) {
360
+ return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
361
+ } finally {
362
+ input.close();
363
+ try {
364
+ await session.interrupt?.();
365
+ } catch {
366
+ /* session already ended */
367
+ }
368
+ try {
369
+ await session.return?.();
370
+ } catch {
371
+ /* generator already closed */
372
+ }
373
+ }
374
+ }
375
+
376
+ // Per-agent loop — same signature/scaffolding as runFleetWorker, but each task
377
+ // is a persistent SDK session instead of a one-shot claude turn.
378
+ export async function runLiveWorker({
379
+ agentId,
380
+ label,
381
+ cwd,
382
+ baseRef,
383
+ getToken,
384
+ getHasWork,
385
+ getMcpUrl,
386
+ isAlive,
387
+ onTokenSuspect,
388
+ }) {
389
+ let phase = '';
390
+ const enter = (p, fn, msg) => {
391
+ if (phase !== p) {
392
+ phase = p;
393
+ fn(`${label} ${msg}`);
394
+ }
395
+ };
396
+
397
+ // One live preview at a time — the branch of the task most recently finished,
398
+ // kept up while it's in review (a gated agent parks, so it lives until review
399
+ // resolves). Replaced when the next task finishes; torn down on shutdown.
400
+ let preview = null;
401
+ const stopPreview = () => {
402
+ if (preview) {
403
+ try {
404
+ preview.stop();
405
+ } catch {
406
+ /* already gone */
407
+ }
408
+ preview = null;
409
+ }
410
+ };
411
+ const startReviewPreview = async (intentId) => {
412
+ stopPreview();
413
+ const cfg = loadPreviewConfig(cwd);
414
+ const kind = cfg?.ui ? 'ui' : cfg?.api ? 'api' : null;
415
+ const entry = kind ? cfg[kind] : null;
416
+ if (!entry || !intentId) return; // no preview config → captured evidence only
417
+ info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
418
+ preview = await startPreview({
419
+ worktree: cwd,
420
+ kind,
421
+ cmd: entry.cmd,
422
+ port: entry.port,
423
+ log: (m) => info(`${label} ${c.dim(m)}`),
424
+ });
425
+ if (preview) {
426
+ await registerLiveTarget(intentId, kind, preview.url);
427
+ ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
428
+ }
429
+ };
430
+
431
+ while (isAlive()) {
432
+ const token = getToken(agentId);
433
+ if (!token) {
434
+ await sleep(IDLE_SECONDS);
435
+ continue;
436
+ }
437
+ if (!getHasWork(agentId)) {
438
+ enter('idle', info, 'idle — no work assigned');
439
+ await sleep(IDLE_SECONDS);
440
+ continue;
441
+ }
442
+ let res;
443
+ try {
444
+ res = await runLiveTask({ mcpUrl: getMcpUrl() ?? MCP_URL, token, cwd, baseRef, isAlive });
445
+ } catch (e) {
446
+ enter('error', warn, `${c.yellow('error')} ${c.dim(`— ${e?.message ?? e}`)}`);
447
+ await sleep(IDLE_SECONDS);
448
+ continue;
449
+ }
450
+ if (!isAlive()) break;
451
+ if (res.outcome === 'nothing') {
452
+ enter('idle', info, 'idle — no work assigned');
453
+ await sleep(IDLE_SECONDS);
454
+ continue;
455
+ }
456
+ if (res.outcome === 'done') {
457
+ ok(`${label} ${c.dim(`finished "${res.title}" — PR opened for your review`)}`);
458
+ phase = '';
459
+ await startReviewPreview(res.intentId);
460
+ continue;
461
+ }
462
+ if (res.outcome === 'parked') {
463
+ // Idle-parked too long on a blocker: we freed the Claude process. The intent
464
+ // stays claimed; a later poll re-claims + resumes (with transcript) once the
465
+ // human answers. Idle, don't hard-stop the worker.
466
+ enter('parked', info, `${c.dim('parked — freed the session; resumes when you answer in Flowviant')}`);
467
+ await sleep(IDLE_SECONDS);
468
+ continue;
469
+ }
470
+ if (res.outcome === 'blocked') {
471
+ // Only reached on shutdown mid-park; the intent stays claimed and resumes
472
+ // on reconnect. Nothing to do but stop cleanly.
473
+ break;
474
+ }
475
+ // stalled / error — usually a stale token or a stuck turn. Refresh + retry.
476
+ enter('reconnect', warn, `${c.yellow(res.outcome)} ${c.dim('— refreshing token, retrying')}`);
477
+ onTokenSuspect?.(agentId);
478
+ phase = '';
479
+ await sleep(IDLE_SECONDS);
480
+ }
481
+ stopPreview();
482
+ info(`${label} stopped`);
483
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `flowviant login` — device-auth, like `gh auth login`. Removes the
3
+ * paste-a-secret-into-your-shell friction: the daemon shows a short code, you
4
+ * approve it in Flowviant (in a project), and the freshly-minted fleet
5
+ * credential is stored locally at ~/.flowviant/credentials.json. After that,
6
+ * plain `flowviant` just runs — no token, no env var.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
13
+ import { c, info, ok, warn, fail } from './ui.mjs';
14
+ import { sleep } from './claude.mjs';
15
+
16
+ const CRED_DIR = join(homedir(), '.flowviant');
17
+ const CRED_FILE = join(CRED_DIR, 'credentials.json');
18
+ const DEVICE_START = FLEET_URL.replace(/\/agents\/?$/, '/device/start');
19
+ const DEVICE_POLL = FLEET_URL.replace(/\/agents\/?$/, '/device/poll');
20
+ const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
21
+
22
+ /** The locally-stored credential from a prior `login`, or null. Read by config. */
23
+ export function readStoredCredential() {
24
+ try {
25
+ return JSON.parse(readFileSync(CRED_FILE, 'utf8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function store(cred) {
32
+ mkdirSync(CRED_DIR, { recursive: true });
33
+ writeFileSync(CRED_FILE, JSON.stringify(cred, null, 2), { mode: 0o600 });
34
+ }
35
+
36
+ async function post(url, body) {
37
+ const res = await fetch(url, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
40
+ body: JSON.stringify(body),
41
+ });
42
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
43
+ const j = await res.json();
44
+ return j.data ?? j;
45
+ }
46
+
47
+ export async function runLogin() {
48
+ console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
49
+ let start;
50
+ try {
51
+ start = await post(DEVICE_START, {});
52
+ } catch (e) {
53
+ fail(`couldn't reach Flowviant (${e.message}).`);
54
+ process.exit(1);
55
+ }
56
+ const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
57
+ const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
58
+ console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Agents')} panel → ${c.bold('Connect a machine')}.`);
59
+ console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
60
+ info('waiting for you to approve…');
61
+
62
+ const deadline = Date.now() + expiresInSeconds * 1000;
63
+ while (Date.now() < deadline) {
64
+ await sleep(intervalSeconds);
65
+ let poll;
66
+ try {
67
+ poll = await post(DEVICE_POLL, { deviceCode });
68
+ } catch {
69
+ continue; // transient — keep polling
70
+ }
71
+ if (poll.status === 'approved') {
72
+ store({ fleetToken: poll.fleetToken, projectId: poll.projectId, mcpUrl: poll.mcpUrl });
73
+ ok('connected — credential saved to ~/.flowviant/credentials.json');
74
+ console.log(`\n Now just run: ${c.bold('npx flowviant')}\n`);
75
+ return;
76
+ }
77
+ if (poll.status === 'expired') {
78
+ warn('that code expired — run `flowviant login` again.');
79
+ process.exit(1);
80
+ }
81
+ }
82
+ warn('login timed out — run `flowviant login` again.');
83
+ process.exit(1);
84
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Startup preflight: this tool DRIVES your local CLIs (it never sees their
3
+ * credentials), so it checks they're present + signed in and tells you exactly
4
+ * what's missing, rather than failing cryptically mid-run.
5
+ */
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { ok, warn, info, c } from './ui.mjs';
9
+
10
+ function present(cmd) {
11
+ try {
12
+ execFileSync(cmd, ['--version'], { stdio: 'ignore' });
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ function ghAuthed() {
20
+ try {
21
+ execFileSync('gh', ['auth', 'status'], { stdio: 'ignore' });
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /** Prints a checklist. Returns false only if a *fatal* prereq (claude, or git
29
+ * when worktrees are used) is missing — callers may warn-and-continue. */
30
+ export function preflight({ needGit = true } = {}) {
31
+ const claude = present('claude');
32
+ const gh = present('gh');
33
+ const node18 = Number(process.versions.node.split('.')[0]) >= 18;
34
+ const git = needGit ? present('git') : true;
35
+
36
+ info('checking your setup (this tool drives these — it never sees their logins):');
37
+ claude
38
+ ? ok(`claude installed ${c.dim('· must be signed in — run `claude` once if you haven’t')}`)
39
+ : warn('claude NOT found — install Claude Code: https://claude.com/claude-code');
40
+ gh && ghAuthed()
41
+ ? ok('gh authenticated')
42
+ : warn(gh ? 'gh not signed in — run: gh auth login' : 'gh NOT found — install it + run: gh auth login (needed to open PRs)');
43
+ if (needGit) (git ? ok('git installed') : warn('git NOT found — install git'));
44
+ node18 ? ok(`node ${process.versions.node}`) : warn(`node ${process.versions.node} — need 18+`);
45
+ console.log('');
46
+
47
+ if (!claude) warn('Without `claude` nothing can run — install + sign in, then restart.');
48
+ return claude && git;
49
+ }