@sparkelf/dsh-plus 0.2.0-rc.9 → 0.2.1-alpha.2

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.
@@ -10,18 +10,22 @@
10
10
  import { spawnSync } from 'node:child_process';
11
11
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
12
12
  import { createRequire } from 'node:module';
13
- import { dirname, join } from 'node:path';
13
+ import { dirname, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { newerVersion } from "./registry-versions.js";
16
+ import { CAPABILITIES, CAPABILITY_ENV_FILE, CAPABILITY_MARKER, CAPABILITY_RECORD, DEFAULT_MINERU_ENDPOINT, capabilityEnvironment, capabilityPatchLayer, detectEnabledCapabilities, editCapabilityPatchLayer, installCapabilityServices, interviewCapabilities, readCapabilityRecord, } from "./standalone-capabilities.js";
16
17
  import { DEFAULT_PORT, STOP_GRACE_MILLISECONDS, choosePort, clearState, isRunning, portAvailable, readState, spawnServer, stateDirectory, waitForAuthenticatedUrl, waitForServer, writeState, } from "./standalone-server.js";
17
18
  import { STANDALONE_PROFILE, applyProfileNpmPatches, ensureProfile, pnpmAvailable, pnpmInstallCommand, pnpmInstallCommands, registryOrder, readDistributionProfile, resolvePaths, } from "./standalone-profile.js";
18
19
  /** Milliseconds a start waits for the server to answer before reporting failure. */
19
20
  const READY_TIMEOUT_MILLISECONDS = 90_000;
21
+ /** Profile patch file the capability interview rewrites (the profile's user layer). */
22
+ const CAPABILITY_PATCH_FILE = 'cordis.patch.yml';
20
23
  function parseStartOptions(argv) {
21
24
  let port = DEFAULT_PORT;
22
25
  let host = '127.0.0.1';
23
26
  let open = true;
24
27
  let foreground = false;
28
+ let capabilities;
25
29
  for (let index = 0; index < argv.length; index += 1) {
26
30
  const token = argv[index];
27
31
  if (token === '--port' || token === '-p') {
@@ -48,9 +52,47 @@ function parseStartOptions(argv) {
48
52
  foreground = true;
49
53
  continue;
50
54
  }
55
+ if (token === '--capabilities') {
56
+ const value = argv[index + 1];
57
+ if (value === undefined)
58
+ throw new Error('--capabilities requires a comma-separated list, or an empty string for none');
59
+ const stated = value === '' ? [] : value.split(',').map(entry => entry.trim()).filter(entry => entry !== '');
60
+ // A stated selection is the only way an unattended install chooses capabilities, so
61
+ // a name this release does not offer fails here: the deployment would otherwise
62
+ // start without a capability its caller believes it selected. Validating at parse
63
+ // time also makes the failure independent of what is installed.
64
+ const offered = new Set(CAPABILITIES.map(capability => capability.id));
65
+ for (const id of stated) {
66
+ if (!offered.has(id)) {
67
+ throw new Error('unknown capability "' + id + '"; this release offers ' + [...offered].join(', '));
68
+ }
69
+ }
70
+ capabilities = stated;
71
+ index += 1;
72
+ continue;
73
+ }
51
74
  throw new Error('unknown option: ' + String(token));
52
75
  }
53
- return { port, host, open, foreground };
76
+ return { port, host, open, foreground, capabilities };
77
+ }
78
+ /**
79
+ * Resolve a stated capability selection.
80
+ *
81
+ * An unattended install states what it wants instead of answering the interview, so a
82
+ * name it does not offer has to fail here rather than silently enable nothing: the
83
+ * deployment would otherwise start with a capability the caller believes it selected.
84
+ *
85
+ * @param ids - capability ids the caller selected.
86
+ * @returns the answers the interview would have returned.
87
+ */
88
+ function selectCapabilities(ids) {
89
+ const selected = [...new Set(ids)];
90
+ // MinerU is started from an endpoint the interview collects; an unattended install
91
+ // gets the documented default rather than an unset one.
92
+ return {
93
+ enabled: selected,
94
+ ...selected.includes('mineru') ? { mineruEndpoint: DEFAULT_MINERU_ENDPOINT } : {},
95
+ };
54
96
  }
55
97
  /**
56
98
  * Where the installation that owns this command keeps its packages.
@@ -84,13 +126,32 @@ function installationRoot() {
84
126
  function installationAnchor() {
85
127
  return join(installationRoot(), 'package.json');
86
128
  }
129
+ /**
130
+ * Path to the installed command's own file, which is where a declaration lookup starts.
131
+ *
132
+ * The declaration belongs to the package the user installed, and that package is a
133
+ * sibling of this module rather than an ancestor: a variant's forwarder imports this
134
+ * CLI in-process, so `import.meta.url` names `@sparkelf/dsh-plus` while the installed
135
+ * command is the variant. `process.argv[1]` is the entry that was actually invoked,
136
+ * which is the package whose declaration applies.
137
+ *
138
+ * Walking out from the installation root cannot reach it either: that root is the
139
+ * project the user ran the command in, so every variant would fall back to the full
140
+ * profile and keep the capabilities it excluded.
141
+ *
142
+ * @returns absolute path to the invoked command, or this module when argv carries none.
143
+ */
144
+ function declarationAnchor() {
145
+ const invoked = process.argv[1];
146
+ return invoked === undefined || invoked === '' ? fileURLToPath(import.meta.url) : resolve(invoked);
147
+ }
87
148
  /** The launcher entry this installation must drive. */
88
149
  function launcherEntry(anchor) {
89
150
  return createRequire(anchor).resolve('@deepseek-ai/dsh/lib/bin.js');
90
151
  }
91
152
  /** Run the server in this process, inheriting stdio. */
92
- function runForeground(entry, port, host, open) {
93
- const args = [entry, '--profile', STANDALONE_PROFILE, '--port', String(port), '--host', host];
153
+ function runForeground(entry, profileName, port, host, open) {
154
+ const args = [entry, '--profile', profileName, '--port', String(port), '--host', host];
94
155
  if (!open)
95
156
  args.push('--no-open');
96
157
  const result = spawnSync(process.execPath, args, { stdio: 'inherit' });
@@ -99,7 +160,9 @@ function runForeground(entry, port, host, open) {
99
160
  async function start(argv) {
100
161
  const options = parseStartOptions(argv);
101
162
  const anchor = installationAnchor();
102
- const paths = resolvePaths(anchor);
163
+ // The profile's own name and omissions come from the package that installed this
164
+ // command, which is a different tree position than the dependencies it resolves.
165
+ const paths = resolvePaths(declarationAnchor());
103
166
  // The profile installs its own dependency tree, which needs pnpm. Asking here rather
104
167
  // than failing inside the install turns a missing prerequisite into a decision the
105
168
  // consumer makes, and the install it can run is the one command that provides it.
@@ -141,26 +204,47 @@ async function start(argv) {
141
204
  }
142
205
  const created = ensureProfile(paths, installationRoot());
143
206
  console.log(created
144
- ? 'Created the ' + STANDALONE_PROFILE + ' profile at ' + paths.profileDirectory
145
- : 'Using the existing ' + STANDALONE_PROFILE + ' profile');
207
+ ? 'Created the ' + paths.profileName + ' profile at ' + paths.profileDirectory
208
+ : 'Using the existing ' + paths.profileName + ' profile');
146
209
  // The profile symlinks the consumer's packages, so a patch lands on the installed
147
210
  // copy the launcher loads. A reinstall restores the published bytes, which is why
148
211
  // this runs on every start rather than only when the profile was created.
149
212
  for (const label of applyProfileNpmPatches(paths.distributionDirectory, paths.profileDirectory)) {
150
213
  console.log('Applied the reviewed patch ' + label);
151
214
  }
215
+ // The capability interview runs once, when the profile is created: a deployment
216
+ // that already answered keeps its answers, and a re-run only reports them. Asking
217
+ // on every start would make a restart look like a first install.
218
+ const needsInterview = created || !existsSync(join(paths.home, CAPABILITY_RECORD));
219
+ const answers = needsInterview
220
+ ? options.capabilities === undefined
221
+ ? await interviewCapabilities(true)
222
+ : selectCapabilities(options.capabilities)
223
+ : undefined;
224
+ if (answers !== undefined) {
225
+ const ready = await installCapabilityServices(answers, paths.home);
226
+ writeCapabilityPatch(paths.profileDirectory, answers);
227
+ writeCapabilityEnvironment(paths.home, answers);
228
+ console.log(' Enabled: ' + (answers.enabled.length === 0 ? '(none)' : answers.enabled.join(', ')));
229
+ if (answers.enabled.length > 0)
230
+ console.log(' Services ready: ' + (ready.length === 0 ? '(none)' : ready.join(', ')));
231
+ if (answers.enabled.includes('exa') && answers.exaApiKey === undefined) {
232
+ console.log(' Exa: add EXA_API_KEY to ' + join(paths.home, CAPABILITY_ENV_FILE) + ' when you have a key.');
233
+ }
234
+ }
152
235
  const entry = launcherEntry(anchor);
153
236
  if (options.foreground)
154
- return runForeground(entry, options.port, options.host, options.open);
237
+ return runForeground(entry, paths.profileName, options.port, options.host, options.open);
155
238
  const existing = readState(paths.home);
156
239
  if (existing !== undefined) {
157
240
  console.log('Plus is already running at ' + existing.url);
158
241
  console.log('Stop it with: dsh-plus stop');
159
242
  return 0;
160
243
  }
161
- return startDetached(paths.home, entry, options);
244
+ return startDetached(paths, entry, options);
162
245
  }
163
- async function startDetached(home, entry, options) {
246
+ async function startDetached(paths, entry, options) {
247
+ const home = paths.home;
164
248
  const port = await choosePort(options.port, options.host);
165
249
  if (port === undefined) {
166
250
  console.error('No free port in the range ' + String(options.port) + '-' + String(options.port + 9) + '.');
@@ -169,9 +253,9 @@ async function startDetached(home, entry, options) {
169
253
  }
170
254
  if (port !== options.port)
171
255
  console.log('Port ' + String(options.port) + ' is in use; using ' + String(port) + '.');
172
- const logPath = join(stateDirectory(home), 'server.log');
173
- const env = { ...process.env, DSH_HOME: home };
174
- const args = [entry, '--profile', STANDALONE_PROFILE, '--port', String(port), '--host', options.host, '--no-open'];
256
+ const logPath = join(stateDirectory(paths.home), 'server.log');
257
+ const env = { ...process.env, DSH_HOME: paths.home };
258
+ const args = [entry, '--profile', paths.profileName, '--port', String(port), '--host', options.host, '--no-open'];
175
259
  const pid = spawnServer({ command: process.execPath, args, env, logPath });
176
260
  const url = 'http://' + options.host + ':' + String(port) + '/';
177
261
  const ready = await waitForServer(url, READY_TIMEOUT_MILLISECONDS);
@@ -282,6 +366,47 @@ async function update(argv) {
282
366
  }
283
367
  return 0;
284
368
  }
369
+ /**
370
+ * Write the profile's capability patch layer.
371
+ *
372
+ * The profile's loader merges this file, so enabling a capability is a data change
373
+ * rather than an edit to the deployment. Writing it on every configured run also
374
+ * turns a capability back off when the interview no longer selects it.
375
+ *
376
+ * @param profileDirectory - the profile whose layer is replaced.
377
+ * @param answers - the interview's answers.
378
+ */
379
+ function writeCapabilityPatch(profileDirectory, answers) {
380
+ const path = join(profileDirectory, CAPABILITY_PATCH_FILE);
381
+ // The profile has exactly one user layer, so the capability rows live in it. A file
382
+ // this command did not write belongs to the deployment, and replacing it would drop
383
+ // whatever the operator put there; keep it beside the new layer instead.
384
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
385
+ // The layer is rewritten whole, so a row this command does not own is carried over
386
+ // rather than dropped. Backing the file up is not a substitute: the operator's rows
387
+ // would stop applying until someone restored it by hand.
388
+ if (existing !== undefined && !existing.includes(CAPABILITY_MARKER)) {
389
+ writeFileSync(path + '.before-capabilities', existing);
390
+ console.log(' Kept the existing profile layer at ' + CAPABILITY_PATCH_FILE + '.before-capabilities');
391
+ }
392
+ writeFileSync(path, capabilityPatchLayer(answers, existing ?? ''));
393
+ }
394
+ /**
395
+ * Write the deployment's capability environment file.
396
+ *
397
+ * MinerU is switched on by the presence of its endpoint and Exa reads its key from
398
+ * the launch environment, so the answers reach those two through `$DSH_HOME/.env`
399
+ * rather than through the profile layer. The file is replaced whole on every
400
+ * configured start, which is also how a capability the user dropped stops applying.
401
+ *
402
+ * @param home - the deployment home whose env file the launcher reads.
403
+ * @param answers - the interview's answers.
404
+ */
405
+ function writeCapabilityEnvironment(home, answers) {
406
+ const path = join(home, CAPABILITY_ENV_FILE);
407
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
408
+ writeFileSync(path, capabilityEnvironment(answers, existing));
409
+ }
285
410
  /** Ask one yes/no question on the terminal. */
286
411
  function confirm(question) {
287
412
  return new Promise((resolveAnswer) => {
@@ -292,6 +417,140 @@ function confirm(question) {
292
417
  });
293
418
  });
294
419
  }
420
+ /**
421
+ * Report and change which optional capabilities this deployment has enabled.
422
+ *
423
+ * The interview runs once, at install time, and a deployment that answered it keeps its
424
+ * answers — so this is the only way to see what it chose or to change one answer later.
425
+ * The report is read from the profile layer and the env file rather than from the record,
426
+ * because those two are what the loader and the launch environment actually consume: a
427
+ * record deleted, or a layer edited by hand, would otherwise describe a deployment that
428
+ * does not exist.
429
+ *
430
+ * `--enable` and `--disable` edit only the rows this module owns. The profile layer is
431
+ * shared with rows an operator wrote themselves, so it is never replaced wholesale.
432
+ *
433
+ * @param argv - arguments after the command word.
434
+ * @returns the process exit code.
435
+ */
436
+ function capabilities(argv) {
437
+ const anchor = installationAnchor();
438
+ const paths = resolvePaths(anchor);
439
+ const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
440
+ const envPath = join(paths.home, CAPABILITY_ENV_FILE);
441
+ const patchText = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
442
+ const envText = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
443
+ const actual = detectEnabledCapabilities(patchText, envText);
444
+ const record = readCapabilityRecord(paths.home);
445
+ let enable = [];
446
+ let disable = [];
447
+ let asJson = false;
448
+ for (let index = 0; index < argv.length; index += 1) {
449
+ const token = argv[index];
450
+ if (token === '--json') {
451
+ asJson = true;
452
+ continue;
453
+ }
454
+ if (token === '--enable' || token === '--disable') {
455
+ const value = argv[index + 1];
456
+ const ids = (value ?? '').split(',').map(id => id.trim()).filter(id => id !== '');
457
+ if (value === undefined || ids.length === 0) {
458
+ console.error(token + ' requires a comma-separated list of capability ids');
459
+ return 1;
460
+ }
461
+ const unknown = ids.filter(id => !CAPABILITIES.some(capability => capability.id === id));
462
+ if (unknown.length > 0) {
463
+ console.error('unknown capability: ' + unknown.join(', '));
464
+ console.error('Known: ' + CAPABILITIES.map(capability => capability.id).join(', '));
465
+ return 1;
466
+ }
467
+ if (token === '--enable')
468
+ enable = [...new Set([...enable, ...ids])];
469
+ else
470
+ disable = [...new Set([...disable, ...ids])];
471
+ index += 1;
472
+ continue;
473
+ }
474
+ console.error('unknown option: ' + String(token));
475
+ return 1;
476
+ }
477
+ const conflicting = enable.filter(id => disable.includes(id));
478
+ if (conflicting.length > 0) {
479
+ console.error('cannot both enable and disable: ' + conflicting.join(', '));
480
+ return 1;
481
+ }
482
+ if (enable.length > 0 || disable.length > 0) {
483
+ const enabledNow = new Set(actual.mounted);
484
+ let nextPatch = patchText;
485
+ for (const id of enable) {
486
+ nextPatch = editCapabilityPatchLayer(nextPatch, id, true, record?.mineruEndpoint);
487
+ enabledNow.add(id);
488
+ }
489
+ for (const id of disable) {
490
+ nextPatch = editCapabilityPatchLayer(nextPatch, id, false);
491
+ enabledNow.delete(id);
492
+ }
493
+ if (!existsSync(patchPath) || !patchText.includes(CAPABILITY_MARKER)) {
494
+ // The layer belongs to the deployment, not to this command. Keep the previous text
495
+ // beside it so a structural mistake is recoverable without a backup.
496
+ const backup = patchPath + '.before-capabilities';
497
+ if (patchText !== '' && !existsSync(backup))
498
+ writeFileSync(backup, patchText);
499
+ }
500
+ writeFileSync(patchPath, nextPatch);
501
+ // The env file is rewritten from the answers so a disabled capability drops the
502
+ // assignment that turned it on, and an enabled one keeps a key already in the file.
503
+ const answers = {
504
+ enabled: [...enabledNow],
505
+ ...record?.mineruEndpoint === undefined ? {} : { mineruEndpoint: record.mineruEndpoint },
506
+ };
507
+ const nextEnv = capabilityEnvironment(answers, envText);
508
+ writeFileSync(envPath, nextEnv);
509
+ const after = detectEnabledCapabilities(nextPatch, nextEnv);
510
+ console.log('Updated ' + CAPABILITY_PATCH_FILE + ' and ' + CAPABILITY_ENV_FILE + '.');
511
+ console.log('Run dsh-plus start (or restart the server) to apply the change.');
512
+ return reportCapabilities(after, record, asJson);
513
+ }
514
+ return reportCapabilities(actual, record, asJson);
515
+ }
516
+ /**
517
+ * Print the capability report.
518
+ *
519
+ * @param actual - the capabilities found in the profile layer and env file.
520
+ * @param record - what the last configured run recorded, when a record exists.
521
+ * @param asJson - when true, print machine-readable JSON instead of the table.
522
+ * @returns the process exit code, always 0.
523
+ */
524
+ function reportCapabilities(actual, record, asJson) {
525
+ const rows = CAPABILITIES.map((capability) => {
526
+ const mounted = actual.mounted.includes(capability.id);
527
+ const recorded = record?.enabled.includes(capability.id);
528
+ return { id: capability.id, title: capability.title, enabled: mounted, recorded: recorded ?? null };
529
+ });
530
+ if (asJson) {
531
+ console.log(JSON.stringify({
532
+ capabilities: rows,
533
+ env: actual.env,
534
+ recordPresent: record !== undefined,
535
+ }, null, 2));
536
+ return 0;
537
+ }
538
+ console.log('Optional capabilities:');
539
+ for (const row of rows) {
540
+ const state = row.enabled ? 'enabled ' : 'disabled';
541
+ // A record that disagrees with the files is the one case worth calling out: the two
542
+ // are supposed to describe the same deployment.
543
+ const drift = row.recorded !== null && row.recorded !== row.enabled ? ' (record says ' + (row.recorded ? 'enabled' : 'disabled') + ')' : '';
544
+ console.log(' ' + (row.enabled ? 'ok ' : '-- ') + row.id.padEnd(14) + state + drift);
545
+ }
546
+ if (record === undefined) {
547
+ console.log('');
548
+ console.log('No ' + CAPABILITY_RECORD + ' yet; the next dsh-plus start will run the interview.');
549
+ }
550
+ console.log('');
551
+ console.log('Change with: dsh-plus capabilities --enable <id> | --disable <id>');
552
+ return 0;
553
+ }
295
554
  async function doctor() {
296
555
  const anchor = installationAnchor();
297
556
  const paths = resolvePaths(anchor);
@@ -307,6 +566,21 @@ async function doctor() {
307
566
  check(existsSync(join(paths.profileDirectory, 'package.json')), 'profile at ' + paths.profileDirectory + ' (dsh-plus start creates it)');
308
567
  const free = await portAvailable(DEFAULT_PORT);
309
568
  check(true, 'port ' + String(DEFAULT_PORT) + (free ? ' is free' : ' is in use; start will choose the next free one'));
569
+ // The capability record and the files that actually enable a capability are written by
570
+ // different runs, so they can disagree: a layer edited by hand, or a record deleted.
571
+ // Reporting the drift here is what turns the record from a write-only file into
572
+ // something a user can trust.
573
+ const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
574
+ const envPath = join(paths.home, CAPABILITY_ENV_FILE);
575
+ const actual = detectEnabledCapabilities(existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '', existsSync(envPath) ? readFileSync(envPath, 'utf8') : '');
576
+ const record = readCapabilityRecord(paths.home);
577
+ const drifted = record === undefined
578
+ ? []
579
+ : CAPABILITIES.filter(capability => record.enabled.includes(capability.id) !== actual.mounted.includes(capability.id));
580
+ check(drifted.length === 0, drifted.length === 0
581
+ ? 'capabilities: ' + (actual.mounted.length === 0 ? '(none enabled)' : actual.mounted.join(', '))
582
+ : 'capabilities disagree with ' + CAPABILITY_RECORD + ': ' + drifted.map(capability => capability.id).join(', ')
583
+ + ' (run dsh-plus capabilities)');
310
584
  console.log(failures === 0 ? 'No problems found.' : String(failures) + ' problem(s) found.');
311
585
  return failures === 0 ? 0 : 1;
312
586
  }
@@ -314,11 +588,12 @@ const USAGE = [
314
588
  'Usage: dsh-plus <command> [options]',
315
589
  '',
316
590
  'Commands:',
317
- ' start start the server (first run creates the profile)',
318
- ' stop stop the server',
319
- ' status report whether the server is running',
320
- ' update move to a newer distribution release',
321
- ' doctor check this installation',
591
+ ' start start the server (first run creates the profile)',
592
+ ' stop stop the server',
593
+ ' status report whether the server is running',
594
+ ' capabilities report or change the optional capabilities',
595
+ ' update move to a newer distribution release',
596
+ ' doctor check this installation',
322
597
  '',
323
598
  'start options:',
324
599
  ' --port <n> port to prefer (default ' + String(DEFAULT_PORT) + '; a taken port moves to the next free one)',
@@ -326,6 +601,11 @@ const USAGE = [
326
601
  ' --no-open do not open a browser',
327
602
  ' --foreground run in this terminal instead of in the background',
328
603
  '',
604
+ 'capabilities options:',
605
+ ' --enable <ids> enable the comma-separated capability ids',
606
+ ' --disable <ids> disable the comma-separated capability ids',
607
+ ' --json print the report as JSON',
608
+ '',
329
609
  'update options:',
330
610
  ' --check report the available release without installing it',
331
611
  ' --yes install without asking',
@@ -348,6 +628,8 @@ export async function runStandaloneCli(argv) {
348
628
  return stop();
349
629
  if (command === 'status')
350
630
  return status();
631
+ if (command === 'capabilities')
632
+ return capabilities(rest);
351
633
  if (command === 'update')
352
634
  return await update(rest);
353
635
  if (command === 'doctor')
@@ -8,12 +8,56 @@
8
8
  * the launcher mounts exactly the bundles the profile names and nothing expands a
9
9
  * bundle's own list.
10
10
  */
11
- /** Profile name a standalone installation owns. */
11
+ /** Profile name a standalone installation owns when its package declares none. */
12
12
  export declare const STANDALONE_PROFILE = "plus";
13
+ /**
14
+ * Read the profile name the installing package declares.
15
+ *
16
+ * A standalone package describes the profile it materializes, so a reduced variant can
17
+ * own a differently named profile beside the full one instead of overwriting it. The
18
+ * declaration travels with the package because npm resolves the tree long before any
19
+ * command of ours runs, and the distribution cannot name a profile for a package it
20
+ * does not own.
21
+ *
22
+ * @param anchor - path inside the installing package's tree.
23
+ * @param fallback - profile name to use when the declaration is absent.
24
+ * @returns the declared profile name, or the fallback.
25
+ */
26
+ export declare function resolveStandaloneProfile(anchor: string, fallback?: string): string;
27
+ /** What the installing package declares about the deployment it owns. */
28
+ export interface StandaloneDeclaration {
29
+ /** Profile name the launcher materializes. */
30
+ readonly profileName: string;
31
+ /**
32
+ * Capabilities the deployment must not install, as package name to override spec.
33
+ *
34
+ * npm substitutes rather than deletes, so each entry names the placeholder the
35
+ * capability is replaced with. The profile workspace applies these as overrides,
36
+ * which is where pnpm reads them.
37
+ */
38
+ readonly omittedPackages: Readonly<Record<string, string>>;
39
+ }
40
+ /**
41
+ * Read the deployment facts the installing package declares.
42
+ *
43
+ * A standalone package describes the profile it materializes and the capabilities it
44
+ * omits, so a reduced variant owns a differently named profile and a reduced install
45
+ * without the distribution naming either. The declaration travels with the package
46
+ * because npm resolves the tree long before any command of ours runs.
47
+ *
48
+ * @param anchor - path inside the installing package's tree.
49
+ * @param fallback - profile name to use when the declaration is absent.
50
+ * @returns the declared profile name and omitted capabilities.
51
+ */
52
+ export declare function readStandaloneDeclaration(anchor: string, fallback?: string): StandaloneDeclaration;
13
53
  /** Resolved locations for one standalone installation. */
14
54
  export interface StandalonePaths {
15
55
  /** DSH home holding profiles, credentials, and session data. */
16
56
  readonly home: string;
57
+ /** Profile name the launcher boots, as the installing package declares it. */
58
+ readonly profileName: string;
59
+ /** Capabilities the deployment omits, as package name to override spec. */
60
+ readonly omittedPackages: Readonly<Record<string, string>>;
17
61
  /** Profile directory the launcher boots. */
18
62
  readonly profileDirectory: string;
19
63
  /** Installed Plus distribution directory. */
@@ -14,8 +14,79 @@ import { createRequire } from 'node:module';
14
14
  import { homedir } from 'node:os';
15
15
  import { dirname, join, posix, resolve, win32 } from 'node:path';
16
16
  import { parseDocument } from 'yaml';
17
- /** Profile name a standalone installation owns. */
17
+ /** Profile name a standalone installation owns when its package declares none. */
18
18
  export const STANDALONE_PROFILE = 'plus';
19
+ /**
20
+ * Read the profile name the installing package declares.
21
+ *
22
+ * A standalone package describes the profile it materializes, so a reduced variant can
23
+ * own a differently named profile beside the full one instead of overwriting it. The
24
+ * declaration travels with the package because npm resolves the tree long before any
25
+ * command of ours runs, and the distribution cannot name a profile for a package it
26
+ * does not own.
27
+ *
28
+ * @param anchor - path inside the installing package's tree.
29
+ * @param fallback - profile name to use when the declaration is absent.
30
+ * @returns the declared profile name, or the fallback.
31
+ */
32
+ export function resolveStandaloneProfile(anchor, fallback = STANDALONE_PROFILE) {
33
+ return readStandaloneDeclaration(anchor, fallback).profileName;
34
+ }
35
+ /**
36
+ * Read the deployment facts the installing package declares.
37
+ *
38
+ * A standalone package describes the profile it materializes and the capabilities it
39
+ * omits, so a reduced variant owns a differently named profile and a reduced install
40
+ * without the distribution naming either. The declaration travels with the package
41
+ * because npm resolves the tree long before any command of ours runs.
42
+ *
43
+ * @param anchor - path inside the installing package's tree.
44
+ * @param fallback - profile name to use when the declaration is absent.
45
+ * @returns the declared profile name and omitted capabilities.
46
+ */
47
+ export function readStandaloneDeclaration(anchor, fallback = STANDALONE_PROFILE) {
48
+ // The anchor is the CLI file; walk out to the package that declares the forwarder.
49
+ let current = resolve(anchor);
50
+ for (;;) {
51
+ const manifestPath = join(current, 'package.json');
52
+ const declared = readStandaloneFacts(manifestPath);
53
+ if (declared !== undefined)
54
+ return declared;
55
+ const parent = dirname(current);
56
+ if (parent === current)
57
+ return { profileName: fallback, omittedPackages: {} };
58
+ current = parent;
59
+ }
60
+ }
61
+ /** Read one manifest's standalone declaration, or undefined when it carries none. */
62
+ function readStandaloneFacts(manifestPath) {
63
+ if (!existsSync(manifestPath))
64
+ return undefined;
65
+ let standalone;
66
+ try {
67
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
68
+ standalone = manifest.dshPlusStandalone;
69
+ }
70
+ catch {
71
+ // A package whose manifest cannot be read declares nothing; the caller keeps walking
72
+ // because the declaring package may sit above it.
73
+ return undefined;
74
+ }
75
+ const profile = standalone?.profile;
76
+ if (standalone === undefined || typeof profile !== 'string' || profile === '')
77
+ return undefined;
78
+ const rawOmitted = standalone.omittedPackages;
79
+ const omittedPackages = {};
80
+ if (rawOmitted !== undefined && typeof rawOmitted === 'object' && !Array.isArray(rawOmitted)) {
81
+ for (const [name, spec] of Object.entries(rawOmitted)) {
82
+ if (typeof spec !== 'string' || spec === '') {
83
+ throw new Error('dshPlusStandalone.omittedPackages.' + name + ' must be a non-empty string');
84
+ }
85
+ omittedPackages[name] = spec;
86
+ }
87
+ }
88
+ return { profileName: profile, omittedPackages };
89
+ }
19
90
  function requireRecord(value, label) {
20
91
  if (value === null || typeof value !== 'object' || Array.isArray(value))
21
92
  throw new Error(label + ' must be an object');
@@ -336,9 +407,12 @@ function linkBundle(consumerModules, name, source) {
336
407
  /** Resolve every path a command needs, without creating anything. */
337
408
  export function resolvePaths(anchor, env = process.env) {
338
409
  const home = resolveHome(env);
410
+ const declaration = readStandaloneDeclaration(anchor);
339
411
  return {
340
412
  home,
341
- profileDirectory: join(home, 'profiles', STANDALONE_PROFILE),
413
+ profileName: declaration.profileName,
414
+ omittedPackages: declaration.omittedPackages,
415
+ profileDirectory: join(home, 'profiles', declaration.profileName),
342
416
  distributionDirectory: resolveDistributionDirectory(anchor),
343
417
  };
344
418
  }
@@ -366,7 +440,7 @@ export function ensureProfile(paths, consumerDirectory) {
366
440
  // script allowlist — and a distribution release changes them. Rewriting on every
367
441
  // start is what lets an upgraded installation receive the new values; a profile
368
442
  // written once keeps whatever its own release decided and can never be corrected.
369
- writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds);
443
+ writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds, paths.omittedPackages);
370
444
  installProfilePackages(paths, consumerDirectory);
371
445
  return false;
372
446
  }
@@ -392,7 +466,7 @@ export function ensureProfile(paths, consumerDirectory) {
392
466
  };
393
467
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
394
468
  // The overrides must reach the workspace before the install that reads them.
395
- writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds);
469
+ writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds, paths.omittedPackages);
396
470
  installProfilePackages(paths, consumerDirectory);
397
471
  return true;
398
472
  }
@@ -406,7 +480,7 @@ export function ensureProfile(paths, consumerDirectory) {
406
480
  * @param profileDirectory - the standalone profile directory.
407
481
  * @param overrides - official package name to published replacement spec.
408
482
  */
409
- function writeProfileOverrides(profileDirectory, overrides, allowBuilds) {
483
+ function writeProfileOverrides(profileDirectory, overrides, allowBuilds, omittedPackages) {
410
484
  const workspacePath = join(profileDirectory, 'pnpm-workspace.yaml');
411
485
  const document = parseDocument(existsSync(workspacePath) ? readFileSync(workspacePath, 'utf8') : '');
412
486
  const [documentError] = document.errors;
@@ -416,6 +490,11 @@ function writeProfileOverrides(profileDirectory, overrides, allowBuilds) {
416
490
  document.set('packages', ['.']);
417
491
  for (const [name, spec] of Object.entries(overrides))
418
492
  document.setIn(['overrides', name], spec);
493
+ // A capability the deployment omits is substituted rather than deleted, because npm's
494
+ // override has no removal form. Writing it here keeps the omission in the one place
495
+ // pnpm reads, so an install never receives the capability the variant excluded.
496
+ for (const [name, spec] of Object.entries(omittedPackages))
497
+ document.setIn(['overrides', name], spec);
419
498
  // pnpm refuses an install whose packages want to run build scripts until each is
420
499
  // decided, so the distribution's reviewed decisions travel with the install rather
421
500
  // than waiting for an interactive approval no start can offer.