@sublang/playbook 6.0.0 → 7.0.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.
@@ -6,8 +6,8 @@
6
6
  // `@sublang/playbook/xstate-runtime`, which Node resolves by walking up
7
7
  // from the artifact's own directory; a globally installed host therefore
8
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
9
+ // module, both `playbook` front ends probe both specifiers with the module's
10
+ // path as resolution parent and, only when a probe fails, symlink the running
11
11
  // host's own installed package roots beside the module. It never shells
12
12
  // out to `npm link` and never installs from the registry.
13
13
 
@@ -133,18 +133,24 @@ function linkState(linkPath) {
133
133
  return existsSync(linkPath) ? 'live' : 'dangling';
134
134
  }
135
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.
136
+ export class EngineProvisioningError extends Error {
137
+ constructor(code, message) {
138
+ super(message);
139
+ this.name = 'EngineProvisioningError';
140
+ this.code = code;
141
+ }
142
+ }
143
+
144
+ // PBCLI-36/37: host-neutral probe and provisioning core. It returns
145
+ // structured link notices and throws a coded error; CLI hosts own prefixes
146
+ // and streams, so the same preparation has no baked-in presentation.
140
147
  export async function provisionEngine({
141
148
  modulePath,
142
- stderr,
143
149
  enabled = true,
144
150
  hostRoots,
145
151
  }) {
146
152
  const missing = missingEngineLinks(modulePath);
147
- if (missing.length === 0) return {};
153
+ if (missing.length === 0) return { createdLinks: [] };
148
154
 
149
155
  const moduleDir = dirname(modulePath);
150
156
  if (!enabled) {
@@ -154,35 +160,27 @@ export async function provisionEngine({
154
160
  for (const name of missing) {
155
161
  const linkPath = join(moduleDir, 'node_modules', name);
156
162
  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`,
163
+ throw new EngineProvisioningError(
164
+ 'stale-link',
165
+ `${linkPath} is a stale engine link to missing ` +
166
+ `${readlinkSync(linkPath)}; rerun without --no-provision to relink`,
160
167
  );
161
- return { code: 1 };
162
168
  }
163
169
  }
164
- return {};
170
+ return { createdLinks: [] };
165
171
  }
166
172
 
167
173
  const manifestPath = declaringManifest(moduleDir);
168
174
  if (manifestPath !== undefined) {
169
- stderr.write(
170
- `playbook run: ${manifestPath} declares @sublang/playbook; ` +
175
+ throw new EngineProvisioningError(
176
+ 'declared-install-missing',
177
+ `${manifestPath} declares @sublang/playbook; ` +
171
178
  'provisioning would shadow the project install — run the ' +
172
- "project's dependency install (e.g. npm install) instead\n",
179
+ "project's dependency install (e.g. npm install) instead",
173
180
  );
174
- return { code: 1 };
175
181
  }
176
182
 
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
- }
183
+ const roots = hostRoots ?? defaultHostRoots();
186
184
 
187
185
  // PBCLI-37: validate every destination before mutating any, so an
188
186
  // occupied-path refusal leaves the module directory unchanged rather
@@ -194,11 +192,11 @@ export async function provisionEngine({
194
192
  if (state === 'occupied' || state === 'live') {
195
193
  // A live-but-unresolvable link is as foreign as a real directory:
196
194
  // 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`,
195
+ throw new EngineProvisioningError(
196
+ 'occupied-link',
197
+ `cannot provision ${linkPath}: the path is already ` +
198
+ `occupied${state === 'live' ? ' by a foreign symbolic link' : ''}`,
200
199
  );
201
- return { code: 1 };
202
200
  }
203
201
  plans.push({
204
202
  linkPath,
@@ -207,22 +205,70 @@ export async function provisionEngine({
207
205
  });
208
206
  }
209
207
 
210
- const created = [];
211
208
  try {
212
209
  for (const { linkPath, target, dangling } of plans) {
213
210
  if (dangling) await unlink(linkPath);
214
211
  await mkdir(dirname(linkPath), { recursive: true });
215
212
  await symlink(target, linkPath, 'dir');
216
- created.push(`${linkPath} -> ${target}`);
217
213
  }
218
214
  } catch (error) {
219
215
  // 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`,
216
+ throw new EngineProvisioningError(
217
+ 'filesystem',
218
+ 'cannot provision engine links: ' +
219
+ `${error instanceof Error ? error.message : String(error)}`,
223
220
  );
224
- return { code: 1 };
225
221
  }
226
- stderr.write(`playbook run: provisioned ${created.join(', ')}\n`);
227
- return {};
222
+ return {
223
+ createdLinks: plans.map(({ linkPath, target }) => ({
224
+ path: linkPath,
225
+ target,
226
+ })),
227
+ };
228
+ }
229
+
230
+ // PBCLI-36/46 (DR-024 as amended by DR-031): adapt the low-level
231
+ // probe/symlink operation to launch-config's prepare-or-throw contract. Both
232
+ // front ends install this hook, filesystem URLs are prepared before the
233
+ // catalog import transaction, and bare/custom specifiers stay untouched.
234
+ export function prepareConfiguredRegistries({
235
+ enabled = true,
236
+ stderr,
237
+ hostRoots,
238
+ commandName = 'playbook',
239
+ }) {
240
+ return async ({ from }) => {
241
+ if (!from.startsWith('file:')) return from;
242
+ const result = await provisionEngine({
243
+ modulePath: fileURLToPath(from),
244
+ enabled,
245
+ hostRoots,
246
+ });
247
+ if (result.createdLinks.length > 0) {
248
+ await writeStream(
249
+ stderr,
250
+ `${commandName}: provisioned ${result.createdLinks
251
+ .map(({ path, target }) => `${path} -> ${target}`)
252
+ .join(', ')}\n`,
253
+ );
254
+ }
255
+ return from;
256
+ };
257
+ }
258
+
259
+ async function writeStream(stream, text) {
260
+ const ready = stream.write(text);
261
+ if (ready !== false || typeof stream.once !== 'function') return;
262
+ await new Promise((resolvePromise, rejectPromise) => {
263
+ const onDrain = () => {
264
+ stream.off?.('error', onError);
265
+ resolvePromise();
266
+ };
267
+ const onError = (error) => {
268
+ stream.off?.('drain', onDrain);
269
+ rejectPromise(error);
270
+ };
271
+ stream.once('drain', onDrain);
272
+ stream.once('error', onError);
273
+ });
228
274
  }