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.
- package/bin/cli.mjs +10 -10
- package/bin/lib/childEnv.mjs +128 -9
- package/bin/lib/config.mjs +28 -0
- package/bin/lib/deploy.mjs +92 -45
- package/bin/lib/env.mjs +595 -852
- package/bin/lib/fleet.mjs +319 -77
- package/bin/lib/git.mjs +63 -0
- package/bin/lib/machines.mjs +39 -13
- package/bin/lib/mcp-cli.mjs +8 -7
- package/bin/lib/vaultArtefacts.mjs +170 -0
- package/bin/lib/work.mjs +34 -43
- package/package.json +1 -1
- package/bin/lib/env-cli.mjs +0 -216
package/bin/lib/machines.mjs
CHANGED
|
@@ -23,11 +23,13 @@
|
|
|
23
23
|
*
|
|
24
24
|
* ── IT IS A RELAY ──
|
|
25
25
|
*
|
|
26
|
-
* The role words
|
|
27
|
-
*
|
|
28
|
-
* cannot disagree about what a box is doing.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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 === '
|
|
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
|
-
|
|
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
|
}
|
package/bin/lib/mcp-cli.mjs
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 {
|
|
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
|
|
2074
|
-
// reads (
|
|
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
|
-
//
|
|
2257
|
-
//
|
|
2258
|
-
//
|
|
2259
|
-
//
|
|
2260
|
-
//
|
|
2261
|
-
//
|
|
2262
|
-
//
|
|
2263
|
-
//
|
|
2264
|
-
//
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
//
|
|
2272
|
-
//
|
|
2273
|
-
//
|
|
2274
|
-
//
|
|
2275
|
-
//
|
|
2276
|
-
//
|
|
2277
|
-
//
|
|
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.
|
|
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": {
|
package/bin/lib/env-cli.mjs
DELETED
|
@@ -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
|
-
}
|