@sparkelf/dsh-plus 0.1.0-rc.9 → 0.2.0-rc.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.
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import { runApply } from "./apply.js";
3
+ import { runStandaloneCli } from "./standalone-cli.js";
4
+ /**
5
+ * Dispatch the command line.
6
+ *
7
+ * `apply` remains the materialization command a source-based installation runs; every
8
+ * other word is a standalone lifecycle command. Reaching the dispatcher first keeps
9
+ * one executable for both, so a user who installed the distribution from the registry
10
+ * never needs to know which half owns a command.
11
+ *
12
+ * @returns the process exit code.
13
+ */
14
+ async function main() {
15
+ const argv = process.argv.slice(2);
16
+ const first = argv[0];
17
+ if (first === 'apply') {
18
+ // `runApply` parses the command word itself, so it receives the arguments this
19
+ // dispatcher already inspected rather than a slice that dropped it.
20
+ runApply(argv);
21
+ return 0;
22
+ }
23
+ return await runStandaloneCli(argv);
24
+ }
25
+ try {
26
+ process.exitCode = await main();
27
+ }
28
+ catch (error) {
29
+ console.error(error instanceof Error ? error.message : String(error));
30
+ process.exitCode = 1;
31
+ }
32
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @sparkelf/dsh-plus carries the Plus Web profile composition, ranged
3
+ * compatibility declarations, and independent patch-package references. The
4
+ * profile composer resolves its Cordis patch through the package manifest; the
5
+ * root module exposes no runtime API, while the explicit CLI lives at ./apply.
6
+ * @module @sparkelf/dsh-plus
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Registry queries behind the `update` command.
3
+ *
4
+ * A standalone installation pins one exact distribution version, so it needs two
5
+ * facts npm's own updater cannot give it: which versions exist, and which of them is
6
+ * newer than the one installed. `pnpm update` treats an exact pin as a range of one
7
+ * and always reports "already up to date".
8
+ */
9
+ /** One published version with the time the registry recorded for it. */
10
+ export interface PublishedVersion {
11
+ /** Published version. */
12
+ readonly version: string;
13
+ /** Publication time in ISO form, when the registry reports one. */
14
+ readonly publishedAt?: string;
15
+ }
16
+ /** Every published version of one package, oldest first, with publication times. */
17
+ export declare function publishedVersions(name: string, registry?: string): PublishedVersion[];
18
+ /**
19
+ * The highest published version that is newer than the installed one.
20
+ *
21
+ * Prerelease ordering matters here: an installation on a release candidate must not
22
+ * be told that an older stable release is an upgrade, and must be told about a newer
23
+ * candidate.
24
+ *
25
+ * @param name - package to query.
26
+ * @param installed - version currently installed.
27
+ * @param registry - registry to query.
28
+ * @returns the newer version, or undefined when the installation is current.
29
+ */
30
+ export declare function newerVersion(name: string, installed: string, registry?: string): PublishedVersion | undefined;
31
+ //# sourceMappingURL=registry-versions.d.ts.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Registry queries behind the `update` command.
3
+ *
4
+ * A standalone installation pins one exact distribution version, so it needs two
5
+ * facts npm's own updater cannot give it: which versions exist, and which of them is
6
+ * newer than the one installed. `pnpm update` treats an exact pin as a range of one
7
+ * and always reports "already up to date".
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import semver from 'semver';
11
+ /** Every published version of one package, oldest first, with publication times. */
12
+ export function publishedVersions(name, registry = 'https://registry.npmjs.org') {
13
+ let raw;
14
+ try {
15
+ raw = execFileSync('npm', ['view', name, 'time', '--json', '--registry', registry], {
16
+ encoding: 'utf8',
17
+ stdio: ['ignore', 'pipe', 'ignore'],
18
+ });
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(raw);
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
31
+ return [];
32
+ const times = parsed;
33
+ const entries = [];
34
+ for (const [version, time] of Object.entries(times)) {
35
+ // `created` and `modified` are registry bookkeeping, not releases.
36
+ if (version === 'created' || version === 'modified')
37
+ continue;
38
+ if (semver.valid(version) === null)
39
+ continue;
40
+ entries.push(typeof time === 'string' ? { version, publishedAt: time } : { version });
41
+ }
42
+ return entries.sort((left, right) => semver.compare(left.version, right.version));
43
+ }
44
+ /**
45
+ * The highest published version that is newer than the installed one.
46
+ *
47
+ * Prerelease ordering matters here: an installation on a release candidate must not
48
+ * be told that an older stable release is an upgrade, and must be told about a newer
49
+ * candidate.
50
+ *
51
+ * @param name - package to query.
52
+ * @param installed - version currently installed.
53
+ * @param registry - registry to query.
54
+ * @returns the newer version, or undefined when the installation is current.
55
+ */
56
+ export function newerVersion(name, installed, registry = 'https://registry.npmjs.org') {
57
+ const candidates = publishedVersions(name, registry)
58
+ .filter(entry => semver.gt(entry.version, installed));
59
+ return candidates.at(-1);
60
+ }
61
+ //# sourceMappingURL=registry-versions.js.map
@@ -0,0 +1,17 @@
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
+ /**
11
+ * Dispatch one command line.
12
+ *
13
+ * @param argv - arguments after the executable and script name.
14
+ * @returns the process exit code.
15
+ */
16
+ export declare function runStandaloneCli(argv: readonly string[]): Promise<number>;
17
+ //# sourceMappingURL=standalone-cli.d.ts.map
@@ -0,0 +1,359 @@
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 { DEFAULT_PORT, STOP_GRACE_MILLISECONDS, choosePort, clearState, isRunning, portAvailable, readState, spawnServer, stateDirectory, waitForAuthenticatedUrl, waitForServer, writeState, } from "./standalone-server.js";
17
+ import { STANDALONE_PROFILE, applyProfileNpmPatches, ensureProfile, pnpmAvailable, pnpmInstallCommand, pnpmInstallCommands, registryOrder, readDistributionProfile, resolvePaths, } from "./standalone-profile.js";
18
+ /** Milliseconds a start waits for the server to answer before reporting failure. */
19
+ const READY_TIMEOUT_MILLISECONDS = 90_000;
20
+ function parseStartOptions(argv) {
21
+ let port = DEFAULT_PORT;
22
+ let host = '127.0.0.1';
23
+ let open = true;
24
+ let foreground = false;
25
+ for (let index = 0; index < argv.length; index += 1) {
26
+ const token = argv[index];
27
+ if (token === '--port' || token === '-p') {
28
+ const value = argv[index + 1];
29
+ if (value === undefined || !/^\d+$/u.test(value))
30
+ throw new Error('--port requires a number');
31
+ port = Number(value);
32
+ index += 1;
33
+ continue;
34
+ }
35
+ if (token === '--host') {
36
+ const value = argv[index + 1];
37
+ if (value === undefined)
38
+ throw new Error('--host requires a value');
39
+ host = value;
40
+ index += 1;
41
+ continue;
42
+ }
43
+ if (token === '--no-open') {
44
+ open = false;
45
+ continue;
46
+ }
47
+ if (token === '--foreground') {
48
+ foreground = true;
49
+ continue;
50
+ }
51
+ throw new Error('unknown option: ' + token);
52
+ }
53
+ return { port, host, open, foreground };
54
+ }
55
+ /**
56
+ * Where the installation that owns this command keeps its packages.
57
+ *
58
+ * A global install places the command in a shared prefix and a local install places
59
+ * it in a project; neither has anything to do with the directory the user happens to
60
+ * be in. The search therefore starts at this module own file and climbs to the tree
61
+ * npm laid out around it.
62
+ *
63
+ * The test is the launcher, not the distribution: inside a workspace the package
64
+ * resolves its own name, so looking for the distribution would stop at the package
65
+ * rather than at the tree that owns every dependency.
66
+ *
67
+ * @returns absolute path to the installation root.
68
+ */
69
+ function installationRoot() {
70
+ const here = dirname(fileURLToPath(import.meta.url));
71
+ let current = here;
72
+ for (;;) {
73
+ if (existsSync(join(current, 'node_modules', '@deepseek-ai', 'dsh')))
74
+ return current;
75
+ const parent = dirname(current);
76
+ // A tree npm did not lay out has no such ancestor; the module own directory
77
+ // keeps the failure message pointing at the installation that was searched.
78
+ if (parent === current)
79
+ return here;
80
+ current = parent;
81
+ }
82
+ }
83
+ /** The package.json this installation resolves its dependencies from. */
84
+ function installationAnchor() {
85
+ return join(installationRoot(), 'package.json');
86
+ }
87
+ /** The launcher entry this installation must drive. */
88
+ function launcherEntry(anchor) {
89
+ return createRequire(anchor).resolve('@deepseek-ai/dsh/lib/bin.js');
90
+ }
91
+ /** 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];
94
+ if (!open)
95
+ args.push('--no-open');
96
+ const result = spawnSync(process.execPath, args, { stdio: 'inherit' });
97
+ return result.status ?? 1;
98
+ }
99
+ async function start(argv) {
100
+ const options = parseStartOptions(argv);
101
+ const anchor = installationAnchor();
102
+ const paths = resolvePaths(anchor);
103
+ // The profile installs its own dependency tree, which needs pnpm. Asking here rather
104
+ // than failing inside the install turns a missing prerequisite into a decision the
105
+ // consumer makes, and the install it can run is the one command that provides it.
106
+ if (!pnpmAvailable()) {
107
+ const command = pnpmInstallCommand();
108
+ console.log('pnpm is required to install the ' + STANDALONE_PROFILE + ' profile, and was not found.');
109
+ console.log('Install it with: ' + command);
110
+ if (!await confirm('Install pnpm now?')) {
111
+ console.log('Install pnpm and run dsh-plus start again.');
112
+ return 1;
113
+ }
114
+ // Try each installer in turn: Corepack is absent on an installation that disabled it,
115
+ // and npm succeeds there. A failure reports the commands rather than a stack trace.
116
+ let installed = false;
117
+ const preferred = registryOrder()[0];
118
+ for (const candidate of pnpmInstallCommands()) {
119
+ // Corepack reads its registry from the environment; npm takes a flag, which the
120
+ // command already carries. Setting it for both keeps the download on the mirror.
121
+ const attempt = spawnSync(candidate, {
122
+ stdio: 'inherit',
123
+ shell: true,
124
+ env: preferred === undefined
125
+ ? process.env
126
+ : { ...process.env, COREPACK_NPM_REGISTRY: preferred },
127
+ });
128
+ if (attempt.status === 0 && pnpmAvailable()) {
129
+ installed = true;
130
+ break;
131
+ }
132
+ }
133
+ if (!installed) {
134
+ console.log('Could not install pnpm automatically.');
135
+ console.log('Run one of these, then run dsh-plus start again:');
136
+ for (const candidate of pnpmInstallCommands())
137
+ console.log(' ' + candidate);
138
+ return 1;
139
+ }
140
+ console.log('pnpm installed.');
141
+ }
142
+ const created = ensureProfile(paths, installationRoot());
143
+ console.log(created
144
+ ? 'Created the ' + STANDALONE_PROFILE + ' profile at ' + paths.profileDirectory
145
+ : 'Using the existing ' + STANDALONE_PROFILE + ' profile');
146
+ // The profile symlinks the consumer's packages, so a patch lands on the installed
147
+ // copy the launcher loads. A reinstall restores the published bytes, which is why
148
+ // this runs on every start rather than only when the profile was created.
149
+ for (const label of applyProfileNpmPatches(paths.distributionDirectory, paths.profileDirectory)) {
150
+ console.log('Applied the reviewed patch ' + label);
151
+ }
152
+ const entry = launcherEntry(anchor);
153
+ if (options.foreground)
154
+ return runForeground(entry, options.port, options.host, options.open);
155
+ const existing = readState(paths.home);
156
+ if (existing !== undefined) {
157
+ console.log('Plus is already running at ' + existing.url);
158
+ console.log('Stop it with: dsh-plus stop');
159
+ return 0;
160
+ }
161
+ return startDetached(paths.home, entry, options);
162
+ }
163
+ async function startDetached(home, entry, options) {
164
+ const port = await choosePort(options.port, options.host);
165
+ if (port === undefined) {
166
+ console.error('No free port in the range ' + String(options.port) + '-' + String(options.port + 9) + '.');
167
+ console.error('Pass --port with a free port.');
168
+ return 1;
169
+ }
170
+ if (port !== options.port)
171
+ 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'];
175
+ const pid = spawnServer({ command: process.execPath, args, env, logPath });
176
+ const url = 'http://' + options.host + ':' + String(port) + '/';
177
+ const ready = await waitForServer(url, READY_TIMEOUT_MILLISECONDS);
178
+ if (!ready) {
179
+ console.error('The server did not answer within ' + String(READY_TIMEOUT_MILLISECONDS / 1000) + 's.');
180
+ console.error('Read ' + logPath + ' for the startup error.');
181
+ if (isRunning(pid))
182
+ process.kill(pid, 'SIGTERM');
183
+ return 1;
184
+ }
185
+ // The launcher prints the URL carrying the process launch token, and the server
186
+ // refuses every request without the cookie that token mints. Reporting the bare
187
+ // address sends the user to a 401 that tells them to reopen the launcher's URL,
188
+ // which lives only in the log this command points at.
189
+ const opened = await waitForAuthenticatedUrl(logPath, READY_TIMEOUT_MILLISECONDS) ?? url;
190
+ writeState(home, { pid, port, url: opened });
191
+ console.log('Plus is running at ' + opened);
192
+ console.log('Logs: ' + logPath);
193
+ console.log('Stop it with: dsh-plus stop');
194
+ return 0;
195
+ }
196
+ async function stop() {
197
+ const anchor = installationAnchor();
198
+ const { home } = resolvePaths(anchor);
199
+ const state = readState(home);
200
+ if (state === undefined) {
201
+ clearState(home);
202
+ console.log('Plus is not running.');
203
+ return 0;
204
+ }
205
+ process.kill(state.pid, 'SIGTERM');
206
+ const deadline = Date.now() + STOP_GRACE_MILLISECONDS;
207
+ while (Date.now() < deadline && isRunning(state.pid)) {
208
+ await new Promise((resolveDelay) => { setTimeout(resolveDelay, 200); });
209
+ }
210
+ if (isRunning(state.pid)) {
211
+ console.log('The server did not exit in ' + String(STOP_GRACE_MILLISECONDS / 1000) + 's; forcing it.');
212
+ process.kill(state.pid, 'SIGKILL');
213
+ }
214
+ clearState(home);
215
+ console.log('Plus has stopped.');
216
+ return 0;
217
+ }
218
+ function status() {
219
+ const anchor = installationAnchor();
220
+ const paths = resolvePaths(anchor);
221
+ const state = readState(paths.home);
222
+ if (state === undefined) {
223
+ console.log('Plus is not running.');
224
+ console.log('Start it with: dsh-plus start');
225
+ return 0;
226
+ }
227
+ console.log('Plus is running at ' + state.url);
228
+ console.log('Process ' + String(state.pid) + ' on port ' + String(state.port));
229
+ return 0;
230
+ }
231
+ /**
232
+ * Move the installation to a newer published release.
233
+ *
234
+ * The profile pins the distribution exactly, so the new version is written into the
235
+ * profile manifest and reinstalled there. A running server keeps the version it
236
+ * started with, so the command reports that a restart is what makes the change take
237
+ * effect rather than pretending the running process changed underneath the user.
238
+ */
239
+ async function update(argv) {
240
+ const checkOnly = argv.includes('--check');
241
+ const assumeYes = argv.includes('--yes');
242
+ const anchor = installationAnchor();
243
+ const paths = resolvePaths(anchor);
244
+ const distribution = readDistributionProfile(paths.distributionDirectory);
245
+ const installed = distribution.version;
246
+ const newer = newerVersion(distribution.name, installed);
247
+ if (newer === undefined) {
248
+ console.log('Plus ' + installed + ' is the newest published release.');
249
+ return 0;
250
+ }
251
+ console.log('Installed: ' + installed);
252
+ console.log('Available: ' + newer.version + (newer.publishedAt === undefined ? '' : ' (' + newer.publishedAt + ')'));
253
+ if (checkOnly)
254
+ return 0;
255
+ if (!assumeYes && !(await confirm('Install ' + newer.version + '?'))) {
256
+ console.log('Nothing changed.');
257
+ return 0;
258
+ }
259
+ const profilePath = join(paths.profileDirectory, 'package.json');
260
+ const profile = JSON.parse(readFileSync(profilePath, 'utf8'));
261
+ const dependencies = profile.dependencies;
262
+ if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
263
+ throw new Error('the profile manifest has no dependencies to update');
264
+ }
265
+ ;
266
+ dependencies['@sparkelf/dsh-plus'] = newer.version;
267
+ writeFileSync(profilePath, JSON.stringify(profile, null, 2) + '\n');
268
+ console.log('Updated the profile to ' + newer.version + '; installing...');
269
+ const result = spawnSync('npm', ['install', '--no-audit', '--no-fund'], {
270
+ cwd: paths.profileDirectory,
271
+ stdio: 'inherit',
272
+ env: { ...process.env, DSH_HOME: paths.home },
273
+ });
274
+ if (result.status !== 0) {
275
+ console.error('The install failed; the profile still requests ' + newer.version + '.');
276
+ return 1;
277
+ }
278
+ console.log('Plus is now ' + newer.version + '.');
279
+ const running = readState(paths.home);
280
+ if (running !== undefined) {
281
+ console.log('The running server still serves ' + installed + '; run dsh-plus restart to load the new release.');
282
+ }
283
+ return 0;
284
+ }
285
+ /** Ask one yes/no question on the terminal. */
286
+ function confirm(question) {
287
+ return new Promise((resolveAnswer) => {
288
+ process.stdout.write(question + ' [y/N] ');
289
+ process.stdin.once('data', (chunk) => {
290
+ const answer = String(chunk).trim().toLowerCase();
291
+ resolveAnswer(answer === 'y' || answer === 'yes');
292
+ });
293
+ });
294
+ }
295
+ async function doctor() {
296
+ const anchor = installationAnchor();
297
+ const paths = resolvePaths(anchor);
298
+ let failures = 0;
299
+ const check = (ok, line) => {
300
+ console.log((ok ? 'ok ' : 'FAIL ') + line);
301
+ if (!ok)
302
+ failures += 1;
303
+ };
304
+ const major = Number(process.versions.node.split('.')[0]);
305
+ check(major >= 22, 'Node ' + process.versions.node + ' (needs 22 or newer)');
306
+ check(existsSync(join(paths.distributionDirectory, 'package.json')), 'Plus distribution at ' + paths.distributionDirectory);
307
+ check(existsSync(join(paths.profileDirectory, 'package.json')), 'profile at ' + paths.profileDirectory + ' (dsh-plus start creates it)');
308
+ const free = await portAvailable(DEFAULT_PORT);
309
+ check(true, 'port ' + String(DEFAULT_PORT) + (free ? ' is free' : ' is in use; start will choose the next free one'));
310
+ console.log(failures === 0 ? 'No problems found.' : String(failures) + ' problem(s) found.');
311
+ return failures === 0 ? 0 : 1;
312
+ }
313
+ const USAGE = [
314
+ 'Usage: dsh-plus <command> [options]',
315
+ '',
316
+ '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',
322
+ '',
323
+ 'start options:',
324
+ ' --port <n> port to prefer (default ' + String(DEFAULT_PORT) + '; a taken port moves to the next free one)',
325
+ ' --host <h> interface to bind (default 127.0.0.1)',
326
+ ' --no-open do not open a browser',
327
+ ' --foreground run in this terminal instead of in the background',
328
+ '',
329
+ 'update options:',
330
+ ' --check report the available release without installing it',
331
+ ' --yes install without asking',
332
+ ].join('\n');
333
+ /**
334
+ * Dispatch one command line.
335
+ *
336
+ * @param argv - arguments after the executable and script name.
337
+ * @returns the process exit code.
338
+ */
339
+ export async function runStandaloneCli(argv) {
340
+ const [command, ...rest] = argv;
341
+ if (command === undefined || command === '--help' || command === '-h' || command === 'help') {
342
+ console.log(USAGE);
343
+ return 0;
344
+ }
345
+ if (command === 'start')
346
+ return await start(rest);
347
+ if (command === 'stop')
348
+ return stop();
349
+ if (command === 'status')
350
+ return status();
351
+ if (command === 'update')
352
+ return await update(rest);
353
+ if (command === 'doctor')
354
+ return doctor();
355
+ console.error('unknown command: ' + command);
356
+ console.error(USAGE);
357
+ return 1;
358
+ }
359
+ //# sourceMappingURL=standalone-cli.js.map