@melite/harness-provisioner 0.0.0-stage → 0.1.5-pre-alpha

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.
@@ -0,0 +1,544 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * infer-provision — profile provisioner for vanilla DSH installs.
4
+ *
5
+ * The bundle patch (cordis.patch.yml) covers the ROWS; this bin covers
6
+ * the PROFILES: renders ready-to-boot profile dirs (melite-test /
7
+ * headless / estate) and installs their link deps. Every render
8
+ * stamps render-provenance.json (F5: who/what/when/flags) and holds
9
+ * the profile's render lock across render+install (F6).
10
+ * Self-locating — every path derives from this file's own location
11
+ * (the provisioner package and its siblings), so a family re-split
12
+ * or repo move changes nothing.
13
+ *
14
+ * Usage:
15
+ * infer-provision all # render + install every profile
16
+ * infer-provision profile <name> # render one profile dir
17
+ * infer-provision preset # install the agent preset
18
+ * Flags:
19
+ * --dsh-home <dir> default $DSH_HOME || ~/.dsh
20
+ * --estate-agent-route <p/m> estate agent brain, "provider/model"
21
+ * --estate-state-root <dir> override the estate row's stateRoot
22
+ * (walls render hermetic roots through
23
+ * config — never an env redirect)
24
+ * --profile-rows <file> operator rows (llm providers…), verbatim
25
+ *
26
+ * Rendered profiles mount the SAME melite rows the bundle patch mounts
27
+ * (the bundle owns the rows, so `dsh plugin add` and rendered profiles
28
+ * can never drift apart). The PROFILE layer (this dir's cordis.patch.yml)
29
+ * owns what no bundle can know: the estate row's seat config — repoRoot
30
+ * always (mechanical: this render's own repo root; the queue seats refuse
31
+ * an unpinned boot loud by design) and agentRoute when the flag names a
32
+ * brain — plus any operator rows spliced verbatim by --profile-rows
33
+ * (llm provider rows ride the profile layer the same way the web
34
+ * profile's own patch carries them; the file is never re-serialized).
35
+ * The template pins: our bundle + the dsh web base for 'melite-test',
36
+ * the plain headless base for 'headless'.
37
+ */
38
+
39
+ import { execSync } from 'node:child_process'
40
+ import { createHash } from 'node:crypto'
41
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
42
+ import { dirname, join, resolve } from 'node:path'
43
+ import { fileURLToPath } from 'node:url'
44
+ import process from 'node:process'
45
+
46
+ const HERE = dirname(fileURLToPath(import.meta.url))
47
+ const PROVISIONER = dirname(HERE) // bin/ -> the provisioner package itself
48
+
49
+ // ── args ────────────────────────────────────────────────────────────────
50
+ const argv = process.argv.slice(2)
51
+ const cmd = argv[0] || 'all'
52
+ const flag = (name, dflt) => {
53
+ const i = argv.indexOf(name)
54
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt
55
+ }
56
+ const DSH_HOME = flag('--dsh-home', process.env.DSH_HOME || join(process.env.HOME || '~', '.dsh'))
57
+ const PROFILE = argv[1] && !argv[1].startsWith('--') ? argv[1] : 'melite-test'
58
+ const ESTATE_AGENT_ROUTE = flag('--estate-agent-route', '')
59
+ const ESTATE_STATE_ROOT = flag('--estate-state-root', '')
60
+ const PROFILE_ROWS = flag('--profile-rows', '')
61
+
62
+ const log = (...a) => console.log(...a)
63
+ const die = (msg) => { console.error('infer-provision: ' + msg); process.exit(1) }
64
+
65
+ // ── F6 guards: atomic writes, the render lock, the install ceiling ──
66
+ // ATOMIC WRITES: the renderer owns its files wholesale but wrote
67
+ // them direct — a crash mid-render left a half-written profile that
68
+ // boots strange. Temp + rename per file (J6's writeJsonAtomic idiom,
69
+ // local: this bin is self-contained, no workspace imports). The tmp
70
+ // name is FIXED per file (never pid-suffixed): a killed run's
71
+ // leftover is overwritten by the next render, never accumulated —
72
+ // and the render lock below serializes renders, so no two writers
73
+ // share a tmp.
74
+ function writeFileAtomic(path, content) {
75
+ writeFileSync(path + '.tmp', content)
76
+ renameSync(path + '.tmp', path)
77
+ }
78
+
79
+ // THE RENDER LOCK: two renders (two `all` runs, a render racing a
80
+ // re-render) interleaved package.json and the patch row by row —
81
+ // both callers rendered-then-installed with no mutual exclusion.
82
+ // One lock file per profile dir, exclusive-create: a live holder
83
+ // refuses loud, a stale one (dead pid) is taken with a warning.
84
+ const LOCK_NAME = '.render.lock'
85
+ function acquireLock(dst) {
86
+ const lock = join(dst, LOCK_NAME)
87
+ const take = () => writeFileSync(lock, String(process.pid) + '\n', { flag: 'wx' })
88
+ try {
89
+ take()
90
+ return
91
+ } catch (err) {
92
+ if (err === null || typeof err !== 'object' || err.code !== 'EEXIST') throw err
93
+ }
94
+ const prev = readFileSync(lock, 'utf8').split('\n')[0]
95
+ const pid = Number(prev)
96
+ let alive = false
97
+ if (Number.isInteger(pid) && pid > 0) {
98
+ try { process.kill(pid, 0); alive = true } catch { /* dead */ }
99
+ }
100
+ if (alive) die(`profile dir ${dst} is already being rendered (lock ${lock}, holder pid ${pid}) — refusing a concurrent render`)
101
+ log(` stale render lock (holder pid ${prev.trim() || '<unparsed>'} dead) — taking it`)
102
+ try { unlinkSync(lock) } catch { /* raced away */ }
103
+ try {
104
+ take()
105
+ } catch {
106
+ die(`profile dir ${dst} lost a lock race (${lock}) — re-run the render`)
107
+ }
108
+ }
109
+ function releaseLock(dst) {
110
+ try { unlinkSync(join(dst, LOCK_NAME)) } catch { /* never held / already gone */ }
111
+ }
112
+
113
+ // PROVENANCE (provisioner audit F5): a rendered profile dir is an
114
+ // artifact with no memory — when a boot misbehaves nothing says
115
+ // which provisioner, which tree, which flags laid it. Every render
116
+ // writes render-provenance.json beside the profile files:
117
+ // machine-readable, DSH-loader-inert (a data file, never a config
118
+ // plane — the loader reads package.json/cordis.* only), the
119
+ // fossil-vs-fresh question answered without archaeology.
120
+ function provenanceDoc(name, provPkg, bundles, profileRowsSha) {
121
+ const root = resolve(PROVISIONER, '../../..')
122
+ let commit = null
123
+ let dirty = null
124
+ try {
125
+ const sha = execSync('git rev-parse HEAD', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
126
+ commit = /^[0-9a-f]{40}$/.test(sha) ? sha : null
127
+ if (commit !== null) {
128
+ dirty = execSync('git status --porcelain', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() !== ''
129
+ }
130
+ } catch { /* not a git checkout — the stamp says so honestly (nulls) */ }
131
+ return {
132
+ renderedAt: new Date().toISOString(),
133
+ renderer: 'infer-provision',
134
+ provisioner: { name: provPkg.name, version: provPkg.version },
135
+ source: { root, commit, dirty },
136
+ profile: name,
137
+ bundles: [...bundles],
138
+ flags: {
139
+ estateAgentRoute: ESTATE_AGENT_ROUTE || null,
140
+ estateStateRoot: ESTATE_STATE_ROOT || null,
141
+ profileRows: PROFILE_ROWS ? { path: PROFILE_ROWS, sha256: profileRowsSha } : null,
142
+ },
143
+ }
144
+ }
145
+
146
+ // agentRoute wears the estate row's own shape (packages/web/melite-estate
147
+ // src/index.ts): "provider/model" — the same regex the seat applies. A
148
+ // malformed route dies HERE, at the earliest resolvable point, never as a
149
+ // silently-unseated engine three boots later.
150
+ if (ESTATE_AGENT_ROUTE && !/^([^/\s]+)\/(.+)$/.test(ESTATE_AGENT_ROUTE)) {
151
+ die('--estate-agent-route wants provider/model (e.g. opencode-go/muse-spark-1.3-contributor), got "' + ESTATE_AGENT_ROUTE + '"')
152
+ }
153
+
154
+ // ── profile rendering ───────────────────────────────────────────────────
155
+ const PROFILE_BASES = {
156
+ // melite-test = THE studio/test lane, ONE name (single-setup law
157
+
158
+ // two names for one mount, and the README + runbook pointed at
159
+ // different ones. `inference` is gone; this base is the only web
160
+ // mount): web app base + the SELF-REGISTERING
161
+
162
+ // its existence row in ITS OWN package — the provisioner stopped
163
+
164
+ // every FRESH boot of profiles mounting both patches).
165
+ // anny LEFT the web bases (stroke 89): its studio died — the form
166
+ // card rides the engine-plane SSOT only, never a browser bundle.
167
+
168
+ // its bundle patch mounts the null host row that materializes
169
+ // @melite/card-base/client for the browser graph — every card
170
+ // client externalizes it, and without the row the loader's
171
+ // require misses the module table at web boot.
172
+ // three-client rides the WEB base for the SAME law (the :3088 boot
173
+
174
+ // externalize @melite/three-client/client, and three-client's own
175
+ // patch is the ONLY row that materializes it ("bundling
176
+ // @melite/three-client in a profile applies this patch" — the
177
+ // package's own contract). Without the bundle, four plugins die
178
+ // at boot with `missed the module table` and the deck pends on
179
+ // remote.manage behind them.
180
+ // dsh-estate rides the web base too (the :3088 browser census,
181
+
182
+ // as child plugins — drafts (bench/subscribe), models (the
183
+ // /v1/model-catalog discovery door), media, queue, runs, manage.
184
+ // Without it the studio lane BOOTS CLEAN but serves a husk: the
185
+ // bench spotlight stream and the catalog toast 500 in the browser
186
+ // console while the boot log stays silent — the same
187
+ // seed-has-it-base-does-not drift as three-client.
188
+ // the two card STUDIOS ride the web base (the H3 bar autopsy,
189
+
190
+ // own their rows in their OWN bundle patches, and this bundle's
191
+ // patch carries no twin ("NO row here — the profile bundles it",
192
+ // cordis.patch.yml) — so while no base mounted them they were
193
+ // deps with no door: installed by the manifest mirror above, rows
194
+ // mounted by nothing, client entries in no browser graph. The H3
195
+ // card then rendered the generic form — NO timeline bar — with
196
+ // every wall green (the e2e seed mounts both; the live base did
197
+ // not). @melite/3d-img-to-mesh needs no bundle row: this bundle's
198
+ // own card-studio-img-to-mesh row mounts it, and a bundle would be
199
+ // the twin-row crash law.
200
+ 'melite-test': ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@melite/harness-provisioner',
201
+ '@melite/harness-tools', '@melite-plugin/dsh-estate', '@melite/card-base', '@melite/three-client',
202
+ '@melite-plugin/dsh-card-view', '@melite-plugin/dsh-card-deck', '@melite-plugin/dsh-gallery',
203
+ '@melite/video-h3-timeline', '@melite/motion-motion'],
204
+ // headless = the agent server: one-shot task runner + the tools
205
+ // plane (harness-tools self-registers its melite-tools row; the
206
+ // renderer disables the web-plane doors row in this mount).
207
+ // dsh-estate rides here too: a headless task that composes media
208
+ // reaches the same runs/queue/drafts seats through melite_flow —
209
+ // without the bundle the tool answers ABSENCE (the estate-drive
210
+
211
+ // unpinned queue seat refuses loud by design).
212
+ headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless', '@melite/harness-provisioner',
213
+ '@melite/harness-tools', '@melite-plugin/dsh-estate'],
214
+ // estate = the job server: the SDK JSON-RPC base (estate-drive
215
+
216
+ // in-process — no second app, no HTTP). dsh-estate bundles the
217
+ // runs/queue/drafts seats — without it melite_flow answers
218
+ // ABSENCE ("no seat of that family is mounted on this lane"),
219
+
220
+ // tools row's HEADLESS interface by its self-registration id.
221
+ estate: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app', '@melite/harness-provisioner',
222
+ '@melite/harness-tools', '@melite-plugin/dsh-estate'],
223
+ }
224
+
225
+ /** Maps every workspace package's name to its directory by reading
226
+ * this repository's pnpm-workspace.yaml globs (the manifest's
227
+ * workspace:* specs are names; pnpm resolves them only inside an
228
+ * install — the profile dir is its own install, so the renderer
229
+ * resolves them itself). @returns Map<name, absolute dir> */
230
+ function workspacePackages() {
231
+ const root = resolve(PROVISIONER, '../../..')
232
+ const wsFile = join(root, 'pnpm-workspace.yaml')
233
+ const out = new Map()
234
+ if (!existsSync(wsFile)) return out
235
+ const globs = [...readFileSync(wsFile, 'utf8').matchAll(/^\s*-\s*['"]?([^'"\s#]+)/gm)]
236
+ .map((m) => m[1]).filter((g) => !g.startsWith('!'))
237
+ const seg = (g) => g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*\/?/g, '(?:.*/)?').replace(/\*/g, '[^/]*')
238
+ const prefixes = [...new Set(globs.map((g) => g.split('*')[0]))]
239
+ .filter((p, i, a) => !a.some((q) => q !== p && p.startsWith(q)))
240
+ for (const g of globs) {
241
+ const re = new RegExp('^' + seg(g) + '$')
242
+ for (const prefix of prefixes) {
243
+ const base = join(root, prefix)
244
+ if (!existsSync(base)) continue
245
+ const walk = (dir, depth) => {
246
+ let entries = []
247
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
248
+ for (const e of entries) {
249
+ if (!e.isDirectory() || e.name === 'node_modules') continue
250
+ const full = join(dir, e.name)
251
+ const rel = full.slice(root.length + 1)
252
+ if (re.test(rel)) {
253
+ const pj = join(full, 'package.json')
254
+ if (existsSync(pj)) {
255
+ try { out.set(JSON.parse(readFileSync(pj, 'utf8')).name, full) } catch { /* not a package */ }
256
+ }
257
+ }
258
+ if (depth < 8) walk(full, depth + 1)
259
+ }
260
+ }
261
+ walk(base, 0)
262
+ }
263
+ }
264
+ return out
265
+ }
266
+
267
+ function renderProfile(name) { // In-box bundles (@deepseek-ai/*) resolve from the DSH INSTALL, never
268
+ // from a registry - only OUR link goes in dependencies. Mirrors what
269
+ // `dsh plugin` itself initializes (PROFILE_TEMPLATES + reconcile).
270
+
271
+ // old `|| PROFILE_BASES.headless` fallback turned a typo into a
272
+ // bootable-looking directory with the wrong rows and exit 0.
273
+ if (!(name in PROFILE_BASES)) {
274
+ console.error(`infer-provision: unknown profile '${name}' — known: ${Object.keys(PROFILE_BASES).join(', ')}`)
275
+ process.exit(1)
276
+ }
277
+ const bundles = PROFILE_BASES[name]
278
+ const dst = join(DSH_HOME, 'profiles', name)
279
+ mkdirSync(dst, { recursive: true })
280
+ const link = 'link:' + PROVISIONER
281
+
282
+ // pnpm does NOT materialize a link: dependency's OWN link:/workspace
283
+ // deps — every plugin package the bundle rows name must become a
284
+ // DIRECT link: dep here. The provisioner's manifest declares them
285
+ // as workspace:* specs; a link:-only scan dropped them, so the
286
+ // headless/estate profiles mounted @melite/harness-tools as a
287
+ // bundle row with NO installed package — the tool lane could not
288
+ // load (ledger li-headless-profile-deps). workspace specs resolve
289
+ // by scanning this repository's own pnpm workspace globs
290
+ // (self-locating; a family re-split moves nothing).
291
+ const provPkg = JSON.parse(readFileSync(join(PROVISIONER, 'package.json'), 'utf8'))
292
+ const rowDeps = {}
293
+ const byName = workspacePackages()
294
+ for (const [depName, spec] of Object.entries(provPkg.dependencies || {})) {
295
+ const s = String(spec)
296
+ if (s.startsWith('link:')) {
297
+ rowDeps[depName] = 'link:' + resolve(PROVISIONER, s.slice(5))
298
+ } else if (byName.has(depName)) {
299
+ rowDeps[depName] = 'link:' + byName.get(depName)
300
+ }
301
+ // Registry specs (e.g. the lane-brain adapter @deepseek-ai/dsh-llm-pi-ai)
302
+ // are deliberately NOT pinned here: the profile package.json is
303
+ // link:-only by contract (the render wall proves the trailing
304
+ // `pnpm install` stays offline-safe). A registry dep rides through
305
+ // its OWNING package's own manifest — the provisioner declares it,
306
+ // the provisioner is linked, its install resolves it in the repo
307
+ // workspace.
308
+ }
309
+
310
+ // non-in-box bundle the profile base mounts ALSO becomes a direct
311
+ // link: dep — the F5 comment's own intent above ("every plugin
312
+ // package the bundle rows name must become a DIRECT link: dep"),
313
+ // which the mirror-the-manifest loop never implemented for bundles
314
+ // outside the provisioner's own manifest (@melite/card-base rode
315
+ // the inference/melite-test bases with no installed package, and
316
+
317
+ // @deepseek-ai/* bundles resolve from the DSH install, never here;
318
+ // any other unresolvable bundle fails the RENDER loud, never a
319
+ // boot three steps later.
320
+ const bundleDeps = {}
321
+ for (const b of bundles) {
322
+ if (b.startsWith('@deepseek-ai/')) continue
323
+ if (!byName.has(b)) die(`profile base mounts ${b} but no workspace package carries that name — refusing an unresolvable bundle`)
324
+ bundleDeps[b] = 'link:' + byName.get(b)
325
+ }
326
+
327
+ const pkgPath = join(dst, 'package.json')
328
+ let pkg = { name: 'dsh-profile-' + name, private: true, dependencies: {} }
329
+ if (existsSync(pkgPath)) {
330
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch { /* re-render */ }
331
+ }
332
+ pkg.name = pkg.name || 'dsh-profile-' + name
333
+ pkg.private = true
334
+
335
+ // never merged — the renderer owns this file wholesale (same law as
336
+ // the rows: "the bundle owns every row, no drift"). The old merge kept
337
+ // fossil link: rows from pre-reorg renders (plugins/harness/dsh/…),
338
+ // and pnpm warned "installing a dependency from a non-existent
339
+ // directory" on every install while the profile silently carried
340
+
341
+ // on every render; bundleDeps cover every mounted non-in-box bundle
342
+ // (the coverage cure above); user-added registry deps belong in the
343
+ // bundle, not here.
344
+ pkg.dependencies = { ...bundleDeps, ...rowDeps, '@melite/harness-provisioner': link }
345
+
346
+ // twin rows for the self-registering packages (channel law
347
+
348
+ // their rows; the twins double-mounted the tool plane and killed
349
+ // every FRESH boot: "tool melite_discover is already
350
+ // registered"). Direct bundles are THE mount now; nothing is
351
+ // dropped when the provisioner joins.
352
+ // THE BUNDLES LIST IS REGENERATED, never merged (stroke 89,
353
+
354
+ // the old union kept RETIRED bundles mounted forever (anny's
355
+ // studio row crash-booted every fresh compose with "cannot
356
+ // resolve profile bundle @melite/3d-anny" after the package left
357
+ // the bases). The bases OWN this list wholesale; a retired
358
+ // package leaves the profile the day it leaves PROFILE_BASES.
359
+ pkg.dsh = { profile: { bundles: [...new Set(bundles)] } }
360
+ writeFileAtomic(join(dst, 'package.json'), JSON.stringify(pkg, null, 2) + String.fromCharCode(10))
361
+ // The cordis.yml USER layer stays EMPTY — the bundle patch owns every
362
+ // melite row (no drift). NON-web mounts (headless/SDK) get the
363
+ // web-surface rows mechanically DISABLED in the profile patch (by the
364
+
365
+ // exists to activate them. The bundle owns the rows; the renderer
366
+ // owns the per-app mount shape and the seat pins below.
367
+ const webMount = bundles.includes('@deepseek-ai/dsh-web-app')
368
+ // melite-tools-doors is web-profiles-only by its own contract (the
369
+ // ✦ doors need webServer+llm — absent in sdk/headless mounts); the
370
+ // renderer disables it with the other web rows in non-web mounts.
371
+ const webRows = ['melite-tools-doors',
372
+ 'infer-card-view', 'ray-card-deck', 'infer-gallery']
373
+
374
+ // decided seating is retired — the estate row's CONFIG owns it. Every
375
+ // profile mounting dsh-estate carries repoRoot here: mechanical (this
376
+ // render's own repo root), so the runs/drafts seats never boot
377
+ // unpinned (they refuse loud by design). agentRoute names the agent
378
+ // brain (provider/model); absent, the estate logs the engine unseated
379
+ // — the honest state, never a silent default.
380
+ const estateRow = bundles.includes('@melite-plugin/dsh-estate')
381
+ ? '# ESTATE SEAT PIN — the estate row config owns every seat:\n'
382
+ + '# repoRoot pins the runs/drafts seats (unpinned boots refuse loud);\n'
383
+ + '# stateRoot overrides it (J12: walls render hermetic roots by\n'
384
+ + '# config — stateRoot wins over repoRoot in the queue seat).\n'
385
+ + '# agentRoute names the agent brain (absent = unseated, logged loud).\n'
386
+ + '- id: melite-estate\n'
387
+ + ' config:\n'
388
+ + ' repoRoot: ' + JSON.stringify(resolve(PROVISIONER, '../../..')) + '\n'
389
+ + (ESTATE_STATE_ROOT
390
+ ? ' stateRoot: ' + JSON.stringify(ESTATE_STATE_ROOT) + '\n'
391
+ : '')
392
+ + (ESTATE_AGENT_ROUTE
393
+ ? ' agentRoute: ' + JSON.stringify(ESTATE_AGENT_ROUTE) + '\n'
394
+ : '')
395
+ : ''
396
+ // OPERATOR ROWS (--profile-rows <file>): verbatim text splice, never
397
+ // re-serialized (the web profile's preset-row law). Llm provider rows
398
+ // ride the profile layer because the bundle patch owns MELITE rows
399
+ // only — the same home the web profile's own patch gives its
400
+ // providers. Rows apply by id; a row here never inserts a melite
401
+ // bundle row (the no-drift law holds — the ids belong to the
402
+ // operator's adapter mounts).
403
+ let operatorRows = ''
404
+ let profileRowsSha = null
405
+ if (PROFILE_ROWS) {
406
+ if (!existsSync(PROFILE_ROWS)) die('--profile-rows: no such file: ' + PROFILE_ROWS)
407
+ const text = readFileSync(PROFILE_ROWS, 'utf8')
408
+ profileRowsSha = createHash('sha256').update(text, 'utf8').digest('hex')
409
+ const firstRow = text.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('#'))
410
+ if (firstRow === undefined || !firstRow.startsWith('- ')) {
411
+ die('--profile-rows: ' + PROFILE_ROWS + ' is not a YAML row list (the first non-comment line must start with "- ")')
412
+ }
413
+ operatorRows = '# operator rows (verbatim splice: ' + PROFILE_ROWS + ')\n'
414
+ + text + (text.endsWith('\n') ? '' : '\n')
415
+ }
416
+ writeFileAtomic(join(dst, 'cordis.patch.yml'),
417
+ webMount
418
+ ? '# rendered by infer-provision - melite rows live in the bundle;\n'
419
+ + '# the rows below are the profile layer (seat pin + operator rows).\n'
420
+ + (estateRow + operatorRows || '[]\n')
421
+ : '# rendered by infer-provision - melite rows live in the bundle.\n'
422
+ + '# No webServer service in this mount: the four web-surface rows\n'
423
+ + '# are disabled so activation cannot pend forever.\n'
424
+ + webRows.map(id => '- id: ' + id + '\n disabled: true\n').join('')
425
+ + estateRow + operatorRows
426
+ // DELEGATED-OPERATOR MOUNTS (estate | headless): this mount
427
+ // RUNS jobs — the tools row selects the HEADLESS interface
428
+ // (the melite_flow + run_status run doors). Pinned in the
429
+ // render (not ambient env) because the interface is profile
430
+ // state: browser mounts compose, the job server executes.
431
+ // The 'headless' agent server rides the same pin — its base
432
+ // list promises melite_flow ("a headless task that composes
433
+ // media reaches the same runs/queue/drafts seats"), and the
434
+
435
+ // compose-only mount: benches composed and validated, every
436
+ // execution refused at the absent verb. Every other mount
437
+ // keeps the browser default.
438
+ + (name === 'estate' || name === 'headless'
439
+ ? '# HEADLESS interface: the delegated operator runs jobs here.\n'
440
+ + '- id: melite-tools\n config:\n headless: true\n'
441
+ : ''))
442
+ writeFileAtomic(join(dst, 'cordis.yml'), '# rendered by infer-provision' + '\n' + '[]' + '\n')
443
+ const ws = join(dst, 'pnpm-workspace.yaml')
444
+ if (!existsSync(ws)) writeFileAtomic(ws, 'packages:' + '\n' + ' - node_modules/*' + '\n')
445
+ // F5 — the provenance stamp (see provenanceDoc): fossil-vs-fresh,
446
+ // answered in one read.
447
+ writeFileAtomic(join(dst, 'render-provenance.json'),
448
+ JSON.stringify(provenanceDoc(name, provPkg, bundles, profileRowsSha), null, 2) + '\n')
449
+
450
+ log('rendered profile :', dst)
451
+ log(' bundles :', bundles.join(', '))
452
+ return dst
453
+ }
454
+
455
+ // INSTALL GUARDS (F6): the install ran with no timeout (a hung
456
+ // registry stalled forever), no catch (a failed install threw a raw
457
+ // stack with no manual-command hint), and both callers ignored the
458
+ // `false` return (missing-pnpm printed a warning, then the boot
459
+ // lines printed as if everything were installed). Now: a 10-minute
460
+ // ceiling (link-only installs take seconds; a cold-registry fetch
461
+ // takes minutes; a hang dies loud, never stalls CI forever) and
462
+ // every failure logged loud with the manual command. The boolean is
463
+ // HONORED by both callers through renderInstall below.
464
+ const INSTALL_TIMEOUT_MS = 600_000
465
+ function installDeps(profileDir) {
466
+ const pm = execSync('command -v pnpm || true').toString().trim()
467
+ || join(process.env.HOME || '', '.local/share/pnpm/pnpm')
468
+ if (!existsSync(pm)) {
469
+ console.error('infer-provision: pnpm not found — deps NOT installed for ' + profileDir
470
+ + '; run manually: (cd ' + profileDir + ' && pnpm install)')
471
+ return false
472
+ }
473
+ log(' installing deps with', pm, '…')
474
+ try {
475
+ execSync('"' + pm + '" install', { cwd: profileDir, stdio: 'inherit', timeout: INSTALL_TIMEOUT_MS })
476
+ } catch (err) {
477
+ const e = (err !== null && typeof err === 'object') ? err : {}
478
+ const why = e.code === 'ETIMEDOUT'
479
+ ? 'timed out after ' + String(INSTALL_TIMEOUT_MS / 1000) + 's'
480
+ : 'failed (status ' + String(e.status ?? e.signal ?? e.code ?? 'unknown') + ') — output above'
481
+ console.error('infer-provision: pnpm install ' + why + ' in ' + profileDir
482
+ + '; fix the failure and re-run, or install manually: (cd ' + profileDir + ' && pnpm install)')
483
+ return false
484
+ }
485
+ return true
486
+ }
487
+
488
+ // RENDER+INSTALL, LOCKED (F6): the one door both commands use — the
489
+ // lock covers the render AND the install (an install reading
490
+ // package.json mid-render is the same race). The install's boolean
491
+ // is honored HERE, and the die sits AFTER the finally on purpose:
492
+ // process.exit skips finally blocks, so a die inside the try would
493
+ // leak the lock forever.
494
+ function renderInstall(name) {
495
+ const dst = join(DSH_HOME, 'profiles', name)
496
+ mkdirSync(dst, { recursive: true })
497
+ acquireLock(dst)
498
+ let dir = dst
499
+ let ok = false
500
+ try {
501
+ dir = renderProfile(name)
502
+ ok = installDeps(dir)
503
+ } finally {
504
+ releaseLock(dst)
505
+ }
506
+ if (!ok) die(`profile '${name}' rendered but deps NOT installed — boot would fail at bundle resolution; run manually: (cd ${dir} && pnpm install)`)
507
+ return dir
508
+ }
509
+
510
+ // ── main ────────────────────────────────────────────────────────────────
511
+ switch (cmd) {
512
+ case 'all': {
513
+
514
+
515
+ // longer exists, so `all` hard-failed at "preset source missing".
516
+ // Presets are operator-managed now (~/.dsh/.agent-presets); this
517
+ // command renders + installs every profile (the gateway health
518
+ // check died with the gateway — there is no second process to
519
+ // probe anymore).
520
+ for (const name of ['melite-test', 'headless', 'estate']) {
521
+ renderInstall(name)
522
+ }
523
+ log('boot with:')
524
+ log(' LANES BOOT ONLY THROUGH THE PROVISION (the lane law, li-bu):')
525
+ log(' ~/Documents/programs/active/dsh-provision/apply.sh launch --lane <name>')
526
+ log(' (this command installs the profiles; the sibling provisioner owns the')
527
+ log(' launch — binary pin, version floor, home, and unit. The shapes below')
528
+ log(' are what the door runs UNDER THE HOOD, never a hand-run shortcut:)')
529
+ log(' dsh --profile melite-test # the studio GUI + TEST lane (one process, no :33081)')
530
+ log(' dsh --profile headless "<task>" # one-shot agent with card tools')
531
+ log(' dsh --profile estate # the SDK job server (estate row)')
532
+ break
533
+ }
534
+ case 'profile': {
535
+ renderInstall(PROFILE)
536
+ log('boot with: dsh --profile ' + PROFILE + (PROFILE === 'melite-test' ? '' : ' "<task>"'))
537
+ break
538
+ }
539
+ // the `preset` arm died with installPreset (undefined since the
540
+
541
+ // reads ~/.dsh/.agent-presets — refuse LOUD instead of a
542
+ // ReferenceError
543
+ default: die('unknown command ' + JSON.stringify(cmd) + ' — use all | profile <name>')
544
+ }