flowviant 0.91.0 → 0.91.2
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 +19 -25
- package/bin/lib/env-cli.mjs +3 -3
- package/bin/lib/env.mjs +207 -48
- package/bin/lib/tty.mjs +43 -17
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -350,15 +350,14 @@ if (process.argv[2] === 'env') {
|
|
|
350
350
|
const { canPrompt, askWithTimeout, selectMenu, menuSupported } = await import('./lib/tty.mjs');
|
|
351
351
|
const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
|
|
352
352
|
|
|
353
|
-
/**
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
const PICK_TIMEOUT_MS = 60_000;
|
|
353
|
+
/** NO ANSWER TIME LIMIT on the start path (2026-09-20, the owner: "why is
|
|
354
|
+
* there an answer time limit, remove that"). The confirm below carried 20s
|
|
355
|
+
* and the picker 60s, each arguing that a restart nobody is watching must
|
|
356
|
+
* not sit dark on a prompt — but `interactive` already excludes exactly that
|
|
357
|
+
* case (no foreground terminal, or a self-update re-exec, and nothing is
|
|
358
|
+
* asked). What was left was a person reading a list being told "no answer
|
|
359
|
+
* in 60s — nothing started". A prompt a person can see waits for the
|
|
360
|
+
* person; tty.mjs's header carries the argument. */
|
|
362
361
|
const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
|
|
363
362
|
|
|
364
363
|
/** Re-exec a plain `flowviant` after an inline login — the login command's own
|
|
@@ -437,15 +436,7 @@ if (!FLEET_TOKEN) {
|
|
|
437
436
|
const res = await selectMenu({
|
|
438
437
|
options,
|
|
439
438
|
defaultIndex: likely >= 0 ? likely : 0,
|
|
440
|
-
timeoutMs: PICK_TIMEOUT_MS,
|
|
441
439
|
});
|
|
442
|
-
if (res.timedOut) {
|
|
443
|
-
console.error(
|
|
444
|
-
`\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
|
|
445
|
-
`Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
|
|
446
|
-
);
|
|
447
|
-
process.exit(1);
|
|
448
|
-
}
|
|
449
440
|
if (res.cancelled) {
|
|
450
441
|
console.error('nothing chosen — nothing started.');
|
|
451
442
|
process.exit(1);
|
|
@@ -460,12 +451,13 @@ if (!FLEET_TOKEN) {
|
|
|
460
451
|
console.log(` ${choices.length + 1}. ${loginLabel}`);
|
|
461
452
|
const hint = likely >= 0 ? ` (enter for ${creds.projectLabel(choices[likely])})` : '';
|
|
462
453
|
const raw = await askWithTimeout(
|
|
463
|
-
`Which project should this daemon serve? [1-${choices.length + 1}]${hint}
|
|
464
|
-
PICK_TIMEOUT_MS
|
|
454
|
+
`Which project should this daemon serve? [1-${choices.length + 1}]${hint} `
|
|
465
455
|
);
|
|
466
456
|
if (raw === null) {
|
|
457
|
+
// The read itself failed — stdin closed, or the terminal went away
|
|
458
|
+
// mid-question. Not a timeout: there is none.
|
|
467
459
|
console.error(
|
|
468
|
-
`\
|
|
460
|
+
`\ncould not read an answer — nothing started. ` +
|
|
469
461
|
`Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
|
|
470
462
|
);
|
|
471
463
|
process.exit(1);
|
|
@@ -519,7 +511,10 @@ if (!FLEET_TOKEN) {
|
|
|
519
511
|
// stop here with the machine serving nothing. A guard that only works once
|
|
520
512
|
// everyone already has it is not a guard.
|
|
521
513
|
//
|
|
522
|
-
//
|
|
514
|
+
// IF THE READ FAILS WE SERVE, AND WE DO NOT BIND. (This used to say "on
|
|
515
|
+
// timeout"; there is no timeout since 2026-09-20 — a person at the prompt
|
|
516
|
+
// is waited for. `null` now means stdin closed under the question.) Those
|
|
517
|
+
// are two decisions:
|
|
523
518
|
// · SERVE, because it is what every version before 0.55.0 did with this
|
|
524
519
|
// exact store, so the silent path is the status quo rather than a new
|
|
525
520
|
// risk — and a daemon that answers is strictly better than one that does
|
|
@@ -531,13 +526,12 @@ if (!FLEET_TOKEN) {
|
|
|
531
526
|
const creds = await import('./lib/credentials.mjs');
|
|
532
527
|
const label = creds.projectLabel(CREDENTIAL.entry);
|
|
533
528
|
const answered = await askWithTimeout(
|
|
534
|
-
`This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n]
|
|
535
|
-
CONFIRM_TIMEOUT_MS
|
|
529
|
+
`This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `
|
|
536
530
|
);
|
|
537
|
-
const raw = answered === null ? null : answered.toLowerCase(); // null =
|
|
531
|
+
const raw = answered === null ? null : answered.toLowerCase(); // null = the read failed
|
|
538
532
|
if (raw === null) {
|
|
539
533
|
console.log(
|
|
540
|
-
`\n
|
|
534
|
+
`\n could not read an answer — serving ${label} for this run ` +
|
|
541
535
|
`without tying it to this repo. Run \`flowviant\` here and answer to make it stick.`
|
|
542
536
|
);
|
|
543
537
|
} else if (raw === '' || raw === 'y' || raw === 'yes') {
|
package/bin/lib/env-cli.mjs
CHANGED
|
@@ -130,7 +130,7 @@ export async function runEnvCommand(args) {
|
|
|
130
130
|
// that file in every worktree.
|
|
131
131
|
const targetFile = argAfter(args, '--file') ?? file.replace(/^\.\//, '');
|
|
132
132
|
const bundle = await fetchBundle();
|
|
133
|
-
if (bundle.status !== 'enrolled') die('this machine is not enrolled —
|
|
133
|
+
if (bundle.status !== 'enrolled') die('this machine is not enrolled yet — leave `flowviant` running in this project; enrolment happens on its own within a poll or two.');
|
|
134
134
|
if (!bundle.projectPub) die('no project env keypair yet — start the daemon once to bootstrap it.');
|
|
135
135
|
const existing = new Map(bundle.keys.map((k) => [k.name, k]));
|
|
136
136
|
let added = 0;
|
|
@@ -159,7 +159,7 @@ export async function runEnvCommand(args) {
|
|
|
159
159
|
const name = args[1];
|
|
160
160
|
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) die('usage: flowviant env set <KEY> [--file <targetFile>]');
|
|
161
161
|
const bundle = await fetchBundle();
|
|
162
|
-
if (bundle.status !== 'enrolled') die('this machine is not enrolled —
|
|
162
|
+
if (bundle.status !== 'enrolled') die('this machine is not enrolled yet — leave `flowviant` running in this project; enrolment happens on its own within a poll or two.');
|
|
163
163
|
if (!bundle.projectPub) die('no project env keypair yet — start the daemon once to bootstrap it.');
|
|
164
164
|
const prior = bundle.keys.find((k) => k.name === name);
|
|
165
165
|
const targetFile = argAfter(args, '--file') ?? prior?.targetFile ?? '.env';
|
|
@@ -179,7 +179,7 @@ export async function runEnvCommand(args) {
|
|
|
179
179
|
|
|
180
180
|
if (cmd === 'show') {
|
|
181
181
|
const bundle = await fetchBundle();
|
|
182
|
-
if (bundle.status !== 'enrolled') die('this machine is not enrolled —
|
|
182
|
+
if (bundle.status !== 'enrolled') die('this machine is not enrolled yet — leave `flowviant` running in this project; enrolment happens on its own within a poll or two.');
|
|
183
183
|
if (!bundle.wrappedPriv) die('no key material for this machine yet.');
|
|
184
184
|
const kp = await ensureKeypair();
|
|
185
185
|
const priv = sodium.crypto_box_seal_open(
|
package/bin/lib/env.mjs
CHANGED
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
* server stores is ciphertext it cannot open.
|
|
6
6
|
*
|
|
7
7
|
* Duties per roster tick (handleRosterEnv):
|
|
8
|
-
* - register this machine's pubkey (once) →
|
|
8
|
+
* - register this machine's pubkey (once) → that IS the enrolment (server
|
|
9
|
+
* 2026-09-20): the box already holds this project's machine credential,
|
|
10
|
+
* which a person handed it by typing a device code into the app, so there is
|
|
11
|
+
* nothing left for anybody to approve. Nobody clicks; nothing is printed
|
|
12
|
+
* except one quiet line.
|
|
9
13
|
* - bootstrap the project keypair when none exists (first machine): generate
|
|
10
|
-
* it + a standing RECOVERY keypair wrapped under a one-time passphrase
|
|
11
|
-
*
|
|
12
|
-
*
|
|
14
|
+
* it + a standing RECOVERY keypair wrapped under a one-time passphrase —
|
|
15
|
+
* rotations re-seal to the same recovery pub, so that passphrase survives
|
|
16
|
+
* forever. Bootstrap itself is SILENT since 2026-09-20: the passphrase is
|
|
17
|
+
* parked in this box's keypair file and printed the first time this box
|
|
18
|
+
* materializes a secret, which is the first moment it is about anything
|
|
19
|
+
* (`stashRecoveryCode`).
|
|
13
20
|
* - sync: on a bundle version change, unwrap the priv, open every sealed
|
|
14
21
|
* value, cache (encrypted under a key derived from our own priv), and
|
|
15
22
|
* rematerialize env files into the agent worktrees.
|
|
16
|
-
* - execute wrap jobs (
|
|
23
|
+
* - execute wrap jobs (another box registered → seal the priv to it).
|
|
17
24
|
* - execute rotations (a machine was revoked → new keypair, re-seal all
|
|
18
25
|
* values, re-wrap every enrolled machine, re-seal recovery).
|
|
19
26
|
*
|
|
@@ -66,33 +73,18 @@ export async function sodiumReady() {
|
|
|
66
73
|
await sodium.ready;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
*
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
h1 = Math.imul(h1 ^ ch, 0x01000193) >>> 0;
|
|
82
|
-
h2 = Math.imul(h2 ^ ch, 0x85ebca6b) >>> 0;
|
|
83
|
-
}
|
|
84
|
-
let out = '';
|
|
85
|
-
for (let i = 0; i < 8; i++) {
|
|
86
|
-
let x = (((i < 4 ? h1 : h2) + i * 0x9e3779b1) >>> 0);
|
|
87
|
-
x ^= x >>> 16;
|
|
88
|
-
x = Math.imul(x, 0x7feb352d) >>> 0;
|
|
89
|
-
x ^= x >>> 15;
|
|
90
|
-
x = Math.imul(x, 0x846ca68b) >>> 0;
|
|
91
|
-
x ^= x >>> 16;
|
|
92
|
-
out += FP_EMOJI[x & 31];
|
|
93
|
-
}
|
|
94
|
-
return out;
|
|
95
|
-
}
|
|
76
|
+
/*
|
|
77
|
+
* `pubkeyEmoji` IS DELETED (2026-09-20), and so is its byte-identical twin in
|
|
78
|
+
* the web app.
|
|
79
|
+
*
|
|
80
|
+
* It existed for exactly one gesture: the terminal printed eight glyphs, the
|
|
81
|
+
* browser's approve card printed the same eight, and a human compared them
|
|
82
|
+
* before pressing Approve. Registering IS enrolling now — the device code that
|
|
83
|
+
* gave this box the project's machine credential was the decision — so there is
|
|
84
|
+
* no approve card, no comparison, and nobody to make it. A fingerprint kept
|
|
85
|
+
* byte-identical across two repos for nobody to look at is worse than none:
|
|
86
|
+
* it reads like a live MITM defence and defends nothing.
|
|
87
|
+
*/
|
|
96
88
|
|
|
97
89
|
/** This machine's persistent keypair (created on first use, 0600). */
|
|
98
90
|
export async function ensureKeypair() {
|
|
@@ -171,6 +163,120 @@ export function readStoredPubB64() {
|
|
|
171
163
|
}
|
|
172
164
|
}
|
|
173
165
|
|
|
166
|
+
/**
|
|
167
|
+
* ── THE RECOVERY CODE WAITS UNTIL THERE IS SOMETHING TO RECOVER (2026-09-20) ──
|
|
168
|
+
*
|
|
169
|
+
* `bootstrapProject` used to end with the loudest artefact this product owns:
|
|
170
|
+
* a blank line, RECOVERY CODE in bold, a passphrase in yellow, and "the only
|
|
171
|
+
* way back into the secrets". That fired on the FIRST DAEMON OF EVERY PROJECT
|
|
172
|
+
* — a keypair is bootstrapped whether or not a single secret exists — so the
|
|
173
|
+
* overwhelming majority of the people who saw it were being handed a code for
|
|
174
|
+
* an empty vault, in custody vocabulary they had not asked for, having run
|
|
175
|
+
* `npx flowviant` to connect a machine. That is the same complaint the approve
|
|
176
|
+
* gate died of, printed instead of clicked, and leaving it in would have
|
|
177
|
+
* contradicted the change it shipped beside.
|
|
178
|
+
*
|
|
179
|
+
* So bootstrap is SILENT (one quiet line that the keypair exists) and the code
|
|
180
|
+
* is kept HERE, in this box's own keypair file, until the first time this box
|
|
181
|
+
* materializes a secret — the moment the person actually has something to
|
|
182
|
+
* protect, and the first moment the sentence is true.
|
|
183
|
+
*
|
|
184
|
+
* THE FILE IS THE RIGHT PLACE AND COSTS NOTHING. It already holds the private
|
|
185
|
+
* key the passphrase would recover — anybody who can read one can read the
|
|
186
|
+
* other — so storing it there adds no exposure that was not already the whole
|
|
187
|
+
* security model of this machine. It is 0600 and stays 0600.
|
|
188
|
+
*
|
|
189
|
+
* EXTENDING THE SHAPE IS SAFE. `ensureKeypair` reads `pub` and `priv` and
|
|
190
|
+
* ignores every other key, and it only WRITES the file when there is none, so
|
|
191
|
+
* an older daemon reading a file with a `recovery` field behaves identically
|
|
192
|
+
* and cannot clobber it.
|
|
193
|
+
*
|
|
194
|
+
* THE ONE ACCEPTED COST, stated: a box that bootstraps and never materializes
|
|
195
|
+
* anything never prints the code. That is the point — there is nothing to
|
|
196
|
+
* recover — and the code is not lost, it is on this disk beside the key.
|
|
197
|
+
*/
|
|
198
|
+
function readKeypairFile() {
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8'));
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 0600 on every write, not just at creation: `writeFileSync`'s `mode` applies
|
|
207
|
+
* only when the file is created, which is the same trap `materializeInto`
|
|
208
|
+
* documents. Returns whether it landed — the caller has to know, because the
|
|
209
|
+
* thing being written is unrecoverable if it does not. */
|
|
210
|
+
function writeKeypairFile(stored) {
|
|
211
|
+
try {
|
|
212
|
+
writeFileSync(KEYPAIR_PATH, JSON.stringify(stored), { mode: 0o600 });
|
|
213
|
+
try {
|
|
214
|
+
chmodSync(KEYPAIR_PATH, 0o600);
|
|
215
|
+
} catch {
|
|
216
|
+
/* a filesystem without modes is not a reason to refuse */
|
|
217
|
+
}
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Park the bootstrap passphrase beside the key it recovers. False means it did
|
|
225
|
+
* NOT land, and the caller's answer to that is to print it immediately —
|
|
226
|
+
* losing a recovery code silently is the one outcome worse than printing it
|
|
227
|
+
* early. */
|
|
228
|
+
export function stashRecoveryCode(code, projectId) {
|
|
229
|
+
const stored = readKeypairFile();
|
|
230
|
+
if (!stored?.priv) return false;
|
|
231
|
+
stored.recovery = { code, projectId: projectId ?? null, shown: false };
|
|
232
|
+
return writeKeypairFile(stored);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Once per process at most, and once per box for real. */
|
|
236
|
+
let recoveryUnprinted = true;
|
|
237
|
+
|
|
238
|
+
function printRecoveryBlock(code) {
|
|
239
|
+
console.log('');
|
|
240
|
+
ok(`${c.cyan('env')} — this machine now holds this project's secrets.`);
|
|
241
|
+
console.log(` ${c.bold('RECOVERY CODE')} ${c.dim('(shown ONCE — save it in a password manager):')}`);
|
|
242
|
+
console.log(` ${c.bold(c.yellow(code))}`);
|
|
243
|
+
note(' If every enrolled machine is ever lost, this code is the only way back into the secrets.');
|
|
244
|
+
console.log('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Print the parked code the first time this box writes a secret to disk, then
|
|
249
|
+
* mark it shown so it never prints again.
|
|
250
|
+
*
|
|
251
|
+
* The mark is written to the FILE, not just to the module flag, because "once"
|
|
252
|
+
* has to survive a restart. If that write fails the flag still stops this
|
|
253
|
+
* process from repeating itself and a later process prints again — noisy in
|
|
254
|
+
* the safe direction, which is the only direction available when the thing at
|
|
255
|
+
* stake is the only way back into somebody's secrets.
|
|
256
|
+
*
|
|
257
|
+
* The parked `projectId` is checked because the keypair file is per BOX while
|
|
258
|
+
* the code is per PROJECT: a daemon serves one project, so a mismatch means
|
|
259
|
+
* this code belongs to a different one and printing it here would attribute it
|
|
260
|
+
* to the wrong vault. An older stash carries no id and prints regardless.
|
|
261
|
+
*/
|
|
262
|
+
function printRecoveryCodeOnce() {
|
|
263
|
+
if (!recoveryUnprinted) return;
|
|
264
|
+
const stored = readKeypairFile();
|
|
265
|
+
const rec = stored?.recovery;
|
|
266
|
+
if (!rec?.code || rec.shown) {
|
|
267
|
+
recoveryUnprinted = false;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (rec.projectId && cachedProjectId && rec.projectId !== cachedProjectId) {
|
|
271
|
+
recoveryUnprinted = false; // one project per daemon — this will not change
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
recoveryUnprinted = false;
|
|
275
|
+
printRecoveryBlock(rec.code);
|
|
276
|
+
stored.recovery = { ...rec, shown: true };
|
|
277
|
+
writeKeypairFile(stored);
|
|
278
|
+
}
|
|
279
|
+
|
|
174
280
|
/** Query params the roster poll carries: identity, materialized version, and
|
|
175
281
|
* the target files we REFUSED to write.
|
|
176
282
|
*
|
|
@@ -623,6 +729,13 @@ export function materializeInto(wt) {
|
|
|
623
729
|
if (anyProblem) cleanWorktrees.delete(wt);
|
|
624
730
|
else cleanWorktrees.add(wt);
|
|
625
731
|
everMaterialized = true;
|
|
732
|
+
|
|
733
|
+
// THE FIRST SECRET THIS BOX EVER WROTE TO DISK is the moment the recovery
|
|
734
|
+
// code stops being a warning about nothing — see `printRecoveryCodeOnce`.
|
|
735
|
+
// Gated on `written`, not on reaching this line: a pass that refused every
|
|
736
|
+
// file for a git reason materialized nothing, and the sentence would be
|
|
737
|
+
// false. Nothing is printed on any later pass.
|
|
738
|
+
if (written.length > 0) printRecoveryCodeOnce();
|
|
626
739
|
}
|
|
627
740
|
|
|
628
741
|
|
|
@@ -664,8 +777,9 @@ export async function handleRosterEnv(env, { projectId } = {}) {
|
|
|
664
777
|
// wedge registration until restart.
|
|
665
778
|
if (env.status === 'none' && !registeredOnce) {
|
|
666
779
|
const label = hostname() || 'daemon';
|
|
780
|
+
let registered = null;
|
|
667
781
|
try {
|
|
668
|
-
await post('register', { pubkey: myPubB64(), label });
|
|
782
|
+
registered = await post('register', { pubkey: myPubB64(), label });
|
|
669
783
|
} catch (e) {
|
|
670
784
|
// A 429 = the project is at its machine cap; retrying every poll would
|
|
671
785
|
// just hammer it. Stop for this session (a restart re-tries).
|
|
@@ -677,12 +791,45 @@ export async function handleRosterEnv(env, { projectId } = {}) {
|
|
|
677
791
|
throw e; // transient — retry next poll (registeredOnce still false)
|
|
678
792
|
}
|
|
679
793
|
registeredOnce = true;
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
794
|
+
/*
|
|
795
|
+
* ONE LINE, AND IT RELAYS WHAT THE SERVER ANSWERED.
|
|
796
|
+
*
|
|
797
|
+
* It used to print a key fingerprint and say "an admin approves it in
|
|
798
|
+
* Settings → Environment (compare the emoji)" — an instruction for a
|
|
799
|
+
* button that no longer exists, about a comparison nobody was making, on
|
|
800
|
+
* top of a credential this box was already trusted with. What replaced it
|
|
801
|
+
* asserted the opposite and just as blindly: "enrolled … secrets sync to
|
|
802
|
+
* this box automatically", printed whatever the response said. A fresh
|
|
803
|
+
* registration comes back `approved`, not `enrolled` — the key is not
|
|
804
|
+
* here yet and arrives only once some box that holds it polls — so the
|
|
805
|
+
* line was claiming a state this box was one or more ticks away from, and
|
|
806
|
+
* on an OLDER server (which still answers `pending`) it was claiming one
|
|
807
|
+
* the box would never reach at all.
|
|
808
|
+
*
|
|
809
|
+
* So the status is read off the response and each state says its own
|
|
810
|
+
* true sentence, and an answer we do not recognise — or no body at all —
|
|
811
|
+
* says only the part we measured: the registration went out.
|
|
812
|
+
*/
|
|
813
|
+
const registeredAs = `${c.cyan('env')} · registered as ${c.bold(label)}`;
|
|
814
|
+
if (registered?.status === 'enrolled') {
|
|
815
|
+
info(`${c.cyan('env')} · enrolled as ${c.bold(label)} — secrets sync to this box automatically`);
|
|
816
|
+
} else if (registered?.status === 'approved') {
|
|
817
|
+
info(`${registeredAs} — secrets sync here as soon as a machine holding the key is online`);
|
|
818
|
+
} else if (registered?.status === 'pending') {
|
|
819
|
+
// An older server, which still has the approve gate. Say what IT is
|
|
820
|
+
// waiting on rather than what we are: this daemon cannot clear it.
|
|
821
|
+
info(`${registeredAs} — this server is waiting on an approval in Settings`);
|
|
822
|
+
} else {
|
|
823
|
+
info(registeredAs);
|
|
824
|
+
}
|
|
683
825
|
return { changed: false };
|
|
684
826
|
}
|
|
685
|
-
|
|
827
|
+
// A HARMLESS WAIT, and the server no longer produces this: registering IS
|
|
828
|
+
// enrolling since 2026-09-20, and a row left `pending` by the old gate is
|
|
829
|
+
// promoted on the register above. Kept because an OLDER server still
|
|
830
|
+
// answers `pending`, and a daemon must not treat an unrecognised state as a
|
|
831
|
+
// reason to act.
|
|
832
|
+
if (env.status === 'pending') return { changed: false };
|
|
686
833
|
if (env.status === 'revoked') return { changed: false };
|
|
687
834
|
|
|
688
835
|
// 2. Bootstrap: no project keypair exists — this machine creates it.
|
|
@@ -701,7 +848,9 @@ export async function handleRosterEnv(env, { projectId } = {}) {
|
|
|
701
848
|
projectPriv = openSealed(bundle.wrappedPriv, keypair.publicKey, keypair.privateKey);
|
|
702
849
|
const projectPub = sodium.from_base64(bundle.projectPub, B64());
|
|
703
850
|
|
|
704
|
-
// Execute
|
|
851
|
+
// Execute pending enrollments: seal the priv to each newly registered
|
|
852
|
+
// machine. Registering is what puts a box on this list now — there is no
|
|
853
|
+
// approval step between the two. The
|
|
705
854
|
// wrap's epoch rides along — the server rejects (stale) if a rotation moved
|
|
706
855
|
// it since we fetched, so nobody enrolls with a dead key.
|
|
707
856
|
if (bundle.pendingWraps.length) {
|
|
@@ -711,7 +860,7 @@ export async function handleRosterEnv(env, { projectId } = {}) {
|
|
|
711
860
|
}));
|
|
712
861
|
const res = await post('wraps', { pubkey: myPubB64(), keyEpoch: bundle.keyEpoch, wraps });
|
|
713
862
|
if (res?.stale) note(`${c.cyan('env')} ${c.dim('— wraps raced a rotation; retrying next poll')}`);
|
|
714
|
-
else ok(`${c.cyan('env')} ${c.dim(`— delivered the key to ${wraps.length} newly
|
|
863
|
+
else ok(`${c.cyan('env')} ${c.dim(`— delivered the key to ${wraps.length} newly registered machine${wraps.length === 1 ? '' : 's'}`)}`);
|
|
715
864
|
}
|
|
716
865
|
|
|
717
866
|
// Decrypt the values we have — carrying each key's VERSION so a rotation can
|
|
@@ -777,9 +926,15 @@ export async function handleRosterEnv(env, { projectId } = {}) {
|
|
|
777
926
|
}
|
|
778
927
|
}
|
|
779
928
|
|
|
780
|
-
/**
|
|
781
|
-
*
|
|
782
|
-
*
|
|
929
|
+
/**
|
|
930
|
+
* First machine creates the project keypair + the standing recovery target.
|
|
931
|
+
*
|
|
932
|
+
* THIS IS SILENT (2026-09-20). Rotations re-seal to the same recovery pub, so
|
|
933
|
+
* the passphrase minted here works forever — which is exactly why it does not
|
|
934
|
+
* have to be shouted at somebody who has no secrets yet. It is parked in this
|
|
935
|
+
* box's keypair file and printed the first time this box materializes a
|
|
936
|
+
* secret; `stashRecoveryCode`'s docblock carries the whole argument.
|
|
937
|
+
*/
|
|
783
938
|
async function bootstrapProject() {
|
|
784
939
|
const project = sodium.crypto_box_keypair();
|
|
785
940
|
const recovery = sodium.crypto_box_keypair();
|
|
@@ -816,10 +971,14 @@ async function bootstrapProject() {
|
|
|
816
971
|
recoverySealed: seal(project.privateKey, sodium.to_base64(recovery.publicKey, B64())),
|
|
817
972
|
});
|
|
818
973
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
974
|
+
// ONE QUIET LINE. The code goes into the keypair file and waits for the
|
|
975
|
+
// first secret. A stash that did NOT land is the one case that prints now:
|
|
976
|
+
// the passphrase exists only in this closure, and losing the only way back
|
|
977
|
+
// into a project's secrets to keep the terminal tidy is not a trade.
|
|
978
|
+
if (stashRecoveryCode(passphrase, cachedProjectId)) {
|
|
979
|
+
ok(`${c.cyan('env')} ${c.dim('— created this project\'s env keypair')}`);
|
|
980
|
+
} else {
|
|
981
|
+
warn('env: could not save the recovery code to this machine — here it is, once:');
|
|
982
|
+
printRecoveryBlock(passphrase);
|
|
983
|
+
}
|
|
825
984
|
}
|
package/bin/lib/tty.mjs
CHANGED
|
@@ -19,13 +19,29 @@
|
|
|
19
19
|
*
|
|
20
20
|
* 2. `askWithTimeout()` — while asking, install no-op SIGTTIN/SIGTTOU handlers.
|
|
21
21
|
* A handler (even an empty one) replaces the default STOP, so a misjudged
|
|
22
|
-
* foreground check degrades to a read that fails
|
|
23
|
-
*
|
|
24
|
-
* still running.
|
|
22
|
+
* foreground check degrades to a read that fails rather than a process
|
|
23
|
+
* that is frozen.
|
|
25
24
|
*
|
|
26
25
|
* The detection is best-effort by design and FAILS TOWARDS ASKING: an unknown
|
|
27
26
|
* platform returns true, because refusing to prompt a human who IS there is a
|
|
28
|
-
* worse failure than a prompt that
|
|
27
|
+
* worse failure than a prompt that waits.
|
|
28
|
+
*
|
|
29
|
+
* A PROMPT A PERSON IS LOOKING AT WAITS FOR THE PERSON (2026-09-20). Both
|
|
30
|
+
* helpers used to carry a mandatory timer — 20s on the binding confirm, 60s
|
|
31
|
+
* on the project picker — on the argument that "anything longer is a restart
|
|
32
|
+
* nobody is watching, and the machine must not sit dark for it". That
|
|
33
|
+
* argument was already answered one layer up: the start path never asks at
|
|
34
|
+
* all unless `canPrompt()` says a foreground terminal is attached AND the
|
|
35
|
+
* process is not a self-update re-exec, so the unattended restart the timer
|
|
36
|
+
* was defending against cannot reach a prompt. What the timer actually did
|
|
37
|
+
* was refuse a person who went to read the list, make a coffee, or answer a
|
|
38
|
+
* message — "no answer in 60s — nothing started" — on the one path where
|
|
39
|
+
* nothing has been started yet and waiting costs nothing. The owner's ruling,
|
|
40
|
+
* verbatim: "why is there an answer time limit, remove that." So `timeoutMs`
|
|
41
|
+
* is OPTIONAL now and the start path passes none; a caller that has a real
|
|
42
|
+
* reason to bound a wait (the install prompt, where silence is "no") may
|
|
43
|
+
* still pass one. The signal handlers stay — they were never about the timer,
|
|
44
|
+
* they are what keeps a backgrounded job from freezing.
|
|
29
45
|
*/
|
|
30
46
|
|
|
31
47
|
import { readFileSync } from 'node:fs';
|
|
@@ -80,11 +96,21 @@ export function canPrompt() {
|
|
|
80
96
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY) && inForeground();
|
|
81
97
|
}
|
|
82
98
|
|
|
99
|
+
/** A timer only when a caller asked for one. `clearTimeout(null)` is a no-op,
|
|
100
|
+
* so the finally blocks need no branch. Non-finite or non-positive means
|
|
101
|
+
* "wait for the person". */
|
|
102
|
+
export function boundedTimer(timeoutMs, fire) {
|
|
103
|
+
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(fire, timeoutMs) : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
83
106
|
/**
|
|
84
|
-
* Ask, and come back
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
107
|
+
* Ask, and come back with an answer or a refusal. Resolves the trimmed
|
|
108
|
+
* answer, or `null` when the read failed (stdin closed under us, the terminal
|
|
109
|
+
* went away) or — only when a caller passed a finite `timeoutMs` — when nobody
|
|
110
|
+
* answered in time. With no `timeoutMs` it waits for the person; see the
|
|
111
|
+
* header. The caller decides what `null` means, because it is not the same
|
|
112
|
+
* answer everywhere (the binding confirm serves unbound; the project picker
|
|
113
|
+
* refuses, exactly as it does headless).
|
|
88
114
|
*/
|
|
89
115
|
export async function askWithTimeout(query, timeoutMs) {
|
|
90
116
|
const noop = () => {};
|
|
@@ -97,7 +123,7 @@ export async function askWithTimeout(query, timeoutMs) {
|
|
|
97
123
|
output: process.stdout,
|
|
98
124
|
});
|
|
99
125
|
const ac = new AbortController();
|
|
100
|
-
const timer =
|
|
126
|
+
const timer = boundedTimer(timeoutMs, () => ac.abort());
|
|
101
127
|
try {
|
|
102
128
|
return (await rl.question(query, { signal: ac.signal })).trim();
|
|
103
129
|
} catch {
|
|
@@ -153,15 +179,15 @@ const MENU_FOOTER = '↑/↓ move · enter select · 1–9 jump · esc cancel';
|
|
|
153
179
|
* An arrow-navigable picker for the start path. Resolves one of:
|
|
154
180
|
* { index } the row taken
|
|
155
181
|
* { cancelled } Esc/q/Ctrl-C
|
|
156
|
-
* { timedOut } nobody drove it
|
|
182
|
+
* { timedOut } a caller passed `timeoutMs` and nobody drove it in time
|
|
183
|
+
* (the start path passes none — see the header)
|
|
157
184
|
* { unsupported } no raw mode — the caller uses the numeric prompt instead
|
|
158
185
|
*
|
|
159
|
-
* Held to this file's one law — no start-path prompt may
|
|
160
|
-
* same way `askWithTimeout` is: it
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* ever reaching here.
|
|
186
|
+
* Held to this file's one law — no start-path prompt may STOP the daemon — the
|
|
187
|
+
* same way `askWithTimeout` is: it installs the SIGTTIN/SIGTTOU no-ops,
|
|
188
|
+
* restores the terminal in every exit, and refuses (returns `unsupported`)
|
|
189
|
+
* rather than half-drawing when raw mode is not really there. The caller still
|
|
190
|
+
* gates on `canPrompt()` before ever reaching here.
|
|
165
191
|
*/
|
|
166
192
|
export async function selectMenu({ options, defaultIndex = 0, timeoutMs }) {
|
|
167
193
|
if (!menuSupported()) return { unsupported: true };
|
|
@@ -220,7 +246,7 @@ export async function selectMenu({ options, defaultIndex = 0, timeoutMs }) {
|
|
|
220
246
|
};
|
|
221
247
|
process.on('SIGTTIN', noop);
|
|
222
248
|
process.on('SIGTTOU', noop);
|
|
223
|
-
const timer =
|
|
249
|
+
const timer = boundedTimer(timeoutMs, () => finish({ timedOut: true }));
|
|
224
250
|
try {
|
|
225
251
|
stdin.setRawMode(true);
|
|
226
252
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.91.
|
|
3
|
+
"version": "0.91.2",
|
|
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": {
|