flowviant 0.55.1 → 0.55.3
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 +61 -17
- package/bin/lib/install.mjs +15 -9
- package/bin/lib/tty.mjs +111 -0
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -237,8 +237,22 @@ if (process.argv[2] === 'env') {
|
|
|
237
237
|
// auto-updated daemon's child sees two TTYs; without this it would stop on the
|
|
238
238
|
// binding confirm below and the machine would stay dark until somebody typed a
|
|
239
239
|
// key. Same reasoning as the headless case, and the same answer.
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
// `canPrompt()`, not a bare isTTY pair: a BACKGROUNDED job (`flowviant &`) has
|
|
241
|
+
// two TTYs and cannot be asked anything — the first read raises SIGTTIN and the
|
|
242
|
+
// kernel STOPS the process, which is why 0.55.2's timeout did not save it (a
|
|
243
|
+
// stopped process runs no timers). See tty.mjs.
|
|
244
|
+
const { canPrompt, askWithTimeout } = await import('./lib/tty.mjs');
|
|
245
|
+
const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
|
|
246
|
+
|
|
247
|
+
/** How long the one-time binding confirm waits before serving unbound. A person
|
|
248
|
+
* who just typed `flowviant` answers in seconds; anything longer is a restart
|
|
249
|
+
* nobody is watching, and the machine must not sit dark for it. */
|
|
250
|
+
const CONFIRM_TIMEOUT_MS = 20_000;
|
|
251
|
+
|
|
252
|
+
/** The picker's own budget. Longer than the confirm: this one asks you to READ
|
|
253
|
+
* a list before answering, and its fallback costs you a start rather than
|
|
254
|
+
* costing you a binding. */
|
|
255
|
+
const PICK_TIMEOUT_MS = 60_000;
|
|
242
256
|
const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
|
|
243
257
|
|
|
244
258
|
/** Re-exec a plain `flowviant` after an inline login — the login command's own
|
|
@@ -278,12 +292,22 @@ if (!FLEET_TOKEN) {
|
|
|
278
292
|
);
|
|
279
293
|
console.log(listLines(choices, creds));
|
|
280
294
|
console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
295
|
+
// Bounded like the confirm below, and for the same reason — but silence
|
|
296
|
+
// means something DIFFERENT here and the difference is load-bearing. There
|
|
297
|
+
// is a real ambiguity to resolve; serving a guess is the skadooble bug.
|
|
298
|
+
// So no answer REFUSES, which is exactly what this branch already does
|
|
299
|
+
// headless, and the message says how to answer without being present.
|
|
300
|
+
const raw = await askWithTimeout(
|
|
301
|
+
`Which project should this daemon serve? [1-${choices.length + 1}] `,
|
|
302
|
+
PICK_TIMEOUT_MS
|
|
303
|
+
);
|
|
304
|
+
if (raw === null) {
|
|
305
|
+
console.error(
|
|
306
|
+
`\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
|
|
307
|
+
`Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
|
|
308
|
+
);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
287
311
|
const n = Number.parseInt(raw, 10);
|
|
288
312
|
if (n === choices.length + 1) await reexecAfterLogin();
|
|
289
313
|
const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
|
|
@@ -321,17 +345,37 @@ if (!FLEET_TOKEN) {
|
|
|
321
345
|
// ONE stored project, never tied to a repo — the pre-0.55.0 world. Ask once;
|
|
322
346
|
// yes binds and every later start is silent. This is the exact question
|
|
323
347
|
// whose absence had a calendar checkout serving skadooble.
|
|
348
|
+
//
|
|
349
|
+
// AND IT TIMES OUT, because a prompt on a start path is a way for a machine
|
|
350
|
+
// to go dark. `FLOWVIANT_REEXEC` above covers the restart THIS version
|
|
351
|
+
// performs, but it cannot cover the one that matters most: the hop that
|
|
352
|
+
// installs a fixed daemon is spawned by the OLD one, which never sets it.
|
|
353
|
+
// 0.55.0 → 0.55.1 was exactly that — an auto-update landing unattended would
|
|
354
|
+
// stop here with the machine serving nothing. A guard that only works once
|
|
355
|
+
// everyone already has it is not a guard.
|
|
356
|
+
//
|
|
357
|
+
// ON TIMEOUT WE SERVE, AND WE DO NOT BIND. Those are two decisions:
|
|
358
|
+
// · SERVE, because it is what every version before 0.55.0 did with this
|
|
359
|
+
// exact store, so the silent path is the status quo rather than a new
|
|
360
|
+
// risk — and a daemon that answers is strictly better than one that does
|
|
361
|
+
// not, which is the whole reason this product has exactly one refusal.
|
|
362
|
+
// · DO NOT BIND, because binding is the thing the question was FOR. Nobody
|
|
363
|
+
// answered, so nothing is cemented; the next human start asks again. That
|
|
364
|
+
// keeps the skadooble case fixed for the person who is actually looking,
|
|
365
|
+
// which is the only person it could ever have been fixed for.
|
|
324
366
|
const creds = await import('./lib/credentials.mjs');
|
|
325
367
|
const label = creds.projectLabel(CREDENTIAL.entry);
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const raw = (
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
368
|
+
const answered = await askWithTimeout(
|
|
369
|
+
`This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `,
|
|
370
|
+
CONFIRM_TIMEOUT_MS
|
|
371
|
+
);
|
|
372
|
+
const raw = answered === null ? null : answered.toLowerCase(); // null = nobody answered
|
|
373
|
+
if (raw === null) {
|
|
374
|
+
console.log(
|
|
375
|
+
`\n no answer in ${Math.round(CONFIRM_TIMEOUT_MS / 1000)}s — serving ${label} for this run ` +
|
|
376
|
+
`without tying it to this repo. Run \`flowviant\` here and answer to make it stick.`
|
|
377
|
+
);
|
|
378
|
+
} else if (raw === '' || raw === 'y' || raw === 'yes') {
|
|
335
379
|
creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
|
|
336
380
|
} else {
|
|
337
381
|
console.error(
|
package/bin/lib/install.mjs
CHANGED
|
@@ -27,17 +27,23 @@ export function addLocalBinToPath() {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** TTY-guarded y/N. Non-interactive
|
|
31
|
-
*
|
|
30
|
+
/** TTY-guarded y/N. Non-interactive never auto-installs → returns false so a
|
|
31
|
+
* headless/cron run just prints the manual instructions instead.
|
|
32
|
+
*
|
|
33
|
+
* `canPrompt()` rather than `stdin.isTTY`, and BOUNDED: preflight runs before
|
|
34
|
+
* the daemon serves anything, so a question here that cannot be answered is a
|
|
35
|
+
* machine that never starts. A backgrounded job has a TTY and cannot be asked
|
|
36
|
+
* — the read raises SIGTTIN and the kernel stops the process. Silence is `no`,
|
|
37
|
+
* which is already what this returns when nobody is there. */
|
|
32
38
|
export async function promptYesNo(question, defaultYes) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
const { canPrompt, askWithTimeout } = await import('./tty.mjs');
|
|
40
|
+
if (!canPrompt()) return false;
|
|
41
|
+
const answer = await askWithTimeout(
|
|
42
|
+
`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `,
|
|
43
|
+
30_000
|
|
38
44
|
);
|
|
39
|
-
|
|
40
|
-
const a = answer.
|
|
45
|
+
if (answer === null) return false;
|
|
46
|
+
const a = answer.toLowerCase();
|
|
41
47
|
if (!a) return defaultYes;
|
|
42
48
|
return a === 'y' || a === 'yes';
|
|
43
49
|
}
|
package/bin/lib/tty.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NO PROMPT ON THE START PATH MAY STOP THE DAEMON.
|
|
3
|
+
*
|
|
4
|
+
* This exists because 0.55.2 added a 20-second timeout to the repo-binding
|
|
5
|
+
* confirm and it did not work in the one case that mattered most. `flowviant &`
|
|
6
|
+
* from an interactive shell puts the process in a background process group; the
|
|
7
|
+
* first TTY read raises SIGTTIN (and a TTY write SIGTTOU), whose DEFAULT
|
|
8
|
+
* disposition is to STOP the process. A stopped process runs no timers, so the
|
|
9
|
+
* AbortController never fires — the guard was on the wrong side of the thing it
|
|
10
|
+
* was guarding against. The shell prints `[1]+ Stopped` and nothing else: no
|
|
11
|
+
* banner, no error, no poll, forever, and `bg` does not rescue it.
|
|
12
|
+
*
|
|
13
|
+
* Two independent defences, because either one alone has a hole:
|
|
14
|
+
*
|
|
15
|
+
* 1. `canPrompt()` — do not ask at all unless we are the terminal's FOREGROUND
|
|
16
|
+
* process group. `stdin.isTTY` is true for a backgrounded job, so it cannot
|
|
17
|
+
* answer this on its own; the foreground group is what actually decides
|
|
18
|
+
* whether a read will succeed or be signalled.
|
|
19
|
+
*
|
|
20
|
+
* 2. `askWithTimeout()` — while asking, install no-op SIGTTIN/SIGTTOU handlers.
|
|
21
|
+
* A handler (even an empty one) replaces the default STOP, so a misjudged
|
|
22
|
+
* foreground check degrades to a read that fails or hangs — and a hang is
|
|
23
|
+
* something the timer can now actually interrupt, because the process is
|
|
24
|
+
* still running.
|
|
25
|
+
*
|
|
26
|
+
* The detection is best-effort by design and FAILS TOWARDS ASKING: an unknown
|
|
27
|
+
* platform returns true, because refusing to prompt a human who IS there is a
|
|
28
|
+
* worse failure than a prompt that times out on its own.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readFileSync } from 'node:fs';
|
|
32
|
+
import { execFileSync } from 'node:child_process';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Is this process in the controlling terminal's foreground process group?
|
|
36
|
+
*
|
|
37
|
+
* Linux: BOTH numbers come out of one `/proc/self/stat` read — field 5 is our
|
|
38
|
+
* own `pgrp` and field 8 is `tpgid`, the foreground group of our controlling
|
|
39
|
+
* terminal. Parsed from AFTER the last ')' because field 2 is the executable
|
|
40
|
+
* name and may itself contain spaces and parentheses.
|
|
41
|
+
*
|
|
42
|
+
* NOT `process.getpgrp()`: it DOES NOT EXIST in Node (verified on 24.16 — it
|
|
43
|
+
* throws `TypeError: process.getpgrp is not a function`). The first cut of this
|
|
44
|
+
* file called it, the throw was swallowed by the fail-open catch below, and the
|
|
45
|
+
* function therefore returned `true` unconditionally — a detector that always
|
|
46
|
+
* says "yes, a human is here" is not a detector, and only the signal handlers
|
|
47
|
+
* in `askWithTimeout` were doing any work. Caught by probing this function in
|
|
48
|
+
* isolation on a real pty; nothing else would have shown it, because the
|
|
49
|
+
* fallback it degraded to still behaves acceptably.
|
|
50
|
+
*
|
|
51
|
+
* Elsewhere: ask `ps` for both. Unknown: assume foreground (see the header).
|
|
52
|
+
*/
|
|
53
|
+
export function inForeground() {
|
|
54
|
+
try {
|
|
55
|
+
if (process.platform === 'linux') {
|
|
56
|
+
const stat = readFileSync('/proc/self/stat', 'utf8');
|
|
57
|
+
const after = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/);
|
|
58
|
+
const pgrp = Number(after[2]); // state ppid PGRP session tty_nr tpgid
|
|
59
|
+
const tpgid = Number(after[5]);
|
|
60
|
+
if (!Number.isFinite(tpgid) || !Number.isFinite(pgrp)) return true;
|
|
61
|
+
if (tpgid <= 0) return true; // no controlling terminal — nothing to be behind
|
|
62
|
+
return tpgid === pgrp;
|
|
63
|
+
}
|
|
64
|
+
const out = execFileSync('ps', ['-o', 'tpgid=,pgid=', '-p', String(process.pid)], {
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
67
|
+
timeout: 2000,
|
|
68
|
+
}).trim().split(/\s+/);
|
|
69
|
+
const tpgid = Number(out[0]);
|
|
70
|
+
const pgrp = Number(out[1]);
|
|
71
|
+
if (!Number.isFinite(tpgid) || !Number.isFinite(pgrp) || tpgid <= 0) return true;
|
|
72
|
+
return tpgid === pgrp;
|
|
73
|
+
} catch {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A human is at this terminal AND can actually be reached by a question. */
|
|
79
|
+
export function canPrompt() {
|
|
80
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY) && inForeground();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ask, and come back no matter what. Resolves the trimmed answer, or `null`
|
|
85
|
+
* when nobody answered within `timeoutMs` — the caller decides what silence
|
|
86
|
+
* means, because it is not the same answer everywhere (the binding confirm
|
|
87
|
+
* serves unbound; the project picker refuses, exactly as it does headless).
|
|
88
|
+
*/
|
|
89
|
+
export async function askWithTimeout(query, timeoutMs) {
|
|
90
|
+
const noop = () => {};
|
|
91
|
+
// Replacing the DEFAULT disposition is the whole point — an empty handler is
|
|
92
|
+
// enough, and it is what keeps the timer below able to run at all.
|
|
93
|
+
process.on('SIGTTIN', noop);
|
|
94
|
+
process.on('SIGTTOU', noop);
|
|
95
|
+
const rl = (await import('node:readline/promises')).createInterface({
|
|
96
|
+
input: process.stdin,
|
|
97
|
+
output: process.stdout,
|
|
98
|
+
});
|
|
99
|
+
const ac = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
101
|
+
try {
|
|
102
|
+
return (await rl.question(query, { signal: ac.signal })).trim();
|
|
103
|
+
} catch {
|
|
104
|
+
return null; // aborted, or the read failed because we are not in front
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
rl.close();
|
|
108
|
+
process.off('SIGTTIN', noop);
|
|
109
|
+
process.off('SIGTTOU', noop);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.3",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|