@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.
- package/cordis.patch.yml +62 -39
- package/lib/bin.js +866 -21
- package/lib/types/standalone-capabilities.d.ts +139 -0
- package/lib/types/standalone-capabilities.js +688 -0
- package/lib/types/standalone-cli.js +300 -18
- package/lib/types/standalone-profile.d.ts +45 -1
- package/lib/types/standalone-profile.js +84 -5
- package/package.json +115 -80
|
@@ -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',
|
|
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
|
-
|
|
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 ' +
|
|
145
|
-
: 'Using the existing ' +
|
|
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
|
|
244
|
+
return startDetached(paths, entry, options);
|
|
162
245
|
}
|
|
163
|
-
async function startDetached(
|
|
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',
|
|
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
|
|
318
|
-
' stop
|
|
319
|
-
' status
|
|
320
|
-
'
|
|
321
|
-
'
|
|
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
|
-
|
|
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.
|