flowviant 0.27.0 → 0.28.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/lib/config.mjs +1 -1
- package/bin/lib/deploy.mjs +252 -0
- package/bin/lib/env.mjs +90 -28
- package/bin/lib/fleet.mjs +10 -0
- package/package.json +1 -1
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.28.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,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare DevOps — the daemon runs the user's own `wrangler`. Broker-not-
|
|
3
|
+
* host: no cloud credential ever reaches Flowviant. A deploy-authorized daemon
|
|
4
|
+
* claims deploy jobs off the roster, runs build → push prod secrets → deploy →
|
|
5
|
+
* verify, and reports the outcome. It also reports its parsed
|
|
6
|
+
* .flowviant/deploy.json so the app can list targets, and (basic) observes
|
|
7
|
+
* out-of-band deployments.
|
|
8
|
+
*
|
|
9
|
+
* Every log line that leaves the machine passes through the env scrubber —
|
|
10
|
+
* wrangler output routinely echoes secrets.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
17
|
+
import { c, note, ok, warn } from './ui.mjs';
|
|
18
|
+
import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
|
|
19
|
+
|
|
20
|
+
const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
|
|
21
|
+
|
|
22
|
+
async function post(tail, body) {
|
|
23
|
+
const res = await fetch(deployUrl(tail), {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
27
|
+
'User-Agent': USER_AGENT,
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
},
|
|
30
|
+
signal: AbortSignal.timeout(30_000),
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
const json = await res.json().catch(() => ({}));
|
|
34
|
+
if (!res.ok || json?.success === false) {
|
|
35
|
+
throw new Error(`${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
|
|
36
|
+
}
|
|
37
|
+
return json?.data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read + parse .flowviant/deploy.json from the repo root. Returns [] if none. */
|
|
41
|
+
export function readDeployConfig(repoRoot) {
|
|
42
|
+
const path = join(repoRoot, '.flowviant', 'deploy.json');
|
|
43
|
+
if (!existsSync(path)) return [];
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
46
|
+
const targets = Array.isArray(parsed?.targets) ? parsed.targets : [];
|
|
47
|
+
// Keep only fields the server + runner need; the daemon holds the commands.
|
|
48
|
+
return targets
|
|
49
|
+
.filter((t) => t && typeof t.id === 'string' && typeof t.command === 'string')
|
|
50
|
+
.slice(0, 20);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
warn(`deploy: .flowviant/deploy.json is not valid JSON — ${e.message}`);
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Report the parsed config to the server (only when it changed). */
|
|
58
|
+
let lastConfigJson = null;
|
|
59
|
+
export async function reportDeployConfig(repoRoot) {
|
|
60
|
+
const targets = readDeployConfig(repoRoot);
|
|
61
|
+
const json = JSON.stringify(targets);
|
|
62
|
+
if (json === lastConfigJson) return;
|
|
63
|
+
// Strip commands/secrets before the server sees the config (metadata only).
|
|
64
|
+
const meta = targets.map((t) => ({
|
|
65
|
+
id: t.id,
|
|
66
|
+
label: t.label,
|
|
67
|
+
provider: t.provider || 'cloudflare',
|
|
68
|
+
command: t.command,
|
|
69
|
+
build: t.build,
|
|
70
|
+
commands: t.commands,
|
|
71
|
+
healthcheck: t.healthcheck,
|
|
72
|
+
healthStatus: t.healthStatus,
|
|
73
|
+
pushSecrets: t.pushSecrets,
|
|
74
|
+
}));
|
|
75
|
+
try {
|
|
76
|
+
await post('deploy-config', { pubkey: myPubB64(), targets: meta });
|
|
77
|
+
lastConfigJson = json;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
warn(`deploy: could not report config — ${e.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Run a shell command ASYNC (never blocks the daemon's event loop — the
|
|
84
|
+
* reconcile poll + the deploy heartbeat must keep firing during a long
|
|
85
|
+
* deploy). Captures combined + scrubbed output; resolves {ok,out,code}. */
|
|
86
|
+
function run(command, { cwd, env, input }) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const child = spawn(command, { cwd, env, shell: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
89
|
+
let buf = '';
|
|
90
|
+
const cap = (d) => {
|
|
91
|
+
buf += d.toString();
|
|
92
|
+
if (buf.length > 512 * 1024) buf = buf.slice(-512 * 1024); // bound memory
|
|
93
|
+
};
|
|
94
|
+
child.stdout.on('data', cap);
|
|
95
|
+
child.stderr.on('data', cap);
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
try {
|
|
98
|
+
child.kill('SIGKILL');
|
|
99
|
+
} catch {
|
|
100
|
+
/* already gone */
|
|
101
|
+
}
|
|
102
|
+
}, 30 * 60_000);
|
|
103
|
+
if (input != null) {
|
|
104
|
+
try {
|
|
105
|
+
child.stdin.write(input);
|
|
106
|
+
child.stdin.end();
|
|
107
|
+
} catch {
|
|
108
|
+
/* stdin closed */
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
child.stdin.end();
|
|
112
|
+
}
|
|
113
|
+
child.on('close', (code) => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
resolve({ ok: code === 0, code: code ?? -1, out: scrub(buf) });
|
|
116
|
+
});
|
|
117
|
+
child.on('error', (e) => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve({ ok: false, code: -1, out: scrub(`${buf}\n${e.message}`) });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const tailLines = (s, n = 40) => s.split('\n').filter(Boolean).slice(-n);
|
|
125
|
+
|
|
126
|
+
/** Health-check a deployed target: GET the URL, expect `status`. Retries a few
|
|
127
|
+
* times for propagation lag. */
|
|
128
|
+
async function verifyHealth(url, status) {
|
|
129
|
+
for (let i = 0; i < 4; i++) {
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'manual' });
|
|
132
|
+
if (res.status === status) return true;
|
|
133
|
+
} catch {
|
|
134
|
+
/* not up yet */
|
|
135
|
+
}
|
|
136
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const claiming = new Set(); // in-flight guard (single-flight per daemon process)
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Process queued deploy jobs from the roster. `ctx` = { repoRoot, baseRef,
|
|
145
|
+
* myPubB64 }. Each job: claim → build → push prod secrets → deploy → verify →
|
|
146
|
+
* report. Runs concurrently but one-per-jobId.
|
|
147
|
+
*/
|
|
148
|
+
export function processDeployJobs(jobs, ctx) {
|
|
149
|
+
if (!Array.isArray(jobs) || !jobs.length) return;
|
|
150
|
+
for (const job of jobs) {
|
|
151
|
+
if (claiming.has(job.id)) continue;
|
|
152
|
+
claiming.add(job.id);
|
|
153
|
+
void (async () => {
|
|
154
|
+
let beat = null;
|
|
155
|
+
try {
|
|
156
|
+
const claimed = await post('deploy-claim', { jobId: job.id, pubkey: ctx.myPubB64() }).catch(() => null);
|
|
157
|
+
if (!claimed?.claimed) return; // another daemon won the claim
|
|
158
|
+
// Keep the claim fresh while we run — a long deploy must never be
|
|
159
|
+
// re-queued out from under us (that would double-deploy). The async
|
|
160
|
+
// run() below keeps the event loop free so this fires.
|
|
161
|
+
beat = setInterval(() => {
|
|
162
|
+
void post('deploy-heartbeat', { jobId: job.id, pubkey: ctx.myPubB64() }).catch(() => {});
|
|
163
|
+
}, 60_000);
|
|
164
|
+
const targets = readDeployConfig(ctx.repoRoot);
|
|
165
|
+
const target = targets.find((t) => t.id === job.targetId);
|
|
166
|
+
if (!target) {
|
|
167
|
+
await report(job, ctx, { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json` });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
note(`${c.cyan('deploy')} ${c.dim(`— ${job.kind} ${job.targetId} → ${job.env}…`)}`);
|
|
171
|
+
const outcome = await runDeploy(job, target, ctx);
|
|
172
|
+
await report(job, ctx, outcome);
|
|
173
|
+
if (outcome.ok) ok(`${c.cyan('deploy')} ${c.dim(`— ${job.targetId} → ${job.env} done${outcome.healthOk === false ? ' (health failed)' : ''}`)}`);
|
|
174
|
+
else warn(`deploy: ${job.targetId} → ${job.env} failed — ${outcome.message}`);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
warn(`deploy job ${job.id} errored: ${e.message}`);
|
|
177
|
+
await report(job, ctx, { ok: false, message: e.message }).catch(() => {});
|
|
178
|
+
} finally {
|
|
179
|
+
if (beat) clearInterval(beat);
|
|
180
|
+
claiming.delete(job.id);
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runDeploy(job, target, ctx) {
|
|
187
|
+
const env = { ...process.env, ...deployCreds() }; // inject infra creds; never a file
|
|
188
|
+
const logs = [];
|
|
189
|
+
// Rollback is a single wrangler command; deploy is build → secrets → deploy.
|
|
190
|
+
if (job.kind === 'rollback') {
|
|
191
|
+
const cmd = target.commands?.[`rollback:${job.env}`] || `npx wrangler rollback`;
|
|
192
|
+
const r = await run(cmd, { cwd: ctx.repoRoot, env });
|
|
193
|
+
logs.push(...tailLines(r.out));
|
|
194
|
+
return { ok: r.ok, message: r.ok ? 'rolled back' : logs.slice(-6).join('\n'), logs };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (target.build) {
|
|
198
|
+
const b = await run(target.build, { cwd: ctx.repoRoot, env });
|
|
199
|
+
logs.push(...tailLines(b.out));
|
|
200
|
+
if (!b.ok) return { ok: false, message: `build failed:\n${logs.slice(-6).join('\n')}`, logs };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Push prod app secrets to the provider's secret store (never written local).
|
|
204
|
+
// `name` is always a validated vault key (alnum/underscore) or skipped, and
|
|
205
|
+
// the VALUE goes only via stdin — never argv (no prod plaintext in ps).
|
|
206
|
+
if (job.env === 'prod' && Array.isArray(target.pushSecrets) && target.pushSecrets.length) {
|
|
207
|
+
const secrets = appSecretsFor('prod');
|
|
208
|
+
for (const name of target.pushSecrets) {
|
|
209
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !(name in secrets)) {
|
|
210
|
+
warn(`deploy: pushSecret "${name}" invalid or not in the vault at prod scope — skipping`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const putCmd = target.commands?.['secretPut']
|
|
214
|
+
? target.commands['secretPut'].replace('{name}', name)
|
|
215
|
+
: `npx wrangler secret put ${name} --env production`;
|
|
216
|
+
const s = await run(putCmd, { cwd: ctx.repoRoot, env, input: secrets[name] });
|
|
217
|
+
if (!s.ok) return { ok: false, message: `pushing secret ${name} failed`, logs };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const cmd = target.commands?.[job.env] || target.command;
|
|
222
|
+
const d = await run(cmd, { cwd: ctx.repoRoot, env });
|
|
223
|
+
logs.push(...tailLines(d.out));
|
|
224
|
+
if (!d.ok) return { ok: false, message: `deploy failed:\n${logs.slice(-8).join('\n')}`, logs };
|
|
225
|
+
|
|
226
|
+
// Extract the wrangler version/deployment id if present.
|
|
227
|
+
const idMatch = d.out.match(/Current Version ID:\s*([0-9a-f-]+)/i);
|
|
228
|
+
const deploymentId = idMatch ? idMatch[1] : null;
|
|
229
|
+
|
|
230
|
+
let healthOk = null;
|
|
231
|
+
if (target.healthcheck) {
|
|
232
|
+
healthOk = await verifyHealth(target.healthcheck, target.healthStatus ?? 200);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
healthOk,
|
|
237
|
+
deploymentId,
|
|
238
|
+
message: healthOk === false ? 'deployed, but health check failed' : 'deployed',
|
|
239
|
+
logs,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function report(job, ctx, outcome) {
|
|
244
|
+
return post('deploy-report', {
|
|
245
|
+
jobId: job.id,
|
|
246
|
+
pubkey: ctx.myPubB64(),
|
|
247
|
+
ok: !!outcome.ok,
|
|
248
|
+
deploymentId: outcome.deploymentId ?? null,
|
|
249
|
+
healthOk: outcome.healthOk ?? null,
|
|
250
|
+
message: scrub(outcome.message || ''),
|
|
251
|
+
}).catch((e) => warn(`deploy: could not report outcome — ${e.message}`));
|
|
252
|
+
}
|
package/bin/lib/env.mjs
CHANGED
|
@@ -64,18 +64,27 @@ export async function sodiumReady() {
|
|
|
64
64
|
/** 6-emoji key fingerprint — algorithm MUST match the web's pubkeyEmoji
|
|
65
65
|
* (EnvironmentSettings.tsx) so the human can compare terminal ↔ approve card. */
|
|
66
66
|
// MUST stay byte-identical to the web's pubkeyEmoji (EnvironmentSettings.tsx) —
|
|
67
|
-
// the human compares the two. 32 glyphs × 8 positions
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
const FP_EMOJI = ['🦊','🐙','🦕','🐝','🦉','🐬','🦁','🐸','🦄','🐢','🦋','🐺','🦜','🐳','🦔','🐌','🦩','🐿️','🦥','🐨','🦦','🐇','🦡','
|
|
67
|
+
// the human compares the two strings. 32 glyphs × 8 positions, effective ~40
|
|
68
|
+
// bits. Two FNV-1a rolling hashes over the whole key + a murmur3 finalizer per
|
|
69
|
+
// glyph (a plain additive sum collapsed the space to ~10 bits — grindable).
|
|
70
|
+
const FP_EMOJI = ['🦊','🐙','🦕','🐝','🦉','🐬','🦁','🐸','🦄','🐢','🦋','🐺','🦜','🐳','🦔','🐌','🦩','🐿️','🦥','🐨','🦦','🐇','🦡','🦂','🦨','🐜','🦢','🐋','🦭','🐞','🦚','🐊'];
|
|
71
71
|
export function pubkeyEmoji(pubkeyB64) {
|
|
72
|
+
let h1 = 0x811c9dc5 >>> 0;
|
|
73
|
+
let h2 = 0xc2b2ae35 >>> 0;
|
|
74
|
+
for (let i = 0; i < pubkeyB64.length; i++) {
|
|
75
|
+
const ch = pubkeyB64.charCodeAt(i);
|
|
76
|
+
h1 = Math.imul(h1 ^ ch, 0x01000193) >>> 0;
|
|
77
|
+
h2 = Math.imul(h2 ^ ch, 0x85ebca6b) >>> 0;
|
|
78
|
+
}
|
|
72
79
|
let out = '';
|
|
73
80
|
for (let i = 0; i < 8; i++) {
|
|
74
|
-
let
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
let x = (((i < 4 ? h1 : h2) + i * 0x9e3779b1) >>> 0);
|
|
82
|
+
x ^= x >>> 16;
|
|
83
|
+
x = Math.imul(x, 0x7feb352d) >>> 0;
|
|
84
|
+
x ^= x >>> 15;
|
|
85
|
+
x = Math.imul(x, 0x846ca68b) >>> 0;
|
|
86
|
+
x ^= x >>> 16;
|
|
87
|
+
out += FP_EMOJI[x & 31];
|
|
79
88
|
}
|
|
80
89
|
return out;
|
|
81
90
|
}
|
|
@@ -186,13 +195,15 @@ function readCache(projectId) {
|
|
|
186
195
|
}
|
|
187
196
|
}
|
|
188
197
|
|
|
189
|
-
/** Offline start: materialize from the encrypted cache before the first poll.
|
|
198
|
+
/** Offline start: materialize from the encrypted cache before the first poll.
|
|
199
|
+
* Also seeds knownTargetFiles so stale-file cleanup survives a restart. */
|
|
190
200
|
export async function loadCachedEnv(projectId) {
|
|
191
201
|
await ensureKeypair();
|
|
192
202
|
const cached = readCache(projectId);
|
|
193
203
|
if (!cached) return false;
|
|
194
204
|
values = cached.values ?? [];
|
|
195
205
|
bundleVersion = cached.bundleVersion ?? -1;
|
|
206
|
+
knownTargetFiles = new Set(cached.knownFiles ?? values.map((v) => v.targetFile));
|
|
196
207
|
cachedProjectId = projectId;
|
|
197
208
|
return values.length > 0;
|
|
198
209
|
}
|
|
@@ -253,9 +264,16 @@ function isTrackedInGit(wt, relPath) {
|
|
|
253
264
|
}
|
|
254
265
|
}
|
|
255
266
|
|
|
256
|
-
// Per-worktree: the target files we last materialized
|
|
257
|
-
// its keys (or a key that moved files) gets its stale copy removed.
|
|
267
|
+
// Per-worktree: the target files we last materialized THIS SESSION.
|
|
258
268
|
const lastFilesByWorktree = new Map();
|
|
269
|
+
// Project-global union of every target file we've ever materialized — PERSISTED
|
|
270
|
+
// in the cache and seeded on load, so a file whose key was deleted while the
|
|
271
|
+
// daemon was down still gets its stale plaintext copy cleaned on the next
|
|
272
|
+
// materialize (lastFilesByWorktree alone is empty after a restart, and
|
|
273
|
+
// `git clean -fd` never removes an info/exclude'd file).
|
|
274
|
+
let knownTargetFiles = new Set();
|
|
275
|
+
|
|
276
|
+
const MATERIALIZE_HEADER = '# Materialized by flowviant env sync';
|
|
259
277
|
|
|
260
278
|
/** Render KEY=value with values that contain newlines/= safely quoted so one
|
|
261
279
|
* value can't fabricate another key line. */
|
|
@@ -266,14 +284,47 @@ function renderEnvFile(list) {
|
|
|
266
284
|
const esc = v.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
|
|
267
285
|
return `${v.name}="${esc}"`;
|
|
268
286
|
});
|
|
269
|
-
return
|
|
287
|
+
return `${MATERIALIZE_HEADER} — DO NOT COMMIT.\n${lines.join('\n')}\n`;
|
|
270
288
|
}
|
|
271
289
|
|
|
272
|
-
/**
|
|
290
|
+
/** Delete a materialized file from a worktree, but ONLY if it's ours (carries
|
|
291
|
+
* our header) and not git-tracked — never touch a file we didn't write. */
|
|
292
|
+
function removeStaleEnvFile(wt, rel) {
|
|
293
|
+
if (isTrackedInGit(wt, rel)) return;
|
|
294
|
+
const abs = join(wt, rel);
|
|
295
|
+
try {
|
|
296
|
+
if (existsSync(abs) && readFileSync(abs, 'utf8').startsWith(MATERIALIZE_HEADER)) {
|
|
297
|
+
rmSync(abs, { force: true });
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
/* best-effort */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Deploy-scope credentials (e.g. CLOUDFLARE_API_TOKEN) as a NAME→value map —
|
|
305
|
+
* injected into the deploy command's process env, NEVER written to a file. */
|
|
306
|
+
export function deployCreds() {
|
|
307
|
+
const out = {};
|
|
308
|
+
for (const v of values) if (v.scope === 'deploy' && v.value) out[v.name] = v.value;
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** app-scope secrets for one environment as a NAME→value map — for pushing to
|
|
313
|
+
* the provider's secret store on a prod deploy (`wrangler secret put`). */
|
|
314
|
+
export function appSecretsFor(env) {
|
|
315
|
+
const out = {};
|
|
316
|
+
for (const v of values) if (v.scope === 'app' && v.env === env && v.value) out[v.name] = v.value;
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Write the decrypted env into ONE worktree. Never call on the wiki worktree.
|
|
321
|
+
* v1 materializes the 'dev' app env (agent test runs + local preview); prod
|
|
322
|
+
* app secrets go to the provider at deploy, deploy creds are injected only. */
|
|
273
323
|
export function materializeInto(wt) {
|
|
274
324
|
if (!wt || !existsSync(wt)) return;
|
|
275
325
|
const byFile = new Map();
|
|
276
326
|
for (const v of values) {
|
|
327
|
+
if (v.scope !== 'app' || v.env !== 'dev') continue; // only local dev secrets hit a worktree file
|
|
277
328
|
if (!isSafeTarget(v.targetFile)) continue;
|
|
278
329
|
const list = byFile.get(v.targetFile) ?? [];
|
|
279
330
|
list.push(v);
|
|
@@ -305,18 +356,15 @@ export function materializeInto(wt) {
|
|
|
305
356
|
}
|
|
306
357
|
}
|
|
307
358
|
|
|
308
|
-
// Remove
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
} catch {
|
|
316
|
-
/* best-effort */
|
|
317
|
-
}
|
|
318
|
-
}
|
|
359
|
+
// Remove any file we ever materialized (this session OR a prior one, via the
|
|
360
|
+
// persisted knownTargetFiles) that has no keys now — a deleted secret's
|
|
361
|
+
// plaintext file must not linger, even across a daemon restart.
|
|
362
|
+
const writtenSet = new Set(written);
|
|
363
|
+
const candidates = new Set([...(lastFilesByWorktree.get(wt) ?? []), ...knownTargetFiles]);
|
|
364
|
+
for (const stale of candidates) {
|
|
365
|
+
if (!writtenSet.has(stale)) removeStaleEnvFile(wt, stale);
|
|
319
366
|
}
|
|
367
|
+
for (const f of written) knownTargetFiles.add(f);
|
|
320
368
|
lastFilesByWorktree.set(wt, written);
|
|
321
369
|
if (written.length) excludeInWorktree(wt, written);
|
|
322
370
|
}
|
|
@@ -359,7 +407,18 @@ export async function handleRosterEnv(env, { projectId } = {}) {
|
|
|
359
407
|
// wedge registration until restart.
|
|
360
408
|
if (env.status === 'none' && !registeredOnce) {
|
|
361
409
|
const label = hostname() || 'daemon';
|
|
362
|
-
|
|
410
|
+
try {
|
|
411
|
+
await post('register', { pubkey: myPubB64(), label });
|
|
412
|
+
} catch (e) {
|
|
413
|
+
// A 429 = the project is at its machine cap; retrying every poll would
|
|
414
|
+
// just hammer it. Stop for this session (a restart re-tries).
|
|
415
|
+
if (/\(429/.test(e.message)) {
|
|
416
|
+
registeredOnce = true;
|
|
417
|
+
warn('env: this project is at its machine limit — env access not requested. Ask an admin to remove an old machine.');
|
|
418
|
+
return { changed: false };
|
|
419
|
+
}
|
|
420
|
+
throw e; // transient — retry next poll (registeredOnce still false)
|
|
421
|
+
}
|
|
363
422
|
registeredOnce = true;
|
|
364
423
|
const fp = pubkeyEmoji(myPubB64());
|
|
365
424
|
info(`${c.cyan('env')} · this machine requested env access as ${c.bold(label)}`);
|
|
@@ -405,7 +464,7 @@ export async function handleRosterEnv(env, { projectId } = {}) {
|
|
|
405
464
|
for (const k of bundle.keys) {
|
|
406
465
|
try {
|
|
407
466
|
const plain = openSealed(k.ciphertext, projectPub, projectPriv);
|
|
408
|
-
opened.push({ name: k.name, env: k.env, targetFile: k.targetFile, value: sodium.to_string(plain), version: k.version });
|
|
467
|
+
opened.push({ name: k.name, env: k.env, scope: k.scope ?? 'app', targetFile: k.targetFile, value: sodium.to_string(plain), version: k.version });
|
|
409
468
|
} catch {
|
|
410
469
|
allOpened = false;
|
|
411
470
|
warn(`env: could not open ${k.name} (epoch ${k.keyEpoch}) — skipping; a rotation should heal it`);
|
|
@@ -443,7 +502,10 @@ export async function handleRosterEnv(env, { projectId } = {}) {
|
|
|
443
502
|
if (needSync) {
|
|
444
503
|
values = opened;
|
|
445
504
|
bundleVersion = bundle.bundleVersion;
|
|
446
|
-
|
|
505
|
+
// Fold the current target files into the persisted known set so stale
|
|
506
|
+
// cleanup survives a restart (a key deleted while down still gets swept).
|
|
507
|
+
for (const v of values) knownTargetFiles.add(v.targetFile);
|
|
508
|
+
if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion, knownFiles: [...knownTargetFiles] });
|
|
447
509
|
ok(`${c.cyan('env')} ${c.dim(`— synced ${values.length} secret${values.length === 1 ? '' : 's'} (env v${bundleVersion})`)}`);
|
|
448
510
|
return { changed: true };
|
|
449
511
|
}
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -59,8 +59,10 @@ import {
|
|
|
59
59
|
envQueryParams,
|
|
60
60
|
handleRosterEnv,
|
|
61
61
|
materializeInto,
|
|
62
|
+
myPubB64,
|
|
62
63
|
scrub as envScrub,
|
|
63
64
|
} from './env.mjs';
|
|
65
|
+
import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
|
|
64
66
|
|
|
65
67
|
async function fetchRoster(haveIds) {
|
|
66
68
|
const url = new URL(FLEET_URL);
|
|
@@ -905,6 +907,14 @@ export async function runFleetDaemon() {
|
|
|
905
907
|
}
|
|
906
908
|
});
|
|
907
909
|
|
|
910
|
+
// Deploy: a deploy-authorized daemon reports its .flowviant/deploy.json and
|
|
911
|
+
// runs queued deploy jobs (the server only sends deployJobs to authorized
|
|
912
|
+
// machines). Config report is cheap + dedup'd; jobs are single-flight.
|
|
913
|
+
if (roster.env?.deployAuthorized) {
|
|
914
|
+
void reportDeployConfig(repoRoot);
|
|
915
|
+
processDeployJobs(roster.deployJobs, { repoRoot, baseRef, myPubB64 });
|
|
916
|
+
}
|
|
917
|
+
|
|
908
918
|
// Stop workers whose agent left the roster (removed in the app).
|
|
909
919
|
for (const [id, w] of [...workers]) {
|
|
910
920
|
if (!rosterIds.has(id)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|