flowviant 0.91.2 → 0.92.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.
@@ -23,11 +23,13 @@
23
23
  *
24
24
  * ── IT IS A RELAY ──
25
25
  *
26
- * The role words (`serving`, `standing by`, `idle`) are the SERVER'S, computed
27
- * in one place so this command, project settings and the account's Home block
28
- * cannot disagree about what a box is doing. Nothing here re-derives a state, and
29
- * a project the server could not answer for says so rather than rendering as
30
- * empty.
26
+ * The role words are the SERVER'S — `serving` and `inactive` today — computed in
27
+ * one place so this command, project settings and the account's Home block
28
+ * cannot disagree about what a box is doing. They are PRINTED AS SENT: there is
29
+ * no mapping table here and no default word, because either is this command
30
+ * having an opinion about a state it did not measure. Nothing here re-derives a
31
+ * state, and a project the server could not answer for says so rather than
32
+ * rendering as empty.
31
33
  *
32
34
  * ── RENDERING IS PURE, FETCHING IS NOT ──
33
35
  *
@@ -113,11 +115,29 @@ export function connectedOn(iso) {
113
115
  return new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
114
116
  }
115
117
 
116
- /** The server's own role word, spelled for a terminal. Unknown values pass
117
- * through untouched: this is a relay, and a role this build has never heard of
118
- * is still the server's answer. */
118
+ /**
119
+ * THE SERVER'S OWN ROLE WORD, PRINTED. A pure relay and nothing else.
120
+ *
121
+ * It carried one special case until 2026-09-21: `'standing-by'` was respelled
122
+ * as `standing by`, because the server sent a hyphenated enum value and a
123
+ * terminal should not print an identifier. The owner replaced that word —
124
+ * asked whether a box should keep saying "standing by", he answered *"no, it
125
+ * can [be] inactive instead"* — so the server sends `serving` and `inactive`,
126
+ * both of which are already words, and the special case had nothing left to
127
+ * translate.
128
+ *
129
+ * IT IS NOT KEPT "IN CASE": a mapping table in a relay is a place for the two
130
+ * ends to disagree, and a stale entry would print one thing while the app
131
+ * printed another about the same box — which is precisely the confusion this
132
+ * command exists to end. A role this build has never heard of passes through
133
+ * untouched for the same reason.
134
+ *
135
+ * An ABSENT role prints nothing rather than a guess. It used to default to
136
+ * `idle`, which is a CLAIM — "the server told us this box is doing nothing" —
137
+ * about an answer the server did not give.
138
+ */
119
139
  function roleWord(role) {
120
- return role === 'standing-by' ? 'standing by' : String(role ?? 'idle');
140
+ return typeof role === 'string' && role.trim() ? role.trim() : '';
121
141
  }
122
142
 
123
143
  /** One box, as a line under its project. The MARK is the only derived thing on
@@ -129,13 +149,18 @@ export function renderBox(box, { me = null, latest = null } = {}, now = Date.now
129
149
  // "LAST heard" is the staleness mark, and the row is never hidden for it —
130
150
  // the owner's answer to "what about a box that stopped" was exactly this.
131
151
  const heard = heardPhrase(box, now);
152
+ // An unreported role DROPS ITS CELL. Every other cell on this row states its
153
+ // own absence ("(checkout not reported)") because a reader who cannot see a
154
+ // path needs to know nobody measured one; a role is different — the row is
155
+ // already carrying the mark, the version and the last-heard, and a
156
+ // placeholder there would be four words of nothing in the widest column.
132
157
  const cells = [
133
158
  `${mark} ${name}`,
134
159
  roleWord(box.role),
135
160
  box.checkoutPath || '(checkout not reported)',
136
161
  box.daemonVersion ? `v${box.daemonVersion}` : 'version not reported',
137
162
  heard,
138
- ];
163
+ ].filter(Boolean);
139
164
  let line = ` ${cells.join(' ')}`;
140
165
  if (me && box.boxId === me) line += ' ← this box';
141
166
  // Said only when the server said it, and it names the version that is
@@ -262,9 +287,10 @@ export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch } =
262
287
  export function otherBoxesLine(boxes, me, now = Date.now()) {
263
288
  const others = (Array.isArray(boxes) ? boxes : []).filter((b) => b && b.boxId !== me);
264
289
  if (others.length === 0) return null;
265
- const parts = others.slice(0, 6).map(
266
- (b) => `${b.boxName || 'an unnamed machine'} (${roleWord(b.role)} · ${heardPhrase(b, now)})`
267
- );
290
+ const parts = others.slice(0, 6).map((b) => {
291
+ const role = roleWord(b.role);
292
+ return `${b.boxName || 'an unnamed machine'} (${role ? `${role} · ` : ''}${heardPhrase(b, now)})`;
293
+ });
268
294
  const more = others.length - parts.length;
269
295
  return `other machines on this project: ${parts.join(', ')}${more > 0 ? `, and ${more} more` : ''}`;
270
296
  }
@@ -6,14 +6,15 @@
6
6
  * per-session `work` tokens the daemon mints for Workbench tabs. That
7
7
  * separation is the point, not bookkeeping: a session's agent reads untrusted
8
8
  * repo and issue text all day, so giving THAT principal tools that write to
9
- * your workspace would mean a hostile string in a README could file work as
10
- * you. The cli credential sees only the management tools and can never work or
11
- * ship a card; a session token can never reach create_task.
9
+ * your project would mean a hostile string in a README could file work as you.
10
+ * The cli credential sees only the management tools and can never work or ship
11
+ * a card; a session token can never reach create_task.
12
12
  *
13
- * There is deliberately no invite capability on it. Invites grant access to a
14
- * paid workspace and are guarded by a human browser session — they are sent
15
- * from the workspace card's gear menu in the app, and never from a CLI
16
- * credential sitting on a shared machine.
13
+ * There is deliberately no invite capability on it. An invite grants access to
14
+ * a PROJECT — which is access to its machine and everything that machine can
15
+ * reach — and is guarded by a human browser session plus admin access on that
16
+ * project. Links are made in the project's own settings, under People, and
17
+ * never from a CLI credential sitting on a shared machine.
17
18
  */
18
19
 
19
20
  import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
@@ -0,0 +1,170 @@
1
+ /**
2
+ * WHAT THE DELETED SECRETS VAULT LEFT ON THIS DISK — swept once, on start
3
+ * (2026-09-21).
4
+ *
5
+ * The vault went whole on the owner's ruling — *"no i dont want it. unless its
6
+ * needed where i want to show the env of each of the machines (for
7
+ * comparison)"* — and deleting the CODE that writes a file does not delete the
8
+ * file. Two artefacts survive an upgrade on every box that ever ran a daemon
9
+ * before this release, and both are plaintext-adjacent:
10
+ *
11
+ * 1. `~/.flowviant/env-cache/<projectId>.json` — the bundle at rest, sealed
12
+ * under a key derived from this box's own private half. Nothing can read it
13
+ * any more (`loadCachedEnv`, `readCache` and `cacheKey` are all deleted), so
14
+ * it is an encrypted blob whose only remaining property is that it is a
15
+ * copy of the project's secrets sitting in the operator's home directory
16
+ * for ever. There is no version of keeping it that is useful.
17
+ * 2. MATERIALIZED `.env` FILES in worktrees — `materializeInto` wrote the
18
+ * decrypted dev secrets into every session and task worktree it cut. Those
19
+ * are PLAINTEXT, they are not maintained by anything any more, and the
20
+ * worktrees themselves outlive daemon restarts and reboots by design.
21
+ *
22
+ * ── THE MARKER IS WHAT MAKES DELETING A FILE ALLOWED ──
23
+ *
24
+ * `renderEnvFile` opened every file it wrote with a fixed first line —
25
+ * `# Materialized by flowviant env sync — DO NOT COMMIT.` — and the vault's own
26
+ * `removeStaleEnvFile` already gated its deletions on exactly that prefix, for
27
+ * exactly this reason. So this sweep inherits a reliable marker rather than
28
+ * guessing, and the rule is absolute: NO MARKER, NO DELETE. An operator's own
29
+ * `.env` is often the only copy of something they pasted in from a provider
30
+ * months ago, and deleting it is unrecoverable from here. A file we did not
31
+ * write is never touched on a guess, a heuristic, or a name match.
32
+ *
33
+ * ── THE WALK IS BOUNDED, AND THE BOUND IS STATED ──
34
+ *
35
+ * The vault's `targetFile` was any relative path, so in principle a marked file
36
+ * could be at `apps/api/.dev.vars`. A full tree walk of every worktree on a
37
+ * daemon's boot path is how a sweep ends up reading somebody's `node_modules`,
38
+ * so this goes three directories deep, skips `node_modules`, `.git` and every
39
+ * dot-directory, visits at most `MAX_DIRS` directories in total, and only opens
40
+ * files whose NAME could plausibly be a target (`.env*`, `*.vars`). A marked
41
+ * file deeper or more exotically named than that is LEFT, and it is left
42
+ * wearing a header that says whose it is — which is a strictly better outcome
43
+ * than an unbounded walk, and is why the bound is acceptable rather than
44
+ * merely convenient.
45
+ *
46
+ * ── IT NEVER THROWS, AND IT IS QUIET WHEN IT DID NOTHING ──
47
+ *
48
+ * It runs on the boot path, before anything is serving, so an EACCES or a
49
+ * vanished directory must cost nothing. And it prints ONLY if it actually
50
+ * removed something: a line saying "cleaned up nothing" on every start, for
51
+ * ever, on every box that never ran the vault, is the standing-readout noise
52
+ * this daemon deletes everywhere else. Once per process, because it is a
53
+ * migration and not a sweep — there is nothing that can put these files back.
54
+ */
55
+
56
+ import { lstatSync, readFileSync, readdirSync, rmSync } from 'node:fs';
57
+ import { homedir } from 'node:os';
58
+ import { join } from 'node:path';
59
+
60
+ /** The exact first line `renderEnvFile` wrote. Compared as a PREFIX, because
61
+ * the full line carried a trailing " — DO NOT COMMIT." that is not worth
62
+ * depending on byte-for-byte across the releases that wrote it. */
63
+ const MATERIALIZE_HEADER = '# Materialized by flowviant env sync';
64
+
65
+ const MAX_DEPTH = 3;
66
+ const MAX_DIRS = 400;
67
+ /** Only these are ever opened. A target could in principle be named anything;
68
+ * see the walk bound above for why the answer to that is a stated limit
69
+ * rather than reading every file in the tree. */
70
+ const couldBeTarget = (name) => name.startsWith('.env') || name.endsWith('.vars');
71
+ const SKIP_DIRS = new Set(['node_modules', '.git']);
72
+
73
+ /** Is this file one we wrote? Read bounded — the header is the first line, so
74
+ * a file that is huge, binary or unreadable answers "not ours" without being
75
+ * slurped. */
76
+ function isMaterialized(abs) {
77
+ try {
78
+ const st = lstatSync(abs);
79
+ // A SYMLINK IS NOT A FILE HERE EITHER. `readFileSync` follows one and
80
+ // `rmSync` would then remove the link while the marker it matched belongs
81
+ // to whatever it points at — a delete primitive must validate its own
82
+ // input, which is the lesson `removeStaleEnvFile` recorded in its own
83
+ // comment.
84
+ if (!st.isFile() || st.size > 1024 * 1024) return false;
85
+ return readFileSync(abs, 'utf8').startsWith(MATERIALIZE_HEADER);
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Do the work. Returns `{ cacheRemoved, filesRemoved }` — the file list is
93
+ * absolute paths, for the caller's one line and for the test.
94
+ *
95
+ * `roots` are directories to walk: the checkout, and the worktree home under
96
+ * `~/.flowviant/worktrees/<repo>-<hash>`. Separated from the once-guard below
97
+ * so the behaviour can be driven repeatedly against temp directories; a
98
+ * migration whose only proof is that it ran once is not proof.
99
+ */
100
+ export function removeVaultArtefacts({ home = homedir(), roots = [] } = {}) {
101
+ const out = { cacheRemoved: false, filesRemoved: [] };
102
+ try {
103
+ const cache = join(home, '.flowviant', 'env-cache');
104
+ if (lstatSync(cache).isDirectory()) {
105
+ rmSync(cache, { recursive: true, force: true });
106
+ out.cacheRemoved = true;
107
+ }
108
+ } catch {
109
+ /* no cache on this box, or we cannot read it — either way, nothing to do */
110
+ }
111
+
112
+ let dirsVisited = 0;
113
+ const walk = (dir, depth) => {
114
+ if (depth > MAX_DEPTH || dirsVisited >= MAX_DIRS) return;
115
+ dirsVisited += 1;
116
+ let entries;
117
+ try {
118
+ entries = readdirSync(dir, { withFileTypes: true });
119
+ } catch {
120
+ return;
121
+ }
122
+ for (const e of entries) {
123
+ const abs = join(dir, e.name);
124
+ if (e.isDirectory()) {
125
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.')) continue;
126
+ walk(abs, depth + 1);
127
+ continue;
128
+ }
129
+ if (!e.isFile() || !couldBeTarget(e.name)) continue;
130
+ if (!isMaterialized(abs)) continue; // NO MARKER, NO DELETE
131
+ try {
132
+ rmSync(abs, { force: true });
133
+ out.filesRemoved.push(abs);
134
+ } catch {
135
+ /* best-effort: a file we cannot remove is one we leave, marked */
136
+ }
137
+ }
138
+ };
139
+ for (const root of roots) {
140
+ if (typeof root === 'string' && root) walk(root, 0);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ let swept = false;
146
+
147
+ /**
148
+ * The boot-path entry point: at most once per process, never throwing, and
149
+ * silent unless something was actually removed. Returns the same shape, or
150
+ * `null` if it has already run — which is what makes "at most once" provable
151
+ * rather than asserted.
152
+ */
153
+ export function sweepVaultArtefactsOnce({ home, roots, log } = {}) {
154
+ if (swept) return null;
155
+ swept = true;
156
+ let res;
157
+ try {
158
+ res = removeVaultArtefacts({ home, roots });
159
+ } catch {
160
+ return null; // a cleanup that breaks the boot path is worse than the files
161
+ }
162
+ const n = res.filesRemoved.length;
163
+ if (typeof log === 'function' && (res.cacheRemoved || n > 0)) {
164
+ const parts = [];
165
+ if (res.cacheRemoved) parts.push('the encrypted env cache');
166
+ if (n > 0) parts.push(`${n} materialized .env file${n === 1 ? '' : 's'}`);
167
+ log(`removed ${parts.join(' and ')} left by the deleted secrets vault.`);
168
+ }
169
+ return res;
170
+ }
package/bin/lib/work.mjs CHANGED
@@ -39,7 +39,14 @@ import {
39
39
  MACHINE_HOST,
40
40
  MODEL,
41
41
  } from './config.mjs';
42
- import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
42
+ import {
43
+ git,
44
+ gitRaw,
45
+ splitNul,
46
+ baseBranchName,
47
+ isSafePathSegment,
48
+ excludeInWorktree,
49
+ } from './git.mjs';
43
50
  import {
44
51
  isPublishRef,
45
52
  publishPushArgs,
@@ -75,13 +82,7 @@ import {
75
82
  AGENT_HUMAN_KICKOFF,
76
83
  AGENT_PRECHECK_KICKOFF,
77
84
  } from './prompts.mjs';
78
- import {
79
- materializeInto,
80
- hasMaterialized,
81
- excludeInWorktree,
82
- myPubB64,
83
- scrub as envScrub,
84
- } from './env.mjs';
85
+ import { myPubB64, scrub as envScrub } from './env.mjs';
85
86
  import {
86
87
  detectRuntimes,
87
88
  canRun,
@@ -2070,8 +2071,9 @@ export function createWorkManager({
2070
2071
  // an untracked `.flowviant/` makes the whole worktree dirty, which refuses
2071
2072
  // every ship, exempts the tree from closed-tab retirement forever, and
2072
2073
  // shows the human's own uploads in the rail as session changes. Same
2073
- // mechanism as the materialized env files — the exclude file git actually
2074
- // reads (env.mjs), which already skips lines it has written before, so
2074
+ // mechanism the materialized env files used until the vault was deleted —
2075
+ // the exclude file git actually reads (git.mjs, where the helper moved when
2076
+ // env.mjs shrank), which already skips lines it has written before, so
2075
2077
  // calling it per fetch is idempotent.
2076
2078
  excludeInWorktree(wt, ['.flowviant/']);
2077
2079
  const written = [];
@@ -2253,39 +2255,28 @@ export function createWorkManager({
2253
2255
  }
2254
2256
  }
2255
2257
  }
2256
- // Synced env into the fresh worktree, exactly like a task checkout gets
2257
- // (worktreeFor): a tab builds and runs dev servers here, and without the
2258
- // bundle every session build was missing its .env while dispatched runs
2259
- // got theirs. Only on creation — a live directory's env belongs to the
2260
- // session, same as a resumed task tree. Ship's dirty-check is safe by
2261
- // construction: materializeInto writes ONLY gitignored paths (it refuses
2262
- // otherwise), and ignored files never appear in `git status --porcelain`.
2263
- // Best-effort, like everywhere else — the session still builds; paths
2264
- // that need secrets may 500.
2265
- try {
2266
- materializeInto(wt);
2267
- } catch {
2268
- /* best-effort */
2269
- }
2270
- } else if (!hasMaterialized(wt)) {
2271
- // CREATION-ONLY NEEDED A SECOND CONDITION. The rule above is right about
2272
- // a LIVE directory — its env belongs to the session and re-writing it
2273
- // mid-flight is not ours to do — but "created" and "ever given a bundle"
2274
- // are different events, and the gap between them is a whole daemon
2275
- // restart: `handleRosterEnv` (which warms the encrypted cache) runs
2276
- // AFTER `processWorkTurns` on the same poll, so a worktree made on the
2277
- // first turn after a restart was materialized against an EMPTY bundle
2278
- // and, being neither fresh nor covered by a bundle CHANGE, never
2279
- // revisited. `materializeInto` now declines to record a pass it made in
2280
- // ignorance (bundleVersion < 0), so this branch is what retries it —
2281
- // once, on the next turn, and never again after it succeeds. Idempotent
2282
- // by construction: identical bodies are not rewritten, so nothing
2283
- // hot-restarts a dev server the driver is watching.
2284
- try {
2285
- materializeInto(wt);
2286
- } catch {
2287
- /* best-effort */
2288
- }
2258
+ // NOTHING IS MATERIALIZED INTO A FRESH WORKTREE ANY MORE (2026-09-21),
2259
+ // and nothing replaced it.
2260
+ //
2261
+ // Two blocks stood here: one that wrote the decrypted vault bundle into a
2262
+ // newly-created worktree, and a second that retried on the next turn for
2263
+ // the restart window where the bundle had not warmed yet. The vault is
2264
+ // deleted — the owner: "no i dont want it" — so Flowviant holds no secret
2265
+ // for this project and has nothing to write.
2266
+ //
2267
+ // WHAT THE SOURCE OF A WORKTREE'S ENV IS NOW: the repo's own `.env`
2268
+ // files, wherever the repo puts them. A worktree branches from the
2269
+ // checkout, and a gitignored `.env` is by definition not in a fresh one —
2270
+ // which is the ordinary behaviour of `git worktree add`, the same thing a
2271
+ // person gets typing it themselves, and the operator's problem to solve
2272
+ // the way they already solve it (a symlink, a `direnv`, a copy in a
2273
+ // setup script). This product no longer claims otherwise, and the claim
2274
+ // is what was worth deleting: a promise that every session tab comes up
2275
+ // with the team's secrets is only kept while a vault exists to keep it.
2276
+ //
2277
+ // The CLI child still inherits the daemon's own environment exactly as it
2278
+ // always did (claude.mjs spawns with `{...process.env}`), so anything
2279
+ // exported in the shell the daemon was started from is present here.
2289
2280
  }
2290
2281
  return { wt, fresh };
2291
2282
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.91.2",
3
+ "version": "0.92.0",
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": {
@@ -1,216 +0,0 @@
1
- /**
2
- * `flowviant env <import|set|show>` — the CLI half of team env sync.
3
- *
4
- * import <file> [--file <targetFile>] seed the synced bundle from an existing
5
- * env file (KEY=value lines; comments and
6
- * blank lines skipped). The onboarding
7
- * moment: one command, whole team synced.
8
- * set <KEY> [--file <targetFile>] set/rotate one value (prompted on stdin,
9
- * never argv — argv leaks into shell
10
- * history and `ps`).
11
- * show [KEY] decrypt locally and print — only works
12
- * on an ENROLLED machine (this is the
13
- * only place values are ever readable).
14
- *
15
- * Writes are sealed to the project pubkey on this machine (same write-only
16
- * crypto as the browser) and pushed via the fleet-token endpoint; every
17
- * enrolled daemon resyncs within seconds via the push channel.
18
- */
19
-
20
- import { readFileSync } from 'node:fs';
21
- import { basename } from 'node:path';
22
- import { createInterface } from 'node:readline';
23
- import sodium from 'libsodium-wrappers-sumo'; // sumo: Argon2 for the recovery KDF (see env.mjs)
24
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
25
- import { ensureKeypair, myPubB64, fetchBundle } from './env.mjs';
26
-
27
- const B64 = () => sodium.base64_variants.ORIGINAL;
28
- const KEYS_URL = FLEET_URL.replace(/\/agents\/?$/, '/env/keys');
29
-
30
- const die = (msg) => {
31
- console.error(`error: ${msg}`);
32
- process.exit(1);
33
- };
34
-
35
- const fingerprint = (value) => (value ? `${value.slice(0, 4)}…(${value.length})` : '');
36
-
37
- async function postKey({ name, targetFile, value, keyEpoch, baseVersion, projectPub }) {
38
- const ciphertext = sodium.to_base64(
39
- sodium.crypto_box_seal(sodium.from_string(value), sodium.from_base64(projectPub, B64())),
40
- B64()
41
- );
42
- const res = await fetch(KEYS_URL, {
43
- method: 'POST',
44
- headers: {
45
- Authorization: `Bearer ${FLEET_TOKEN}`,
46
- 'User-Agent': USER_AGENT,
47
- 'Content-Type': 'application/json',
48
- },
49
- signal: AbortSignal.timeout(30_000),
50
- body: JSON.stringify({
51
- pubkey: myPubB64(),
52
- name,
53
- env: 'dev',
54
- targetFile,
55
- ciphertext,
56
- fingerprint: fingerprint(value),
57
- keyEpoch,
58
- baseVersion: baseVersion ?? null,
59
- }),
60
- });
61
- const json = await res.json().catch(() => ({}));
62
- if (!res.ok || json?.success === false) {
63
- throw new Error(`${name}: ${json?.error ?? `HTTP ${res.status}`}`);
64
- }
65
- }
66
-
67
- function parseEnvFile(text) {
68
- const out = [];
69
- for (const raw of text.split('\n')) {
70
- const line = raw.trim();
71
- if (!line || line.startsWith('#')) continue;
72
- const eq = line.indexOf('=');
73
- if (eq <= 0) continue;
74
- const name = line.slice(0, eq).trim().replace(/^export\s+/, '');
75
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
76
- let value = line.slice(eq + 1).trim();
77
- // Strip one layer of matching quotes — the convention .env parsers follow.
78
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
79
- value = value.slice(1, -1);
80
- }
81
- if (value) out.push({ name, value });
82
- }
83
- return out;
84
- }
85
-
86
- function argAfter(args, flag) {
87
- const i = args.indexOf(flag);
88
- return i >= 0 && args[i + 1] ? args[i + 1] : null;
89
- }
90
-
91
- async function readSecretFromStdin(promptText) {
92
- // Muted input — the typed secret must NOT echo to the terminal (a shoulder-
93
- // surf / screen-share leak). We write the prompt ourselves, then swallow ALL
94
- // readline output while reading. Keying the mute on "does this write contain
95
- // the prompt?" is unsafe: readline's line-refresh (backspace, mid-line edit,
96
- // paste, resize) re-emits `prompt + buffer` as ONE string, which would sail
97
- // through such a check and echo the secret. So: mute EVERYTHING.
98
- process.stderr.write(promptText);
99
- const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
100
- rl._writeToOutput = () => {}; // swallow every echo/refresh unconditionally
101
- const value = await new Promise((resolve) => rl.question('', resolve));
102
- process.stderr.write('\n');
103
- rl.close();
104
- return value.trim();
105
- }
106
-
107
- export async function runEnvCommand(args) {
108
- if (!FLEET_TOKEN)
109
- die(
110
- 'no credential resolves here — run `flowviant login`, or run this inside the ' +
111
- "project's own repo / pass `--project <name|id>` (`flowviant projects` lists them)."
112
- );
113
- await sodium.ready;
114
- await ensureKeypair();
115
- const cmd = args[0];
116
-
117
- if (cmd === 'import') {
118
- const file = args[1];
119
- if (!file) die('usage: flowviant env import <path/to/.env> [--file <targetFile>]');
120
- let text;
121
- try {
122
- text = readFileSync(file, 'utf8');
123
- } catch (e) {
124
- die(`could not read ${file}: ${e.message}`);
125
- }
126
- const entries = parseEnvFile(text);
127
- if (!entries.length) die(`no KEY=value lines found in ${file}.`);
128
- // Default target: the file's repo-relative-looking path as given (minus
129
- // leading ./) — `flowviant env import apps/api/.dev.vars` targets exactly
130
- // that file in every worktree.
131
- const targetFile = argAfter(args, '--file') ?? file.replace(/^\.\//, '');
132
- const bundle = await fetchBundle();
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
- if (!bundle.projectPub) die('no project env keypair yet — start the daemon once to bootstrap it.');
135
- const existing = new Map(bundle.keys.map((k) => [k.name, k]));
136
- let added = 0;
137
- let updated = 0;
138
- for (const e of entries) {
139
- const prior = existing.get(e.name);
140
- try {
141
- await postKey({
142
- name: e.name,
143
- targetFile,
144
- value: e.value,
145
- keyEpoch: bundle.keyEpoch,
146
- baseVersion: prior?.version ?? null,
147
- projectPub: bundle.projectPub,
148
- });
149
- prior ? updated++ : added++;
150
- } catch (err) {
151
- console.error(` skip ${err.message}`);
152
- }
153
- }
154
- console.log(`imported ${basename(file)} → ${targetFile}: ${added} added, ${updated} updated. Every daemon syncs in seconds.`);
155
- return;
156
- }
157
-
158
- if (cmd === 'set') {
159
- const name = args[1];
160
- if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) die('usage: flowviant env set <KEY> [--file <targetFile>]');
161
- const bundle = await fetchBundle();
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
- if (!bundle.projectPub) die('no project env keypair yet — start the daemon once to bootstrap it.');
164
- const prior = bundle.keys.find((k) => k.name === name);
165
- const targetFile = argAfter(args, '--file') ?? prior?.targetFile ?? '.env';
166
- const value = await readSecretFromStdin(`value for ${name} (hidden): `);
167
- if (!value) die('empty value — nothing set.');
168
- await postKey({
169
- name,
170
- targetFile,
171
- value,
172
- keyEpoch: bundle.keyEpoch,
173
- baseVersion: prior?.version ?? null,
174
- projectPub: bundle.projectPub,
175
- });
176
- console.log(`${name} ${prior ? `rotated (v${prior.version + 1})` : 'added'} → ${targetFile}. Every daemon syncs in seconds.`);
177
- return;
178
- }
179
-
180
- if (cmd === 'show') {
181
- const bundle = await fetchBundle();
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
- if (!bundle.wrappedPriv) die('no key material for this machine yet.');
184
- const kp = await ensureKeypair();
185
- const priv = sodium.crypto_box_seal_open(
186
- sodium.from_base64(bundle.wrappedPriv, B64()),
187
- kp.publicKey,
188
- kp.privateKey
189
- );
190
- const pub = sodium.from_base64(bundle.projectPub, B64());
191
- const filter = args[1];
192
- let shown = 0;
193
- for (const k of bundle.keys) {
194
- if (filter && k.name !== filter) continue;
195
- try {
196
- const plain = sodium.to_string(sodium.crypto_box_seal_open(sodium.from_base64(k.ciphertext, B64()), pub, priv));
197
- console.log(`${k.name}=${plain}`);
198
- shown++;
199
- } catch {
200
- console.error(`# ${k.name}: cannot open (stale epoch — a rotation should heal it)`);
201
- }
202
- }
203
- if (filter && !shown) die(`no key named ${filter}.`);
204
- return;
205
- }
206
-
207
- console.log(
208
- [
209
- 'flowviant env — team-synced, end-to-end-encrypted dev secrets',
210
- '',
211
- ' flowviant env import <file> [--file <targetFile>] seed from an existing env file',
212
- ' flowviant env set <KEY> [--file <targetFile>] set/rotate one value (stdin)',
213
- ' flowviant env show [KEY] decrypt locally (enrolled machines only)',
214
- ].join('\n')
215
- );
216
- }