flowviant 0.27.1 → 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 +21 -2
- 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
|
@@ -301,11 +301,30 @@ function removeStaleEnvFile(wt, rel) {
|
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
/**
|
|
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. */
|
|
305
323
|
export function materializeInto(wt) {
|
|
306
324
|
if (!wt || !existsSync(wt)) return;
|
|
307
325
|
const byFile = new Map();
|
|
308
326
|
for (const v of values) {
|
|
327
|
+
if (v.scope !== 'app' || v.env !== 'dev') continue; // only local dev secrets hit a worktree file
|
|
309
328
|
if (!isSafeTarget(v.targetFile)) continue;
|
|
310
329
|
const list = byFile.get(v.targetFile) ?? [];
|
|
311
330
|
list.push(v);
|
|
@@ -445,7 +464,7 @@ export async function handleRosterEnv(env, { projectId } = {}) {
|
|
|
445
464
|
for (const k of bundle.keys) {
|
|
446
465
|
try {
|
|
447
466
|
const plain = openSealed(k.ciphertext, projectPub, projectPriv);
|
|
448
|
-
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 });
|
|
449
468
|
} catch {
|
|
450
469
|
allOpened = false;
|
|
451
470
|
warn(`env: could not open ${k.name} (epoch ${k.keyEpoch}) — skipping; a rotation should heal it`);
|
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": {
|