@sparkelf/dsh-plus 0.2.4-alpha.47 → 0.2.5-alpha.49
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/lib/bin.js +780 -326
- package/lib/types/bin.js +1 -1
- package/lib/types/capabilities.d.ts +139 -0
- package/lib/types/capabilities.js +688 -0
- package/lib/types/cli.d.ts +17 -0
- package/lib/types/cli.js +913 -0
- package/lib/types/manifest.d.ts +137 -0
- package/lib/types/manifest.js +398 -0
- package/lib/types/profile.d.ts +218 -0
- package/lib/types/profile.js +670 -0
- package/lib/types/server.d.ts +70 -0
- package/lib/types/server.js +158 -0
- package/lib/types/standalone-cli.js +274 -93
- package/lib/types/standalone-profile.d.ts +54 -17
- package/lib/types/standalone-profile.js +273 -247
- package/package.json +169 -124
package/lib/types/cli.js
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dsh-plus` command line for a standalone installation.
|
|
3
|
+
*
|
|
4
|
+
* The commands exist because the launcher underneath is a developer surface: it
|
|
5
|
+
* refuses an existing profile, reports a taken port as a module-resolution stack
|
|
6
|
+
* trace, and exits with the terminal. This dispatcher supplies the missing product
|
|
7
|
+
* layer — a profile created on first start, a free port chosen without asking, a
|
|
8
|
+
* server that survives the terminal, and one place to read the URL.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { newerVersion } from "./registry-versions.js";
|
|
16
|
+
import { assertRedirectsPublished, projectEdition, readDistributionFacts, writeProfileProjection, } from "./manifest.js";
|
|
17
|
+
import { CAPABILITIES, CAPABILITY_ENV_FILE, CAPABILITY_MARKER, CAPABILITY_RECORD, DEFAULT_MINERU_ENDPOINT, capabilityEnvironment, capabilityPatchLayer, detectEnabledCapabilities, editCapabilityPatchLayer, installCapabilityServices, interviewCapabilities, readCapabilityRecord, } from "./capabilities.js";
|
|
18
|
+
import { DEFAULT_PORT, STOP_GRACE_MILLISECONDS, choosePort, clearState, isRunning, portAvailable, readState, spawnServer, stateDirectory, waitForAuthenticatedUrl, waitForServer, writeState, } from "./server.js";
|
|
19
|
+
import { DEFAULT_PROFILE, applyProfileNpmPatches, ensureProfile, pnpmAvailable, pnpmInstallCommand, pnpmInstallCommands, registryOrder, readDistributionProfile, resolveDistributionDirectory, resolveInvocationAnchor, resolvePaths, } from "./profile.js";
|
|
20
|
+
/** Edition the offline build projects when the caller names none. */
|
|
21
|
+
const DEFAULT_EDITION = 'intranet-team';
|
|
22
|
+
/** Milliseconds a start waits for the server to answer before reporting failure. */
|
|
23
|
+
const READY_TIMEOUT_MILLISECONDS = 90_000;
|
|
24
|
+
/** Profile patch file the capability interview rewrites (the profile's user layer). */
|
|
25
|
+
const CAPABILITY_PATCH_FILE = 'cordis.patch.yml';
|
|
26
|
+
function parseStartOptions(argv) {
|
|
27
|
+
let port = DEFAULT_PORT;
|
|
28
|
+
let host = '127.0.0.1';
|
|
29
|
+
let open = true;
|
|
30
|
+
let foreground = false;
|
|
31
|
+
let capabilities;
|
|
32
|
+
let edition;
|
|
33
|
+
const exclude = [];
|
|
34
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
35
|
+
const token = argv[index];
|
|
36
|
+
if (token === '--port' || token === '-p') {
|
|
37
|
+
const value = argv[index + 1];
|
|
38
|
+
if (value === undefined || !/^\d+$/u.test(value))
|
|
39
|
+
throw new Error('--port requires a number');
|
|
40
|
+
port = Number(value);
|
|
41
|
+
index += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (token === '--host') {
|
|
45
|
+
const value = argv[index + 1];
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
throw new Error('--host requires a value');
|
|
48
|
+
host = value;
|
|
49
|
+
index += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (token === '--no-open') {
|
|
53
|
+
open = false;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (token === '--foreground') {
|
|
57
|
+
foreground = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (token === '--edition') {
|
|
61
|
+
const value = argv[index + 1];
|
|
62
|
+
if (value === undefined || value === '')
|
|
63
|
+
throw new Error('--edition requires a value');
|
|
64
|
+
edition = value;
|
|
65
|
+
index += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (token === '--exclude') {
|
|
69
|
+
const value = argv[index + 1];
|
|
70
|
+
if (value === undefined || value === '')
|
|
71
|
+
throw new Error('--exclude requires a package name');
|
|
72
|
+
exclude.push(value);
|
|
73
|
+
index += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (token === '--capabilities') {
|
|
77
|
+
const value = argv[index + 1];
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
throw new Error('--capabilities requires a comma-separated list, or an empty string for none');
|
|
80
|
+
const stated = value === '' ? [] : value.split(',').map(entry => entry.trim()).filter(entry => entry !== '');
|
|
81
|
+
// A stated selection is the only way an unattended install chooses capabilities, so
|
|
82
|
+
// a name this release does not offer fails here: the deployment would otherwise
|
|
83
|
+
// start without a capability its caller believes it selected. Validating at parse
|
|
84
|
+
// time also makes the failure independent of what is installed.
|
|
85
|
+
const offered = new Set(CAPABILITIES.map(capability => capability.id));
|
|
86
|
+
for (const id of stated) {
|
|
87
|
+
if (!offered.has(id)) {
|
|
88
|
+
throw new Error('unknown capability "' + id + '"; this release offers ' + [...offered].join(', '));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
capabilities = stated;
|
|
92
|
+
index += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
throw new Error('unknown option: ' + String(token));
|
|
96
|
+
}
|
|
97
|
+
return { port, host, open, foreground, capabilities, edition, exclude };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a stated capability selection.
|
|
101
|
+
*
|
|
102
|
+
* An unattended install states what it wants instead of answering the interview, so a
|
|
103
|
+
* name it does not offer has to fail here rather than silently enable nothing: the
|
|
104
|
+
* deployment would otherwise start with a capability the caller believes it selected.
|
|
105
|
+
*
|
|
106
|
+
* @param ids - capability ids the caller selected.
|
|
107
|
+
* @returns the answers the interview would have returned.
|
|
108
|
+
*/
|
|
109
|
+
function selectCapabilities(ids) {
|
|
110
|
+
const selected = [...new Set(ids)];
|
|
111
|
+
// MinerU is started from an endpoint the interview collects; an unattended install
|
|
112
|
+
// gets the documented default rather than an unset one.
|
|
113
|
+
return {
|
|
114
|
+
enabled: selected,
|
|
115
|
+
...selected.includes('mineru') ? { mineruEndpoint: DEFAULT_MINERU_ENDPOINT } : {},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Where the installation that owns this command keeps its packages.
|
|
120
|
+
*
|
|
121
|
+
* A global install places the command in a shared prefix and a local install places
|
|
122
|
+
* it in a project; neither has anything to do with the directory the user happens to
|
|
123
|
+
* be in. The search therefore starts at this module own file and climbs to the tree
|
|
124
|
+
* npm laid out around it.
|
|
125
|
+
*
|
|
126
|
+
* The test is the launcher, not the distribution: inside a workspace the package
|
|
127
|
+
* resolves its own name, so looking for the distribution would stop at the package
|
|
128
|
+
* rather than at the tree that owns every dependency.
|
|
129
|
+
*
|
|
130
|
+
* @returns absolute path to the installation root.
|
|
131
|
+
*/
|
|
132
|
+
function installationRoot() {
|
|
133
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
134
|
+
let current = here;
|
|
135
|
+
for (;;) {
|
|
136
|
+
if (existsSync(join(current, 'node_modules', '@deepseek-ai', 'dsh')))
|
|
137
|
+
return current;
|
|
138
|
+
const parent = dirname(current);
|
|
139
|
+
// A tree npm did not lay out has no such ancestor; the module own directory
|
|
140
|
+
// keeps the failure message pointing at the installation that was searched.
|
|
141
|
+
if (parent === current)
|
|
142
|
+
return here;
|
|
143
|
+
current = parent;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The package.json this installation resolves its dependencies from. */
|
|
147
|
+
function installationAnchor() {
|
|
148
|
+
return join(installationRoot(), 'package.json');
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Path to the installed command's own file, which is where a declaration lookup starts.
|
|
152
|
+
*
|
|
153
|
+
* The declaration belongs to the package the user installed, and that package is a
|
|
154
|
+
* sibling of this module rather than an ancestor: a variant's forwarder imports this
|
|
155
|
+
* CLI in-process, so `import.meta.url` names `@sparkelf/dsh-plus` while the installed
|
|
156
|
+
* command is the variant. `process.argv[1]` is the entry that was actually invoked,
|
|
157
|
+
* which is the package whose declaration applies.
|
|
158
|
+
*
|
|
159
|
+
* Walking out from the installation root cannot reach it either: that root is the
|
|
160
|
+
* project the user ran the command in, so every variant would fall back to the full
|
|
161
|
+
* profile and keep the capabilities it excluded.
|
|
162
|
+
*
|
|
163
|
+
* @returns absolute path to the invoked command, or this module when argv carries none.
|
|
164
|
+
*/
|
|
165
|
+
function declarationAnchor() {
|
|
166
|
+
// A global install links the command into a bin directory; the link target is what owning
|
|
167
|
+
// package resolution must start from.
|
|
168
|
+
return resolveInvocationAnchor(process.argv[1], fileURLToPath(import.meta.url));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The launcher entry this installation must drive.
|
|
172
|
+
*
|
|
173
|
+
* The profile owns the runtime tree, so the launcher is resolved from the profile rather than
|
|
174
|
+
* from the directory that installed the command: a global distribution has no launcher of its
|
|
175
|
+
* own, and one resolved from the consumer would be a second copy of the process the profile
|
|
176
|
+
* mounts.
|
|
177
|
+
*
|
|
178
|
+
* @param profileDirectory - profile whose node_modules carries the launcher.
|
|
179
|
+
* @returns absolute path to the launcher entry.
|
|
180
|
+
*/
|
|
181
|
+
function launcherEntry(profileDirectory) {
|
|
182
|
+
return createRequire(join(profileDirectory, 'package.json')).resolve('@deepseek-ai/dsh/lib/bin.js');
|
|
183
|
+
}
|
|
184
|
+
/** Run the server in this process, inheriting stdio. */
|
|
185
|
+
function runForeground(entry, profileName, port, host, open) {
|
|
186
|
+
const args = [entry, '--profile', profileName, '--port', String(port), '--host', host];
|
|
187
|
+
if (!open)
|
|
188
|
+
args.push('--no-open');
|
|
189
|
+
const result = spawnSync(process.execPath, args, { stdio: 'inherit' });
|
|
190
|
+
return result.status ?? 1;
|
|
191
|
+
}
|
|
192
|
+
async function start(argv) {
|
|
193
|
+
const options = parseStartOptions(argv);
|
|
194
|
+
// The profile's own name and omissions come from the package that installed this
|
|
195
|
+
// command, which is a different tree position than the dependencies it resolves.
|
|
196
|
+
const paths = resolvePaths(declarationAnchor(), process.env, options.edition);
|
|
197
|
+
// The profile installs its own dependency tree, which needs pnpm. Asking here rather
|
|
198
|
+
// than failing inside the install turns a missing prerequisite into a decision the
|
|
199
|
+
// consumer makes, and the install it can run is the one command that provides it.
|
|
200
|
+
if (!pnpmAvailable()) {
|
|
201
|
+
const command = pnpmInstallCommand();
|
|
202
|
+
console.log('pnpm is required to install the ' + DEFAULT_PROFILE + ' profile, and was not found.');
|
|
203
|
+
console.log('Install it with: ' + command);
|
|
204
|
+
if (!await confirm('Install pnpm now?')) {
|
|
205
|
+
console.log('Install pnpm and run dsh-plus start again.');
|
|
206
|
+
return 1;
|
|
207
|
+
}
|
|
208
|
+
// Try each installer in turn: Corepack is absent on an installation that disabled it,
|
|
209
|
+
// and npm succeeds there. A failure reports the commands rather than a stack trace.
|
|
210
|
+
let installed = false;
|
|
211
|
+
const preferred = registryOrder()[0];
|
|
212
|
+
for (const candidate of pnpmInstallCommands()) {
|
|
213
|
+
// Corepack reads its registry from the environment; npm takes a flag, which the
|
|
214
|
+
// command already carries. Setting it for both keeps the download on the mirror.
|
|
215
|
+
const attempt = spawnSync(candidate, {
|
|
216
|
+
stdio: 'inherit',
|
|
217
|
+
shell: true,
|
|
218
|
+
env: preferred === undefined
|
|
219
|
+
? process.env
|
|
220
|
+
: { ...process.env, COREPACK_NPM_REGISTRY: preferred },
|
|
221
|
+
});
|
|
222
|
+
if (attempt.status === 0 && pnpmAvailable()) {
|
|
223
|
+
installed = true;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!installed) {
|
|
228
|
+
console.log('Could not install pnpm automatically.');
|
|
229
|
+
console.log('Run one of these, then run dsh-plus start again:');
|
|
230
|
+
for (const candidate of pnpmInstallCommands())
|
|
231
|
+
console.log(' ' + candidate);
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
console.log('pnpm installed.');
|
|
235
|
+
}
|
|
236
|
+
const created = ensureProfile(paths, options.edition, options.exclude);
|
|
237
|
+
console.log(created
|
|
238
|
+
? 'Created the ' + paths.profileName + ' profile at ' + paths.profileDirectory
|
|
239
|
+
: 'Using the existing ' + paths.profileName + ' profile');
|
|
240
|
+
// The profile symlinks the consumer's packages, so a patch lands on the installed
|
|
241
|
+
// copy the launcher loads. A reinstall restores the published bytes, which is why
|
|
242
|
+
// this runs on every start rather than only when the profile was created.
|
|
243
|
+
for (const label of applyProfileNpmPatches(paths.distributionDirectory, paths.profileDirectory)) {
|
|
244
|
+
console.log('Applied the reviewed patch ' + label);
|
|
245
|
+
}
|
|
246
|
+
// The capability interview runs once, when the profile is created: a deployment
|
|
247
|
+
// that already answered keeps its answers, and a re-run only reports them. Asking
|
|
248
|
+
// on every start would make a restart look like a first install.
|
|
249
|
+
const needsInterview = created || !existsSync(join(paths.home, CAPABILITY_RECORD));
|
|
250
|
+
const answers = needsInterview
|
|
251
|
+
? options.capabilities === undefined
|
|
252
|
+
? await interviewCapabilities(true)
|
|
253
|
+
: selectCapabilities(options.capabilities)
|
|
254
|
+
: undefined;
|
|
255
|
+
if (answers !== undefined) {
|
|
256
|
+
const ready = installCapabilityServices(answers, paths.home);
|
|
257
|
+
writeCapabilityPatch(paths.profileDirectory, answers);
|
|
258
|
+
writeCapabilityEnvironment(paths.home, answers);
|
|
259
|
+
console.log(' Enabled: ' + (answers.enabled.length === 0 ? '(none)' : answers.enabled.join(', ')));
|
|
260
|
+
if (answers.enabled.length > 0)
|
|
261
|
+
console.log(' Services ready: ' + (ready.length === 0 ? '(none)' : ready.join(', ')));
|
|
262
|
+
if (answers.enabled.includes('exa') && answers.exaApiKey === undefined) {
|
|
263
|
+
console.log(' Exa: add EXA_API_KEY to ' + join(paths.home, CAPABILITY_ENV_FILE) + ' when you have a key.');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const entry = launcherEntry(paths.profileDirectory);
|
|
267
|
+
if (options.foreground)
|
|
268
|
+
return runForeground(entry, paths.profileName, options.port, options.host, options.open);
|
|
269
|
+
const existing = readState(paths.home);
|
|
270
|
+
if (existing !== undefined) {
|
|
271
|
+
console.log('Plus is already running at ' + existing.url);
|
|
272
|
+
console.log('Stop it with: dsh-plus stop');
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
return startDetached(paths, entry, options);
|
|
276
|
+
}
|
|
277
|
+
async function startDetached(paths, entry, options) {
|
|
278
|
+
const home = paths.home;
|
|
279
|
+
const port = await choosePort(options.port, options.host);
|
|
280
|
+
if (port === undefined) {
|
|
281
|
+
console.error('No free port in the range ' + String(options.port) + '-' + String(options.port + 9) + '.');
|
|
282
|
+
console.error('Pass --port with a free port.');
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
if (port !== options.port)
|
|
286
|
+
console.log('Port ' + String(options.port) + ' is in use; using ' + String(port) + '.');
|
|
287
|
+
const logPath = join(stateDirectory(paths.home), 'server.log');
|
|
288
|
+
const env = { ...process.env, DSH_HOME: paths.home };
|
|
289
|
+
const args = [entry, '--profile', paths.profileName, '--port', String(port), '--host', options.host, '--no-open'];
|
|
290
|
+
const pid = spawnServer({ command: process.execPath, args, env, logPath });
|
|
291
|
+
const url = 'http://' + options.host + ':' + String(port) + '/';
|
|
292
|
+
const ready = await waitForServer(url, READY_TIMEOUT_MILLISECONDS);
|
|
293
|
+
if (!ready) {
|
|
294
|
+
console.error('The server did not answer within ' + String(READY_TIMEOUT_MILLISECONDS / 1000) + 's.');
|
|
295
|
+
console.error('Read ' + logPath + ' for the startup error.');
|
|
296
|
+
if (isRunning(pid))
|
|
297
|
+
process.kill(pid, 'SIGTERM');
|
|
298
|
+
return 1;
|
|
299
|
+
}
|
|
300
|
+
// The launcher prints the URL carrying the process launch token, and the server
|
|
301
|
+
// refuses every request without the cookie that token mints. Reporting the bare
|
|
302
|
+
// address sends the user to a 401 that tells them to reopen the launcher's URL,
|
|
303
|
+
// which lives only in the log this command points at.
|
|
304
|
+
const opened = await waitForAuthenticatedUrl(logPath, READY_TIMEOUT_MILLISECONDS) ?? url;
|
|
305
|
+
writeState(home, { pid, port, url: opened });
|
|
306
|
+
console.log('Plus is running at ' + opened);
|
|
307
|
+
console.log('Logs: ' + logPath);
|
|
308
|
+
console.log('Stop it with: dsh-plus stop');
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
async function stop() {
|
|
312
|
+
const anchor = installationAnchor();
|
|
313
|
+
const { home } = resolvePaths(anchor);
|
|
314
|
+
const state = readState(home);
|
|
315
|
+
if (state === undefined) {
|
|
316
|
+
clearState(home);
|
|
317
|
+
console.log('Plus is not running.');
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
process.kill(state.pid, 'SIGTERM');
|
|
321
|
+
const deadline = Date.now() + STOP_GRACE_MILLISECONDS;
|
|
322
|
+
while (Date.now() < deadline && isRunning(state.pid)) {
|
|
323
|
+
await new Promise((resolveDelay) => { setTimeout(resolveDelay, 200); });
|
|
324
|
+
}
|
|
325
|
+
if (isRunning(state.pid)) {
|
|
326
|
+
console.log('The server did not exit in ' + String(STOP_GRACE_MILLISECONDS / 1000) + 's; forcing it.');
|
|
327
|
+
process.kill(state.pid, 'SIGKILL');
|
|
328
|
+
}
|
|
329
|
+
clearState(home);
|
|
330
|
+
console.log('Plus has stopped.');
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
function status() {
|
|
334
|
+
const anchor = installationAnchor();
|
|
335
|
+
const paths = resolvePaths(anchor);
|
|
336
|
+
const state = readState(paths.home);
|
|
337
|
+
if (state === undefined) {
|
|
338
|
+
console.log('Plus is not running.');
|
|
339
|
+
console.log('Start it with: dsh-plus start');
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
console.log('Plus is running at ' + state.url);
|
|
343
|
+
console.log('Process ' + String(state.pid) + ' on port ' + String(state.port));
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Move the installation to a newer published release.
|
|
348
|
+
*
|
|
349
|
+
* The profile pins the distribution exactly, so the new version is written into the
|
|
350
|
+
* profile manifest and reinstalled there. A running server keeps the version it
|
|
351
|
+
* started with, so the command reports that a restart is what makes the change take
|
|
352
|
+
* effect rather than pretending the running process changed underneath the user.
|
|
353
|
+
*/
|
|
354
|
+
async function update(argv) {
|
|
355
|
+
const checkOnly = argv.includes('--check');
|
|
356
|
+
const assumeYes = argv.includes('--yes');
|
|
357
|
+
const anchor = installationAnchor();
|
|
358
|
+
const paths = resolvePaths(anchor);
|
|
359
|
+
const distribution = readDistributionProfile(paths.distributionDirectory);
|
|
360
|
+
const installed = distribution.version;
|
|
361
|
+
const newer = newerVersion(distribution.name, installed);
|
|
362
|
+
if (newer === undefined) {
|
|
363
|
+
console.log('Plus ' + installed + ' is the newest published release.');
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
console.log('Installed: ' + installed);
|
|
367
|
+
console.log('Available: ' + newer.version + (newer.publishedAt === undefined ? '' : ' (' + newer.publishedAt + ')'));
|
|
368
|
+
if (checkOnly)
|
|
369
|
+
return 0;
|
|
370
|
+
if (!assumeYes && !(await confirm('Install ' + newer.version + '?'))) {
|
|
371
|
+
console.log('Nothing changed.');
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
const profilePath = join(paths.profileDirectory, 'package.json');
|
|
375
|
+
const profile = JSON.parse(readFileSync(profilePath, 'utf8'));
|
|
376
|
+
const dependencies = profile.dependencies;
|
|
377
|
+
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
|
|
378
|
+
throw new Error('the profile manifest has no dependencies to update');
|
|
379
|
+
}
|
|
380
|
+
;
|
|
381
|
+
dependencies['@sparkelf/dsh-plus'] = newer.version;
|
|
382
|
+
writeFileSync(profilePath, JSON.stringify(profile, null, 2) + '\n');
|
|
383
|
+
console.log('Updated the profile to ' + newer.version + '; installing...');
|
|
384
|
+
const result = spawnSync('npm', ['install', '--no-audit', '--no-fund'], {
|
|
385
|
+
cwd: paths.profileDirectory,
|
|
386
|
+
stdio: 'inherit',
|
|
387
|
+
env: { ...process.env, DSH_HOME: paths.home },
|
|
388
|
+
});
|
|
389
|
+
if (result.status !== 0) {
|
|
390
|
+
console.error('The install failed; the profile still requests ' + newer.version + '.');
|
|
391
|
+
return 1;
|
|
392
|
+
}
|
|
393
|
+
console.log('Plus is now ' + newer.version + '.');
|
|
394
|
+
const running = readState(paths.home);
|
|
395
|
+
if (running !== undefined) {
|
|
396
|
+
console.log('The running server still serves ' + installed + '; run dsh-plus restart to load the new release.');
|
|
397
|
+
}
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Write the profile's capability patch layer.
|
|
402
|
+
*
|
|
403
|
+
* The profile's loader merges this file, so enabling a capability is a data change
|
|
404
|
+
* rather than an edit to the deployment. Writing it on every configured run also
|
|
405
|
+
* turns a capability back off when the interview no longer selects it.
|
|
406
|
+
*
|
|
407
|
+
* @param profileDirectory - the profile whose layer is replaced.
|
|
408
|
+
* @param answers - the interview's answers.
|
|
409
|
+
*/
|
|
410
|
+
function writeCapabilityPatch(profileDirectory, answers) {
|
|
411
|
+
const path = join(profileDirectory, CAPABILITY_PATCH_FILE);
|
|
412
|
+
// The profile has exactly one user layer, so the capability rows live in it. A file
|
|
413
|
+
// this command did not write belongs to the deployment, and replacing it would drop
|
|
414
|
+
// whatever the operator put there; keep it beside the new layer instead.
|
|
415
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
416
|
+
// The layer is rewritten whole, so a row this command does not own is carried over
|
|
417
|
+
// rather than dropped. Backing the file up is not a substitute: the operator's rows
|
|
418
|
+
// would stop applying until someone restored it by hand.
|
|
419
|
+
if (existing !== undefined && !existing.includes(CAPABILITY_MARKER)) {
|
|
420
|
+
writeFileSync(path + '.before-capabilities', existing);
|
|
421
|
+
console.log(' Kept the existing profile layer at ' + CAPABILITY_PATCH_FILE + '.before-capabilities');
|
|
422
|
+
}
|
|
423
|
+
writeFileSync(path, capabilityPatchLayer(answers, existing ?? ''));
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Write the deployment's capability environment file.
|
|
427
|
+
*
|
|
428
|
+
* MinerU is switched on by the presence of its endpoint and Exa reads its key from
|
|
429
|
+
* the launch environment, so the answers reach those two through `$DSH_HOME/.env`
|
|
430
|
+
* rather than through the profile layer. The file is replaced whole on every
|
|
431
|
+
* configured start, which is also how a capability the user dropped stops applying.
|
|
432
|
+
*
|
|
433
|
+
* @param home - the deployment home whose env file the launcher reads.
|
|
434
|
+
* @param answers - the interview's answers.
|
|
435
|
+
*/
|
|
436
|
+
function writeCapabilityEnvironment(home, answers) {
|
|
437
|
+
const path = join(home, CAPABILITY_ENV_FILE);
|
|
438
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
439
|
+
writeFileSync(path, capabilityEnvironment(answers, existing));
|
|
440
|
+
}
|
|
441
|
+
/** Ask one yes/no question on the terminal. */
|
|
442
|
+
function confirm(question) {
|
|
443
|
+
return new Promise((resolveAnswer) => {
|
|
444
|
+
process.stdout.write(question + ' [y/N] ');
|
|
445
|
+
process.stdin.once('data', (chunk) => {
|
|
446
|
+
const answer = String(chunk).trim().toLowerCase();
|
|
447
|
+
resolveAnswer(answer === 'y' || answer === 'yes');
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Report and change which optional capabilities this deployment has enabled.
|
|
453
|
+
*
|
|
454
|
+
* The interview runs once, at install time, and a deployment that answered it keeps its
|
|
455
|
+
* answers — so this is the only way to see what it chose or to change one answer later.
|
|
456
|
+
* The report is read from the profile layer and the env file rather than from the record,
|
|
457
|
+
* because those two are what the loader and the launch environment actually consume: a
|
|
458
|
+
* record deleted, or a layer edited by hand, would otherwise describe a deployment that
|
|
459
|
+
* does not exist.
|
|
460
|
+
*
|
|
461
|
+
* `--enable` and `--disable` edit only the rows this module owns. The profile layer is
|
|
462
|
+
* shared with rows an operator wrote themselves, so it is never replaced wholesale.
|
|
463
|
+
*
|
|
464
|
+
* @param argv - arguments after the command word.
|
|
465
|
+
* @returns the process exit code.
|
|
466
|
+
*/
|
|
467
|
+
function capabilities(argv) {
|
|
468
|
+
const anchor = installationAnchor();
|
|
469
|
+
const paths = resolvePaths(anchor);
|
|
470
|
+
const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
|
|
471
|
+
const envPath = join(paths.home, CAPABILITY_ENV_FILE);
|
|
472
|
+
const patchText = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
|
|
473
|
+
const envText = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
|
|
474
|
+
const actual = detectEnabledCapabilities(patchText, envText);
|
|
475
|
+
const record = readCapabilityRecord(paths.home);
|
|
476
|
+
let enable = [];
|
|
477
|
+
let disable = [];
|
|
478
|
+
let asJson = false;
|
|
479
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
480
|
+
const token = argv[index];
|
|
481
|
+
if (token === '--json') {
|
|
482
|
+
asJson = true;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (token === '--enable' || token === '--disable') {
|
|
486
|
+
const value = argv[index + 1];
|
|
487
|
+
const ids = (value ?? '').split(',').map(id => id.trim()).filter(id => id !== '');
|
|
488
|
+
if (value === undefined || ids.length === 0) {
|
|
489
|
+
console.error(token + ' requires a comma-separated list of capability ids');
|
|
490
|
+
return 1;
|
|
491
|
+
}
|
|
492
|
+
const unknown = ids.filter(id => !CAPABILITIES.some(capability => capability.id === id));
|
|
493
|
+
if (unknown.length > 0) {
|
|
494
|
+
console.error('unknown capability: ' + unknown.join(', '));
|
|
495
|
+
console.error('Known: ' + CAPABILITIES.map(capability => capability.id).join(', '));
|
|
496
|
+
return 1;
|
|
497
|
+
}
|
|
498
|
+
if (token === '--enable')
|
|
499
|
+
enable = [...new Set([...enable, ...ids])];
|
|
500
|
+
else
|
|
501
|
+
disable = [...new Set([...disable, ...ids])];
|
|
502
|
+
index += 1;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
console.error('unknown option: ' + String(token));
|
|
506
|
+
return 1;
|
|
507
|
+
}
|
|
508
|
+
const conflicting = enable.filter(id => disable.includes(id));
|
|
509
|
+
if (conflicting.length > 0) {
|
|
510
|
+
console.error('cannot both enable and disable: ' + conflicting.join(', '));
|
|
511
|
+
return 1;
|
|
512
|
+
}
|
|
513
|
+
if (enable.length > 0 || disable.length > 0) {
|
|
514
|
+
const enabledNow = new Set(actual.mounted);
|
|
515
|
+
let nextPatch = patchText;
|
|
516
|
+
for (const id of enable) {
|
|
517
|
+
nextPatch = editCapabilityPatchLayer(nextPatch, id, true, record?.mineruEndpoint);
|
|
518
|
+
enabledNow.add(id);
|
|
519
|
+
}
|
|
520
|
+
for (const id of disable) {
|
|
521
|
+
nextPatch = editCapabilityPatchLayer(nextPatch, id, false);
|
|
522
|
+
enabledNow.delete(id);
|
|
523
|
+
}
|
|
524
|
+
if (!existsSync(patchPath) || !patchText.includes(CAPABILITY_MARKER)) {
|
|
525
|
+
// The layer belongs to the deployment, not to this command. Keep the previous text
|
|
526
|
+
// beside it so a structural mistake is recoverable without a backup.
|
|
527
|
+
const backup = patchPath + '.before-capabilities';
|
|
528
|
+
if (patchText !== '' && !existsSync(backup))
|
|
529
|
+
writeFileSync(backup, patchText);
|
|
530
|
+
}
|
|
531
|
+
writeFileSync(patchPath, nextPatch);
|
|
532
|
+
// The env file is rewritten from the answers so a disabled capability drops the
|
|
533
|
+
// assignment that turned it on, and an enabled one keeps a key already in the file.
|
|
534
|
+
const answers = {
|
|
535
|
+
enabled: [...enabledNow],
|
|
536
|
+
...record?.mineruEndpoint === undefined ? {} : { mineruEndpoint: record.mineruEndpoint },
|
|
537
|
+
};
|
|
538
|
+
const nextEnv = capabilityEnvironment(answers, envText);
|
|
539
|
+
writeFileSync(envPath, nextEnv);
|
|
540
|
+
const after = detectEnabledCapabilities(nextPatch, nextEnv);
|
|
541
|
+
console.log('Updated ' + CAPABILITY_PATCH_FILE + ' and ' + CAPABILITY_ENV_FILE + '.');
|
|
542
|
+
console.log('Run dsh-plus start (or restart the server) to apply the change.');
|
|
543
|
+
return reportCapabilities(after, record, asJson);
|
|
544
|
+
}
|
|
545
|
+
return reportCapabilities(actual, record, asJson);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Print the capability report.
|
|
549
|
+
*
|
|
550
|
+
* @param actual - the capabilities found in the profile layer and env file.
|
|
551
|
+
* @param record - what the last configured run recorded, when a record exists.
|
|
552
|
+
* @param asJson - when true, print machine-readable JSON instead of the table.
|
|
553
|
+
* @returns the process exit code, always 0.
|
|
554
|
+
*/
|
|
555
|
+
function reportCapabilities(actual, record, asJson) {
|
|
556
|
+
const rows = CAPABILITIES.map((capability) => {
|
|
557
|
+
const mounted = actual.mounted.includes(capability.id);
|
|
558
|
+
const recorded = record?.enabled.includes(capability.id);
|
|
559
|
+
return { id: capability.id, title: capability.title, enabled: mounted, recorded: recorded ?? null };
|
|
560
|
+
});
|
|
561
|
+
if (asJson) {
|
|
562
|
+
console.log(JSON.stringify({
|
|
563
|
+
capabilities: rows,
|
|
564
|
+
env: actual.env,
|
|
565
|
+
recordPresent: record !== undefined,
|
|
566
|
+
}, null, 2));
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
console.log('Optional capabilities:');
|
|
570
|
+
for (const row of rows) {
|
|
571
|
+
const state = row.enabled ? 'enabled ' : 'disabled';
|
|
572
|
+
// A record that disagrees with the files is the one case worth calling out: the two
|
|
573
|
+
// are supposed to describe the same deployment.
|
|
574
|
+
const drift = row.recorded !== null && row.recorded !== row.enabled ? ' (record says ' + (row.recorded ? 'enabled' : 'disabled') + ')' : '';
|
|
575
|
+
console.log(' ' + (row.enabled ? 'ok ' : '-- ') + row.id.padEnd(14) + state + drift);
|
|
576
|
+
}
|
|
577
|
+
if (record === undefined) {
|
|
578
|
+
console.log('');
|
|
579
|
+
console.log('No ' + CAPABILITY_RECORD + ' yet; the next dsh-plus start will run the interview.');
|
|
580
|
+
}
|
|
581
|
+
console.log('');
|
|
582
|
+
console.log('Change with: dsh-plus capabilities --enable <id> | --disable <id>');
|
|
583
|
+
return 0;
|
|
584
|
+
}
|
|
585
|
+
async function doctor() {
|
|
586
|
+
const anchor = installationAnchor();
|
|
587
|
+
const paths = resolvePaths(anchor);
|
|
588
|
+
let failures = 0;
|
|
589
|
+
const check = (ok, line) => {
|
|
590
|
+
console.log((ok ? 'ok ' : 'FAIL ') + line);
|
|
591
|
+
if (!ok)
|
|
592
|
+
failures += 1;
|
|
593
|
+
};
|
|
594
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
595
|
+
check(major >= 22, 'Node ' + process.versions.node + ' (needs 22 or newer)');
|
|
596
|
+
check(existsSync(join(paths.distributionDirectory, 'package.json')), 'Plus distribution at ' + paths.distributionDirectory);
|
|
597
|
+
check(existsSync(join(paths.profileDirectory, 'package.json')), 'profile at ' + paths.profileDirectory + ' (dsh-plus start creates it)');
|
|
598
|
+
const free = await portAvailable(DEFAULT_PORT);
|
|
599
|
+
check(true, 'port ' + String(DEFAULT_PORT) + (free ? ' is free' : ' is in use; start will choose the next free one'));
|
|
600
|
+
// The capability record and the files that actually enable a capability are written by
|
|
601
|
+
// different runs, so they can disagree: a layer edited by hand, or a record deleted.
|
|
602
|
+
// Reporting the drift here is what turns the record from a write-only file into
|
|
603
|
+
// something a user can trust.
|
|
604
|
+
const patchPath = join(paths.profileDirectory, CAPABILITY_PATCH_FILE);
|
|
605
|
+
const envPath = join(paths.home, CAPABILITY_ENV_FILE);
|
|
606
|
+
const actual = detectEnabledCapabilities(existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '', existsSync(envPath) ? readFileSync(envPath, 'utf8') : '');
|
|
607
|
+
const record = readCapabilityRecord(paths.home);
|
|
608
|
+
const drifted = record === undefined
|
|
609
|
+
? []
|
|
610
|
+
: CAPABILITIES.filter(capability => record.enabled.includes(capability.id) !== actual.mounted.includes(capability.id));
|
|
611
|
+
check(drifted.length === 0, drifted.length === 0
|
|
612
|
+
? 'capabilities: ' + (actual.mounted.length === 0 ? '(none enabled)' : actual.mounted.join(', '))
|
|
613
|
+
: 'capabilities disagree with ' + CAPABILITY_RECORD + ': ' + drifted.map(capability => capability.id).join(', ')
|
|
614
|
+
+ ' (run dsh-plus capabilities)');
|
|
615
|
+
console.log(failures === 0 ? 'No problems found.' : String(failures) + ' problem(s) found.');
|
|
616
|
+
return failures === 0 ? 0 : 1;
|
|
617
|
+
}
|
|
618
|
+
function parseInstallOptions(argv) {
|
|
619
|
+
let edition;
|
|
620
|
+
const exclude = [];
|
|
621
|
+
let dryRun = false;
|
|
622
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
623
|
+
const token = argv[index];
|
|
624
|
+
const value = argv[index + 1];
|
|
625
|
+
if (token === '--dry-run') {
|
|
626
|
+
dryRun = true;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (token === '--edition') {
|
|
630
|
+
if (value === undefined || value === '')
|
|
631
|
+
throw new Error('--edition requires a value');
|
|
632
|
+
edition = value;
|
|
633
|
+
index += 1;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (token === '--exclude') {
|
|
637
|
+
if (value === undefined || value === '')
|
|
638
|
+
throw new Error('--exclude requires a package name');
|
|
639
|
+
exclude.push(value);
|
|
640
|
+
index += 1;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
throw new Error('unknown option: ' + String(token));
|
|
644
|
+
}
|
|
645
|
+
return { edition, exclude, dryRun };
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Materialize a profile without starting the server.
|
|
649
|
+
*
|
|
650
|
+
* The build system installs the distribution once and needs the profile on disk before it
|
|
651
|
+
* snapshots the image; starting a server to create it would also start a process the image
|
|
652
|
+
* must then kill.
|
|
653
|
+
*
|
|
654
|
+
* @param argv - arguments after the command word.
|
|
655
|
+
* @returns the process exit code.
|
|
656
|
+
*/
|
|
657
|
+
function install(argv) {
|
|
658
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
659
|
+
console.log(USAGE);
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
662
|
+
const options = parseInstallOptions(argv);
|
|
663
|
+
// One axis: the edition names the package and bundle set this deployment installs, and its
|
|
664
|
+
// profile name travels with it. No edition means the full distribution.
|
|
665
|
+
const paths = resolvePaths(declarationAnchor(), process.env, options.edition);
|
|
666
|
+
const created = ensureProfile(paths, options.edition, options.exclude, options.dryRun);
|
|
667
|
+
console.log((created ? 'Created the ' : 'Using the existing ') + paths.profileName + ' profile at ' + paths.profileDirectory);
|
|
668
|
+
return 0;
|
|
669
|
+
}
|
|
670
|
+
function parseManifestOptions(argv) {
|
|
671
|
+
let distribution;
|
|
672
|
+
let out;
|
|
673
|
+
// The DataOps workspace image is the only build consumer of this command, so its edition is
|
|
674
|
+
// the default; a generic caller states the edition it wants.
|
|
675
|
+
let edition = DEFAULT_EDITION;
|
|
676
|
+
const exclude = [];
|
|
677
|
+
let runtime;
|
|
678
|
+
let registry = 'https://registry.npmjs.org';
|
|
679
|
+
let verify = false;
|
|
680
|
+
let check = false;
|
|
681
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
682
|
+
const token = argv[index];
|
|
683
|
+
const value = argv[index + 1];
|
|
684
|
+
if (token === '--distribution') {
|
|
685
|
+
if (value === undefined)
|
|
686
|
+
throw new Error('--distribution requires a value');
|
|
687
|
+
distribution = value;
|
|
688
|
+
index += 1;
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (token === '--out') {
|
|
692
|
+
if (value === undefined)
|
|
693
|
+
throw new Error('--out requires a value');
|
|
694
|
+
out = value;
|
|
695
|
+
index += 1;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (token === '--edition') {
|
|
699
|
+
if (value === undefined)
|
|
700
|
+
throw new Error('--edition requires a value');
|
|
701
|
+
edition = value;
|
|
702
|
+
index += 1;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
if (token === '--exclude') {
|
|
706
|
+
if (value === undefined)
|
|
707
|
+
throw new Error('--exclude requires a package name');
|
|
708
|
+
exclude.push(value);
|
|
709
|
+
index += 1;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (token === '--runtime') {
|
|
713
|
+
if (value === undefined)
|
|
714
|
+
throw new Error('--runtime requires a value');
|
|
715
|
+
runtime = value;
|
|
716
|
+
index += 1;
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (token === '--registry') {
|
|
720
|
+
if (value === undefined)
|
|
721
|
+
throw new Error('--registry requires a value');
|
|
722
|
+
registry = value;
|
|
723
|
+
index += 1;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (token === '--verify') {
|
|
727
|
+
verify = true;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (token === '--check') {
|
|
731
|
+
check = true;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
throw new Error('unknown option: ' + String(token));
|
|
735
|
+
}
|
|
736
|
+
return { distribution, out, edition, exclude, runtime, registry, verify, check };
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Write or verify the profile manifest a build installs.
|
|
740
|
+
*
|
|
741
|
+
* `--out` names the profile directory. `--check` compares what is there against a fresh
|
|
742
|
+
* projection instead of writing, so a committed profile cannot drift from the distribution.
|
|
743
|
+
*
|
|
744
|
+
* @param argv - arguments after the command word.
|
|
745
|
+
* @returns the process exit code.
|
|
746
|
+
*/
|
|
747
|
+
async function manifest(argv) {
|
|
748
|
+
const options = parseManifestOptions(argv);
|
|
749
|
+
const directory = options.distribution ?? resolveDistributionDirectory(declarationAnchor());
|
|
750
|
+
const facts = options.runtime === undefined
|
|
751
|
+
? readDistributionFacts(directory)
|
|
752
|
+
: { ...readDistributionFacts(directory), runtimeVersion: options.runtime };
|
|
753
|
+
const projection = projectEdition(facts, options.edition, options.exclude);
|
|
754
|
+
if (options.verify)
|
|
755
|
+
await assertRedirectsPublished({ ...facts.redirects, ...projection.omittedPackages }, options.registry);
|
|
756
|
+
if (options.out === undefined)
|
|
757
|
+
throw new Error('manifest requires --out <directory>');
|
|
758
|
+
if (options.check) {
|
|
759
|
+
const written = join(options.out, 'package.json');
|
|
760
|
+
if (!existsSync(written))
|
|
761
|
+
throw new Error('manifest: ' + written + ' does not exist; run the generator');
|
|
762
|
+
const actual = JSON.parse(readFileSync(written, 'utf8'));
|
|
763
|
+
const expectedDependencies = {
|
|
764
|
+
'@sparkelf/dsh-plus': facts.version,
|
|
765
|
+
'@deepseek-ai/dsh': '>=' + facts.runtimeVersion,
|
|
766
|
+
...projection.dependencies,
|
|
767
|
+
};
|
|
768
|
+
const drift = [];
|
|
769
|
+
for (const [name, spec] of Object.entries(expectedDependencies)) {
|
|
770
|
+
if (actual.dependencies?.[name] !== spec)
|
|
771
|
+
drift.push('dependency ' + name);
|
|
772
|
+
}
|
|
773
|
+
for (const name of Object.keys(actual.dependencies ?? {})) {
|
|
774
|
+
if (expectedDependencies[name] === undefined)
|
|
775
|
+
drift.push('extra dependency ' + name);
|
|
776
|
+
}
|
|
777
|
+
for (const bundle of projection.bundles) {
|
|
778
|
+
if (!(actual.dsh?.profile?.bundles ?? []).includes(bundle))
|
|
779
|
+
drift.push('bundle ' + bundle);
|
|
780
|
+
}
|
|
781
|
+
if (drift.length > 0)
|
|
782
|
+
throw new Error('manifest: ' + written + ' is stale in ' + drift.join(', '));
|
|
783
|
+
console.log('manifest: ' + written + ' matches the distribution');
|
|
784
|
+
return 0;
|
|
785
|
+
}
|
|
786
|
+
writeProfileProjection(options.out, facts, options.edition, options.exclude);
|
|
787
|
+
console.log(JSON.stringify({
|
|
788
|
+
profile: projection.profileName,
|
|
789
|
+
runtime: facts.runtimeVersion,
|
|
790
|
+
bundles: projection.bundles.length,
|
|
791
|
+
dependencies: Object.keys(projection.dependencies).length,
|
|
792
|
+
redirects: Object.keys(facts.redirects).length,
|
|
793
|
+
overrides: Object.keys(projection.overrides).length,
|
|
794
|
+
omitted: Object.keys(projection.omittedPackages),
|
|
795
|
+
}));
|
|
796
|
+
return 0;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Print the distribution facts a build or a release gate consumes.
|
|
800
|
+
*
|
|
801
|
+
* @param argv - accepts `--distribution <dir>` only.
|
|
802
|
+
* @returns the process exit code.
|
|
803
|
+
*/
|
|
804
|
+
function inspect(argv) {
|
|
805
|
+
let directory;
|
|
806
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
807
|
+
if (argv[index] !== '--distribution')
|
|
808
|
+
throw new Error('unknown option: ' + String(argv[index]));
|
|
809
|
+
directory = argv[index + 1];
|
|
810
|
+
if (directory === undefined)
|
|
811
|
+
throw new Error('--distribution requires a value');
|
|
812
|
+
index += 1;
|
|
813
|
+
}
|
|
814
|
+
const facts = readDistributionFacts(directory ?? resolveDistributionDirectory(declarationAnchor()));
|
|
815
|
+
console.log(JSON.stringify({
|
|
816
|
+
name: facts.name,
|
|
817
|
+
version: facts.version,
|
|
818
|
+
runtime: facts.runtimeVersion,
|
|
819
|
+
profile: facts.name,
|
|
820
|
+
bundles: facts.bundles,
|
|
821
|
+
dependencies: facts.dependencies,
|
|
822
|
+
redirects: facts.redirects,
|
|
823
|
+
redirectPatch: facts.redirectIds,
|
|
824
|
+
editions: Object.entries(facts.editions).map(([id, edition]) => ({
|
|
825
|
+
id,
|
|
826
|
+
label: edition.label,
|
|
827
|
+
profile: edition.profile,
|
|
828
|
+
bundles: edition.bundles.length,
|
|
829
|
+
packages: edition.packages.length,
|
|
830
|
+
omit: edition.omit.length,
|
|
831
|
+
})),
|
|
832
|
+
}, null, 2));
|
|
833
|
+
return 0;
|
|
834
|
+
}
|
|
835
|
+
const USAGE = [
|
|
836
|
+
'Usage: dsh-plus <command> [options]',
|
|
837
|
+
'',
|
|
838
|
+
'Commands:',
|
|
839
|
+
' install materialize the profile without starting the server',
|
|
840
|
+
' manifest write or verify the profile manifest a build installs',
|
|
841
|
+
' inspect print the distribution facts as JSON',
|
|
842
|
+
' start start the server (first run creates the profile)',
|
|
843
|
+
' stop stop the server',
|
|
844
|
+
' status report whether the server is running',
|
|
845
|
+
' capabilities report or change the optional capabilities',
|
|
846
|
+
' update move to a newer distribution release',
|
|
847
|
+
' doctor check this installation',
|
|
848
|
+
'',
|
|
849
|
+
'install options:',
|
|
850
|
+
' --edition <id> product edition to materialize (personal, team, intranet-team)',
|
|
851
|
+
' --exclude <pkg> drop a package and its bundle row (repeatable)',
|
|
852
|
+
'',
|
|
853
|
+
'manifest options:',
|
|
854
|
+
' --out <dir> profile directory to write',
|
|
855
|
+
' --edition <id> product edition to project (default: ' + DEFAULT_EDITION + ')',
|
|
856
|
+
' --exclude <pkg> drop a package and its bundle row (repeatable)',
|
|
857
|
+
' --runtime <version> override the runtime version the redirects name',
|
|
858
|
+
' --registry <url> registry checked by --verify',
|
|
859
|
+
' --verify fail unless every redirect target is published',
|
|
860
|
+
' --check compare --out with a fresh projection instead of writing',
|
|
861
|
+
'',
|
|
862
|
+
'start options:',
|
|
863
|
+
' --port <n> port to prefer (default ' + String(DEFAULT_PORT) + '; a taken port moves to the next free one)',
|
|
864
|
+
' --host <h> interface to bind (default 127.0.0.1)',
|
|
865
|
+
' --no-open do not open a browser',
|
|
866
|
+
' --foreground run in this terminal instead of in the background',
|
|
867
|
+
' --edition <id> product edition to materialize',
|
|
868
|
+
' --exclude <pkg> drop a package and its bundle row (repeatable)',
|
|
869
|
+
'',
|
|
870
|
+
'capabilities options:',
|
|
871
|
+
' --enable <ids> enable the comma-separated capability ids',
|
|
872
|
+
' --disable <ids> disable the comma-separated capability ids',
|
|
873
|
+
' --json print the report as JSON',
|
|
874
|
+
'',
|
|
875
|
+
'update options:',
|
|
876
|
+
' --check report the available release without installing it',
|
|
877
|
+
' --yes install without asking',
|
|
878
|
+
].join('\n');
|
|
879
|
+
/**
|
|
880
|
+
* Dispatch one command line.
|
|
881
|
+
*
|
|
882
|
+
* @param argv - arguments after the executable and script name.
|
|
883
|
+
* @returns the process exit code.
|
|
884
|
+
*/
|
|
885
|
+
export async function runStandaloneCli(argv) {
|
|
886
|
+
const [command, ...rest] = argv;
|
|
887
|
+
if (command === undefined || command === '--help' || command === '-h' || command === 'help') {
|
|
888
|
+
console.log(USAGE);
|
|
889
|
+
return 0;
|
|
890
|
+
}
|
|
891
|
+
if (command === 'install')
|
|
892
|
+
return install(rest);
|
|
893
|
+
if (command === 'manifest')
|
|
894
|
+
return await manifest(rest);
|
|
895
|
+
if (command === 'inspect')
|
|
896
|
+
return inspect(rest);
|
|
897
|
+
if (command === 'start')
|
|
898
|
+
return await start(rest);
|
|
899
|
+
if (command === 'stop')
|
|
900
|
+
return stop();
|
|
901
|
+
if (command === 'status')
|
|
902
|
+
return status();
|
|
903
|
+
if (command === 'capabilities')
|
|
904
|
+
return capabilities(rest);
|
|
905
|
+
if (command === 'update')
|
|
906
|
+
return await update(rest);
|
|
907
|
+
if (command === 'doctor')
|
|
908
|
+
return doctor();
|
|
909
|
+
console.error('unknown command: ' + command);
|
|
910
|
+
console.error(USAGE);
|
|
911
|
+
return 1;
|
|
912
|
+
}
|
|
913
|
+
//# sourceMappingURL=cli.js.map
|