@sparkelf/dsh-plus 0.2.4-alpha.48 → 0.2.6-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 +170 -127
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime paths and profile composition for a standalone Plus installation.
|
|
3
|
+
*
|
|
4
|
+
* A standalone consumer installs the distribution from the registry and owns its
|
|
5
|
+
* profile, so this module answers the two questions every command shares: where the
|
|
6
|
+
* DSH home and profile live, and what the profile manifest must contain. The bundle
|
|
7
|
+
* order comes from the installed distribution rather than from a copy here, because
|
|
8
|
+
* the launcher mounts exactly the bundles the profile names and nothing expands a
|
|
9
|
+
* bundle's own list.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { dirname, join, posix, resolve, win32 } from 'node:path';
|
|
16
|
+
import { parseDocument } from 'yaml';
|
|
17
|
+
import { projectEdition, readDistributionFacts } from "./manifest.js";
|
|
18
|
+
import { readPatchSet } from "./patch-set.js";
|
|
19
|
+
/** Profile name the launcher materializes when no edition is selected. */
|
|
20
|
+
export const DEFAULT_PROFILE = 'plus';
|
|
21
|
+
function requireRecord(value, label) {
|
|
22
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
23
|
+
throw new Error(label + ' must be an object');
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the file that was actually invoked from the path the shell was handed.
|
|
28
|
+
*
|
|
29
|
+
* A global npm install links `<prefix>/bin/dsh-plus` to the package entry. Resolving
|
|
30
|
+
* `@sparkelf/dsh-plus` from that link starts at the bin directory, which has no
|
|
31
|
+
* `node_modules`, so every globally installed command reported the distribution as
|
|
32
|
+
* missing. The real path is the package file, and the package resolves its own name.
|
|
33
|
+
*
|
|
34
|
+
* @param invoked - `process.argv[1]`, or undefined when node ran without a script.
|
|
35
|
+
* @param fallback - this module's own file, used when there is no script path.
|
|
36
|
+
* @returns an absolute path that exists, or the input when it cannot be resolved.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveInvocationAnchor(invoked, fallback) {
|
|
39
|
+
if (invoked === undefined || invoked === '')
|
|
40
|
+
return realpathSync(fallback);
|
|
41
|
+
try {
|
|
42
|
+
return realpathSync(invoked);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A path the filesystem no longer knows cannot be resolved; the caller's error message is
|
|
46
|
+
// more useful with what it was given than with a second failure here.
|
|
47
|
+
return resolve(invoked);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Resolve the DSH home, honouring the environment override the launcher itself reads. */
|
|
51
|
+
export function resolveHome(env = process.env) {
|
|
52
|
+
const configured = env.DSH_HOME;
|
|
53
|
+
return configured === undefined || configured === '' ? join(homedir(), '.dsh') : resolve(configured);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Whether one absolute path is the other or sits beneath it.
|
|
57
|
+
*
|
|
58
|
+
* A path outside the parent has a relative form starting with `..`, which every
|
|
59
|
+
* platform answers the same way. Comparing the text against a literal separator is
|
|
60
|
+
* not: Windows separates with a backslash, so a nested path never matched its own
|
|
61
|
+
* ancestor and every Windows installation reported the distribution as sitting
|
|
62
|
+
* outside its own tree.
|
|
63
|
+
*
|
|
64
|
+
* @param candidate - absolute path to test.
|
|
65
|
+
* @param parent - absolute path that may contain it.
|
|
66
|
+
* @param platform - separating convention, injectable so the Windows answer is
|
|
67
|
+
* testable where the suite runs on a POSIX host.
|
|
68
|
+
* @returns true when the candidate is the parent or inside it.
|
|
69
|
+
*/
|
|
70
|
+
export function isWithin(candidate, parent, platform = process.platform) {
|
|
71
|
+
if (candidate === parent)
|
|
72
|
+
return true;
|
|
73
|
+
const path = platform === 'win32' ? win32 : posix;
|
|
74
|
+
const inside = path.relative(parent, candidate);
|
|
75
|
+
return inside !== '' && !inside.startsWith('..') && !inside.startsWith(path.sep + '..');
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the installed distribution directory from one requiring anchor.
|
|
79
|
+
*
|
|
80
|
+
* The anchor must be a path inside the consumer's own tree. Resolving from this
|
|
81
|
+
* module instead would find the distribution the module itself was loaded from,
|
|
82
|
+
* which is the repository during development and the consumer's install otherwise.
|
|
83
|
+
*
|
|
84
|
+
* @param anchor - `package.json` path or directory in the consumer tree.
|
|
85
|
+
* @returns absolute path to the installed distribution.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveDistributionDirectory(anchor) {
|
|
88
|
+
let resolved;
|
|
89
|
+
try {
|
|
90
|
+
resolved = dirname(createRequire(anchor).resolve('@sparkelf/dsh-plus/package.json'));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error('@sparkelf/dsh-plus is not installed in ' + dirname(anchor) + '; run this command from the directory that installed it');
|
|
94
|
+
}
|
|
95
|
+
// A resolution that leaves the consumer tree means the module answered from its own
|
|
96
|
+
// installation, which reports health for a distribution the consumer never installed.
|
|
97
|
+
// The comparison walks up from the anchor because the resolved package sits under the
|
|
98
|
+
// anchor's \`node_modules\`, not beside it: requiring \`<root>/package.json\` legitimately
|
|
99
|
+
// reports \`<root>/node_modules/@sparkelf/dsh-plus\`.
|
|
100
|
+
let current = resolve(dirname(anchor));
|
|
101
|
+
for (;;) {
|
|
102
|
+
if (isWithin(resolved, current))
|
|
103
|
+
return resolved;
|
|
104
|
+
const parent = dirname(current);
|
|
105
|
+
if (parent === current)
|
|
106
|
+
break;
|
|
107
|
+
current = parent;
|
|
108
|
+
}
|
|
109
|
+
throw new Error('@sparkelf/dsh-plus resolved outside ' + resolve(dirname(anchor)) + ' (found ' + resolved + '); run this command from the directory that installed it');
|
|
110
|
+
}
|
|
111
|
+
/** The reviewed bundle order and pins the installed distribution declares, for one variant. */
|
|
112
|
+
export function readDistributionProfile(distributionDirectory, editionId, extraExcludes = []) {
|
|
113
|
+
const facts = readDistributionFacts(distributionDirectory);
|
|
114
|
+
const projection = projectEdition(facts, editionId, extraExcludes);
|
|
115
|
+
return {
|
|
116
|
+
name: facts.name,
|
|
117
|
+
version: facts.version,
|
|
118
|
+
dshRange: '>=' + facts.runtimeVersion,
|
|
119
|
+
runtimeVersion: facts.runtimeVersion,
|
|
120
|
+
bundles: projection.bundles,
|
|
121
|
+
dependencies: projection.dependencies,
|
|
122
|
+
allowBuilds: projection.allowBuilds,
|
|
123
|
+
overrides: projection.overrides,
|
|
124
|
+
omittedPackages: projection.omittedPackages,
|
|
125
|
+
editions: facts.editions,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Give the profile its own installed tree, built from the distribution's declarations.
|
|
130
|
+
*
|
|
131
|
+
* The launcher resolves a bundle from the profile directory, so the profile needs its
|
|
132
|
+
* own `node_modules`. Pointing it at the consumer's tree was cheaper, but it made the
|
|
133
|
+
* profile inherit whatever npm had already installed — including the official packages
|
|
134
|
+
* the distribution's `overrides` exist to replace. npm applies `overrides` only from a
|
|
135
|
+
* project's own root, so a profile without its own tree cannot receive them at all, and
|
|
136
|
+
* a patch delivered that way silently never arrives.
|
|
137
|
+
*
|
|
138
|
+
* Installing here makes the profile that root: pnpm reads `overrides` from the
|
|
139
|
+
* profile's own `pnpm-workspace.yaml`, which `writeProfileOverrides` writes before this
|
|
140
|
+
* runs. The install is skipped once the tree exists so a start does not pay for it
|
|
141
|
+
* twice; `dsh-plus apply` remains the command that reinstalls after a change.
|
|
142
|
+
*
|
|
143
|
+
* @param paths - resolved standalone paths.
|
|
144
|
+
*/
|
|
145
|
+
export function installProfilePackages(paths, omitted = new Set(), runtimeVersion, registry) {
|
|
146
|
+
// The profile owns its dependency tree: it declares the launcher and every plugin, so it can
|
|
147
|
+
// install without a consumer tree beside it. A globally installed distribution has none --
|
|
148
|
+
// its only node_modules is the one npm laid out around the command itself.
|
|
149
|
+
const profileModules = join(paths.profileDirectory, 'node_modules');
|
|
150
|
+
if (existsSync(profileModules)) {
|
|
151
|
+
// A profile installed before the distribution declared overrides still needs the
|
|
152
|
+
// packages it reaches from the consumer tree, which npm nested rather than hoisted.
|
|
153
|
+
linkNestedBundles(paths, profileModules);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
installWithRegistryFallback(paths.profileDirectory);
|
|
157
|
+
// A range admits the next prerelease, so the official family lands one generation ahead of
|
|
158
|
+
// the runtime the republished builds were cut against. Realign, then finish the tree.
|
|
159
|
+
if (runtimeVersion !== undefined && registry !== undefined) {
|
|
160
|
+
alignOfficialVersions(paths.profileDirectory, runtimeVersion, registry);
|
|
161
|
+
}
|
|
162
|
+
alignReplacedPackageNames(profileModules, omitted);
|
|
163
|
+
linkNestedBundles(paths, profileModules);
|
|
164
|
+
assertProfileBundles(paths.profileDirectory);
|
|
165
|
+
}
|
|
166
|
+
/** Package names a package declares as peers on the official runtime family. */
|
|
167
|
+
function officialPeers(profileDirectory) {
|
|
168
|
+
const peers = new Set();
|
|
169
|
+
const manifests = [];
|
|
170
|
+
for (const scope of [join(profileDirectory, 'node_modules'), join(profileDirectory, 'node_modules', '@deepseek-ai')]) {
|
|
171
|
+
if (!existsSync(scope))
|
|
172
|
+
continue;
|
|
173
|
+
for (const entry of readdirSync(scope)) {
|
|
174
|
+
if (entry.startsWith('.'))
|
|
175
|
+
continue;
|
|
176
|
+
if (entry.startsWith('@') && entry !== '@deepseek-ai')
|
|
177
|
+
continue;
|
|
178
|
+
if (entry === '@deepseek-ai') {
|
|
179
|
+
for (const scoped of readdirSync(scope))
|
|
180
|
+
manifests.push(join(scope, scoped, 'package.json'));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
manifests.push(join(scope, entry, 'package.json'));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const manifestPath of manifests) {
|
|
187
|
+
if (!existsSync(manifestPath))
|
|
188
|
+
continue;
|
|
189
|
+
try {
|
|
190
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
191
|
+
for (const peer of Object.keys(manifest.peerDependencies ?? {})) {
|
|
192
|
+
if (peer.startsWith('@deepseek-ai/dsh') && peer !== '@deepseek-ai/dsh')
|
|
193
|
+
peers.add(peer);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// A package whose manifest cannot be read declares no peer this step can act on.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return peers;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Which of the given package versions a registry serves.
|
|
204
|
+
*
|
|
205
|
+
* The probe runs in one Node child: one process per name is slow, and the caller needs the
|
|
206
|
+
* answer before the second install.
|
|
207
|
+
*
|
|
208
|
+
* @param names - package names to check.
|
|
209
|
+
* @param version - exact version each must serve.
|
|
210
|
+
* @param registry - registry base URL.
|
|
211
|
+
* @returns the names the registry serves at that version.
|
|
212
|
+
*/
|
|
213
|
+
function servedVersions(names, version, registry) {
|
|
214
|
+
if (names.length === 0)
|
|
215
|
+
return new Set();
|
|
216
|
+
const script = `
|
|
217
|
+
const [names, version, registry] = JSON.parse(process.argv[1])
|
|
218
|
+
const base = registry.replace(/\\/$/, '')
|
|
219
|
+
const queue = [...names]
|
|
220
|
+
const served = []
|
|
221
|
+
await Promise.all(Array.from({ length: 10 }, async () => {
|
|
222
|
+
while (queue.length > 0) {
|
|
223
|
+
const name = queue.shift()
|
|
224
|
+
try {
|
|
225
|
+
const response = await fetch(base + '/' + name.replace('/', '%2f') + '/' + version)
|
|
226
|
+
if (response.ok) served.push(name)
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
}))
|
|
230
|
+
process.stdout.write(served.join('\\n'))
|
|
231
|
+
`;
|
|
232
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script, JSON.stringify([names, version, registry])], { encoding: 'utf8' });
|
|
233
|
+
if (result.status !== 0)
|
|
234
|
+
return new Set();
|
|
235
|
+
return new Set(result.stdout.split('\n').map(entry => entry.trim()).filter(entry => entry !== ''));
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Move the shared official services onto the runtime's exact version.
|
|
239
|
+
*
|
|
240
|
+
* The republished builds were cut against one runtime, and DSH disables a row when the exact
|
|
241
|
+
* peers a plugin declares disagree with the process it shares. Plugins declare those services as
|
|
242
|
+
* peers, so the names worth pinning are the peers actually installed -- read from the tree and
|
|
243
|
+
* checked against the registry. The rest of the official family keeps its resolved versions:
|
|
244
|
+
* pinning everything traded one mismatch for a tree with holes.
|
|
245
|
+
*
|
|
246
|
+
* @param profileDirectory - profile whose tree was just installed.
|
|
247
|
+
* @param runtimeVersion - exact runtime the profile boots.
|
|
248
|
+
* @param registry - registry the profile installs from.
|
|
249
|
+
* @returns how many services were realigned.
|
|
250
|
+
*/
|
|
251
|
+
export function alignOfficialVersions(profileDirectory, runtimeVersion, registry) {
|
|
252
|
+
const wanted = [...officialPeers(profileDirectory)];
|
|
253
|
+
const served = servedVersions(wanted, runtimeVersion, registry);
|
|
254
|
+
if (served.size === 0)
|
|
255
|
+
return 0;
|
|
256
|
+
const workspacePath = join(profileDirectory, 'pnpm-workspace.yaml');
|
|
257
|
+
const document = parseDocument(readFileSync(workspacePath, 'utf8'));
|
|
258
|
+
let changed = 0;
|
|
259
|
+
for (const name of served) {
|
|
260
|
+
if (document.getIn(['overrides', name]) === runtimeVersion)
|
|
261
|
+
continue;
|
|
262
|
+
document.setIn(['overrides', name], runtimeVersion);
|
|
263
|
+
changed += 1;
|
|
264
|
+
}
|
|
265
|
+
if (changed === 0)
|
|
266
|
+
return 0;
|
|
267
|
+
writeFileSync(workspacePath, String(document));
|
|
268
|
+
rmSync(join(profileDirectory, 'pnpm-lock.yaml'), { force: true });
|
|
269
|
+
installWithRegistryFallback(profileDirectory);
|
|
270
|
+
return changed;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Assert every bundle row the profile declares resolves in its installed tree.
|
|
274
|
+
*
|
|
275
|
+
* A row without an installed package mounts nothing and reports nothing: the client module
|
|
276
|
+
* system simply never registers it, and every earlier step passes. Reading the rows back from
|
|
277
|
+
* the manifest on disk rather than from the projection is what makes this cover the tree the
|
|
278
|
+
* launcher actually boots.
|
|
279
|
+
*
|
|
280
|
+
* @param profileDirectory - profile whose node_modules was just installed.
|
|
281
|
+
*/
|
|
282
|
+
export function assertProfileBundles(profileDirectory) {
|
|
283
|
+
const manifest = JSON.parse(readFileSync(join(profileDirectory, 'package.json'), 'utf8'));
|
|
284
|
+
const missing = [];
|
|
285
|
+
for (const name of manifest.dsh?.profile?.bundles ?? []) {
|
|
286
|
+
if (!existsSync(join(profileDirectory, 'node_modules', name, 'package.json')))
|
|
287
|
+
missing.push(name);
|
|
288
|
+
}
|
|
289
|
+
if (missing.length > 0) {
|
|
290
|
+
throw new Error('the profile mounts bundles it did not install: ' + missing.join(', '));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Install the profile from the first registry that answers.
|
|
295
|
+
*
|
|
296
|
+
* The install fetches a full dependency closure, and a mainland consumer reaches the
|
|
297
|
+
* mirror far faster than the origin. Trying the preferred registry and falling back once
|
|
298
|
+
* keeps a first start quick without failing when the preferred one is unreachable.
|
|
299
|
+
*
|
|
300
|
+
* @param paths - resolved standalone paths.
|
|
301
|
+
*/
|
|
302
|
+
function installWithRegistryFallback(profileDirectory) {
|
|
303
|
+
const registries = registryOrder();
|
|
304
|
+
for (const [index, registry] of registries.entries()) {
|
|
305
|
+
const result = spawnSync('pnpm', ['install', '--no-frozen-lockfile', '--registry', registry], {
|
|
306
|
+
cwd: profileDirectory,
|
|
307
|
+
stdio: 'inherit',
|
|
308
|
+
shell: process.platform === 'win32',
|
|
309
|
+
});
|
|
310
|
+
if (result.error !== undefined)
|
|
311
|
+
throw result.error;
|
|
312
|
+
if (result.status === 0)
|
|
313
|
+
return;
|
|
314
|
+
const next = registries[index + 1];
|
|
315
|
+
if (next === undefined) {
|
|
316
|
+
throw new Error('pnpm install in the plus profile failed with exit code ' + String(result.status));
|
|
317
|
+
}
|
|
318
|
+
console.log('Install from ' + registry + ' failed; trying ' + next + '.');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Make each replaced package declare the name of the location it occupies.
|
|
323
|
+
*
|
|
324
|
+
* \`overrides\` installs our build at the official path, but the manifest inside still
|
|
325
|
+
* names our scope. The client module system resolves a loader entry's declared name and
|
|
326
|
+
* then requires the manifest it finds to declare that same name
|
|
327
|
+
* (\`client/modules\`: \`name === expectedPackageName\`); a mismatch makes the package own no
|
|
328
|
+
* browser module at all. The symptom is silent — the packages install, the server starts,
|
|
329
|
+
* and the panels those packages render simply never appear.
|
|
330
|
+
*
|
|
331
|
+
* The rewrite replaces the file rather than writing through it: pnpm hard-links a package
|
|
332
|
+
* manifest into its content-addressed store, so an in-place write would edit every
|
|
333
|
+
* profile sharing that store entry.
|
|
334
|
+
*
|
|
335
|
+
* @param profileModules - the profile's \`node_modules\` directory.
|
|
336
|
+
*/
|
|
337
|
+
export function alignReplacedPackageNames(profileModules, omitted = new Set()) {
|
|
338
|
+
if (!existsSync(profileModules))
|
|
339
|
+
return;
|
|
340
|
+
// A replaced package is installed at the name its loader row declares, but the manifest inside
|
|
341
|
+
// still names our scope: the client module system requires the directory's package to declare
|
|
342
|
+
// the directory name, so the panels it renders would never register. Both scopes are walked
|
|
343
|
+
// because a row can be an unscoped alias (`dsh-better-sidebar`) onto a scoped build.
|
|
344
|
+
const align = (expected, manifestPath) => {
|
|
345
|
+
if (!existsSync(manifestPath))
|
|
346
|
+
return;
|
|
347
|
+
const declared = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
348
|
+
if (declared.name === expected || omitted.has(expected))
|
|
349
|
+
return;
|
|
350
|
+
const temporary = manifestPath + '.dsh-name';
|
|
351
|
+
writeFileSync(temporary, JSON.stringify({ ...declared, name: expected }, null, 2) + '\n');
|
|
352
|
+
renameSync(temporary, manifestPath);
|
|
353
|
+
};
|
|
354
|
+
for (const entry of readdirSync(profileModules)) {
|
|
355
|
+
if (entry.startsWith('.'))
|
|
356
|
+
continue;
|
|
357
|
+
if (!entry.startsWith('@')) {
|
|
358
|
+
// An unscoped row is an npm alias onto a scoped build. The package imports its real
|
|
359
|
+
// scoped name, so renaming it would break its own imports; exposing the scoped name as
|
|
360
|
+
// a link is what makes both the row and the package resolve.
|
|
361
|
+
const manifestPath = join(profileModules, entry, 'package.json');
|
|
362
|
+
if (!existsSync(manifestPath))
|
|
363
|
+
continue;
|
|
364
|
+
const declared = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
365
|
+
if (declared.name === undefined || declared.name === entry)
|
|
366
|
+
continue;
|
|
367
|
+
const [scope, name] = declared.name.split('/');
|
|
368
|
+
if (scope === undefined || name === undefined)
|
|
369
|
+
continue;
|
|
370
|
+
mkdirSync(join(profileModules, scope), { recursive: true });
|
|
371
|
+
const link = join(profileModules, scope, name);
|
|
372
|
+
if (existsSync(link))
|
|
373
|
+
continue;
|
|
374
|
+
try {
|
|
375
|
+
symlinkSync(join('..', entry), link, 'junction');
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// A concurrent install may have created the same link; the existing one is equivalent.
|
|
379
|
+
}
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
for (const scoped of readdirSync(join(profileModules, entry))) {
|
|
383
|
+
align(entry + '/' + scoped, join(profileModules, entry, scoped, 'package.json'));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** The npm registry the distribution installs from by default. */
|
|
388
|
+
const OFFICIAL_REGISTRY = 'https://registry.npmjs.org';
|
|
389
|
+
/** The mainland mirror, which serves the same packages and answers faster there. */
|
|
390
|
+
const MAINLAND_REGISTRY = 'https://registry.npmmirror.com';
|
|
391
|
+
/**
|
|
392
|
+
* The registries to try, in order, for this machine.
|
|
393
|
+
*
|
|
394
|
+
* A mainland locale reaches the mirror faster than the origin, which is why the Desktop
|
|
395
|
+
* installer already prefers it there. The same choice belongs to the profile install: it
|
|
396
|
+
* fetches a full dependency closure, so the delay is the first thing a consumer notices.
|
|
397
|
+
* `DSH_PLUS_INSTALL_REGISTRY` overrides the choice, and a failure falls back once.
|
|
398
|
+
*
|
|
399
|
+
* @returns registries to try in order.
|
|
400
|
+
*/
|
|
401
|
+
export function registryOrder() {
|
|
402
|
+
const configured = process.env.DSH_PLUS_INSTALL_REGISTRY;
|
|
403
|
+
if (configured !== undefined && configured !== '')
|
|
404
|
+
return [configured, MAINLAND_REGISTRY];
|
|
405
|
+
// Intl reports the system locale on every platform. The POSIX variables are empty on
|
|
406
|
+
// Windows, so reading them alone classified every Windows console as non-mainland and
|
|
407
|
+
// reached the origin first — measured in a consumer's log, which showed npmjs failing
|
|
408
|
+
// before the mirror answered.
|
|
409
|
+
const locale = [process.env.LANG, process.env.LC_ALL, Intl.DateTimeFormat().resolvedOptions().locale]
|
|
410
|
+
.filter(value => value !== undefined)
|
|
411
|
+
.join(' ')
|
|
412
|
+
.toLowerCase();
|
|
413
|
+
const mainlandFirst = locale.includes('zh') || locale.includes('cn');
|
|
414
|
+
return mainlandFirst ? [MAINLAND_REGISTRY, OFFICIAL_REGISTRY] : [OFFICIAL_REGISTRY, MAINLAND_REGISTRY];
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Report whether pnpm can run.
|
|
418
|
+
*
|
|
419
|
+
* The profile installs its own tree so its \`overrides\` apply, and only pnpm reads those
|
|
420
|
+
* from a workspace, so pnpm is a prerequisite the distribution cannot supply. Probing
|
|
421
|
+
* first turns a cryptic failure from the install into a message naming what is missing.
|
|
422
|
+
*
|
|
423
|
+
* @returns \`true\` when pnpm answers with a version.
|
|
424
|
+
*/
|
|
425
|
+
export function pnpmAvailable() {
|
|
426
|
+
const probe = spawnSync('pnpm', ['--version'], {
|
|
427
|
+
stdio: 'pipe',
|
|
428
|
+
shell: process.platform === 'win32',
|
|
429
|
+
encoding: 'utf8',
|
|
430
|
+
});
|
|
431
|
+
return probe.status === 0;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* The commands that install pnpm, in the order worth trying.
|
|
435
|
+
*
|
|
436
|
+
* Corepack ships with Node and needs no download, so it comes first; npm is the fallback
|
|
437
|
+
* for an installation whose Corepack is absent or disabled. Both are the consumer's own
|
|
438
|
+
* toolchain, which is what lets the first start offer to install rather than only report.
|
|
439
|
+
*
|
|
440
|
+
* @returns commands to try in order, stopping at the first that works.
|
|
441
|
+
*/
|
|
442
|
+
export function pnpmInstallCommands() {
|
|
443
|
+
const registry = registryOrder()[0];
|
|
444
|
+
if (registry === undefined)
|
|
445
|
+
return ['corepack enable pnpm', 'npm install -g pnpm'];
|
|
446
|
+
// Both installers take the registry explicitly. npm does so with a flag; corepack reads
|
|
447
|
+
// COREPACK_NPM_REGISTRY, which the caller sets. A mainland consumer therefore downloads
|
|
448
|
+
// the package from the mirror, for the same reason the profile install prefers it.
|
|
449
|
+
return ['corepack enable pnpm', 'npm install -g pnpm --registry ' + registry];
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The command to show a consumer who declines the automatic install.
|
|
453
|
+
*
|
|
454
|
+
* @returns the preferred command, which is the one the offer runs first.
|
|
455
|
+
*/
|
|
456
|
+
export function pnpmInstallCommand() {
|
|
457
|
+
return pnpmInstallCommands()[0] ?? 'npm install -g pnpm';
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Expose the distribution's nested packages at the top level the profile searches.
|
|
461
|
+
*
|
|
462
|
+
* The profile resolves a bundle from one directory, so a package npm nested under the
|
|
463
|
+
* distribution is invisible there even though the installation carries it. Linking each
|
|
464
|
+
* one beside the hoisted packages keeps a single installed copy and needs no reinstall.
|
|
465
|
+
*
|
|
466
|
+
* @param paths - resolved standalone paths.
|
|
467
|
+
* @param consumerModules - the consumer's `node_modules` directory.
|
|
468
|
+
*/
|
|
469
|
+
function linkNestedBundles(paths, consumerModules) {
|
|
470
|
+
const nested = join(paths.distributionDirectory, 'node_modules');
|
|
471
|
+
if (!existsSync(nested))
|
|
472
|
+
return;
|
|
473
|
+
for (const entry of readdirSync(nested)) {
|
|
474
|
+
if (entry.startsWith('@')) {
|
|
475
|
+
for (const scoped of readdirSync(join(nested, entry))) {
|
|
476
|
+
linkBundle(consumerModules, join(entry, scoped), join(nested, entry, scoped));
|
|
477
|
+
}
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
linkBundle(consumerModules, entry, join(nested, entry));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** Link one nested bundle into the consumer's top level when it is not already there. */
|
|
484
|
+
function linkBundle(consumerModules, name, source) {
|
|
485
|
+
const destination = join(consumerModules, name);
|
|
486
|
+
if (existsSync(destination))
|
|
487
|
+
return;
|
|
488
|
+
try {
|
|
489
|
+
symlinkSync(source, destination, 'junction');
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// A concurrent start may have created the same link; the existing one is equivalent.
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/** Resolve every path a command needs, without creating anything. */
|
|
496
|
+
export function resolvePaths(anchor, env = process.env, editionId) {
|
|
497
|
+
const home = resolveHome(env);
|
|
498
|
+
const distributionDirectory = resolveDistributionDirectory(anchor);
|
|
499
|
+
// The edition is a fact of the distribution: it names the profile and the capabilities the
|
|
500
|
+
// deployment omits. No edition means the full distribution and its default profile.
|
|
501
|
+
let profileName = DEFAULT_PROFILE;
|
|
502
|
+
let omittedPackages = {};
|
|
503
|
+
if (editionId !== undefined) {
|
|
504
|
+
const projection = projectEdition(readDistributionFacts(distributionDirectory), editionId);
|
|
505
|
+
profileName = projection.profileName;
|
|
506
|
+
omittedPackages = projection.omittedPackages;
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
home,
|
|
510
|
+
profileName,
|
|
511
|
+
omittedPackages,
|
|
512
|
+
profileDirectory: join(home, 'profiles', profileName),
|
|
513
|
+
distributionDirectory,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Write the profile manifest when it is absent, leaving an existing one untouched.
|
|
518
|
+
*
|
|
519
|
+
* The distribution's own plugin dependencies are seeded here so the launcher can
|
|
520
|
+
* resolve every bundle from the profile directory: a bundle the profile cannot
|
|
521
|
+
* resolve fails activation with a module-resolution error, and no step in the
|
|
522
|
+
* launcher expands the distribution's bundle list on its own.
|
|
523
|
+
*
|
|
524
|
+
* The profile also links the consumer's installed packages, because the launcher
|
|
525
|
+
* resolves each bundle from the profile directory rather than from the directory
|
|
526
|
+
* that installed them.
|
|
527
|
+
*
|
|
528
|
+
* @param paths - resolved standalone paths.
|
|
529
|
+
* @param editionId - product edition to materialize.
|
|
530
|
+
* @param extraExcludes - packages the caller drops on top of the edition's own exclusions.
|
|
531
|
+
* @param dryRun - write the profile files without installing their dependency tree.
|
|
532
|
+
* @returns whether this call created the manifest.
|
|
533
|
+
*/
|
|
534
|
+
export function ensureProfile(paths, editionId, extraExcludes = [], dryRun = false) {
|
|
535
|
+
const manifestPath = join(paths.profileDirectory, 'package.json');
|
|
536
|
+
const distribution = readDistributionProfile(paths.distributionDirectory, editionId, extraExcludes);
|
|
537
|
+
if (existsSync(manifestPath)) {
|
|
538
|
+
// The workspace carries decisions the distribution owns — overrides and the build
|
|
539
|
+
// script allowlist — and a distribution release changes them. Rewriting on every
|
|
540
|
+
// start is what lets an upgraded installation receive the new values; a profile
|
|
541
|
+
// written once keeps whatever its own release decided and can never be corrected.
|
|
542
|
+
writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds, { ...paths.omittedPackages, ...distribution.omittedPackages });
|
|
543
|
+
if (!dryRun) {
|
|
544
|
+
installProfilePackages(paths, new Set(Object.keys(distribution.omittedPackages)), distribution.runtimeVersion, registryOrder()[0]);
|
|
545
|
+
}
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
mkdirSync(paths.profileDirectory, { recursive: true });
|
|
549
|
+
const manifest = {
|
|
550
|
+
name: 'dsh-profile-' + paths.profileName,
|
|
551
|
+
private: true,
|
|
552
|
+
type: 'module',
|
|
553
|
+
dependencies: {
|
|
554
|
+
'@sparkelf/dsh-plus': distribution.version,
|
|
555
|
+
// The launcher's own tree supplies every service package the bundles mount. A
|
|
556
|
+
// profile that lists only the distribution's plugins installs a partial tree and
|
|
557
|
+
// fails at load with a module the launcher would have carried.
|
|
558
|
+
'@deepseek-ai/dsh': distribution.dshRange,
|
|
559
|
+
...distribution.dependencies,
|
|
560
|
+
},
|
|
561
|
+
dsh: {
|
|
562
|
+
profile: {
|
|
563
|
+
bundles: distribution.bundles,
|
|
564
|
+
patchReload: 'live',
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
569
|
+
// The overrides must reach the workspace before the install that reads them.
|
|
570
|
+
writeProfileOverrides(paths.profileDirectory, distribution.overrides, distribution.allowBuilds, { ...paths.omittedPackages, ...distribution.omittedPackages });
|
|
571
|
+
if (!dryRun) {
|
|
572
|
+
installProfilePackages(paths, new Set(Object.keys(distribution.omittedPackages)), distribution.runtimeVersion, registryOrder()[0]);
|
|
573
|
+
}
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Record the distribution's package substitutions in the profile workspace.
|
|
578
|
+
*
|
|
579
|
+
* pnpm reads \`overrides\` from \`pnpm-workspace.yaml\` since version 10 and ignores the
|
|
580
|
+
* same key in \`package.json\`, so a profile that carried it in the manifest would
|
|
581
|
+
* silently install the official package the override meant to replace.
|
|
582
|
+
*
|
|
583
|
+
* @param profileDirectory - the standalone profile directory.
|
|
584
|
+
* @param overrides - official package name to published replacement spec.
|
|
585
|
+
*/
|
|
586
|
+
function writeProfileOverrides(profileDirectory, overrides, allowBuilds, omittedPackages) {
|
|
587
|
+
const workspacePath = join(profileDirectory, 'pnpm-workspace.yaml');
|
|
588
|
+
const document = parseDocument(existsSync(workspacePath) ? readFileSync(workspacePath, 'utf8') : '');
|
|
589
|
+
const [documentError] = document.errors;
|
|
590
|
+
if (documentError !== undefined)
|
|
591
|
+
throw new Error('Plus profile workspace is not valid YAML', { cause: documentError });
|
|
592
|
+
if (document.get('packages') === undefined)
|
|
593
|
+
document.set('packages', ['.']);
|
|
594
|
+
for (const [name, spec] of Object.entries(overrides))
|
|
595
|
+
document.setIn(['overrides', name], spec);
|
|
596
|
+
// A capability the deployment omits is substituted rather than deleted, because npm's
|
|
597
|
+
// override has no removal form. Writing it here keeps the omission in the one place
|
|
598
|
+
// pnpm reads, so an install never receives the capability the variant excluded.
|
|
599
|
+
for (const [name, spec] of Object.entries(omittedPackages))
|
|
600
|
+
document.setIn(['overrides', name], spec);
|
|
601
|
+
// pnpm refuses an install whose packages want to run build scripts until each is
|
|
602
|
+
// decided, so the distribution's reviewed decisions travel with the install rather
|
|
603
|
+
// than waiting for an interactive approval no start can offer.
|
|
604
|
+
for (const [name, allowed] of Object.entries(allowBuilds))
|
|
605
|
+
document.setIn(['allowBuilds', name], allowed);
|
|
606
|
+
// The profile resolves bundles from this directory, so peers the official tree would
|
|
607
|
+
// supply have to come from what the consumer installed.
|
|
608
|
+
if (document.get('nodeLinker') === undefined)
|
|
609
|
+
document.set('nodeLinker', 'hoisted');
|
|
610
|
+
if (document.get('autoInstallPeers') === undefined)
|
|
611
|
+
document.set('autoInstallPeers', false);
|
|
612
|
+
writeFileSync(workspacePath, String(document));
|
|
613
|
+
}
|
|
614
|
+
/** Run git in one directory, returning undefined instead of throwing when asked to. */
|
|
615
|
+
function git(root, args, acceptFailure = false) {
|
|
616
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
617
|
+
if (result.status === 0)
|
|
618
|
+
return result.stdout.trim();
|
|
619
|
+
if (acceptFailure)
|
|
620
|
+
return undefined;
|
|
621
|
+
const detail = result.stderr.trim();
|
|
622
|
+
throw new Error('git ' + args.join(' ') + ' failed' + (detail === '' ? '' : ': ' + detail));
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Apply the reviewed npm-target patches to the profile's installed packages.
|
|
626
|
+
*
|
|
627
|
+
* A standalone installation runs no `apply` step: it installs the distribution from
|
|
628
|
+
* the registry, links the consumer's packages, and starts. The npm patches a
|
|
629
|
+
* distribution declares therefore need an owner that does not require an official
|
|
630
|
+
* source checkout — the source half of the apply step needs one, this does not.
|
|
631
|
+
*
|
|
632
|
+
* The work is idempotent: a patch whose reverse already applies is left alone, so a
|
|
633
|
+
* second start neither re-applies nor fails. A reinstall restores the published bytes,
|
|
634
|
+
* which is why this runs on every start rather than once.
|
|
635
|
+
*
|
|
636
|
+
* @param distributionDirectory - the installed `@sparkelf/dsh-plus` directory.
|
|
637
|
+
* @param profileDirectory - the standalone profile directory.
|
|
638
|
+
* @returns the labels of the patches that were applied.
|
|
639
|
+
*/
|
|
640
|
+
export function applyProfileNpmPatches(distributionDirectory, profileDirectory) {
|
|
641
|
+
const manifest = requireRecord(JSON.parse(readFileSync(join(distributionDirectory, 'package.json'), 'utf8')), 'Plus distribution manifest');
|
|
642
|
+
const patchSet = readPatchSet(distributionDirectory, manifest);
|
|
643
|
+
const applied = [];
|
|
644
|
+
for (const entry of [...patchSet.entries, ...patchSet.thirdPartyPatches]) {
|
|
645
|
+
if (entry.target.kind !== 'npm')
|
|
646
|
+
continue;
|
|
647
|
+
const targetName = entry.target.name;
|
|
648
|
+
const patched = resolveInstalledPackage(profileDirectory, targetName);
|
|
649
|
+
const file = resolve(distributionDirectory, entry.file);
|
|
650
|
+
if (git(patched.directory, ['apply', '--reverse', '--check', file], true) !== undefined)
|
|
651
|
+
continue;
|
|
652
|
+
git(patched.directory, ['apply', file]);
|
|
653
|
+
applied.push(entry.id + ' -> ' + targetName);
|
|
654
|
+
}
|
|
655
|
+
return applied;
|
|
656
|
+
}
|
|
657
|
+
/** Resolve one installed package's manifest from a requiring directory. */
|
|
658
|
+
function resolveInstalledPackage(from, packageName) {
|
|
659
|
+
const requireFrom = createRequire(join(from, 'package.json'));
|
|
660
|
+
let manifestPath;
|
|
661
|
+
try {
|
|
662
|
+
manifestPath = requireFrom.resolve(packageName + '/package.json');
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
throw new Error(packageName + ' is not installed under ' + from);
|
|
666
|
+
}
|
|
667
|
+
const manifest = requireRecord(JSON.parse(readFileSync(manifestPath, 'utf8')), packageName + ' manifest');
|
|
668
|
+
return { name: packageName, version: String(manifest.version), directory: dirname(manifestPath), manifest };
|
|
669
|
+
}
|
|
670
|
+
//# sourceMappingURL=profile.js.map
|