flowviant 0.97.0 → 0.99.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 +13 -3
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +24 -1
- package/bin/lib/credentials.mjs +27 -4
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +99 -28
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +115 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +2 -2
- package/bin/lib/machines.mjs +16 -7
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +5 -0
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1148 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -2
package/bin/lib/deploy.mjs
CHANGED
|
@@ -64,12 +64,26 @@
|
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
import { spawn } from 'node:child_process';
|
|
67
|
-
import {
|
|
67
|
+
import { createHash } from 'node:crypto';
|
|
68
|
+
import {
|
|
69
|
+
copyFileSync,
|
|
70
|
+
existsSync,
|
|
71
|
+
lstatSync,
|
|
72
|
+
mkdirSync,
|
|
73
|
+
readdirSync,
|
|
74
|
+
realpathSync,
|
|
75
|
+
rmdirSync,
|
|
76
|
+
rmSync,
|
|
77
|
+
symlinkSync,
|
|
78
|
+
unlinkSync,
|
|
79
|
+
} from 'node:fs';
|
|
80
|
+
import { homedir } from 'node:os';
|
|
81
|
+
import { basename, isAbsolute, join, relative, sep } from 'node:path';
|
|
68
82
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
|
|
69
83
|
import { c, note, ok, warn } from './ui.mjs';
|
|
70
84
|
import { scrub, myPubB64 } from './env.mjs';
|
|
71
85
|
import { childEnv } from './childEnv.mjs';
|
|
72
|
-
import { git } from './git.mjs';
|
|
86
|
+
import { git, gitNetAsync } from './git.mjs';
|
|
73
87
|
|
|
74
88
|
const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
|
|
75
89
|
|
|
@@ -157,24 +171,60 @@ export async function reportDeployConfig(repoRoot, baseRef) {
|
|
|
157
171
|
o && typeof o === 'object'
|
|
158
172
|
? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, scrub(String(v ?? ''))]))
|
|
159
173
|
: o;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
/**
|
|
175
|
+
* NORMALIZE BEFORE POSTING (2026-09-24, the audit). `.flowviant/deploy.json`
|
|
176
|
+
* is a hand-written file; the shared schema treats `healthStatus` /
|
|
177
|
+
* `healthcheck` / `build` / `label` as display metadata and drops what it
|
|
178
|
+
* cannot read rather than refusing the whole target — but this file used to
|
|
179
|
+
* send them through unmodified, so a `"healthStatus": "200"` (which
|
|
180
|
+
* `verifyHealth` below already coerces with `Number()`) or a `null` label
|
|
181
|
+
* used to reach the wire looking present-but-unreadable. Send exactly what
|
|
182
|
+
* the mirror is going to keep instead of what the file happened to spell:
|
|
183
|
+
* a numeric `healthStatus` when the value is numeric or a numeric string,
|
|
184
|
+
* and no key at all for `label` / `healthcheck` / `build` when the file's
|
|
185
|
+
* value is not a non-empty string.
|
|
186
|
+
*/
|
|
187
|
+
const numOrOmit = (v) => {
|
|
188
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
189
|
+
if (typeof v === 'string' && /^\s*\d+\s*$/.test(v)) return Number(v);
|
|
190
|
+
return undefined;
|
|
191
|
+
};
|
|
192
|
+
const strOrOmit = (v) => (typeof v === 'string' && v.trim() ? v : undefined);
|
|
193
|
+
const meta = targets.map((t) => {
|
|
194
|
+
const label = strOrOmit(t.label);
|
|
195
|
+
const build = strOrOmit(t.build);
|
|
196
|
+
const healthcheck = strOrOmit(t.healthcheck);
|
|
197
|
+
const healthStatus = numOrOmit(t.healthStatus);
|
|
198
|
+
return {
|
|
199
|
+
id: t.id,
|
|
200
|
+
...(label !== undefined ? { label } : {}),
|
|
201
|
+
provider: t.provider || 'cloudflare',
|
|
202
|
+
command: scrub(String(t.command ?? '')),
|
|
203
|
+
...(build !== undefined ? { build: scrub(build) } : {}),
|
|
204
|
+
commands: scrubCmds(t.commands),
|
|
205
|
+
...(healthcheck !== undefined ? { healthcheck } : {}),
|
|
206
|
+
...(healthStatus !== undefined ? { healthStatus } : {}),
|
|
207
|
+
pushSecrets: t.pushSecrets,
|
|
208
|
+
// Deploy-on-merge: the env this target auto-deploys to when commits land
|
|
209
|
+
// on base. MUST ride this map — a field forgotten here never reaches the
|
|
210
|
+
// server, and the server is what turns a landed report into the job.
|
|
211
|
+
...(typeof t.onMerge === 'string' ? { onMerge: t.onMerge } : {}),
|
|
212
|
+
};
|
|
213
|
+
});
|
|
175
214
|
try {
|
|
176
|
-
await post('deploy-config', { pubkey: myPubB64(), targets: meta });
|
|
215
|
+
const data = await post('deploy-config', { pubkey: myPubB64(), targets: meta });
|
|
216
|
+
// Set on any 2xx, rejected targets included — the route now accepts a
|
|
217
|
+
// report per-target rather than failing the whole thing, so a target it
|
|
218
|
+
// could not read is not a reason to re-post this same file forever.
|
|
177
219
|
lastConfigJson = json;
|
|
220
|
+
// THE SERVER NAMES WHAT IT COULD NOT READ. Warned once per CHANGED config —
|
|
221
|
+
// this function only reaches here when the file did — never once per poll.
|
|
222
|
+
const rejected = Array.isArray(data?.rejected) ? data.rejected : [];
|
|
223
|
+
for (const r of rejected) {
|
|
224
|
+
warn(
|
|
225
|
+
`deploy: .flowviant/deploy.json target "${r?.id ?? '?'}" was not accepted — ${r?.reason ?? 'invalid'}`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
178
228
|
} catch (e) {
|
|
179
229
|
warn(`deploy: could not report config — ${e.message}`);
|
|
180
230
|
}
|
|
@@ -323,14 +373,8 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
323
373
|
}
|
|
324
374
|
});
|
|
325
375
|
}, 60_000);
|
|
326
|
-
const targets = readDeployConfig(ctx.repoRoot, ctx.baseRef);
|
|
327
|
-
const target = targets.find((t) => t.id === job.targetId);
|
|
328
|
-
if (!target) {
|
|
329
|
-
await report(job, ctx, { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json` });
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
376
|
note(`${c.cyan('deploy')} ${c.dim(`— ${job.kind} ${job.targetId} → ${job.env}…`)}`);
|
|
333
|
-
const outcome = await runDeploy(job,
|
|
377
|
+
const outcome = await runDeploy(job, ctx);
|
|
334
378
|
// From here the work is DONE. Whatever the report does, this job must
|
|
335
379
|
// never run again in this process.
|
|
336
380
|
ran.add(job.id);
|
|
@@ -351,7 +395,271 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
351
395
|
}
|
|
352
396
|
}
|
|
353
397
|
|
|
354
|
-
|
|
398
|
+
/**
|
|
399
|
+
* A DEPLOY BUILDS THE BASE COMMIT, NEVER THE OPERATOR'S WORKING TREE
|
|
400
|
+
* (audit 2026-09-24).
|
|
401
|
+
*
|
|
402
|
+
* `readDeployConfig` reads the TARGET off base so that authoring a command
|
|
403
|
+
* requires landing it — and then every command ran with `cwd: repoRoot`, the
|
|
404
|
+
* operator's own checkout, on whatever branch they left it with whatever they
|
|
405
|
+
* had not committed. So `npm run build` and `wrangler deploy` read the
|
|
406
|
+
* package.json scripts, wrangler.toml and source of that working tree:
|
|
407
|
+
* deploy-on-merge (queued by a teammate's ship, a PR merge or an agent merge,
|
|
408
|
+
* none of which moves the checkout) shipped code WITHOUT the merge that
|
|
409
|
+
* triggered it, and a co-owner's "deploy it to prod" shipped the operator's
|
|
410
|
+
* half-finished branch. Reproduced: a checkout on `feature` with an
|
|
411
|
+
* uncommitted edit deployed the edit.
|
|
412
|
+
*
|
|
413
|
+
* So a deploy runs in a THROWAWAY DETACHED WORKTREE at the base tip — the
|
|
414
|
+
* shipMerge shape — and the config is read from THAT SHA, so the command and
|
|
415
|
+
* the code it builds are one commit. The directory dies in a `finally`.
|
|
416
|
+
*
|
|
417
|
+
* What a fresh worktree does not have is what git does not track: installed
|
|
418
|
+
* dependencies and the operator's env files. Those are the checkout's
|
|
419
|
+
* ENVIRONMENT, not its code, so for every directory base tracks (three levels
|
|
420
|
+
* deep) a `node_modules` in the checkout is re-created as a directory of links
|
|
421
|
+
* to its entries — a WORKSPACE package's link re-pointed at base's own copy of
|
|
422
|
+
* that package, never the checkout's working tree — and a `.env` / `.env.*` /
|
|
423
|
+
* `.dev.vars` the base commit does not carry is COPIED in. Stated residual:
|
|
424
|
+
* installed third-party packages and those env files are still the checkout's,
|
|
425
|
+
* so a dependency installed for a feature branch, or an untracked env file an
|
|
426
|
+
* agent edited, reaches the build; so does a workspace package's untracked
|
|
427
|
+
* build output (`dist/`), which is simply absent here and fails the build
|
|
428
|
+
* loudly rather than shipping the checkout's. A `.bin` shim is linked whole, so
|
|
429
|
+
* a workspace package's own CLI still runs from the checkout. Submodules are
|
|
430
|
+
* not initialised. The code and the commands no longer come from the checkout.
|
|
431
|
+
*/
|
|
432
|
+
const ENV_FILE_RE = /^(?:\.env(?:\..+)?|\.dev\.vars)$/;
|
|
433
|
+
const lexists = (p) => {
|
|
434
|
+
try {
|
|
435
|
+
lstatSync(p);
|
|
436
|
+
return true;
|
|
437
|
+
} catch {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/** Bring the checkout's untracked environment into a deploy worktree. Returns
|
|
443
|
+
* every path it created, so the cleanup can unlink them BEFORE the worktree
|
|
444
|
+
* is removed (a recursive delete must never be handed a symlink into the
|
|
445
|
+
* operator's own node_modules). */
|
|
446
|
+
export function linkCheckoutEnvironment(repoRoot, dir) {
|
|
447
|
+
const made = [];
|
|
448
|
+
let realRoot = repoRoot;
|
|
449
|
+
try {
|
|
450
|
+
realRoot = realpathSync(repoRoot);
|
|
451
|
+
} catch {
|
|
452
|
+
/* compared as given */
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* A node_modules is a REAL directory of per-entry links, not one link to the
|
|
456
|
+
* checkout's (review, audit 2026-09-24). A workspace package — npm, yarn and
|
|
457
|
+
* pnpm all install one as a link like `node_modules/@x/shared ->
|
|
458
|
+
* ../../packages/shared` — resolves THROUGH the checkout's node_modules into
|
|
459
|
+
* the checkout's WORKING TREE, so a whole-directory link had every bundler
|
|
460
|
+
* import the operator's uncommitted `packages/shared/src` into a deploy of
|
|
461
|
+
* base. An entry whose real path is inside the checkout but outside any
|
|
462
|
+
* node_modules is re-pointed at the SAME path in this worktree (base's copy);
|
|
463
|
+
* one base does not carry is left out, since base cannot import what it does
|
|
464
|
+
* not have. Every other entry links to the checkout's own, as before.
|
|
465
|
+
*/
|
|
466
|
+
const linkNodeModules = (src, dst, depth) => {
|
|
467
|
+
let entries;
|
|
468
|
+
try {
|
|
469
|
+
entries = readdirSync(src, { withFileTypes: true });
|
|
470
|
+
mkdirSync(dst);
|
|
471
|
+
made.push(dst);
|
|
472
|
+
} catch {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
for (const e of entries) {
|
|
476
|
+
const s = join(src, e.name);
|
|
477
|
+
const d = join(dst, e.name);
|
|
478
|
+
// A scope directory holds packages, not a package: one level down.
|
|
479
|
+
if (depth === 0 && e.name.startsWith('@') && e.isDirectory()) {
|
|
480
|
+
linkNodeModules(s, d, 1);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
let target = s;
|
|
484
|
+
if (e.isSymbolicLink()) {
|
|
485
|
+
let real;
|
|
486
|
+
try {
|
|
487
|
+
real = realpathSync(s);
|
|
488
|
+
} catch {
|
|
489
|
+
continue; // a dangling link — nothing to bring
|
|
490
|
+
}
|
|
491
|
+
const rel = relative(realRoot, real);
|
|
492
|
+
const inCheckout = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
493
|
+
if (inCheckout && !rel.split(sep).includes('node_modules')) {
|
|
494
|
+
if (!existsSync(join(dir, rel))) continue;
|
|
495
|
+
target = join(dir, rel);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
symlinkSync(target, d);
|
|
500
|
+
made.push(d);
|
|
501
|
+
} catch {
|
|
502
|
+
/* the build says so if it needed it */
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
const walk = (rel, depth) => {
|
|
507
|
+
let entries;
|
|
508
|
+
try {
|
|
509
|
+
entries = readdirSync(join(repoRoot, rel), { withFileTypes: true });
|
|
510
|
+
} catch {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
for (const e of entries) {
|
|
514
|
+
const src = join(repoRoot, rel, e.name);
|
|
515
|
+
const dst = join(dir, rel, e.name);
|
|
516
|
+
if (e.name === '.git') continue;
|
|
517
|
+
if (e.name === 'node_modules') {
|
|
518
|
+
if (e.isDirectory() && !lexists(dst)) linkNodeModules(src, dst, 0);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (e.isFile() && ENV_FILE_RE.test(e.name)) {
|
|
522
|
+
if (!lexists(dst)) {
|
|
523
|
+
try {
|
|
524
|
+
copyFileSync(src, dst);
|
|
525
|
+
made.push(dst);
|
|
526
|
+
} catch {
|
|
527
|
+
/* unreadable — the build says so if it needed it */
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
// Recurse only into directories BASE tracks — an untracked `dist/` or a
|
|
533
|
+
// cache is neither code nor environment, and walking it costs a scan.
|
|
534
|
+
if (e.isDirectory() && depth < 3 && existsSync(dst) && !lexists(join(dst, '.git'))) {
|
|
535
|
+
walk(join(rel, e.name), depth + 1);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
walk('', 0);
|
|
540
|
+
return made;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup }`, or
|
|
545
|
+
* throws with a sentence when base does not resolve. `worktreeDir` defaults to
|
|
546
|
+
* the daemon's own worktree home for this repo (fleet.mjs keys it the same
|
|
547
|
+
* way); the job id is hashed into the path, never joined raw — it is a
|
|
548
|
+
* server-named string.
|
|
549
|
+
*/
|
|
550
|
+
export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir }) {
|
|
551
|
+
if (!baseRef) throw new Error('no base branch resolved, so there is nothing to deploy from');
|
|
552
|
+
try {
|
|
553
|
+
// A NETWORK call, so timed, non-interactive and off the event loop — a
|
|
554
|
+
// bare `git fetch` here is `execFileSync` with no timeout, and a remote
|
|
555
|
+
// that prompts or a half-open connection froze every poll and lease on
|
|
556
|
+
// the machine until it returned.
|
|
557
|
+
await gitNetAsync(['fetch', 'origin', '--quiet'], repoRoot);
|
|
558
|
+
} catch {
|
|
559
|
+
/* offline — deploy the base this box last saw, and say which */
|
|
560
|
+
}
|
|
561
|
+
let sha;
|
|
562
|
+
try {
|
|
563
|
+
sha = git(['rev-parse', '--verify', `${baseRef}^{commit}`], repoRoot);
|
|
564
|
+
} catch {
|
|
565
|
+
throw new Error(`the base branch ${baseRef} does not resolve on this machine`);
|
|
566
|
+
}
|
|
567
|
+
const home =
|
|
568
|
+
worktreeDir ??
|
|
569
|
+
join(
|
|
570
|
+
homedir(),
|
|
571
|
+
'.flowviant',
|
|
572
|
+
'worktrees',
|
|
573
|
+
`${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`
|
|
574
|
+
);
|
|
575
|
+
const dir = join(home, 'deploy', createHash('sha256').update(String(jobId)).digest('hex').slice(0, 16));
|
|
576
|
+
mkdirSync(join(home, 'deploy'), { recursive: true });
|
|
577
|
+
const drop = (made = [], target = dir) => {
|
|
578
|
+
// Newest first, so a node_modules directory we made is empty of our links
|
|
579
|
+
// by the time it is removed.
|
|
580
|
+
for (const p of [...made].reverse()) {
|
|
581
|
+
try {
|
|
582
|
+
unlinkSync(p);
|
|
583
|
+
} catch {
|
|
584
|
+
try {
|
|
585
|
+
rmdirSync(p);
|
|
586
|
+
} catch {
|
|
587
|
+
/* already gone */
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
git(['worktree', 'remove', '--force', target], repoRoot);
|
|
593
|
+
} catch {
|
|
594
|
+
/* not registered */
|
|
595
|
+
}
|
|
596
|
+
// Every symlink we made is already unlinked, and `rmSync` unlinks a
|
|
597
|
+
// symlink rather than descending it, so this can only delete our own copy.
|
|
598
|
+
try {
|
|
599
|
+
rmSync(target, { recursive: true, force: true });
|
|
600
|
+
} catch {
|
|
601
|
+
/* best-effort */
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
git(['worktree', 'prune'], repoRoot);
|
|
605
|
+
} catch {
|
|
606
|
+
/* best-effort */
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
drop(); // a corpse from a crashed deploy of the same job
|
|
610
|
+
// …and any other job's corpse old enough that no deploy can still be running
|
|
611
|
+
// in it (two commands at most, 30 minutes each), so a daemon killed
|
|
612
|
+
// mid-deploy does not leave a checkout on disk for ever.
|
|
613
|
+
try {
|
|
614
|
+
const cutoff = Date.now() - 3 * 60 * 60 * 1000;
|
|
615
|
+
for (const name of readdirSync(join(home, 'deploy'))) {
|
|
616
|
+
const p = join(home, 'deploy', name);
|
|
617
|
+
try {
|
|
618
|
+
if (p !== dir && lstatSync(p).mtimeMs < cutoff) drop([], p);
|
|
619
|
+
} catch {
|
|
620
|
+
/* gone meanwhile */
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
} catch {
|
|
624
|
+
/* nothing to sweep */
|
|
625
|
+
}
|
|
626
|
+
git(['worktree', 'add', '--detach', dir, sha], repoRoot);
|
|
627
|
+
let made = [];
|
|
628
|
+
try {
|
|
629
|
+
made = linkCheckoutEnvironment(repoRoot, dir);
|
|
630
|
+
} catch {
|
|
631
|
+
/* the build says so if it needed any of it */
|
|
632
|
+
}
|
|
633
|
+
return { dir, sha, cleanup: () => drop(made) };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export async function runDeploy(job, ctx) {
|
|
637
|
+
let checkout;
|
|
638
|
+
try {
|
|
639
|
+
checkout = await openDeployCheckout({
|
|
640
|
+
repoRoot: ctx.repoRoot,
|
|
641
|
+
baseRef: ctx.baseRef,
|
|
642
|
+
jobId: job.id,
|
|
643
|
+
worktreeDir: ctx.worktreeDir,
|
|
644
|
+
});
|
|
645
|
+
} catch (e) {
|
|
646
|
+
return { ok: false, message: `deploy not run — ${e.message}`, logs: [] };
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
const targets = readDeployConfig(ctx.repoRoot, checkout.sha);
|
|
650
|
+
const target = targets.find((t) => t.id === job.targetId);
|
|
651
|
+
if (!target) {
|
|
652
|
+
return { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json`, logs: [] };
|
|
653
|
+
}
|
|
654
|
+
const outcome = await runDeployIn(job, target, checkout.dir);
|
|
655
|
+
const short = checkout.sha.slice(0, 12);
|
|
656
|
+
return { ...outcome, sha: checkout.sha, message: `${outcome.message} (${short})` };
|
|
657
|
+
} finally {
|
|
658
|
+
checkout.cleanup();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function runDeployIn(job, target, cwd) {
|
|
355
663
|
// AN ALLOWLIST, not `{...process.env}` minus names. What stood here was
|
|
356
664
|
//
|
|
357
665
|
// const env = { ...process.env, ...deployCreds() };
|
|
@@ -369,18 +677,44 @@ async function runDeploy(job, target, ctx) {
|
|
|
369
677
|
// nothing else. It replaced `extra: deployCreds()` when the vault was deleted
|
|
370
678
|
// — the credentials are the OPERATOR's now, in the shell they started the
|
|
371
679
|
// daemon in, rather than values Flowviant decrypted onto the box.
|
|
372
|
-
const env = childEnv({ cwd
|
|
680
|
+
const env = childEnv({ cwd, deploy: true }); // infra creds; never a file
|
|
373
681
|
const logs = [];
|
|
682
|
+
/**
|
|
683
|
+
* THE ENV-ISOLATION GUARD, RE-APPLIED HERE (2026-09-24, the audit).
|
|
684
|
+
*
|
|
685
|
+
* The server already refuses a non-prod trigger whose target declares no
|
|
686
|
+
* per-env override — `createDeployJob`'s own comment names the same
|
|
687
|
+
* fallback this file resolves, `commands?.[env] || command`, as the reason:
|
|
688
|
+
* the base `command` (and the bare `npx wrangler rollback` default) is the
|
|
689
|
+
* PROD command, so a dev/preview trigger with no override would silently
|
|
690
|
+
* run production. But that check reads the SERVER'S MIRROR
|
|
691
|
+
* (`reportDeployConfig`'s last post), and this function reads the file OFF
|
|
692
|
+
* THE COMMIT it is about to build and run — a window the trigger-time check
|
|
693
|
+
* cannot close: the file on base changed after the last report, or this
|
|
694
|
+
* daemon has not reported yet. So the same rule is asked again here,
|
|
695
|
+
* against the only copy that is about to matter, and prod is the one env
|
|
696
|
+
* this never touches.
|
|
697
|
+
*/
|
|
698
|
+
if (job.env !== 'prod') {
|
|
699
|
+
const key = job.kind === 'rollback' ? `rollback:${job.env}` : job.env;
|
|
700
|
+
if (!target.commands?.[key]) {
|
|
701
|
+
return {
|
|
702
|
+
ok: false,
|
|
703
|
+
message: `target declares no ${job.env} command — refusing to fall back to the prod command`,
|
|
704
|
+
logs,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
}
|
|
374
708
|
// Rollback is a single wrangler command; deploy is build → secrets → deploy.
|
|
375
709
|
if (job.kind === 'rollback') {
|
|
376
710
|
const cmd = target.commands?.[`rollback:${job.env}`] || `npx wrangler rollback`;
|
|
377
|
-
const r = await run(cmd, { cwd
|
|
711
|
+
const r = await run(cmd, { cwd, env });
|
|
378
712
|
logs.push(...tailLines(r.out));
|
|
379
713
|
return { ok: r.ok, message: r.ok ? 'rolled back' : logs.slice(-6).join('\n'), logs };
|
|
380
714
|
}
|
|
381
715
|
|
|
382
716
|
if (target.build) {
|
|
383
|
-
const b = await run(target.build, { cwd
|
|
717
|
+
const b = await run(target.build, { cwd, env });
|
|
384
718
|
logs.push(...tailLines(b.out));
|
|
385
719
|
if (!b.ok) return { ok: false, message: `build failed:\n${logs.slice(-6).join('\n')}`, logs };
|
|
386
720
|
}
|
|
@@ -397,7 +731,7 @@ async function runDeploy(job, target, ctx) {
|
|
|
397
731
|
// values belong there. `target.pushSecrets` stays parsed and reported, which
|
|
398
732
|
// is what every retired key in this product does.
|
|
399
733
|
const cmd = target.commands?.[job.env] || target.command;
|
|
400
|
-
const d = await run(cmd, { cwd
|
|
734
|
+
const d = await run(cmd, { cwd, env });
|
|
401
735
|
logs.push(...tailLines(d.out));
|
|
402
736
|
if (!d.ok) return { ok: false, message: `deploy failed:\n${logs.slice(-8).join('\n')}`, logs };
|
|
403
737
|
|
package/bin/lib/env.mjs
CHANGED
|
@@ -69,8 +69,19 @@
|
|
|
69
69
|
* caller.
|
|
70
70
|
*/
|
|
71
71
|
|
|
72
|
-
import {
|
|
73
|
-
|
|
72
|
+
import {
|
|
73
|
+
readdirSync,
|
|
74
|
+
readFileSync,
|
|
75
|
+
mkdirSync,
|
|
76
|
+
lstatSync,
|
|
77
|
+
openSync,
|
|
78
|
+
writeSync,
|
|
79
|
+
fsyncSync,
|
|
80
|
+
closeSync,
|
|
81
|
+
linkSync,
|
|
82
|
+
unlinkSync,
|
|
83
|
+
} from 'node:fs';
|
|
84
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
74
85
|
import { homedir } from 'node:os';
|
|
75
86
|
import { join, dirname } from 'node:path';
|
|
76
87
|
// STILL SUMO, and deliberately not narrowed to the standard build in the same
|
|
@@ -170,7 +181,17 @@ function characterClasses(value) {
|
|
|
170
181
|
}
|
|
171
182
|
function worthRedacting(value) {
|
|
172
183
|
if (typeof value !== 'string' || value.length < SCRUB_MIN_LENGTH) return false;
|
|
173
|
-
|
|
184
|
+
// THE PLAIN-IDENTIFIER EXEMPTION DOES NOT COVER A CASE-AND-DIGIT MIX
|
|
185
|
+
// (2026-09-24, the audit). It was checked first and alone, so a generated
|
|
186
|
+
// password — `Tq8vZ2mKp4Lx9RbN`, the `pwgen -s 16` shape, or a twelve-
|
|
187
|
+
// character `Hx93kPq2Lm7w` — starts with a letter, fits sixteen characters of
|
|
188
|
+
// [A-Za-z0-9] and was left in plain text everywhere this daemon posts, against
|
|
189
|
+
// this block's own promise that "a case mix" is redacted. A word, a hostname,
|
|
190
|
+
// a region or a version string does not draw on upper, lower AND digits at
|
|
191
|
+
// once; a minted secret almost always does.
|
|
192
|
+
if (SCRUB_PLAIN_RE.test(value) && !(/[a-z]/.test(value) && /[A-Z]/.test(value) && /[0-9]/.test(value))) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
174
195
|
if (value.length < 12 && characterClasses(value) < 3) return false;
|
|
175
196
|
return true;
|
|
176
197
|
}
|
|
@@ -277,19 +298,87 @@ export async function ensureKeypair() {
|
|
|
277
298
|
};
|
|
278
299
|
return keypair;
|
|
279
300
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
writeFileSync(
|
|
301
|
+
const minted = sodium.crypto_box_keypair();
|
|
302
|
+
const winner = mintKeypairFile(
|
|
283
303
|
KEYPAIR_PATH,
|
|
284
304
|
JSON.stringify({
|
|
285
|
-
pub: sodium.to_base64(
|
|
286
|
-
priv: sodium.to_base64(
|
|
287
|
-
})
|
|
288
|
-
{ mode: 0o600 }
|
|
305
|
+
pub: sodium.to_base64(minted.publicKey, B64()),
|
|
306
|
+
priv: sodium.to_base64(minted.privateKey, B64()),
|
|
307
|
+
})
|
|
289
308
|
);
|
|
309
|
+
keypair = {
|
|
310
|
+
publicKey: sodium.from_base64(winner.pub, B64()),
|
|
311
|
+
privateKey: sodium.from_base64(winner.priv, B64()),
|
|
312
|
+
};
|
|
290
313
|
return keypair;
|
|
291
314
|
}
|
|
292
315
|
|
|
316
|
+
/**
|
|
317
|
+
* WRITE THE BOX'S IDENTITY ONCE, ATOMICALLY, AND LET THE FIRST WRITER WIN
|
|
318
|
+
* (2026-09-24, the audit). Returns the `{ pub, priv }` that is ON DISK after
|
|
319
|
+
* the call — ours, or the one a concurrent first start got there with.
|
|
320
|
+
*
|
|
321
|
+
* It was a plain `writeFileSync` reached by every process that saw ENOENT, so
|
|
322
|
+
* two daemons starting together on a fresh box (two projects, two units at
|
|
323
|
+
* boot) each minted a key and the LAST writer won: the first kept a key in
|
|
324
|
+
* memory the disk no longer held and arrived, after its next re-exec, as a
|
|
325
|
+
* different box. And a crash inside the write left a torn file that every later
|
|
326
|
+
* start refuses to read — the only-ENOENT-mints rule doing its job over a file
|
|
327
|
+
* nothing can fix.
|
|
328
|
+
*
|
|
329
|
+
* So the key goes to a private temp file, is fsync'd, and is LINKED into place:
|
|
330
|
+
* `link` fails with EEXIST instead of replacing, so exactly one identity is ever
|
|
331
|
+
* published and every loser reads the winner's. The file at the real path is
|
|
332
|
+
* either absent or complete — never half of one.
|
|
333
|
+
*/
|
|
334
|
+
export function mintKeypairFile(path, body) {
|
|
335
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
336
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
337
|
+
try {
|
|
338
|
+
const fd = openSync(tmp, 'wx', 0o600);
|
|
339
|
+
try {
|
|
340
|
+
writeSync(fd, body);
|
|
341
|
+
fsyncSync(fd);
|
|
342
|
+
} finally {
|
|
343
|
+
closeSync(fd);
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
linkSync(tmp, path);
|
|
347
|
+
return JSON.parse(body);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
if (e?.code === 'EEXIST') {
|
|
350
|
+
// Somebody else published first. Theirs is the identity; ours is dropped.
|
|
351
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
352
|
+
}
|
|
353
|
+
// A filesystem with no hard links (some network and FUSE mounts answer
|
|
354
|
+
// EPERM/ENOTSUP) must not cost the box its identity — the plain write
|
|
355
|
+
// this replaced worked there. Fall back to an EXCLUSIVE create: still
|
|
356
|
+
// first-writer-wins, only without the torn-write guarantee.
|
|
357
|
+
if (!['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EXDEV'].includes(e?.code)) throw e;
|
|
358
|
+
let fd2;
|
|
359
|
+
try {
|
|
360
|
+
fd2 = openSync(path, 'wx', 0o600);
|
|
361
|
+
} catch (e2) {
|
|
362
|
+
if (e2?.code !== 'EEXIST') throw e2;
|
|
363
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
writeSync(fd2, body);
|
|
367
|
+
fsyncSync(fd2);
|
|
368
|
+
} finally {
|
|
369
|
+
closeSync(fd2);
|
|
370
|
+
}
|
|
371
|
+
return JSON.parse(body);
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
try {
|
|
375
|
+
unlinkSync(tmp);
|
|
376
|
+
} catch {
|
|
377
|
+
/* already gone */
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
293
382
|
export function myPubB64() {
|
|
294
383
|
return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
|
|
295
384
|
}
|
|
@@ -720,12 +809,35 @@ export function scanEnvForScrub(repoRoot, salt = PROJECT_ID) {
|
|
|
720
809
|
* ordinary word, a short flag and anything under the length floor are simply
|
|
721
810
|
* not in it. */
|
|
722
811
|
export function scrub(text) {
|
|
723
|
-
if (typeof text !== 'string' || !text
|
|
812
|
+
if (typeof text !== 'string' || !text) return text;
|
|
724
813
|
let out = text;
|
|
725
814
|
for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
|
|
726
|
-
return out;
|
|
815
|
+
return out.replace(FLOWVIANT_TOKEN_RE, `[REDACTED:${FLOWVIANT_TOKEN_NAME}]`);
|
|
727
816
|
}
|
|
728
817
|
|
|
818
|
+
/**
|
|
819
|
+
* EVERY FLOWVIANT CREDENTIAL, CAUGHT BY SHAPE (2026-09-24, the audit).
|
|
820
|
+
*
|
|
821
|
+
* The value lists above are the CHECKOUT'S secrets and the process
|
|
822
|
+
* environment's, and neither holds the one secret that costs the project
|
|
823
|
+
* itself: the MACHINE CREDENTIAL. It sits in `~/.flowviant/credentials.json` —
|
|
824
|
+
* for EVERY project connected on this box, 0600 and readable by every turn,
|
|
825
|
+
* since a turn runs as this uid — and the server mints more of the same shape
|
|
826
|
+
* per turn (the work and capture tokens a tab's MCP server carries). A turn
|
|
827
|
+
* that read the store and wrote it into an artifact, a trace or an answer
|
|
828
|
+
* shipped it verbatim, because `scrub` had no value to match; and a design
|
|
829
|
+
* artifact runs scripts in a frame that may navigate itself, so the page that
|
|
830
|
+
* held it could carry it anywhere.
|
|
831
|
+
*
|
|
832
|
+
* Every one of them is `fva_` + 40 characters of nanoid (the server's
|
|
833
|
+
* `AGENT_TOKEN_PREFIX`), so a SHAPE catches them all, including ones this
|
|
834
|
+
* process never saw, with no store read and nothing to keep in sync. Twenty is
|
|
835
|
+
* the floor so the 12-character prefix the app shows for identification
|
|
836
|
+
* (`fva_` + 8) still reads as itself.
|
|
837
|
+
*/
|
|
838
|
+
const FLOWVIANT_TOKEN_RE = /fva_[A-Za-z0-9_-]{20,}/g;
|
|
839
|
+
const FLOWVIANT_TOKEN_NAME = 'FLOWVIANT_TOKEN';
|
|
840
|
+
|
|
729
841
|
/**
|
|
730
842
|
* The NAME of the first known secret whose value appears in `bytes` as an
|
|
731
843
|
* exact byte substring, or null. For the payloads `scrub` cannot touch: a
|
|
@@ -738,7 +850,12 @@ export function scrub(text) {
|
|
|
738
850
|
* it is used (artifacts.mjs).
|
|
739
851
|
*/
|
|
740
852
|
export function secretIn(bytes) {
|
|
741
|
-
if (!Buffer.isBuffer(bytes) || !bytes.length
|
|
853
|
+
if (!Buffer.isBuffer(bytes) || !bytes.length) return null;
|
|
742
854
|
for (const v of values) if (bytes.includes(v.value, 0, 'utf8')) return v.name;
|
|
855
|
+
// The token SHAPE too (see `scrub`). `latin1` maps every byte to one
|
|
856
|
+
// character, so the ASCII pattern matches exactly where the bytes do.
|
|
857
|
+
if (bytes.includes('fva_') && new RegExp(FLOWVIANT_TOKEN_RE.source).test(bytes.toString('latin1'))) {
|
|
858
|
+
return FLOWVIANT_TOKEN_NAME;
|
|
859
|
+
}
|
|
743
860
|
return null;
|
|
744
861
|
}
|