flowviant 0.25.0 → 0.27.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 CHANGED
@@ -101,6 +101,15 @@ if (process.argv[2] === 'clean') {
101
101
  process.exit(0);
102
102
  }
103
103
 
104
+ // `flowviant env <import|set|show>` — the CLI half of team env sync. Values
105
+ // are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
106
+ // the browser); `show` decrypts locally — it only works on an ENROLLED machine.
107
+ if (process.argv[2] === 'env') {
108
+ const { runEnvCommand } = await import('./lib/env-cli.mjs');
109
+ await runEnvCommand(process.argv.slice(3));
110
+ process.exit(0);
111
+ }
112
+
104
113
  if (!FLEET_TOKEN && tokens.length === 0) {
105
114
  console.error(
106
115
  'error: no credential found. Easiest:\n' +
@@ -35,7 +35,10 @@ Operate this loop:
35
35
  5. Return to step 1.
36
36
 
37
37
  Keep every change scoped to the claimed intent. If a tool errors, report_progress with
38
- the error, then retry or report_blocker.`;
38
+ the error, then retry or report_blocker.
39
+ SECRETS: env files (.env, .dev.vars, …) hold the team's synced secrets. Their VALUES
40
+ must NEVER appear in evidence, progress, summaries, commits, or PRs — reference keys
41
+ by NAME only. Never commit an env file.`;
39
42
 
40
43
  // Single-task turn (FLEET mode): claim EXACTLY ONE intent, then stop. The daemon
41
44
  // owns the loop so it can reset the worktree + start a fresh conversation per task.
@@ -63,7 +66,10 @@ Do EXACTLY ONE task this turn:
63
66
 
64
67
  Do NOT claim a second intent — exactly one per turn. Keep every change scoped to the
65
68
  claimed intent. If a tool errors, report_progress with the error, then retry or
66
- report_blocker.`;
69
+ report_blocker.
70
+ SECRETS: env files (.env, .dev.vars, …) hold the team's synced secrets. Their VALUES
71
+ must NEVER appear in evidence, progress, summaries, commits, or PRs — reference keys
72
+ by NAME only. Never commit an env file.`;
67
73
 
68
74
  export const KICKOFF =
69
75
  'Begin the loop: claim and complete all dispatched Flowviant intents per your instructions.';
@@ -77,83 +83,122 @@ export const SINGLE_RESUME =
77
83
  'Resume your current task. Call get_blocker_resolution for the blocker you reported; ' +
78
84
  'if resolved, apply the human’s answer and finish this one intent, then stop.';
79
85
 
80
- // Wiki-gen turn: the local Claude READS the repo and writes the living code wiki
81
- // via MCP. It never edits/commits code the ONLY writes are emit_wiki_node calls.
82
- export const SYSTEM_WIKI = `You are Flowviant's codebase cartographer, running FULLY AUTONOMOUSLY via the
83
- "flowviant" MCP server. There is NO interactive user and NO terminal. You do NOT
84
- write, edit, or commit code — you READ this repository and document it as a living
85
- wiki by calling MCP tools.
86
-
87
- Goal: map the WHOLE codebase into a graph of wiki nodes an engineer new to the
88
- project could read to understand it. Explore the REAL files (Read, Grep, Glob, ls,
89
- git) — never guess. Ground every claim in files you actually read.
90
-
91
- Emit each node with emit_wiki_node. Cover, at least:
92
- - ONE "overview" node (id: "overview") what the product is, the big picture, how to run it.
93
- - ONE "architecture" node (id: "architecture") — the major pieces, how they fit, the data flow.
94
- - "schema" node(s) the data model (DB tables / core types) when the repo has one.
95
- - "module" nodes ONE per significant area/package/subsystem, at the level a developer
96
- thinks in (NOT one per file).
97
- - "api" / "testing" / "adr" / "note" nodes where warranted (public API surface, how tests
98
- run, notable decisions, cross-cutting flows).
99
-
100
- For each node:
101
- - id: a STABLE slug YOU choose ("overview", "architecture", "schema", "module:apps/web",
102
- "api:rest", "note:auth-flow"). Reuse the SAME id to refine a node.
103
- - title: human-readable.
104
- - body: the markdown page an engineer would write after reading the code purpose, key
105
- files and what they do, important flows, gotchas. Link related nodes with [[their-id]].
106
- - citations: the real repo-relative files the page draws from.
107
- - filePaths: the files the node covers (module nodes especially).
108
- - edges: links FROM this node to related node ids (targetId + kind ref|coupling|bridge).
109
- - groundedAtSha: the commit you were told to ground to.
110
-
111
- Judge significance YOURSELF: a big/important area gets its own node; trivial things fold
112
- into a parent node's body. Do NOT emit a node per file.
113
-
114
- When the whole codebase is mapped, call finish_wiki_generation ONCE with keepNodeIds =
115
- EVERY id you emitted, then output exactly WIKI_DONE on its own line and stop.
116
-
117
- Be efficient — this spends the user's Claude quota. Read broadly and sample enough to
118
- document each area accurately; you needn't read every file. If a tool errors, retry a
119
- couple of times, then move on — never stall waiting on a human.`;
120
-
121
- export const WIKI_KICKOFF = (sha) =>
122
- `Map this repository into the living code wiki now. Ground everything to commit ${sha}. ` +
123
- `Read the real files, emit a node per significant area with emit_wiki_node, then call ` +
124
- `finish_wiki_generation with all your node ids and output WIKI_DONE.`;
125
-
126
- // Delivery re-ground turn: a feature just MERGED. Update only the touched wiki
127
- // nodes + record a persistent feature-history node. INCREMENTALnever a full
128
- // rewrite, never finish_wiki_generation (that prunes; this only adds/updates).
129
- export const SYSTEM_REGROUND = `You are Flowviant's codebase cartographer, running FULLY AUTONOMOUSLY via the
130
- "flowviant" MCP server. There is NO interactive user and NO terminal. You do NOT
131
- write, edit, or commit code a feature just MERGED and you update the living code
132
- wiki to reflect it, by calling MCP tools.
86
+ // Wiki-gen turn: the local Claude READS the repo (cwd) and writes/maintains the
87
+ // knowledge VAULT a plain directory of markdown files with [[wikilinks]]
88
+ // (Obsidian-style). No MCP tools involved: the vault is just files, and the
89
+ // daemon hash-diff syncs them to Flowviant after the turn. The repo itself is
90
+ // strictly read-only.
91
+ export const SYSTEM_WIKI = (vaultDir) => `You are Flowviant's codebase cartographer, running FULLY AUTONOMOUSLY. There is
92
+ NO interactive user and NO terminal to ask in. You READ the repository you are
93
+ running in and maintain a knowledge VAULT of markdown files at:
94
+
95
+ ${vaultDir}
96
+
97
+ That vault directory is the ONLY place you may create, edit, or delete files.
98
+ NEVER modify the repository itselfno code edits, no commits, no git writes.
99
+
100
+ The vault is an LLM wiki: its readers are AI agents (including future you), so
101
+ optimize for machine-usable DETAIL and DENSITY over human polish. Depth
102
+ compounds a page should teach its code area to an agent that has never read
103
+ the code. Conventions:
104
+
105
+ - One markdown file per topic: each significant module/subsystem, core concept,
106
+ data model, key flow, notable decision. Organize with folders as you see fit
107
+ (e.g. modules/, concepts/, decisions/). More pages is fine granular beats
108
+ monolithic.
109
+ - Link related pages inline with [[wikilinks]] — link LIBERALLY; the link graph
110
+ IS the map. A [[link]] to a page you haven't written yet marks it as worth
111
+ writing.
112
+ - index.md the entry point: a categorized catalog of every page with a
113
+ one-line summary each. Keep it current.
114
+ - log.md append-only history: one "## [<sha7>] <what happened>" entry per
115
+ pass. When log.md grows past ~150KB, compact its OLDEST entries into a short
116
+ summary section at the top (never let it exceed the 256KB sync cap).
117
+ - Every page STARTS with YAML frontmatter listing the REAL repo files it
118
+ documents, then a "# Title" heading, then the body:
119
+
120
+ ---
121
+ files:
122
+ - apps/web/src/example.ts
123
+ ---
124
+ # Page Title
125
+
126
+ Body: purpose, how it works, key functions/types/tables, invariants, gotchas,
127
+ cross-references to [[related-pages]].
128
+
129
+ Ground EVERY claim in files you actually read (Read, Grep, Glob, ls, git in the
130
+ repo) never guess.
131
+
132
+ THE HUMAN DOCS docs/ inside the vault. After the vault pages are current,
133
+ COMPILE human documentation FROM them (distill your own vault pages don't
134
+ re-read the whole repo; spot-check a cited file only when something looks off).
135
+ Docs are for humans: clear prose, short sections, a reading order. Fixed spine:
136
+ - docs/00-start-here.md "Start Here": what this codebase is, how to run it,
137
+ the handful of files that matter most, where to go next.
138
+ - docs/01-architecture.md the big picture: major pieces, how they fit, data
139
+ flow, and a map of the chapters below.
140
+ - docs/1N-<chapter>.md — ONE chapter per major subsystem (10, 11, 12 …), YOUR
141
+ choice of chapters, derived from the vault's hub pages.
142
+ - docs/90-decisions.md — notable design decisions and their why.
143
+ - docs/91-glossary.md — the project's terms of art.
144
+ Docs pages use the same frontmatter files: lists and [[wikilinks]] (they may
145
+ link to vault pages); numeric prefixes are the reading order.
146
+
147
+ Full-sweep protocol:
148
+ 1. If the vault already has pages, read index.md + log.md FIRST — update and
149
+ extend rather than rewrite; delete vault pages whose code no longer exists.
150
+ 2. Explore the repo broadly, then write/refresh pages area by area.
151
+ 3. Compile/refresh the docs/ chapters from the finished vault pages.
152
+ 4. Refresh index.md, append a log.md entry, then output exactly WIKI_DONE on
153
+ its own line and stop.
154
+
155
+ Be efficient — this spends the user's Claude quota. Read broadly and sample
156
+ enough to document each area accurately; you needn't read every file. If a tool
157
+ errors, retry a couple of times, then move on — never stall waiting on a human.`;
158
+
159
+ export const WIKI_KICKOFF = (sha, vaultDir) =>
160
+ `Map this repository into the knowledge vault now (vault: ${vaultDir}). Ground ` +
161
+ `everything to commit ${sha}. Read the real files, write/refresh the vault pages, ` +
162
+ `compile the docs/ chapters from them, update index.md and log.md, then output WIKI_DONE.`;
163
+
164
+ // Delivery re-ground turn: a feature just MERGED. Update only the vault pages
165
+ // the change touched + append the durable feature-history log entry.
166
+ // INCREMENTAL — never a full rewrite.
167
+ export const SYSTEM_REGROUND = (vaultDir) => `You are Flowviant's codebase cartographer, running FULLY AUTONOMOUSLY. There is
168
+ NO interactive user and NO terminal. A feature just MERGED and you update the
169
+ knowledge VAULT of markdown files at:
170
+
171
+ ${vaultDir}
172
+
173
+ That vault directory is the ONLY place you may create, edit, or delete files.
174
+ NEVER modify the repository itself — no code edits, no commits, no git writes.
133
175
 
134
176
  Steps:
135
- 1. Call list_wiki_nodes to see the current wiki (node ids + the files each covers).
136
- 2. For each existing node whose files OVERLAP the changed files, RE-READ that area's
137
- real code and re-emit the node with emit_wiki_node using the SAME id (updating it
138
- in place). Touch ONLY nodes the change actually affected — this is incremental.
139
- If the change adds a genuinely new area with no node, emit a new one.
140
- 3. Emit ONE feature-history node recording what shipped: id "feature:<short-slug>",
141
- kind "note", state "built", title = the feature, body = what it added and why
142
- (a durable record), citations = the changed files, edges linking to the code
143
- nodes it touched. state "built" makes it permanent — a future full sweep keeps it.
144
- 4. Do NOT call finish_wiki_generation that is only for a full sweep and would
145
- prune. Just emit, then output exactly REGROUND_DONE on its own line and stop.
177
+ 1. Read the vault's index.md (and log.md tail) to see the current pages and the
178
+ repo files each documents (their frontmatter "files:" lists).
179
+ 2. For each existing page whose files OVERLAP the changed files, RE-READ that
180
+ area's real code and update the page in place. Touch ONLY pages the change
181
+ actually affected — this is incremental. If the change adds a genuinely new
182
+ area, write a new page (with frontmatter + [[links]]) and add it to index.md.
183
+ 3. If any docs/ chapter cites or covers the updated vault pages, refresh THAT
184
+ chapter (docs are compiled from the vault keep them consistent; touch only
185
+ affected chapters).
186
+ 4. Append ONE feature-history entry to log.md:
187
+ "## [<sha7>] shipped: <feature title>" followed by a short durable record of
188
+ what it added and why, citing the changed files and [[touched-pages]].
189
+ 5. Output exactly REGROUND_DONE on its own line and stop.
146
190
 
147
191
  Ground every claim in files you actually read. Be efficient — look only at the
148
192
  changed area, not the whole repo; spend little quota.`;
149
193
 
150
- export const REGROUND_KICKOFF = ({ sha, title, files }) =>
151
- `A feature just merged. Re-ground the living wiki for it.\n\n` +
194
+ export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir }) =>
195
+ `A feature just merged. Re-ground the knowledge vault (${vaultDir}) for it.\n\n` +
152
196
  `Feature: ${title}\n` +
153
197
  `Grounded commit: ${sha}\n` +
154
198
  `Changed files:\n${files.map((f) => `- ${f}`).join('\n')}\n\n` +
155
- `Follow your instructions: list_wiki_nodes, re-emit the touched nodes (same ids), ` +
156
- `emit the feature-history node (state "built"), then output REGROUND_DONE.`;
199
+ `Follow your instructions: update the touched vault pages (and any docs/\n` +
200
+ `chapter that covers them), append the feature-history entry to log.md,\n` +
201
+ `then output REGROUND_DONE.`;
157
202
 
158
203
  // Unattended (default) skips prompts so the agent never stalls with no terminal;
159
204
  // FLOWVIANT_SAFE=1 restricts to a curated toolset instead.
@@ -173,6 +218,36 @@ const PERM = SAFE
173
218
  ]
174
219
  : ['--dangerously-skip-permissions'];
175
220
 
221
+ // Wiki turns are read-the-repo + write-the-vault ONLY — always curated, never
222
+ // --dangerously-skip-permissions: no gh, no push-capable git, no package
223
+ // managers, and nothing that can EXECUTE arbitrary commands — no `find`
224
+ // (-exec/-delete) and no `git grep` (-O<pager> runs a shell; the Grep tool
225
+ // covers search). Command execution is the line: it enables network exfil,
226
+ // which plain file writes never do. `rm` IS allowed: pruning a stale vault
227
+ // page requires a real file deletion (that's how the sync protocol learns of
228
+ // it), and the blast radius is bounded — the daemon resets the repo worktree
229
+ // after every wiki turn, and the vault has its own git history.
230
+ // (Write/Edit can't be path-scoped here; the worktree reset is the backstop.)
231
+ const WIKI_PERM = [
232
+ '--allowedTools',
233
+ 'Read',
234
+ 'Grep',
235
+ 'Glob',
236
+ 'Edit',
237
+ 'Write',
238
+ 'Bash(ls:*)',
239
+ 'Bash(wc:*)',
240
+ 'Bash(head:*)',
241
+ 'Bash(cat:*)',
242
+ 'Bash(mkdir:*)',
243
+ 'Bash(rm:*)',
244
+ 'Bash(git status:*)',
245
+ 'Bash(git log:*)',
246
+ 'Bash(git show:*)',
247
+ 'Bash(git diff:*)',
248
+ 'Bash(git rev-parse:*)',
249
+ ];
250
+
176
251
  export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
177
252
 
178
253
  // Sentinels must appear on their OWN line (the prompts require it). Substring
@@ -215,6 +290,15 @@ export function humanizeToolUse(name, input = {}, cwd = '') {
215
290
  switch (name) {
216
291
  case 'Read':
217
292
  return { kind: 'read', label: `read ${shortPath(input.file_path, cwd)}` };
293
+ // Vault authoring: Write/Edit of a markdown page is the "writing" signal
294
+ // (the wiki turn's only legal writes are vault files). Label with the last
295
+ // two path segments — the vault lives outside cwd, so shortPath can't trim.
296
+ case 'Write':
297
+ case 'Edit': {
298
+ const p = String(input.file_path ?? '');
299
+ const tail = p.split('/').slice(-2).join('/');
300
+ return { kind: 'write', label: `${name === 'Write' ? '+ page' : '~ page'} ${tail}` };
301
+ }
218
302
  case 'Grep':
219
303
  return {
220
304
  kind: 'search',
@@ -286,16 +370,18 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
286
370
  // returned string for sentinel detection, and each activity is handed to
287
371
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
288
372
  // off and keep the raw text passthrough + line sentinels.
289
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity }) {
373
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity, wikiPerm }) {
290
374
  return new Promise((resolve) => {
291
375
  const args = [];
292
376
  if (resume) args.push('--continue');
293
- args.push('-p', prompt, '--mcp-config', mcpConfig, '--append-system-prompt', system);
377
+ args.push('-p', prompt, '--append-system-prompt', system);
378
+ // Wiki-vault turns are pure file work — no MCP server at all.
379
+ if (mcpConfig) args.push('--mcp-config', mcpConfig);
294
380
  // Pin the model — never inherit the user's global default (which may be a
295
381
  // 1M/long-context tier their subscription can't bill autonomous work on).
296
382
  args.push('--model', MODEL);
297
383
  if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
298
- args.push(...PERM);
384
+ args.push(...(wikiPerm ? WIKI_PERM : PERM));
299
385
  // Force the user's Claude Code subscription — never the API. A key exported in
300
386
  // the shell would otherwise silently bill every poll-mode turn as raw API
301
387
  // usage (same invariant live mode enforces on its SDK session env).
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
 
7
- export const VERSION = '0.25.0';
7
+ export const VERSION = '0.27.0';
8
8
 
9
9
  // The model EVERY daemon Claude turn runs on — pinned so autonomous work never
10
10
  // inherits your interactive `~/.claude/settings.json` default. That matters: a
@@ -0,0 +1,212 @@
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';
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) die('no fleet credential — run `flowviant login` first.');
109
+ await sodium.ready;
110
+ await ensureKeypair();
111
+ const cmd = args[0];
112
+
113
+ if (cmd === 'import') {
114
+ const file = args[1];
115
+ if (!file) die('usage: flowviant env import <path/to/.env> [--file <targetFile>]');
116
+ let text;
117
+ try {
118
+ text = readFileSync(file, 'utf8');
119
+ } catch (e) {
120
+ die(`could not read ${file}: ${e.message}`);
121
+ }
122
+ const entries = parseEnvFile(text);
123
+ if (!entries.length) die(`no KEY=value lines found in ${file}.`);
124
+ // Default target: the file's repo-relative-looking path as given (minus
125
+ // leading ./) — `flowviant env import apps/api/.dev.vars` targets exactly
126
+ // that file in every worktree.
127
+ const targetFile = argAfter(args, '--file') ?? file.replace(/^\.\//, '');
128
+ const bundle = await fetchBundle();
129
+ if (bundle.status !== 'enrolled') die('this machine is not enrolled — approve it in Settings → Environment first.');
130
+ if (!bundle.projectPub) die('no project env keypair yet — start the daemon once to bootstrap it.');
131
+ const existing = new Map(bundle.keys.map((k) => [k.name, k]));
132
+ let added = 0;
133
+ let updated = 0;
134
+ for (const e of entries) {
135
+ const prior = existing.get(e.name);
136
+ try {
137
+ await postKey({
138
+ name: e.name,
139
+ targetFile,
140
+ value: e.value,
141
+ keyEpoch: bundle.keyEpoch,
142
+ baseVersion: prior?.version ?? null,
143
+ projectPub: bundle.projectPub,
144
+ });
145
+ prior ? updated++ : added++;
146
+ } catch (err) {
147
+ console.error(` skip ${err.message}`);
148
+ }
149
+ }
150
+ console.log(`imported ${basename(file)} → ${targetFile}: ${added} added, ${updated} updated. Every daemon syncs in seconds.`);
151
+ return;
152
+ }
153
+
154
+ if (cmd === 'set') {
155
+ const name = args[1];
156
+ if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) die('usage: flowviant env set <KEY> [--file <targetFile>]');
157
+ const bundle = await fetchBundle();
158
+ if (bundle.status !== 'enrolled') die('this machine is not enrolled — approve it in Settings → Environment first.');
159
+ if (!bundle.projectPub) die('no project env keypair yet — start the daemon once to bootstrap it.');
160
+ const prior = bundle.keys.find((k) => k.name === name);
161
+ const targetFile = argAfter(args, '--file') ?? prior?.targetFile ?? '.env';
162
+ const value = await readSecretFromStdin(`value for ${name} (hidden): `);
163
+ if (!value) die('empty value — nothing set.');
164
+ await postKey({
165
+ name,
166
+ targetFile,
167
+ value,
168
+ keyEpoch: bundle.keyEpoch,
169
+ baseVersion: prior?.version ?? null,
170
+ projectPub: bundle.projectPub,
171
+ });
172
+ console.log(`${name} ${prior ? `rotated (v${prior.version + 1})` : 'added'} → ${targetFile}. Every daemon syncs in seconds.`);
173
+ return;
174
+ }
175
+
176
+ if (cmd === 'show') {
177
+ const bundle = await fetchBundle();
178
+ if (bundle.status !== 'enrolled') die('this machine is not enrolled — approve it in Settings → Environment first.');
179
+ if (!bundle.wrappedPriv) die('no key material for this machine yet.');
180
+ const kp = await ensureKeypair();
181
+ const priv = sodium.crypto_box_seal_open(
182
+ sodium.from_base64(bundle.wrappedPriv, B64()),
183
+ kp.publicKey,
184
+ kp.privateKey
185
+ );
186
+ const pub = sodium.from_base64(bundle.projectPub, B64());
187
+ const filter = args[1];
188
+ let shown = 0;
189
+ for (const k of bundle.keys) {
190
+ if (filter && k.name !== filter) continue;
191
+ try {
192
+ const plain = sodium.to_string(sodium.crypto_box_seal_open(sodium.from_base64(k.ciphertext, B64()), pub, priv));
193
+ console.log(`${k.name}=${plain}`);
194
+ shown++;
195
+ } catch {
196
+ console.error(`# ${k.name}: cannot open (stale epoch — a rotation should heal it)`);
197
+ }
198
+ }
199
+ if (filter && !shown) die(`no key named ${filter}.`);
200
+ return;
201
+ }
202
+
203
+ console.log(
204
+ [
205
+ 'flowviant env — team-synced, end-to-end-encrypted dev secrets',
206
+ '',
207
+ ' flowviant env import <file> [--file <targetFile>] seed from an existing env file',
208
+ ' flowviant env set <KEY> [--file <targetFile>] set/rotate one value (stdin)',
209
+ ' flowviant env show [KEY] decrypt locally (enrolled machines only)',
210
+ ].join('\n')
211
+ );
212
+ }