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 +9 -0
- package/bin/lib/claude.mjs +159 -73
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/env-cli.mjs +212 -0
- package/bin/lib/env.mjs +504 -0
- package/bin/lib/fleet.mjs +167 -50
- package/bin/lib/live.mjs +14 -3
- package/bin/lib/vault.mjs +258 -0
- package/package.json +2 -1
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' +
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -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
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 itself — no 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.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
in place
|
|
139
|
-
If the change adds a genuinely new
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
(
|
|
143
|
-
|
|
144
|
-
4.
|
|
145
|
-
|
|
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
|
|
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:
|
|
156
|
-
`
|
|
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, '--
|
|
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).
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|