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.
@@ -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.27.1';
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
- /** Write the decrypted env into ONE worktree. Never call on the wiki worktree. */
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.27.1",
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": {