@sublang/playbook 2.0.0 → 3.1.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/README.md +102 -338
- package/docs/cli.md +123 -0
- package/docs/configuration.md +158 -0
- package/docs/embedding.md +161 -0
- package/package.json +7 -2
- package/reference/sdlc/code.md +105 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +237 -38
- package/reference/sdlc/code.playbook/bin/provision.js +228 -0
- package/reference/sdlc/code.playbook/bin/run.js +76 -3
- package/reference/sdlc/code.playbook/code.fsm.js +38 -36
- package/reference/sdlc/code.playbook/code.fsm.ts +38 -36
- package/reference/sdlc/code.playbook/code.gears.md +30 -26
- package/reference/sdlc/code.playbook/code.playbook.js +4 -0
- package/reference/sdlc/code.playbook/code.playbook.ts +6 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +67 -8
- package/reference/sdlc/code.playbook/playbook-captain.ts +80 -9
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +38 -32
- package/reference/sdlc/discuss.md +93 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +5 -4
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +5 -4
- package/reference/sdlc/discuss.playbook/discuss.gears.md +19 -12
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +3 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +5 -0
- package/slc/link.md +20 -3
- package/src/xstate-playbook-runtime.d.ts +17 -0
- package/src/xstate-playbook-runtime.js +49 -0
- package/src/xstate-playbook-runtime.ts +81 -4
- package/src/xstate-runtime.d.ts +1 -0
- package/src/xstate-runtime.js +19 -0
- package/src/xstate-runtime.ts +20 -0
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
import { homedir, tmpdir } from 'node:os';
|
|
18
18
|
import { dirname, join, resolve } from 'node:path';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
parse as parseYaml,
|
|
22
|
+
parseDocument as parseYamlDocument,
|
|
23
|
+
stringify as stringifyYaml,
|
|
24
|
+
} from 'yaml';
|
|
21
25
|
|
|
22
26
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
27
|
const templatePath = resolve(here, '..', 'playbook.config.template.yaml');
|
|
@@ -25,8 +29,8 @@ const templatePath = resolve(here, '..', 'playbook.config.template.yaml');
|
|
|
25
29
|
// PBCLI-1/8: the launcher composes a tmux-play config whose Captain is the
|
|
26
30
|
// Playbook Captain shell adapter module.
|
|
27
31
|
export const PLAYBOOK_CAPTAIN_MODULE = '@sublang/playbook/playbook-captain';
|
|
28
|
-
// PBCLI-
|
|
29
|
-
//
|
|
32
|
+
// PBCLI-12: known adapter shorthands — the adapters with readiness
|
|
33
|
+
// predicates.
|
|
30
34
|
const ADAPTER_SHORTHANDS = ['claude', 'codex'];
|
|
31
35
|
// PBCLI-8: launcher-owned keys inside a `playbooks.<id>` block; every other
|
|
32
36
|
// key belongs to that playbook's option slice.
|
|
@@ -63,6 +67,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
63
67
|
...(options.createAgent ? { createAgent: options.createAgent } : {}),
|
|
64
68
|
...(options.readStdin ? { readStdin: options.readStdin } : {}),
|
|
65
69
|
...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
|
|
70
|
+
...(options.hostRoots ? { hostRoots: options.hostRoots } : {}),
|
|
66
71
|
});
|
|
67
72
|
}
|
|
68
73
|
|
|
@@ -103,6 +108,15 @@ export async function runPlaybookCli(options = {}) {
|
|
|
103
108
|
|
|
104
109
|
seedUserConfigIfMissing(userConfigPath, stderr);
|
|
105
110
|
|
|
111
|
+
// DR-021 §3: an existing profiles-based config is rewritten in place once,
|
|
112
|
+
// with the original kept beside it, so the user launches without editing.
|
|
113
|
+
try {
|
|
114
|
+
migrateUserConfigIfRetired(userConfigPath, stderr);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
117
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
118
|
+
}
|
|
119
|
+
|
|
106
120
|
let composed;
|
|
107
121
|
try {
|
|
108
122
|
let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
|
|
@@ -114,7 +128,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
114
128
|
for (const overlayPath of withPaths) {
|
|
115
129
|
top = mergeConfigs(top, loadOverlayFragment(overlayPath));
|
|
116
130
|
}
|
|
117
|
-
composed = await composeGenericConfig(top, loadModule);
|
|
131
|
+
composed = await composeGenericConfig(top, loadModule, userConfigPath);
|
|
118
132
|
} catch (error) {
|
|
119
133
|
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
120
134
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
@@ -235,29 +249,213 @@ export function resolveUserConfigPath(env = process.env, home = homedir()) {
|
|
|
235
249
|
return join(resolveConfigHome(env, home), 'playbook', 'playbook.config.yaml');
|
|
236
250
|
}
|
|
237
251
|
|
|
238
|
-
// PBCLI-8:
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
export function resolveAgent(value,
|
|
243
|
-
if (typeof value === 'string') {
|
|
244
|
-
|
|
245
|
-
|
|
252
|
+
// PBCLI-8 (DR-021): a scalar `captain` / `players.<role>` value is an
|
|
253
|
+
// adapter shorthand; a full block is a self-contained tmux-play agent block
|
|
254
|
+
// carrying its own adapter/model/effort/permissions. There is no profile
|
|
255
|
+
// indirection, so retuning one agent cannot change another.
|
|
256
|
+
export function resolveAgent(value, path) {
|
|
257
|
+
if (typeof value === 'string') return { adapter: value };
|
|
258
|
+
if (isObject(value)) return { ...value };
|
|
259
|
+
throw new Error(`${path} must be an adapter shorthand or an agent block`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// DR-021 §3: migrate the user's config on disk, once, keeping the original.
|
|
263
|
+
// The backup is written before the rewrite and never overwrites an existing
|
|
264
|
+
// file, so a prior backup — or a user's own .bak — cannot be lost.
|
|
265
|
+
function migrateUserConfigIfRetired(userConfigPath, stderr) {
|
|
266
|
+
let text;
|
|
267
|
+
try {
|
|
268
|
+
text = readFileSync(userConfigPath, 'utf8');
|
|
269
|
+
} catch {
|
|
270
|
+
return;
|
|
246
271
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
272
|
+
let migrated;
|
|
273
|
+
try {
|
|
274
|
+
migrated = migrateRetiredProfiles(text);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`cannot migrate the retired profiles config at ${userConfigPath}: ` +
|
|
278
|
+
`${errorMessage(error)} — edit it by hand: each agent takes its own ` +
|
|
279
|
+
'adapter, model, effort, and permissions',
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (migrated === undefined) return;
|
|
283
|
+
const backupPath = freeBackupPath(userConfigPath);
|
|
284
|
+
writeFileSync(backupPath, text, { mode: 0o600 });
|
|
285
|
+
writeFileSync(userConfigPath, migrated);
|
|
286
|
+
stderr.write(
|
|
287
|
+
`playbook: migrated ${userConfigPath} to inline agent settings ` +
|
|
288
|
+
`(the top-level "profiles" map was removed in 3.0.0); ` +
|
|
289
|
+
`the original is at ${backupPath}\n`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function freeBackupPath(userConfigPath) {
|
|
294
|
+
const first = `${userConfigPath}.bak`;
|
|
295
|
+
if (!existsSync(first)) return first;
|
|
296
|
+
for (let n = 2; ; n += 1) {
|
|
297
|
+
const candidate = `${userConfigPath}.bak.${n}`;
|
|
298
|
+
if (!existsSync(candidate)) return candidate;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// DR-021 §3: rewrite a config written for the retired profiles model in
|
|
303
|
+
// place, inlining each agent's settings and keeping the original beside it.
|
|
304
|
+
// Edits go through the YAML Document API so the user's comments survive;
|
|
305
|
+
// only the profiles block and its own commentary are removed. Returns the
|
|
306
|
+
// migrated text, or undefined when there is nothing to migrate.
|
|
307
|
+
export function migrateRetiredProfiles(text) {
|
|
308
|
+
const doc = parseYamlDocument(text);
|
|
309
|
+
const contents = doc.contents;
|
|
310
|
+
if (!contents || !Array.isArray(contents.items)) return undefined;
|
|
311
|
+
const profiles = doc.get('profiles');
|
|
312
|
+
const agentPaths = [['captain']];
|
|
313
|
+
const playbooks = doc.get('playbooks');
|
|
314
|
+
if (playbooks && Array.isArray(playbooks.items)) {
|
|
315
|
+
for (const entry of playbooks.items) {
|
|
316
|
+
const id = String(entry.key);
|
|
317
|
+
const players = doc.getIn(['playbooks', id, 'players']);
|
|
318
|
+
if (!players || !Array.isArray(players.items)) continue;
|
|
319
|
+
for (const player of players.items) {
|
|
320
|
+
agentPaths.push(['playbooks', id, 'players', String(player.key)]);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const profileSettings = (name) =>
|
|
326
|
+
profiles && typeof profiles.get === 'function'
|
|
327
|
+
? profiles.get(name)
|
|
328
|
+
: undefined;
|
|
329
|
+
|
|
330
|
+
let changed = false;
|
|
331
|
+
for (const path of agentPaths) {
|
|
332
|
+
const node = doc.getIn(path, true);
|
|
333
|
+
if (node && typeof node.value === 'string' && !Array.isArray(node.items)) {
|
|
334
|
+
// A scalar that named a profile; a bare adapter shorthand stays.
|
|
335
|
+
const settings = profileSettings(node.value);
|
|
336
|
+
if (settings === undefined) continue;
|
|
337
|
+
const inlined = settings.clone();
|
|
338
|
+
// The scalar carried any comment on that line, and replacing the node
|
|
339
|
+
// would drop it. Re-attach it above the block that replaces it.
|
|
340
|
+
carryScalarComment(node, inlined);
|
|
341
|
+
doc.setIn(path, inlined);
|
|
342
|
+
changed = true;
|
|
343
|
+
} else if (node && Array.isArray(node.items)) {
|
|
344
|
+
const named = node.get?.('profile');
|
|
345
|
+
if (named === undefined) continue;
|
|
346
|
+
const settings = profileSettings(named);
|
|
347
|
+
if (settings === undefined) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`${path.join('.')}.profile names "${String(named)}", which no ` +
|
|
350
|
+
'profiles entry defines',
|
|
351
|
+
);
|
|
253
352
|
}
|
|
254
|
-
|
|
353
|
+
// Fill the block from its profile in place — never rebuild it — so
|
|
354
|
+
// the user's own keys, ordering, and comments survive untouched. The
|
|
355
|
+
// block's own fields stay authoritative, so only absent keys are added.
|
|
356
|
+
node.delete('profile');
|
|
357
|
+
for (const item of settings.items) {
|
|
358
|
+
if (node.has(String(item.key))) continue;
|
|
359
|
+
// Append the whole pair, not a rebuilt key/value: a comment above a
|
|
360
|
+
// setting rides on that setting's key node, so stringifying the key
|
|
361
|
+
// would drop it.
|
|
362
|
+
node.add(item.clone());
|
|
363
|
+
}
|
|
364
|
+
changed = true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (profiles !== undefined) {
|
|
369
|
+
// The comment block above `profiles` usually carries the file's own
|
|
370
|
+
// header, which must outlive the removed section: keep every paragraph
|
|
371
|
+
// except the last, which documents profiles themselves.
|
|
372
|
+
const index = contents.items.findIndex(
|
|
373
|
+
(item) => String(item.key) === 'profiles',
|
|
374
|
+
);
|
|
375
|
+
const lead = index === -1 ? undefined : contents.items[index]?.key
|
|
376
|
+
?.commentBefore;
|
|
377
|
+
doc.delete('profiles');
|
|
378
|
+
const header = keptHeaderComment(lead);
|
|
379
|
+
const next = contents.items[0];
|
|
380
|
+
if (header !== undefined && next?.key) {
|
|
381
|
+
next.key.commentBefore =
|
|
382
|
+
next.key.commentBefore === undefined
|
|
383
|
+
? header
|
|
384
|
+
: `${header}\n\n${next.key.commentBefore}`;
|
|
255
385
|
}
|
|
256
|
-
|
|
386
|
+
changed = true;
|
|
257
387
|
}
|
|
258
|
-
|
|
259
|
-
|
|
388
|
+
if (!changed) return undefined;
|
|
389
|
+
// Say what happened at the top of the file the user will open next:
|
|
390
|
+
// some of their remaining comments describe the retired model.
|
|
391
|
+
doc.commentBefore = MIGRATION_NOTE;
|
|
392
|
+
return doc.toString();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const MIGRATION_NOTE =
|
|
396
|
+
' Migrated by playbook 3.0.0: the top-level `profiles` map was removed and\n' +
|
|
397
|
+
' each agent now carries its settings inline. The pre-migration file is\n' +
|
|
398
|
+
' kept beside this one as a .bak. Comments below may still describe the\n' +
|
|
399
|
+
' retired profiles model.';
|
|
400
|
+
|
|
401
|
+
// Move a scalar agent's own comments onto the block that replaces it, so
|
|
402
|
+
// `captain: base # the judge` keeps its note. The pair's key comments are
|
|
403
|
+
// untouched by the replacement and need no carrying.
|
|
404
|
+
function carryScalarComment(node, inlined) {
|
|
405
|
+
const parts = [node.commentBefore, node.comment].filter(
|
|
406
|
+
(part) => typeof part === 'string' && part.trim() !== '',
|
|
260
407
|
);
|
|
408
|
+
if (parts.length === 0) return;
|
|
409
|
+
const first = inlined.items?.[0]?.key;
|
|
410
|
+
if (!first) return;
|
|
411
|
+
// A flow map carrying a comment renders as a multi-line brace block; the
|
|
412
|
+
// ordinary block form is what the rest of the config looks like.
|
|
413
|
+
inlined.flow = false;
|
|
414
|
+
const carried = parts.join('\n');
|
|
415
|
+
first.commentBefore =
|
|
416
|
+
first.commentBefore === undefined
|
|
417
|
+
? carried
|
|
418
|
+
: `${carried}\n${first.commentBefore}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Drop the trailing paragraph — the one describing the profiles block —
|
|
422
|
+
// and keep the rest of the leading comment (SPDX header, file overview).
|
|
423
|
+
function keptHeaderComment(comment) {
|
|
424
|
+
if (typeof comment !== 'string' || comment.trim() === '') return undefined;
|
|
425
|
+
const paragraphs = comment.split('\n\n');
|
|
426
|
+
const kept = paragraphs.slice(0, -1).join('\n\n');
|
|
427
|
+
return kept.trim() === '' ? undefined : kept;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// A `profile` key that survives migration — introduced by a `--with`
|
|
431
|
+
// overlay rather than the user's own config — is still rejected.
|
|
432
|
+
function assertNoRetiredProfiles(top, configPath) {
|
|
433
|
+
const where = configPath ? ` in ${configPath}` : '';
|
|
434
|
+
if (top.profiles !== undefined) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`top-level "profiles" was removed${where}: write each agent's settings ` +
|
|
437
|
+
'inline under captain and each playbooks.<id>.players.<role> ' +
|
|
438
|
+
'(adapter, model, effort, permissions)',
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
const blocks = [['captain', top.captain]];
|
|
442
|
+
const playbooksCfg = isObject(top.playbooks) ? top.playbooks : {};
|
|
443
|
+
for (const [id, block] of Object.entries(playbooksCfg)) {
|
|
444
|
+
const playersMap = isObject(block) && isObject(block.players)
|
|
445
|
+
? block.players
|
|
446
|
+
: {};
|
|
447
|
+
for (const [role, agent] of Object.entries(playersMap)) {
|
|
448
|
+
blocks.push([`playbooks.${id}.players.${role}`, agent]);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
for (const [path, block] of blocks) {
|
|
452
|
+
if (isObject(block) && block.profile !== undefined) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
`${path}.profile was removed${where}: write the agent's settings ` +
|
|
455
|
+
'inline in that block (adapter, model, effort, permissions)',
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
261
459
|
}
|
|
262
460
|
|
|
263
461
|
function isValidRegistryEntry(value) {
|
|
@@ -272,19 +470,12 @@ function isValidRegistryEntry(value) {
|
|
|
272
470
|
);
|
|
273
471
|
}
|
|
274
472
|
|
|
275
|
-
// PBCLI-8/9/10: normalize the top-level `
|
|
473
|
+
// PBCLI-8/9/10: normalize the top-level `playbooks` config into
|
|
276
474
|
// a tmux-play config (Captain = the shell adapter; `captain.options.playbooks`
|
|
277
475
|
// the normalized enablement; a launch-time namespaced `<id>-<role>` roster;
|
|
278
476
|
// launcher-owned `layout.initialVisible`).
|
|
279
|
-
export async function composeGenericConfig(top, loadModule) {
|
|
280
|
-
|
|
281
|
-
for (const id of Object.keys(profiles)) {
|
|
282
|
-
if (ADAPTER_SHORTHANDS.includes(id)) {
|
|
283
|
-
throw new Error(
|
|
284
|
-
`profiles.${id} collides with the "${id}" adapter shorthand`,
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
477
|
+
export async function composeGenericConfig(top, loadModule, configPath) {
|
|
478
|
+
assertNoRetiredProfiles(top, configPath);
|
|
288
479
|
|
|
289
480
|
const playbooksCfg = requireObject(top.playbooks, 'playbooks');
|
|
290
481
|
const ids = Object.keys(playbooksCfg);
|
|
@@ -294,7 +485,7 @@ export async function composeGenericConfig(top, loadModule) {
|
|
|
294
485
|
|
|
295
486
|
const captain = {
|
|
296
487
|
from: PLAYBOOK_CAPTAIN_MODULE,
|
|
297
|
-
...resolveAgent(top.captain,
|
|
488
|
+
...resolveAgent(top.captain, 'captain'),
|
|
298
489
|
};
|
|
299
490
|
if (captain.adapter === undefined) {
|
|
300
491
|
throw new Error('captain must resolve an adapter');
|
|
@@ -389,7 +580,6 @@ export async function composeGenericConfig(top, loadModule) {
|
|
|
389
580
|
for (const role of roles) {
|
|
390
581
|
const agent = resolveAgent(
|
|
391
582
|
playersMap[role],
|
|
392
|
-
profiles,
|
|
393
583
|
`playbooks.${id}.players.${role}`,
|
|
394
584
|
);
|
|
395
585
|
if (agent.adapter === undefined) {
|
|
@@ -419,7 +609,16 @@ export async function composeGenericConfig(top, loadModule) {
|
|
|
419
609
|
listing.push({ id, command, intent: entry.intent });
|
|
420
610
|
}
|
|
421
611
|
|
|
422
|
-
|
|
612
|
+
// DR-013 A1: the shell cannot see its own captain's adapter through the
|
|
613
|
+
// tmux-play CaptainContext, so the launcher — which resolved it — passes it
|
|
614
|
+
// through. The shell needs it to decide whether an explicit empty tool
|
|
615
|
+
// allowlist can be enforced or must degrade to prompt-level restriction.
|
|
616
|
+
captain.options = {
|
|
617
|
+
playbooks: optionsPlaybooks,
|
|
618
|
+
...(typeof captain.adapter === 'string' && captain.adapter.length > 0
|
|
619
|
+
? { captainAdapter: captain.adapter }
|
|
620
|
+
: {}),
|
|
621
|
+
};
|
|
423
622
|
const config = { captain, players: roster };
|
|
424
623
|
// PBCLI-10: carry the user's tmux-play layout window/weight fields through;
|
|
425
624
|
// the launcher owns `layout.initialVisible` (first enabled playbook).
|
|
@@ -504,9 +703,9 @@ function helpText({ userConfigPath, failingAdapters = [] }) {
|
|
|
504
703
|
' codex: run Codex CLI once or set OPENAI_API_KEY.',
|
|
505
704
|
'',
|
|
506
705
|
'Agent swap recipe:',
|
|
507
|
-
' -
|
|
508
|
-
'
|
|
509
|
-
'
|
|
706
|
+
' - set each agent inline: the top-level captain and every',
|
|
707
|
+
' playbooks.<id>.players.<role> takes an adapter shorthand',
|
|
708
|
+
' (claude, codex) or a block with adapter/model/effort/permissions',
|
|
510
709
|
' - the launcher injects captain.from and the namespaced <id>-<role>',
|
|
511
710
|
' host players',
|
|
512
711
|
'',
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
// PBCLI-36/37 (DR-024): probe-first engine provisioning for filesystem
|
|
5
|
+
// registry modules. A compiled thin artifact imports `xstate` and
|
|
6
|
+
// `@sublang/playbook/xstate-runtime`, which Node resolves by walking up
|
|
7
|
+
// from the artifact's own directory; a globally installed host therefore
|
|
8
|
+
// fails at artifact load in a bare directory. Before importing such a
|
|
9
|
+
// module, `playbook run` probes both specifiers with the module's path as
|
|
10
|
+
// resolution parent and, only when a probe fails, symlinks the running
|
|
11
|
+
// host's own installed package roots beside the module. It never shells
|
|
12
|
+
// out to `npm link` and never installs from the registry.
|
|
13
|
+
|
|
14
|
+
import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs';
|
|
15
|
+
import { mkdir, symlink, unlink } from 'node:fs/promises';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
// PBCLI-37: probe specifier → the package name provisioning may link.
|
|
21
|
+
const ENGINE_LINKS = new Map([
|
|
22
|
+
['xstate', 'xstate'],
|
|
23
|
+
['@sublang/playbook/xstate-runtime', '@sublang/playbook'],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const DEPENDENCY_FIELDS = [
|
|
27
|
+
'dependencies',
|
|
28
|
+
'devDependencies',
|
|
29
|
+
'peerDependencies',
|
|
30
|
+
'optionalDependencies',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// PBCLI-37: package names whose probes fail with the module as resolution
|
|
34
|
+
// parent. `createRequire` follows the same walk-up Node uses for the
|
|
35
|
+
// module's own imports, so an empty result means a project-local (or
|
|
36
|
+
// already provisioned) engine wins and provisioning must touch nothing.
|
|
37
|
+
function missingEngineLinks(modulePath) {
|
|
38
|
+
const req = createRequire(modulePath);
|
|
39
|
+
const missing = [];
|
|
40
|
+
for (const [specifier, name] of ENGINE_LINKS) {
|
|
41
|
+
try {
|
|
42
|
+
req.resolve(specifier);
|
|
43
|
+
} catch {
|
|
44
|
+
missing.push(name);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return missing;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// PBCLI-36: a manifest at or above the module declaring @sublang/playbook
|
|
51
|
+
// means a project chose a dependency and its install is broken or absent;
|
|
52
|
+
// shadow-provisioning would mask the real fix.
|
|
53
|
+
function declaringManifest(startDir) {
|
|
54
|
+
for (let dir = startDir; ; ) {
|
|
55
|
+
const manifestPath = join(dir, 'package.json');
|
|
56
|
+
if (existsSync(manifestPath)) {
|
|
57
|
+
try {
|
|
58
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
59
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
60
|
+
const block = manifest?.[field];
|
|
61
|
+
if (
|
|
62
|
+
block !== null &&
|
|
63
|
+
typeof block === 'object' &&
|
|
64
|
+
Object.prototype.hasOwnProperty.call(block, '@sublang/playbook')
|
|
65
|
+
) {
|
|
66
|
+
return manifestPath;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// An unreadable manifest cannot declare the dependency.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const parent = dirname(dir);
|
|
74
|
+
if (parent === dir) return undefined;
|
|
75
|
+
dir = parent;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function packageRootUpward(startDir, name) {
|
|
80
|
+
for (let dir = startDir; ; ) {
|
|
81
|
+
const manifestPath = join(dir, 'package.json');
|
|
82
|
+
if (existsSync(manifestPath)) {
|
|
83
|
+
try {
|
|
84
|
+
if (JSON.parse(readFileSync(manifestPath, 'utf8')).name === name) {
|
|
85
|
+
return dir;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Keep walking past an unreadable manifest.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const parent = dirname(dir);
|
|
92
|
+
if (parent === dir) return undefined;
|
|
93
|
+
dir = parent;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// PBCLI-37: the running host's own installed package roots, resolved from
|
|
98
|
+
// the host's module scope — this file lives inside @sublang/playbook, and
|
|
99
|
+
// xstate resolves from that root's own dependency tree.
|
|
100
|
+
function defaultHostRoots() {
|
|
101
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
102
|
+
const playbookRoot = packageRootUpward(here, '@sublang/playbook');
|
|
103
|
+
if (playbookRoot === undefined) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
'cannot locate the running @sublang/playbook package root for provisioning',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const req = createRequire(join(playbookRoot, 'package.json'));
|
|
109
|
+
let xstateRoot;
|
|
110
|
+
try {
|
|
111
|
+
xstateRoot = packageRootUpward(dirname(req.resolve('xstate')), 'xstate');
|
|
112
|
+
} catch {
|
|
113
|
+
xstateRoot = undefined;
|
|
114
|
+
}
|
|
115
|
+
if (xstateRoot === undefined) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
"cannot locate the running host's own xstate package for provisioning",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return { xstate: xstateRoot, '@sublang/playbook': playbookRoot };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 'absent' | 'dangling' | 'live' (a symlink with an existing target) |
|
|
124
|
+
// 'occupied' (a real file or directory, never removed).
|
|
125
|
+
function linkState(linkPath) {
|
|
126
|
+
let stat;
|
|
127
|
+
try {
|
|
128
|
+
stat = lstatSync(linkPath);
|
|
129
|
+
} catch {
|
|
130
|
+
return 'absent';
|
|
131
|
+
}
|
|
132
|
+
if (!stat.isSymbolicLink()) return 'occupied';
|
|
133
|
+
return existsSync(linkPath) ? 'live' : 'dangling';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// PBCLI-36/37: probe, then provision the missing engine links beside a
|
|
137
|
+
// filesystem registry module. Returns {} when the run may proceed (either
|
|
138
|
+
// nothing was needed or links were created and logged) or { code: 1 }
|
|
139
|
+
// after writing one `playbook run: <message>` diagnostic to stderr.
|
|
140
|
+
export async function provisionEngine({
|
|
141
|
+
modulePath,
|
|
142
|
+
stderr,
|
|
143
|
+
enabled = true,
|
|
144
|
+
hostRoots,
|
|
145
|
+
}) {
|
|
146
|
+
const missing = missingEngineLinks(modulePath);
|
|
147
|
+
if (missing.length === 0) return {};
|
|
148
|
+
|
|
149
|
+
const moduleDir = dirname(modulePath);
|
|
150
|
+
if (!enabled) {
|
|
151
|
+
// PBCLI-36: --no-provision still owes the dangling-link diagnostic —
|
|
152
|
+
// a stale link we (or a prior host) created must not surface as a raw
|
|
153
|
+
// module-not-found error.
|
|
154
|
+
for (const name of missing) {
|
|
155
|
+
const linkPath = join(moduleDir, 'node_modules', name);
|
|
156
|
+
if (linkState(linkPath) === 'dangling') {
|
|
157
|
+
stderr.write(
|
|
158
|
+
`playbook run: ${linkPath} is a stale engine link to missing ` +
|
|
159
|
+
`${readlinkSync(linkPath)}; rerun without --no-provision to relink\n`,
|
|
160
|
+
);
|
|
161
|
+
return { code: 1 };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const manifestPath = declaringManifest(moduleDir);
|
|
168
|
+
if (manifestPath !== undefined) {
|
|
169
|
+
stderr.write(
|
|
170
|
+
`playbook run: ${manifestPath} declares @sublang/playbook; ` +
|
|
171
|
+
'provisioning would shadow the project install — run the ' +
|
|
172
|
+
"project's dependency install (e.g. npm install) instead\n",
|
|
173
|
+
);
|
|
174
|
+
return { code: 1 };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let roots;
|
|
178
|
+
try {
|
|
179
|
+
roots = hostRoots ?? defaultHostRoots();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
stderr.write(
|
|
182
|
+
`playbook run: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
183
|
+
);
|
|
184
|
+
return { code: 1 };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// PBCLI-37: validate every destination before mutating any, so an
|
|
188
|
+
// occupied-path refusal leaves the module directory unchanged rather
|
|
189
|
+
// than half-provisioned.
|
|
190
|
+
const plans = [];
|
|
191
|
+
for (const name of missing) {
|
|
192
|
+
const linkPath = join(moduleDir, 'node_modules', name);
|
|
193
|
+
const state = linkState(linkPath);
|
|
194
|
+
if (state === 'occupied' || state === 'live') {
|
|
195
|
+
// A live-but-unresolvable link is as foreign as a real directory:
|
|
196
|
+
// neither is a link this host may replace.
|
|
197
|
+
stderr.write(
|
|
198
|
+
`playbook run: cannot provision ${linkPath}: the path is already ` +
|
|
199
|
+
`occupied${state === 'live' ? ' by a foreign symbolic link' : ''}\n`,
|
|
200
|
+
);
|
|
201
|
+
return { code: 1 };
|
|
202
|
+
}
|
|
203
|
+
plans.push({
|
|
204
|
+
linkPath,
|
|
205
|
+
target: roots[name],
|
|
206
|
+
dangling: state === 'dangling',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const created = [];
|
|
211
|
+
try {
|
|
212
|
+
for (const { linkPath, target, dangling } of plans) {
|
|
213
|
+
if (dangling) await unlink(linkPath);
|
|
214
|
+
await mkdir(dirname(linkPath), { recursive: true });
|
|
215
|
+
await symlink(target, linkPath, 'dir');
|
|
216
|
+
created.push(`${linkPath} -> ${target}`);
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// PBCLI-37: a filesystem failure is a load fault, not a raw crash.
|
|
220
|
+
stderr.write(
|
|
221
|
+
'playbook run: cannot provision engine links: ' +
|
|
222
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
223
|
+
);
|
|
224
|
+
return { code: 1 };
|
|
225
|
+
}
|
|
226
|
+
stderr.write(`playbook run: provisioned ${created.join(', ')}\n`);
|
|
227
|
+
return {};
|
|
228
|
+
}
|