flowviant 0.103.0 → 0.104.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.
Files changed (140) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +183 -2383
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +2 -2
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,265 @@
1
+ /**
2
+ * THE THROWAWAY CHECKOUT A DEPLOY RUNS IN — a detached worktree at the base
3
+ * tip, the operator's untracked environment linked in, and all of it taken
4
+ * out again without ever following a link into the operator's own tree.
5
+ *
6
+ * Split out of deploy.mjs (SOLID F060): this half changes with filesystem and
7
+ * git rules (what a worktree carries, what is linked and what is copied, how a
8
+ * crashed deploy's corpse is swept). The lease, the config and the commands
9
+ * do not.
10
+ */
11
+
12
+ import { createHash } from 'node:crypto';
13
+ import {
14
+ copyFileSync,
15
+ existsSync,
16
+ lstatSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ realpathSync,
20
+ rmdirSync,
21
+ rmSync,
22
+ symlinkSync,
23
+ unlinkSync,
24
+ } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { basename, isAbsolute, join, relative, sep } from 'node:path';
27
+ import { git, gitNetAsync } from './git.mjs';
28
+
29
+ /**
30
+ * A DEPLOY BUILDS THE BASE COMMIT, NEVER THE OPERATOR'S WORKING TREE
31
+ * (audit 2026-09-24).
32
+ *
33
+ * `readDeployConfig` reads the TARGET off base so that authoring a command
34
+ * requires landing it — and then every command ran with `cwd: repoRoot`, the
35
+ * operator's own checkout, on whatever branch they left it with whatever they
36
+ * had not committed. So `npm run build` and `wrangler deploy` read the
37
+ * package.json scripts, wrangler.toml and source of that working tree:
38
+ * deploy-on-merge (queued by a teammate's ship, a PR merge or an agent merge,
39
+ * none of which moves the checkout) shipped code WITHOUT the merge that
40
+ * triggered it, and a co-owner's "deploy it to prod" shipped the operator's
41
+ * half-finished branch. Reproduced: a checkout on `feature` with an
42
+ * uncommitted edit deployed the edit.
43
+ *
44
+ * So a deploy runs in a THROWAWAY DETACHED WORKTREE at the base tip — the
45
+ * shipMerge shape — and the config is read from THAT SHA, so the command and
46
+ * the code it builds are one commit. The directory dies in a `finally`.
47
+ *
48
+ * What a fresh worktree does not have is what git does not track: installed
49
+ * dependencies and the operator's env files. Those are the checkout's
50
+ * ENVIRONMENT, not its code, so for every directory base tracks (three levels
51
+ * deep) a `node_modules` in the checkout is re-created as a directory of links
52
+ * to its entries — a WORKSPACE package's link re-pointed at base's own copy of
53
+ * that package, never the checkout's working tree — and a `.env` / `.env.*` /
54
+ * `.dev.vars` the base commit does not carry is COPIED in. Stated residual:
55
+ * installed third-party packages and those env files are still the checkout's,
56
+ * so a dependency installed for a feature branch, or an untracked env file an
57
+ * agent edited, reaches the build; so does a workspace package's untracked
58
+ * build output (`dist/`), which is simply absent here and fails the build
59
+ * loudly rather than shipping the checkout's. A `.bin` shim is linked whole, so
60
+ * a workspace package's own CLI still runs from the checkout. Submodules are
61
+ * not initialised. The code and the commands no longer come from the checkout.
62
+ */
63
+ const ENV_FILE_RE = /^(?:\.env(?:\..+)?|\.dev\.vars)$/;
64
+ const lexists = (p) => {
65
+ try {
66
+ lstatSync(p);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ };
72
+
73
+ /** Bring the checkout's untracked environment into a deploy worktree. Returns
74
+ * every path it created, so the cleanup can unlink them BEFORE the worktree
75
+ * is removed (a recursive delete must never be handed a symlink into the
76
+ * operator's own node_modules). */
77
+ export function linkCheckoutEnvironment(repoRoot, dir) {
78
+ const made = [];
79
+ let realRoot = repoRoot;
80
+ try {
81
+ realRoot = realpathSync(repoRoot);
82
+ } catch {
83
+ /* compared as given */
84
+ }
85
+ /**
86
+ * A node_modules is a REAL directory of per-entry links, not one link to the
87
+ * checkout's (review, audit 2026-09-24). A workspace package — npm, yarn and
88
+ * pnpm all install one as a link like `node_modules/@x/shared ->
89
+ * ../../packages/shared` — resolves THROUGH the checkout's node_modules into
90
+ * the checkout's WORKING TREE, so a whole-directory link had every bundler
91
+ * import the operator's uncommitted `packages/shared/src` into a deploy of
92
+ * base. An entry whose real path is inside the checkout but outside any
93
+ * node_modules is re-pointed at the SAME path in this worktree (base's copy);
94
+ * one base does not carry is left out, since base cannot import what it does
95
+ * not have. Every other entry links to the checkout's own, as before.
96
+ */
97
+ const linkNodeModules = (src, dst, depth) => {
98
+ let entries;
99
+ try {
100
+ entries = readdirSync(src, { withFileTypes: true });
101
+ mkdirSync(dst);
102
+ made.push(dst);
103
+ } catch {
104
+ return;
105
+ }
106
+ for (const e of entries) {
107
+ const s = join(src, e.name);
108
+ const d = join(dst, e.name);
109
+ // A scope directory holds packages, not a package: one level down.
110
+ if (depth === 0 && e.name.startsWith('@') && e.isDirectory()) {
111
+ linkNodeModules(s, d, 1);
112
+ continue;
113
+ }
114
+ let target = s;
115
+ if (e.isSymbolicLink()) {
116
+ let real;
117
+ try {
118
+ real = realpathSync(s);
119
+ } catch {
120
+ continue; // a dangling link — nothing to bring
121
+ }
122
+ const rel = relative(realRoot, real);
123
+ const inCheckout = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
124
+ if (inCheckout && !rel.split(sep).includes('node_modules')) {
125
+ if (!existsSync(join(dir, rel))) continue;
126
+ target = join(dir, rel);
127
+ }
128
+ }
129
+ try {
130
+ symlinkSync(target, d);
131
+ made.push(d);
132
+ } catch {
133
+ /* the build says so if it needed it */
134
+ }
135
+ }
136
+ };
137
+ const walk = (rel, depth) => {
138
+ let entries;
139
+ try {
140
+ entries = readdirSync(join(repoRoot, rel), { withFileTypes: true });
141
+ } catch {
142
+ return;
143
+ }
144
+ for (const e of entries) {
145
+ const src = join(repoRoot, rel, e.name);
146
+ const dst = join(dir, rel, e.name);
147
+ if (e.name === '.git') continue;
148
+ if (e.name === 'node_modules') {
149
+ if (e.isDirectory() && !lexists(dst)) linkNodeModules(src, dst, 0);
150
+ continue;
151
+ }
152
+ if (e.isFile() && ENV_FILE_RE.test(e.name)) {
153
+ if (!lexists(dst)) {
154
+ try {
155
+ copyFileSync(src, dst);
156
+ made.push(dst);
157
+ } catch {
158
+ /* unreadable — the build says so if it needed it */
159
+ }
160
+ }
161
+ continue;
162
+ }
163
+ // Recurse only into directories BASE tracks — an untracked `dist/` or a
164
+ // cache is neither code nor environment, and walking it costs a scan.
165
+ if (e.isDirectory() && depth < 3 && existsSync(dst) && !lexists(join(dst, '.git'))) {
166
+ walk(join(rel, e.name), depth + 1);
167
+ }
168
+ }
169
+ };
170
+ walk('', 0);
171
+ return made;
172
+ }
173
+
174
+ /**
175
+ * Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup }`, or
176
+ * throws with a sentence when base does not resolve. `worktreeDir` defaults to
177
+ * the daemon's own worktree home for this repo (fleetStartup.mjs keys it the same
178
+ * way); the job id is hashed into the path, never joined raw — it is a
179
+ * server-named string.
180
+ */
181
+ export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir }) {
182
+ if (!baseRef) throw new Error('no base branch resolved, so there is nothing to deploy from');
183
+ try {
184
+ // A NETWORK call, so timed, non-interactive and off the event loop — a
185
+ // bare `git fetch` here is `execFileSync` with no timeout, and a remote
186
+ // that prompts or a half-open connection froze every poll and lease on
187
+ // the machine until it returned.
188
+ await gitNetAsync(['fetch', 'origin', '--quiet'], repoRoot);
189
+ } catch {
190
+ /* offline — deploy the base this box last saw, and say which */
191
+ }
192
+ let sha;
193
+ try {
194
+ sha = git(['rev-parse', '--verify', `${baseRef}^{commit}`], repoRoot);
195
+ } catch {
196
+ throw new Error(`the base branch ${baseRef} does not resolve on this machine`);
197
+ }
198
+ const home =
199
+ worktreeDir ??
200
+ join(
201
+ homedir(),
202
+ '.flowviant',
203
+ 'worktrees',
204
+ `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`
205
+ );
206
+ const dir = join(home, 'deploy', createHash('sha256').update(String(jobId)).digest('hex').slice(0, 16));
207
+ mkdirSync(join(home, 'deploy'), { recursive: true });
208
+ const drop = (made = [], target = dir) => {
209
+ // Newest first, so a node_modules directory we made is empty of our links
210
+ // by the time it is removed.
211
+ for (const p of [...made].reverse()) {
212
+ try {
213
+ unlinkSync(p);
214
+ } catch {
215
+ try {
216
+ rmdirSync(p);
217
+ } catch {
218
+ /* already gone */
219
+ }
220
+ }
221
+ }
222
+ try {
223
+ git(['worktree', 'remove', '--force', target], repoRoot);
224
+ } catch {
225
+ /* not registered */
226
+ }
227
+ // Every symlink we made is already unlinked, and `rmSync` unlinks a
228
+ // symlink rather than descending it, so this can only delete our own copy.
229
+ try {
230
+ rmSync(target, { recursive: true, force: true });
231
+ } catch {
232
+ /* best-effort */
233
+ }
234
+ try {
235
+ git(['worktree', 'prune'], repoRoot);
236
+ } catch {
237
+ /* best-effort */
238
+ }
239
+ };
240
+ drop(); // a corpse from a crashed deploy of the same job
241
+ // …and any other job's corpse old enough that no deploy can still be running
242
+ // in it (two commands at most, 30 minutes each), so a daemon killed
243
+ // mid-deploy does not leave a checkout on disk for ever.
244
+ try {
245
+ const cutoff = Date.now() - 3 * 60 * 60 * 1000;
246
+ for (const name of readdirSync(join(home, 'deploy'))) {
247
+ const p = join(home, 'deploy', name);
248
+ try {
249
+ if (p !== dir && lstatSync(p).mtimeMs < cutoff) drop([], p);
250
+ } catch {
251
+ /* gone meanwhile */
252
+ }
253
+ }
254
+ } catch {
255
+ /* nothing to sweep */
256
+ }
257
+ git(['worktree', 'add', '--detach', dir, sha], repoRoot);
258
+ let made = [];
259
+ try {
260
+ made = linkCheckoutEnvironment(repoRoot, dir);
261
+ } catch {
262
+ /* the build says so if it needed any of it */
263
+ }
264
+ return { dir, sha, cleanup: () => drop(made) };
265
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * WHAT THIS REPO DECLARES AS DEPLOY TARGETS — `.flowviant/deploy.json` read
3
+ * off the BASE commit, and the redacted, normalized metadata reported to the
4
+ * server's mirror.
5
+ *
6
+ * Split out of deploy.mjs (SOLID F060): this half changes with the file's
7
+ * shape and the `/fleet/deploy-config` wire. The job lease (deploy.mjs), the
8
+ * throwaway checkout (deployCheckout.mjs) and the commands (deployRunner.mjs)
9
+ * each change for their own reasons. The runner asks `readDeployConfig` again
10
+ * at the very commit it is about to build, so this is the file's one reader.
11
+ */
12
+
13
+ import { git } from './git.mjs';
14
+ import { warn } from './ui.mjs';
15
+ import { scrub } from './uplinkScrub.mjs';
16
+ import { myPubB64 } from './boxIdentity.mjs';
17
+ import { post } from './deployWire.mjs';
18
+
19
+ /**
20
+ * Read + parse `.flowviant/deploy.json` AS IT IS ON THE BASE BRANCH. Returns []
21
+ * if the branch has none.
22
+ *
23
+ * IT USED TO READ THE WORKING TREE, which made the feature's one stated bound
24
+ * false. `deploy_target`'s own description says "only ids already declared in
25
+ * `.flowviant/deploy.json` on MAIN can be named (the daemon reads and runs from
26
+ * the repo ROOT, never a session worktree, so an agent cannot author the command
27
+ * it triggers without shipping it first)" — and the repo root's WORKING TREE is
28
+ * exactly where the machine operator's tabs stand (their place is the checkout).
29
+ * So an agent that had read an injected instruction could write an uncommitted
30
+ * `.flowviant/deploy.json` naming any shell command, call `deploy_target`, and
31
+ * have the daemon run it from the repo root with `CLOUDFLARE_API_TOKEN` and
32
+ * every other deploy-scope credential in its environment. Nothing about that
33
+ * needed a commit, a review, or an owner.
34
+ *
35
+ * Reading the COMMITTED tree is what makes the sentence true: authoring the
36
+ * command now requires landing it on base, which is a reviewed act. The file is
37
+ * read through git rather than the filesystem, so an uncommitted edit is simply
38
+ * not there.
39
+ *
40
+ * A base ref that does not resolve yields NO TARGETS, and says so once. That is
41
+ * the withholding direction and it is the right one here — a deploy is
42
+ * irreversible and running the wrong file is worse than running nothing.
43
+ */
44
+ export function readDeployConfig(repoRoot, baseRef) {
45
+ let raw;
46
+ if (baseRef) {
47
+ try {
48
+ raw = git(['show', `${baseRef}:.flowviant/deploy.json`], repoRoot);
49
+ } catch {
50
+ // No such file on base, or a base ref that does not resolve. Both mean
51
+ // "this branch declares no targets", which is a real answer.
52
+ return [];
53
+ }
54
+ } else {
55
+ // No base ref in hand (a caller that has not been updated). Refuse rather
56
+ // than silently falling back to the working tree — that fallback IS the bug.
57
+ warn('deploy: no base branch resolved, so no deploy targets were read.');
58
+ return [];
59
+ }
60
+ try {
61
+ const parsed = JSON.parse(raw);
62
+ const targets = Array.isArray(parsed?.targets) ? parsed.targets : [];
63
+ // Keep only fields the server + runner need; the daemon holds the commands.
64
+ return targets
65
+ .filter((t) => t && typeof t.id === 'string' && typeof t.command === 'string')
66
+ .slice(0, 20);
67
+ } catch (e) {
68
+ warn(`deploy: .flowviant/deploy.json on the base branch is not valid JSON — ${e.message}`);
69
+ return [];
70
+ }
71
+ }
72
+
73
+ /** Report the parsed config to the server (only when it changed). */
74
+ let lastConfigJson = null;
75
+ export async function reportDeployConfig(repoRoot, baseRef) {
76
+ const targets = readDeployConfig(repoRoot, baseRef);
77
+ const json = JSON.stringify(targets);
78
+ if (json === lastConfigJson) return;
79
+ // Scrub command strings before the server sees them — a command line can embed
80
+ // an internal host or a synced secret. Only redacted metadata leaves the box.
81
+ const scrubCmds = (o) =>
82
+ o && typeof o === 'object'
83
+ ? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, scrub(String(v ?? ''))]))
84
+ : o;
85
+ /**
86
+ * NORMALIZE BEFORE POSTING (2026-09-24, the audit). `.flowviant/deploy.json`
87
+ * is a hand-written file; the shared schema treats `healthStatus` /
88
+ * `healthcheck` / `build` / `label` as display metadata and drops what it
89
+ * cannot read rather than refusing the whole target — but this file used to
90
+ * send them through unmodified, so a `"healthStatus": "200"` (which
91
+ * `verifyHealth` in deployRunner.mjs already coerces with `Number()`) or a `null` label
92
+ * used to reach the wire looking present-but-unreadable. Send exactly what
93
+ * the mirror is going to keep instead of what the file happened to spell:
94
+ * a numeric `healthStatus` when the value is numeric or a numeric string,
95
+ * and no key at all for `label` / `healthcheck` / `build` when the file's
96
+ * value is not a non-empty string.
97
+ */
98
+ const numOrOmit = (v) => {
99
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
100
+ if (typeof v === 'string' && /^\s*\d+\s*$/.test(v)) return Number(v);
101
+ return undefined;
102
+ };
103
+ const strOrOmit = (v) => (typeof v === 'string' && v.trim() ? v : undefined);
104
+ const meta = targets.map((t) => {
105
+ const label = strOrOmit(t.label);
106
+ const build = strOrOmit(t.build);
107
+ const healthcheck = strOrOmit(t.healthcheck);
108
+ const healthStatus = numOrOmit(t.healthStatus);
109
+ return {
110
+ id: t.id,
111
+ ...(label !== undefined ? { label } : {}),
112
+ provider: t.provider || 'cloudflare',
113
+ command: scrub(String(t.command ?? '')),
114
+ ...(build !== undefined ? { build: scrub(build) } : {}),
115
+ commands: scrubCmds(t.commands),
116
+ ...(healthcheck !== undefined ? { healthcheck } : {}),
117
+ ...(healthStatus !== undefined ? { healthStatus } : {}),
118
+ pushSecrets: t.pushSecrets,
119
+ // Deploy-on-merge: the env this target auto-deploys to when commits land
120
+ // on base. MUST ride this map — a field forgotten here never reaches the
121
+ // server, and the server is what turns a landed report into the job.
122
+ ...(typeof t.onMerge === 'string' ? { onMerge: t.onMerge } : {}),
123
+ };
124
+ });
125
+ try {
126
+ const data = await post('deploy-config', { pubkey: myPubB64(), targets: meta });
127
+ // Set on any 2xx, rejected targets included — the route now accepts a
128
+ // report per-target rather than failing the whole thing, so a target it
129
+ // could not read is not a reason to re-post this same file forever.
130
+ lastConfigJson = json;
131
+ // THE SERVER NAMES WHAT IT COULD NOT READ. Warned once per CHANGED config —
132
+ // this function only reaches here when the file did — never once per poll.
133
+ const rejected = Array.isArray(data?.rejected) ? data.rejected : [];
134
+ for (const r of rejected) {
135
+ warn(
136
+ `deploy: .flowviant/deploy.json target "${r?.id ?? '?'}" was not accepted — ${r?.reason ?? 'invalid'}`
137
+ );
138
+ }
139
+ } catch (e) {
140
+ warn(`deploy: could not report config — ${e.message}`);
141
+ }
142
+ }