flowviant 0.55.2 → 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 +32 -28
- 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,13 +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';
|
|
242
246
|
|
|
243
247
|
/** How long the one-time binding confirm waits before serving unbound. A person
|
|
244
248
|
* who just typed `flowviant` answers in seconds; anything longer is a restart
|
|
245
249
|
* nobody is watching, and the machine must not sit dark for it. */
|
|
246
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;
|
|
247
256
|
const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
|
|
248
257
|
|
|
249
258
|
/** Re-exec a plain `flowviant` after an inline login — the login command's own
|
|
@@ -283,12 +292,22 @@ if (!FLEET_TOKEN) {
|
|
|
283
292
|
);
|
|
284
293
|
console.log(listLines(choices, creds));
|
|
285
294
|
console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
+
}
|
|
292
311
|
const n = Number.parseInt(raw, 10);
|
|
293
312
|
if (n === choices.length + 1) await reexecAfterLogin();
|
|
294
313
|
const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
|
|
@@ -346,26 +365,11 @@ if (!FLEET_TOKEN) {
|
|
|
346
365
|
// which is the only person it could ever have been fixed for.
|
|
347
366
|
const creds = await import('./lib/credentials.mjs');
|
|
348
367
|
const label = creds.projectLabel(CREDENTIAL.entry);
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
const timer = setTimeout(() => ac.abort(), CONFIRM_TIMEOUT_MS);
|
|
355
|
-
let raw = null; // null = nobody answered
|
|
356
|
-
try {
|
|
357
|
-
raw = (
|
|
358
|
-
await rl.question(
|
|
359
|
-
`This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `,
|
|
360
|
-
{ signal: ac.signal }
|
|
361
|
-
)
|
|
362
|
-
).trim().toLowerCase();
|
|
363
|
-
} catch {
|
|
364
|
-
/* aborted — nobody is at this terminal */
|
|
365
|
-
} finally {
|
|
366
|
-
clearTimeout(timer);
|
|
367
|
-
rl.close();
|
|
368
|
-
}
|
|
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
|
|
369
373
|
if (raw === null) {
|
|
370
374
|
console.log(
|
|
371
375
|
`\n no answer in ${Math.round(CONFIRM_TIMEOUT_MS / 1000)}s — serving ${label} for this run ` +
|
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": {
|