@chatcode/cco-market 1.45.1
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/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- package/src/verify.ts +492 -0
package/lib/profile.js
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile filesystem reads — everything the market learns from a dsh
|
|
3
|
+
* profile directory (manifest, lockfile, installed package trees). Pure
|
|
4
|
+
* functions of the directory contents; no processes, no network.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
8
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
9
|
+
import { resolveChatCodeHome } from './home-paths.js';
|
|
10
|
+
import { githubRemoteIdentities, githubRepoIdentities } from './sources.js';
|
|
11
|
+
/**
|
|
12
|
+
* Whether a profile name follows DSH's own directory-name contract.
|
|
13
|
+
*
|
|
14
|
+
* Keep this aligned with `@deepseek-ai/dsh-app-boot`'s
|
|
15
|
+
* `resolveProfileDir`: dots, spaces, and Unicode are ordinary name
|
|
16
|
+
* characters; only empty, traversal-shaped, launcher-owned, or
|
|
17
|
+
* separator-bearing names are refused.
|
|
18
|
+
*/
|
|
19
|
+
export function isDshProfileName(profile) {
|
|
20
|
+
return profile !== ''
|
|
21
|
+
&& profile !== '.'
|
|
22
|
+
&& profile !== '..'
|
|
23
|
+
&& profile !== 'node_modules'
|
|
24
|
+
&& !profile.includes('/')
|
|
25
|
+
&& !profile.includes('\\')
|
|
26
|
+
&& !profile.includes('\0');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a profile name beneath the canonical ChatCode CLI Home.
|
|
30
|
+
* An explicit directory is used by hosts, such as DSH Desktop, that own the
|
|
31
|
+
* active profile location rather than deriving it from process environment.
|
|
32
|
+
*/
|
|
33
|
+
export function profileDir(profile, explicitDir) {
|
|
34
|
+
if (explicitDir !== undefined)
|
|
35
|
+
return explicitDir;
|
|
36
|
+
if (!isDshProfileName(profile))
|
|
37
|
+
throw new Error(`dsh-market: invalid profile name ${JSON.stringify(profile)}`);
|
|
38
|
+
return join(resolveChatCodeHome(), 'profiles', profile);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The in-box bundles dsh's profile templates install themselves — the ONLY
|
|
42
|
+
* names the market hides from the installed list. Community plugins may
|
|
43
|
+
* legitimately publish under the official scope (#28), so a whole-scope
|
|
44
|
+
* filter would make them invisible and fail install validation.
|
|
45
|
+
* (Diagnosis and fix proposed in #28 by @Lograthmic.)
|
|
46
|
+
*/
|
|
47
|
+
export const INBOX_BUNDLES = new Set([
|
|
48
|
+
'@deepseek-ai/dsh-base',
|
|
49
|
+
'@deepseek-ai/dsh-web-app',
|
|
50
|
+
'@deepseek-ai/dsh-headless',
|
|
51
|
+
]);
|
|
52
|
+
/** Community dependencies of the profile (in-box bundles filtered out). */
|
|
53
|
+
export function readInstalled(profile, explicitDir) {
|
|
54
|
+
try {
|
|
55
|
+
const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'package.json'), 'utf8'));
|
|
56
|
+
const installed = {};
|
|
57
|
+
for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
|
|
58
|
+
if (!INBOX_BUNDLES.has(name))
|
|
59
|
+
installed[name] = spec;
|
|
60
|
+
}
|
|
61
|
+
return installed;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* RAW dependency map of the profile manifest — including the in-box bundles
|
|
69
|
+
* readInstalled() filters out. This is the rollback snapshot (#65): restoring
|
|
70
|
+
* a filtered view would delete @deepseek-ai/dsh-base and friends.
|
|
71
|
+
*/
|
|
72
|
+
export function readManifestDeps(profile, explicitDir) {
|
|
73
|
+
try {
|
|
74
|
+
const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'package.json'), 'utf8'));
|
|
75
|
+
return { ...manifest.dependencies };
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function objectRecord(value) {
|
|
82
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
83
|
+
? value
|
|
84
|
+
: undefined;
|
|
85
|
+
}
|
|
86
|
+
/** Read dependencies and the exact `dsh.profile.bundles` field before a package operation. */
|
|
87
|
+
export function readProfileManifestSnapshot(profile, explicitDir) {
|
|
88
|
+
try {
|
|
89
|
+
const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'package.json'), 'utf8'));
|
|
90
|
+
const profileManifest = objectRecord(manifest.dsh?.profile);
|
|
91
|
+
const present = profileManifest !== undefined && Object.hasOwn(profileManifest, 'bundles');
|
|
92
|
+
return {
|
|
93
|
+
dependencies: { ...manifest.dependencies },
|
|
94
|
+
profileBundles: present
|
|
95
|
+
? { present: true, value: structuredClone(profileManifest.bundles) }
|
|
96
|
+
: { present: false },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { dependencies: {}, profileBundles: { present: false } };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** String package names carried by one valid bundle-list value. */
|
|
104
|
+
function bundleNames(value) {
|
|
105
|
+
return Array.isArray(value) ? value.filter((name) => typeof name === 'string') : [];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Restore the profile manifest fields a package operation may mutate:
|
|
109
|
+
* `dependencies` and `dsh.profile.bundles`. pnpm and `dsh plugin add` can
|
|
110
|
+
* write both before a later fetch or build-script failure (#65, #69, #339),
|
|
111
|
+
* leaving either an unresolvable dependency or a bundle the next boot cannot
|
|
112
|
+
* activate. Every unrelated manifest field remains untouched. The lockfile is
|
|
113
|
+
* left as-is; pnpm reconciles it from the manifest on the next run.
|
|
114
|
+
*
|
|
115
|
+
* The write is atomic because rollback runs after another operation already
|
|
116
|
+
* failed; a partial repair must not turn a valid profile into invalid JSON.
|
|
117
|
+
* @returns names whose entries were dropped or reverted, empty when nothing changed.
|
|
118
|
+
*/
|
|
119
|
+
export function restoreProfileManifest(profile, snapshot, explicitDir) {
|
|
120
|
+
const file = join(profileDir(profile, explicitDir), 'package.json');
|
|
121
|
+
let manifest;
|
|
122
|
+
try {
|
|
123
|
+
manifest = JSON.parse(readFileSync(file, 'utf8'));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const current = manifest.dependencies ?? {};
|
|
129
|
+
const touched = new Set();
|
|
130
|
+
for (const name of Object.keys(current)) {
|
|
131
|
+
if (current[name] !== snapshot.dependencies[name])
|
|
132
|
+
touched.add(name);
|
|
133
|
+
}
|
|
134
|
+
for (const name of Object.keys(snapshot.dependencies)) {
|
|
135
|
+
if (current[name] !== snapshot.dependencies[name])
|
|
136
|
+
touched.add(name);
|
|
137
|
+
}
|
|
138
|
+
const currentDsh = objectRecord(manifest.dsh);
|
|
139
|
+
const currentProfile = objectRecord(currentDsh?.profile);
|
|
140
|
+
const currentBundles = currentProfile !== undefined && Object.hasOwn(currentProfile, 'bundles')
|
|
141
|
+
? { present: true, value: currentProfile.bundles }
|
|
142
|
+
: { present: false };
|
|
143
|
+
const bundlesChanged = currentBundles.present !== snapshot.profileBundles.present
|
|
144
|
+
|| (currentBundles.present && snapshot.profileBundles.present
|
|
145
|
+
&& !isDeepStrictEqual(currentBundles.value, snapshot.profileBundles.value));
|
|
146
|
+
if (bundlesChanged) {
|
|
147
|
+
const currentNames = new Set(currentBundles.present ? bundleNames(currentBundles.value) : []);
|
|
148
|
+
const snapshotNames = new Set(snapshot.profileBundles.present ? bundleNames(snapshot.profileBundles.value) : []);
|
|
149
|
+
let namedBundleChange = false;
|
|
150
|
+
for (const name of currentNames) {
|
|
151
|
+
if (!snapshotNames.has(name)) {
|
|
152
|
+
touched.add(name);
|
|
153
|
+
namedBundleChange = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const name of snapshotNames) {
|
|
157
|
+
if (!currentNames.has(name)) {
|
|
158
|
+
touched.add(name);
|
|
159
|
+
namedBundleChange = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Presence, order, duplicates, or a malformed non-array value can differ
|
|
163
|
+
// without changing the set of package names. Still report that rollback.
|
|
164
|
+
if (!namedBundleChange)
|
|
165
|
+
touched.add('dsh.profile.bundles');
|
|
166
|
+
}
|
|
167
|
+
if (touched.size === 0)
|
|
168
|
+
return [];
|
|
169
|
+
manifest.dependencies = { ...snapshot.dependencies };
|
|
170
|
+
if (snapshot.profileBundles.present) {
|
|
171
|
+
const dsh = currentDsh ?? {};
|
|
172
|
+
const profileManifest = currentProfile ?? {};
|
|
173
|
+
manifest.dsh = dsh;
|
|
174
|
+
dsh.profile = profileManifest;
|
|
175
|
+
profileManifest.bundles = structuredClone(snapshot.profileBundles.value);
|
|
176
|
+
}
|
|
177
|
+
else if (currentProfile !== undefined) {
|
|
178
|
+
delete currentProfile.bundles;
|
|
179
|
+
}
|
|
180
|
+
writeManifestAtomic(file, manifest);
|
|
181
|
+
return [...touched];
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Remove a package from BOTH manifest lists — dependencies and
|
|
185
|
+
* dsh.profile.bundles. The uninstall counterpart of restoreProfileManifest:
|
|
186
|
+
* pnpm can fail a remove after deleting node_modules but before saving
|
|
187
|
+
* package.json (the #65 write-order's mirror image — a file locked mid-
|
|
188
|
+
* unlink aborts the run), leaving the manifest pointing at a package that
|
|
189
|
+
* no longer exists on disk. The next boot then fails to activate the ghost
|
|
190
|
+
* dependency. When disk truth says the package is gone, this finishes the
|
|
191
|
+
* removal the CLI could not. Every other manifest field is untouched.
|
|
192
|
+
*
|
|
193
|
+
* Written atomically because it runs only after something already went wrong
|
|
194
|
+
* mid-uninstall, so it is the worst place to leave a half-written manifest.
|
|
195
|
+
* @returns true when either list still mentioned the package.
|
|
196
|
+
*/
|
|
197
|
+
export function dropFromManifest(profile, name, explicitDir) {
|
|
198
|
+
const file = join(profileDir(profile, explicitDir), 'package.json');
|
|
199
|
+
let manifest;
|
|
200
|
+
try {
|
|
201
|
+
manifest = JSON.parse(readFileSync(file, 'utf8'));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
let touched = false;
|
|
207
|
+
if (manifest.dependencies !== undefined && manifest.dependencies[name] !== undefined) {
|
|
208
|
+
delete manifest.dependencies[name];
|
|
209
|
+
touched = true;
|
|
210
|
+
}
|
|
211
|
+
const bundles = manifest.dsh?.profile?.bundles;
|
|
212
|
+
if (Array.isArray(bundles) && bundles.includes(name)) {
|
|
213
|
+
manifest.dsh.profile.bundles = bundles.filter(bundle => bundle !== name);
|
|
214
|
+
touched = true;
|
|
215
|
+
}
|
|
216
|
+
if (!touched)
|
|
217
|
+
return false;
|
|
218
|
+
writeManifestAtomic(file, manifest);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
/** The version actually present in the profile's node_modules, or null. */
|
|
222
|
+
export function readInstalledVersion(profile, name, explicitDir) {
|
|
223
|
+
try {
|
|
224
|
+
const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'node_modules', name, 'package.json'), 'utf8'));
|
|
225
|
+
return manifest.version ?? null;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** The installed package manifest, or null when absent or malformed. */
|
|
232
|
+
export function readInstalledManifest(profile, name, explicitDir) {
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'node_modules', name, 'package.json'), 'utf8'));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Whether a package or one of its direct dependencies ships a native addon.
|
|
242
|
+
*
|
|
243
|
+
* The question behind it: can unloading this plugin actually free its files?
|
|
244
|
+
* For ordinary JavaScript, yes — and on POSIX it does not even matter,
|
|
245
|
+
* because replacing an open file leaves the old inode to whoever holds it.
|
|
246
|
+
* For a native addon it is no on both counts: Node has no dlclose, so once a
|
|
247
|
+
* `.node` is loaded the process holds it until it exits. On Windows that
|
|
248
|
+
* turns "uninstall, then install again" into an EPERM on the rename, which
|
|
249
|
+
* is what @yandidan1 hit with node-hid (#441) — and no amount of disabling,
|
|
250
|
+
* unmounting or uninstalling from inside the running process can fix it.
|
|
251
|
+
*
|
|
252
|
+
* Deliberately a cheap structural check rather than a scan. Walking a
|
|
253
|
+
* dependency's tree for `*.node` means recursing through packages that can
|
|
254
|
+
* be tens of thousands of files, on the uninstall path, to answer a question
|
|
255
|
+
* three `existsSync` calls answer for every native module built or shipped
|
|
256
|
+
* the conventional way: node-gyp's `build/Release`, prebuild's `prebuilds/`,
|
|
257
|
+
* and the `binding.gyp` that names the addon in the first place.
|
|
258
|
+
*
|
|
259
|
+
* Direct dependencies are included because that is where these live: the
|
|
260
|
+
* plugin is JavaScript and the addon is a package it depends on, hoisted to
|
|
261
|
+
* the profile root beside it.
|
|
262
|
+
* @param profile - profile name.
|
|
263
|
+
* @param name - the installed package to ask about.
|
|
264
|
+
* @param explicitDir - resolved profile directory, when the caller has it.
|
|
265
|
+
* @returns true when a native addon is present in the package or a direct dependency.
|
|
266
|
+
*/
|
|
267
|
+
export function holdsNativeAddon(profile, name, explicitDir) {
|
|
268
|
+
const modules = join(profileDir(profile, explicitDir), 'node_modules');
|
|
269
|
+
const shipsAddon = (packageName) => {
|
|
270
|
+
const dir = join(modules, packageName);
|
|
271
|
+
return existsSync(join(dir, 'build', 'Release'))
|
|
272
|
+
|| existsSync(join(dir, 'prebuilds'))
|
|
273
|
+
|| existsSync(join(dir, 'binding.gyp'));
|
|
274
|
+
};
|
|
275
|
+
if (shipsAddon(name))
|
|
276
|
+
return true;
|
|
277
|
+
const manifest = readInstalledManifest(profile, name, explicitDir);
|
|
278
|
+
if (manifest === null || typeof manifest !== 'object')
|
|
279
|
+
return false;
|
|
280
|
+
const dependencies = manifest.dependencies;
|
|
281
|
+
if (dependencies === null || typeof dependencies !== 'object')
|
|
282
|
+
return false;
|
|
283
|
+
return Object.keys(dependencies)
|
|
284
|
+
.filter(dependency => PACKAGE_NAME_RE.test(dependency))
|
|
285
|
+
.some(shipsAddon);
|
|
286
|
+
}
|
|
287
|
+
const PACKAGE_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
288
|
+
function localSpecDirectory(root, spec) {
|
|
289
|
+
const match = /^(?:link|file):(.+)$/i.exec(spec);
|
|
290
|
+
if (match === null)
|
|
291
|
+
return null;
|
|
292
|
+
let path = match[1];
|
|
293
|
+
try {
|
|
294
|
+
path = decodeURIComponent(path);
|
|
295
|
+
}
|
|
296
|
+
catch { /* keep the literal pnpm path */ }
|
|
297
|
+
// file:// URLs are uncommon in profile manifests; reject them rather than
|
|
298
|
+
// guessing across platforms. Normal pnpm link:/file: directory specs reach
|
|
299
|
+
// this code as absolute or profile-relative filesystem paths.
|
|
300
|
+
if (path.startsWith('//'))
|
|
301
|
+
return null;
|
|
302
|
+
const candidate = isAbsolute(path) ? path : resolve(root, path);
|
|
303
|
+
try {
|
|
304
|
+
return statSync(candidate).isDirectory() ? realpathSync(candidate) : null;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function installedPackageDirectory(root, name) {
|
|
311
|
+
try {
|
|
312
|
+
const candidate = join(root, 'node_modules', name);
|
|
313
|
+
return statSync(candidate).isDirectory() ? realpathSync(candidate) : null;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function manifestAt(dir) {
|
|
320
|
+
try {
|
|
321
|
+
const value = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
322
|
+
return typeof value === 'object' && value !== null ? value : null;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function manifestRepository(manifest) {
|
|
329
|
+
const repository = manifest?.repository;
|
|
330
|
+
if (typeof repository === 'string')
|
|
331
|
+
return { url: repository, directory: null };
|
|
332
|
+
if (typeof repository !== 'object' || repository === null)
|
|
333
|
+
return null;
|
|
334
|
+
const value = repository;
|
|
335
|
+
if (typeof value.url !== 'string')
|
|
336
|
+
return null;
|
|
337
|
+
return { url: value.url, directory: typeof value.directory === 'string' ? value.directory : null };
|
|
338
|
+
}
|
|
339
|
+
function gitConfigPath(marker, worktreeRoot) {
|
|
340
|
+
try {
|
|
341
|
+
if (statSync(marker).isDirectory()) {
|
|
342
|
+
const direct = join(marker, 'config');
|
|
343
|
+
return existsSync(direct) ? direct : null;
|
|
344
|
+
}
|
|
345
|
+
const pointer = /^gitdir:\s*(.+)$/im.exec(readFileSync(marker, 'utf8'));
|
|
346
|
+
if (pointer === null)
|
|
347
|
+
return null;
|
|
348
|
+
const gitDir = resolve(worktreeRoot, pointer[1].trim());
|
|
349
|
+
const direct = join(gitDir, 'config');
|
|
350
|
+
if (existsSync(direct))
|
|
351
|
+
return direct;
|
|
352
|
+
const commonDir = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
|
|
353
|
+
const common = join(resolve(gitDir, commonDir), 'config');
|
|
354
|
+
return existsSync(common) ? common : null;
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function originFromConfig(file) {
|
|
361
|
+
try {
|
|
362
|
+
let origin = false;
|
|
363
|
+
for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
364
|
+
const section = /^\s*\[remote\s+"([^"]+)"\]\s*$/.exec(line);
|
|
365
|
+
if (section !== null) {
|
|
366
|
+
origin = section[1] === 'origin';
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (!origin)
|
|
370
|
+
continue;
|
|
371
|
+
const url = /^\s*url\s*=\s*(.+?)\s*$/.exec(line);
|
|
372
|
+
if (url !== null)
|
|
373
|
+
return url[1];
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch { /* unreadable git metadata carries no identity */ }
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
function gitCheckout(start) {
|
|
380
|
+
let current = start;
|
|
381
|
+
while (true) {
|
|
382
|
+
const marker = join(current, '.git');
|
|
383
|
+
if (existsSync(marker)) {
|
|
384
|
+
const config = gitConfigPath(marker, current);
|
|
385
|
+
const origin = config === null ? null : originFromConfig(config);
|
|
386
|
+
return origin === null ? null : { root: current, origin };
|
|
387
|
+
}
|
|
388
|
+
const parent = dirname(current);
|
|
389
|
+
if (parent === current)
|
|
390
|
+
return null;
|
|
391
|
+
current = parent;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function checkoutSubpath(root, packageDir) {
|
|
395
|
+
const value = relative(root, packageDir).replaceAll('\\', '/');
|
|
396
|
+
return value === '' || value === '.' || value.startsWith('../') ? null : value;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Strong repository identities for a locally linked dependency (#141).
|
|
400
|
+
* Explicit github: specs already carry this evidence; only link:/file: need
|
|
401
|
+
* filesystem discovery. This compatibility wrapper returns only declared
|
|
402
|
+
* package.json identities; Git origins are exposed separately as hints.
|
|
403
|
+
*/
|
|
404
|
+
export function readInstalledRepoIdentities(profile, name, spec, explicitDir) {
|
|
405
|
+
return readInstalledRepoEvidence(profile, name, spec, explicitDir).identities;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Discover declared repository identities and weaker local-origin hints. A
|
|
409
|
+
* package.json repository declaration is authoritative; Git origin is only a
|
|
410
|
+
* disambiguation hint because a checkout may legitimately point at a fork.
|
|
411
|
+
*/
|
|
412
|
+
export function readInstalledRepoEvidence(profile, name, spec, explicitDir) {
|
|
413
|
+
if (!PACKAGE_NAME_RE.test(name) || !/^(?:link|file):/i.test(spec))
|
|
414
|
+
return { identities: [], hints: [] };
|
|
415
|
+
const root = profileDir(profile, explicitDir);
|
|
416
|
+
const sourceDir = localSpecDirectory(root, spec);
|
|
417
|
+
const installedDir = installedPackageDirectory(root, name);
|
|
418
|
+
const manifestDir = installedDir ?? sourceDir;
|
|
419
|
+
const manifest = manifestDir === null ? readInstalledManifest(profile, name, explicitDir) : manifestAt(manifestDir);
|
|
420
|
+
const repository = manifestRepository(typeof manifest === 'object' && manifest !== null ? manifest : null);
|
|
421
|
+
const checkoutDir = sourceDir ?? (installedDir !== null && /^(?:link):/i.test(spec) ? installedDir : null);
|
|
422
|
+
const checkout = checkoutDir === null ? null : gitCheckout(checkoutDir);
|
|
423
|
+
if (repository !== null) {
|
|
424
|
+
const identities = githubRepoIdentities(repository.url, repository.directory);
|
|
425
|
+
if (identities.length > 0)
|
|
426
|
+
return { identities, hints: [] };
|
|
427
|
+
}
|
|
428
|
+
if (checkout !== null) {
|
|
429
|
+
return { identities: [], hints: githubRemoteIdentities(checkout.origin, checkoutSubpath(checkout.root, checkoutDir)) };
|
|
430
|
+
}
|
|
431
|
+
// node_modules is intentionally not searched upward for .git: a copied
|
|
432
|
+
// file: package may sit inside an unrelated profile checkout. Only the
|
|
433
|
+
// explicit local source directory is valid Git-origin evidence.
|
|
434
|
+
return { identities: [], hints: [] };
|
|
435
|
+
}
|
|
436
|
+
/** Pinned commit per `owner/repo` from the profile lockfile's codeload tarball URLs. */
|
|
437
|
+
export function readLockCommits(profile, explicitDir) {
|
|
438
|
+
const commits = new Map();
|
|
439
|
+
try {
|
|
440
|
+
const lock = readFileSync(join(profileDir(profile, explicitDir), 'pnpm-lock.yaml'), 'utf8');
|
|
441
|
+
for (const m of lock.matchAll(/codeload\.github\.com\/([^/\s]+\/[^/\s]+)\/tar\.gz\/([0-9a-f]{40})/g)) {
|
|
442
|
+
commits.set(m[1].toLowerCase(), m[2]);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch { /* no lockfile — no git installs to report */ }
|
|
446
|
+
return commits;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Commit recorded for a non-codeload git resolution (`type: git` in pnpm's
|
|
450
|
+
* lockfile). Matched against the install spec so a Gitea/GitLab URL can
|
|
451
|
+
* compare HEAD without mistaking a same-named npm package (#525).
|
|
452
|
+
*/
|
|
453
|
+
export function readGitResolutionCommit(profile, spec, explicitDir) {
|
|
454
|
+
const want = normalizeGitRepoKey(spec);
|
|
455
|
+
if (want === null)
|
|
456
|
+
return null;
|
|
457
|
+
try {
|
|
458
|
+
const lock = readFileSync(join(profileDir(profile, explicitDir), 'pnpm-lock.yaml'), 'utf8');
|
|
459
|
+
for (const m of lock.matchAll(/resolution:\s*\{([^}]*)\}/g)) {
|
|
460
|
+
const body = m[1];
|
|
461
|
+
const commit = /\bcommit:\s*([0-9a-f]{40})\b/i.exec(body);
|
|
462
|
+
const repo = /\brepo:\s*([^\s,}]+)/.exec(body);
|
|
463
|
+
if (commit === null || repo === null)
|
|
464
|
+
continue;
|
|
465
|
+
if (normalizeGitRepoKey(repo[1]) === want)
|
|
466
|
+
return commit[1].toLowerCase();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
catch { /* no lockfile */ }
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
/** Lowercased transport-agnostic key for comparing two git remote spellings. */
|
|
473
|
+
function normalizeGitRepoKey(spec) {
|
|
474
|
+
let remote = spec.trim().replace(/^git\+/i, '');
|
|
475
|
+
const scp = /^git@([^:]+):(.+)$/.exec(remote);
|
|
476
|
+
if (scp !== null)
|
|
477
|
+
remote = `https://${scp[1]}/${scp[2].replace(/^\/*/, '')}`;
|
|
478
|
+
const hash = remote.indexOf('#');
|
|
479
|
+
if (hash !== -1)
|
|
480
|
+
remote = remote.slice(0, hash);
|
|
481
|
+
const query = remote.indexOf('?');
|
|
482
|
+
if (query !== -1)
|
|
483
|
+
remote = remote.slice(0, query);
|
|
484
|
+
remote = remote.replace(/\/+$/, '').toLowerCase();
|
|
485
|
+
if (!/^https?:\/\//.test(remote) && !remote.includes('.git'))
|
|
486
|
+
return null;
|
|
487
|
+
return remote;
|
|
488
|
+
}
|
|
489
|
+
/** True when the installed package's manifest declares a dsh plugin surface. */
|
|
490
|
+
export function hasDshManifest(dir) {
|
|
491
|
+
try {
|
|
492
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
493
|
+
return manifest.dsh !== undefined;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* True when the package's declared entry artifact actually exists — github
|
|
501
|
+
* source checkouts of build-required plugins ship no lib/, and promoting one
|
|
502
|
+
* into the bundle layer bricks the next boot (ERR_MODULE_NOT_FOUND kills the
|
|
503
|
+
* whole profile, #18).
|
|
504
|
+
*/
|
|
505
|
+
export function entryArtifactExists(dir) {
|
|
506
|
+
try {
|
|
507
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
508
|
+
const candidates = [];
|
|
509
|
+
if (typeof manifest.main === 'string')
|
|
510
|
+
candidates.push(manifest.main);
|
|
511
|
+
const rootExport = typeof manifest.exports === 'string'
|
|
512
|
+
? manifest.exports
|
|
513
|
+
: manifest.exports?.['.'];
|
|
514
|
+
if (typeof rootExport === 'string')
|
|
515
|
+
candidates.push(rootExport);
|
|
516
|
+
else if (rootExport !== null && typeof rootExport === 'object') {
|
|
517
|
+
for (const value of Object.values(rootExport))
|
|
518
|
+
if (typeof value === 'string')
|
|
519
|
+
candidates.push(value);
|
|
520
|
+
}
|
|
521
|
+
if (candidates.length === 0)
|
|
522
|
+
candidates.push('index.js');
|
|
523
|
+
return candidates.some(rel => existsSync(join(dir, rel)));
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Package names a bundle patch mounts — the `name:` rows of the package's
|
|
531
|
+
* declared `dsh.bundle.patch` file. Line-wise on purpose: the strict
|
|
532
|
+
* hot-mount parser rejects config/expression rows, but for "what does this
|
|
533
|
+
* bundle bring in" any name row counts.
|
|
534
|
+
*/
|
|
535
|
+
export function bundlePatchTargets(dir) {
|
|
536
|
+
return readBundlePatchRows(dir).names;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Loader entry ids a bundle patch inserts. Cordis refuses to boot a tree
|
|
540
|
+
* with a duplicate entry id ("duplicate loader entry id: storage", #122), so
|
|
541
|
+
* these are what two bundles can collide on.
|
|
542
|
+
*/
|
|
543
|
+
export function bundlePatchEntryIds(dir) {
|
|
544
|
+
return readBundlePatchRows(dir).ids;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Loader entry ids the patch INSERTS — the rows the package owns, as opposed
|
|
548
|
+
* to rows of OTHER plugins it merely configures (#147).
|
|
549
|
+
*
|
|
550
|
+
* A bundle patch has two kinds of entry:
|
|
551
|
+
*
|
|
552
|
+
* - insert: ← rows this package brings into the tree
|
|
553
|
+
* - id: vision-router
|
|
554
|
+
* name: dsh-vision-router
|
|
555
|
+
* - id: attachment-local ← someone else's row, only reconfigured
|
|
556
|
+
* config: { maxImageBytes: … }
|
|
557
|
+
*
|
|
558
|
+
* Treating both as "this package's rows" made disabling one plugin write
|
|
559
|
+
* `disabled: true` onto the official rows it tuned — killing attachments and
|
|
560
|
+
* the DeepSeek model with it.
|
|
561
|
+
*/
|
|
562
|
+
export function bundlePatchInsertedIds(dir) {
|
|
563
|
+
return readBundlePatchRows(dir).insertedIds;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* `name:` and `id:` rows of the package's declared bundle patch. Line-wise
|
|
567
|
+
* on purpose: the strict hot-mount parser rejects config/expression rows,
|
|
568
|
+
* but for "what does this bundle bring in" any row counts. `insertedIds` is
|
|
569
|
+
* the subset nested under an `insert:` key (#147).
|
|
570
|
+
*/
|
|
571
|
+
/**
|
|
572
|
+
* Rows of one patch file. Exported because a package may ship its patch at
|
|
573
|
+
* the conventional path INSTEAD of declaring `dsh.bundle.patch`, and the
|
|
574
|
+
* patch layer has to read that one by the same rules — a second hand-rolled
|
|
575
|
+
* scan drifted from this one and re-introduced #147 on that path (it closed
|
|
576
|
+
* the insert block only on `id:` lines, so `- disable:` followed by nested
|
|
577
|
+
* ids claimed the neighbour's rows).
|
|
578
|
+
*/
|
|
579
|
+
export function parsePatchRows(text) {
|
|
580
|
+
const names = [];
|
|
581
|
+
const ids = [];
|
|
582
|
+
const insertedIds = [];
|
|
583
|
+
{
|
|
584
|
+
// `insert:` opens a nested list; every id below it, at deeper
|
|
585
|
+
// indentation, is a row this package brings in. A row at or above the
|
|
586
|
+
// `insert:` indentation closes the block — those target OTHER plugins.
|
|
587
|
+
let insertIndent = null;
|
|
588
|
+
// CRLF too, for consistency with the hot-mount parser, which a
|
|
589
|
+
// Windows-authored patch genuinely broke. Here it changes no outcome
|
|
590
|
+
// today — every row pattern below is `^`-anchored and a comment line
|
|
591
|
+
// always starts with `#`, so an unstripped comment matches nothing —
|
|
592
|
+
// and it is deliberately NOT covered by a spec, because a test that
|
|
593
|
+
// passes with or without the change tests nothing.
|
|
594
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
595
|
+
const line = raw.replace(/#.*$/, '');
|
|
596
|
+
if (line.trim() === '')
|
|
597
|
+
continue;
|
|
598
|
+
const indent = line.length - line.trimStart().length;
|
|
599
|
+
if (insertIndent !== null && indent <= insertIndent && !/^\s*-?\s*(id|name|config):/u.test(line)) {
|
|
600
|
+
insertIndent = null;
|
|
601
|
+
}
|
|
602
|
+
if (/^\s*-?\s*insert:\s*$/u.test(line)) {
|
|
603
|
+
insertIndent = indent;
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const name = /^\s*-?\s*name:\s*['"]?([^'"\s]+)/.exec(line);
|
|
607
|
+
if (name !== null && !names.includes(name[1]))
|
|
608
|
+
names.push(name[1]);
|
|
609
|
+
const id = /^\s*-?\s*id:\s*['"]?([^'"\s]+)/.exec(line);
|
|
610
|
+
if (id !== null) {
|
|
611
|
+
if (!ids.includes(id[1]))
|
|
612
|
+
ids.push(id[1]);
|
|
613
|
+
// A top-level `- id:` row closes any open insert block: it is a
|
|
614
|
+
// sibling of `- insert:`, not a member of it.
|
|
615
|
+
if (insertIndent !== null && indent > insertIndent) {
|
|
616
|
+
if (!insertedIds.includes(id[1]))
|
|
617
|
+
insertedIds.push(id[1]);
|
|
618
|
+
}
|
|
619
|
+
else if (indent <= (insertIndent ?? -1)) {
|
|
620
|
+
insertIndent = null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return { names, ids, insertedIds };
|
|
626
|
+
}
|
|
627
|
+
/** Rows of the patch a package DECLARES through `dsh.bundle.patch`. */
|
|
628
|
+
function readBundlePatchRows(dir) {
|
|
629
|
+
const empty = { names: [], ids: [], insertedIds: [] };
|
|
630
|
+
try {
|
|
631
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
632
|
+
const declared = manifest.dsh?.bundle?.patch;
|
|
633
|
+
if (typeof declared !== 'string' || declared === '')
|
|
634
|
+
return empty;
|
|
635
|
+
return parsePatchRows(readFileSync(join(dir, declared), 'utf8'));
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return empty;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
|
|
642
|
+
export function readProfileBundles(profileDirectory) {
|
|
643
|
+
try {
|
|
644
|
+
const manifest = JSON.parse(readFileSync(join(profileDirectory, 'package.json'), 'utf8'));
|
|
645
|
+
const bundles = manifest.dsh?.profile?.bundles;
|
|
646
|
+
return Array.isArray(bundles) ? bundles.filter((name) => typeof name === 'string') : [];
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Write the profile manifest atomically: a temp file in the same directory is
|
|
654
|
+
* written first, then renamed over package.json, so a crash mid-toggle never
|
|
655
|
+
* leaves a half-written manifest (the same guarantee order.ts's writer gives
|
|
656
|
+
* the reorder path). The trailing newline + 2-space indent match how every
|
|
657
|
+
* other writer in this repo serializes the manifest.
|
|
658
|
+
*/
|
|
659
|
+
function writeManifestAtomic(manifestPath, manifest) {
|
|
660
|
+
const temp = `${manifestPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
661
|
+
writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
662
|
+
renameSync(temp, manifestPath);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Drop one bundle from the profile manifest's `dsh.profile.bundles`, leaving
|
|
666
|
+
* the package installed as a dependency. This is the carrier-bundle half of a
|
|
667
|
+
* toggle-off (#224): a bundle whose patch reconfigures plugins it does NOT own
|
|
668
|
+
* (dsh-postgres-backends disables session-persistence-jsonl and reroutes
|
|
669
|
+
* storage-domain) keeps applying those side-effect rows on every boot while it
|
|
670
|
+
* stays in the stack, and the #147 ownership rule deliberately never writes
|
|
671
|
+
* them — so removing the bundle from the stack is the only thing that stops
|
|
672
|
+
* them all at once. The package itself stays installed; enabling re-adds it.
|
|
673
|
+
* @returns true when the bundle was present and removed.
|
|
674
|
+
*/
|
|
675
|
+
export function removeProfileBundle(profileDirectory, name) {
|
|
676
|
+
const manifestPath = join(profileDirectory, 'package.json');
|
|
677
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
678
|
+
const bundles = manifest.dsh?.profile?.bundles;
|
|
679
|
+
if (!Array.isArray(bundles))
|
|
680
|
+
return false;
|
|
681
|
+
const next = bundles.filter((entry) => typeof entry !== 'string' || entry !== name);
|
|
682
|
+
if (next.length === bundles.length)
|
|
683
|
+
return false;
|
|
684
|
+
manifest.dsh ??= {};
|
|
685
|
+
manifest.dsh.profile ??= {};
|
|
686
|
+
manifest.dsh.profile.bundles = next;
|
|
687
|
+
writeManifestAtomic(manifestPath, manifest);
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Re-add a bundle to `dsh.profile.bundles` after a carrier toggle-off (#224).
|
|
692
|
+
* Idempotent: a bundle already present is left untouched. The name is appended
|
|
693
|
+
* (the install flow appends too); the loader re-validates ordering on the next
|
|
694
|
+
* composition, so a declared before/after rule surfaces there rather than here.
|
|
695
|
+
* @returns true when the bundle was added, false when it was already present.
|
|
696
|
+
*/
|
|
697
|
+
export function addProfileBundle(profileDirectory, name) {
|
|
698
|
+
const manifestPath = join(profileDirectory, 'package.json');
|
|
699
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
700
|
+
manifest.dsh ??= {};
|
|
701
|
+
manifest.dsh.profile ??= {};
|
|
702
|
+
const existing = manifest.dsh.profile.bundles;
|
|
703
|
+
const bundles = Array.isArray(existing) ? existing.filter((entry) => typeof entry === 'string') : [];
|
|
704
|
+
if (bundles.includes(name))
|
|
705
|
+
return false;
|
|
706
|
+
bundles.push(name);
|
|
707
|
+
manifest.dsh.profile.bundles = bundles;
|
|
708
|
+
writeManifestAtomic(manifestPath, manifest);
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Loader entry ids a newly added package would collide on with bundles the
|
|
713
|
+
* profile ALREADY loads (#122).
|
|
714
|
+
*
|
|
715
|
+
* Cordis hard-fails the whole tree on a duplicate id, so this is not a
|
|
716
|
+
* cosmetic conflict: installing a TUI bundle into a web profile (both
|
|
717
|
+
* declare `id: storage`) leaves DSH unable to start at all, with an error
|
|
718
|
+
* naming neither plugin. Checked against the profile's own bundle list so a
|
|
719
|
+
* package is never compared with itself.
|
|
720
|
+
* @returns colliding ids mapped to the already-installed bundle that owns them.
|
|
721
|
+
*/
|
|
722
|
+
export function conflictingEntryIds(profileDirectory, candidate, installedBundles) {
|
|
723
|
+
// INSERTED ids on both sides, not every id in the file. What bricks the
|
|
724
|
+
// next boot is two entries created under one id; a row that merely
|
|
725
|
+
// CONFIGURES another plugin's entry creates nothing, so counting it here
|
|
726
|
+
// refuses a legitimate plugin outright — the same distinction #147 drew
|
|
727
|
+
// for the disable path, which this guard was left out of.
|
|
728
|
+
const mine = bundlePatchInsertedIds(join(profileDirectory, 'node_modules', candidate));
|
|
729
|
+
if (mine.length === 0)
|
|
730
|
+
return [];
|
|
731
|
+
const conflicts = [];
|
|
732
|
+
for (const bundle of installedBundles) {
|
|
733
|
+
if (bundle === candidate)
|
|
734
|
+
continue;
|
|
735
|
+
const theirs = new Set(bundlePatchInsertedIds(join(profileDirectory, 'node_modules', bundle)));
|
|
736
|
+
for (const id of mine) {
|
|
737
|
+
if (theirs.has(id) && !conflicts.some(hit => hit.id === id))
|
|
738
|
+
conflicts.push({ id, owner: bundle });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return conflicts;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Whether the loader has anything to load for this package: its own entry
|
|
745
|
+
* artifact, or — for CARRIER bundles — patch rows naming other packages that
|
|
746
|
+
* do have one.
|
|
747
|
+
*
|
|
748
|
+
* Carriers are why `entryArtifactExists` alone is the wrong test (#103):
|
|
749
|
+
* `@linxin666/dsh-skins` ships skin assets plus a patch mounting
|
|
750
|
+
* `@linxin666/dsh-client-ui-skin-center`, and declares no main/exports/
|
|
751
|
+
* index.js of its own. Judged by its own entry it looks like the
|
|
752
|
+
* source-only checkout the #18 guard removes — so the market both flagged it
|
|
753
|
+
* broken AND uninstalled it right after installing.
|
|
754
|
+
* @param profileDirectory - resolved profile directory (host-authoritative under Desktop).
|
|
755
|
+
* @param name - installed package name.
|
|
756
|
+
*/
|
|
757
|
+
export function hasLoadableEntry(profileDirectory, name) {
|
|
758
|
+
const dir = join(profileDirectory, 'node_modules', name);
|
|
759
|
+
if (entryArtifactExists(dir))
|
|
760
|
+
return true;
|
|
761
|
+
// A carrier is only sound when something it mounts is itself loadable.
|
|
762
|
+
// Targets resolve hoisted (the dsh profile default), nested under the
|
|
763
|
+
// carrier, or — #203 — one level up: pnpm hoists shared/in-box packages to
|
|
764
|
+
// `<profiles>/node_modules` when the profile is a workspace member, the
|
|
765
|
+
// same workspace-root fallback readProfileVisibleVersion (check.ts) already
|
|
766
|
+
// uses. A carrier naming an in-box package (@deepseek-ai/dsh-mcp-client and
|
|
767
|
+
// similar) resolves there and nowhere this function used to look, so pnpm
|
|
768
|
+
// exiting 0 was immediately followed by the market removing what it had
|
|
769
|
+
// just, correctly, installed.
|
|
770
|
+
const workspaceRoot = dirname(profileDirectory);
|
|
771
|
+
return bundlePatchTargets(dir)
|
|
772
|
+
.filter(target => target !== name)
|
|
773
|
+
.some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
|
|
774
|
+
|| entryArtifactExists(join(dir, 'node_modules', target))
|
|
775
|
+
|| entryArtifactExists(join(workspaceRoot, 'node_modules', target)));
|
|
776
|
+
}
|
|
777
|
+
/** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
|
|
778
|
+
export function pluginSubdirs(root) {
|
|
779
|
+
const found = [];
|
|
780
|
+
let level1 = [];
|
|
781
|
+
try {
|
|
782
|
+
level1 = readdirSync(root, { withFileTypes: true })
|
|
783
|
+
.filter(dirent => dirent.isDirectory() && /^[A-Za-z0-9_.-]+$/.test(dirent.name) && dirent.name !== 'node_modules')
|
|
784
|
+
.map(dirent => dirent.name);
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
return found;
|
|
788
|
+
}
|
|
789
|
+
for (const sub of level1) {
|
|
790
|
+
if (hasDshManifest(join(root, sub))) {
|
|
791
|
+
found.push(sub);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
try {
|
|
795
|
+
for (const inner of readdirSync(join(root, sub), { withFileTypes: true })) {
|
|
796
|
+
if (!inner.isDirectory() || !/^[A-Za-z0-9_.-]+$/.test(inner.name) || inner.name === 'node_modules')
|
|
797
|
+
continue;
|
|
798
|
+
if (hasDshManifest(join(root, sub, inner.name)))
|
|
799
|
+
found.push(`${sub}/${inner.name}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
catch { /* unreadable level — skip */ }
|
|
803
|
+
if (found.length >= 8)
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
return found.slice(0, 8);
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Allow the given packages' build scripts in the profile's
|
|
810
|
+
* pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
|
|
811
|
+
* merging with existing entries and leaving the rest of the yaml intact.
|
|
812
|
+
* (#6 by @qichuang321.)
|
|
813
|
+
* @returns every package now allowed.
|
|
814
|
+
*/
|
|
815
|
+
/**
|
|
816
|
+
* Quote a YAML block-mapping key when a plain scalar would be invalid.
|
|
817
|
+
* Scoped npm names start with `@` — a reserved YAML indicator — so an
|
|
818
|
+
* unquoted `@scope/pkg: true` entry breaks the whole pnpm-workspace.yaml
|
|
819
|
+
* for every later pnpm run in the profile (and for the market itself).
|
|
820
|
+
* Keys containing `: ` or ending with `:` are quoted for the same reason;
|
|
821
|
+
* git keys like `name@git+https://…` keep their existing plain form.
|
|
822
|
+
*/
|
|
823
|
+
function quoteYamlKey(key) {
|
|
824
|
+
if (/^[-?:,[\]{}#&*!|>'"%@`]/.test(key) || /:(\s|$)/.test(key)) {
|
|
825
|
+
return `'${key.replace(/'/g, "''")}'`;
|
|
826
|
+
}
|
|
827
|
+
return key;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Allow the given packages' build scripts in the profile's
|
|
831
|
+
* pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
|
|
832
|
+
* merging with existing entries and leaving the rest of the yaml intact.
|
|
833
|
+
* (#6 by @qichuang321.)
|
|
834
|
+
* @returns every package now allowed.
|
|
835
|
+
*/
|
|
836
|
+
export function setAllowBuilds(profile, packages, explicitDir) {
|
|
837
|
+
const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml');
|
|
838
|
+
let yaml = '';
|
|
839
|
+
try {
|
|
840
|
+
yaml = readFileSync(file, 'utf8');
|
|
841
|
+
}
|
|
842
|
+
catch { /* created below */ }
|
|
843
|
+
// `\r?\n`, not `\n`: a CRLF pnpm-workspace.yaml (every Windows editor, and
|
|
844
|
+
// git with core.autocrlf=true) put a `\r` between `allowBuilds:` and the
|
|
845
|
+
// newline, so the old pattern never matched an EXISTING block and appended
|
|
846
|
+
// a second one. Two top-level `allowBuilds:` keys is invalid YAML, and pnpm
|
|
847
|
+
// then refuses every install in that profile — not just the one that
|
|
848
|
+
// triggered it (#231 by @MichengAI).
|
|
849
|
+
const blockRe = /allowBuilds:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/g;
|
|
850
|
+
const map = {};
|
|
851
|
+
// Every block, not just the first: a profile already broken by the bug
|
|
852
|
+
// above carries two, and merging them is what repairs it — dropping the
|
|
853
|
+
// extra silently would also drop whatever approvals it held.
|
|
854
|
+
const blockMatches = [...yaml.matchAll(blockRe)];
|
|
855
|
+
const blockMatch = blockMatches[0] ?? null;
|
|
856
|
+
for (const match of blockMatches) {
|
|
857
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
858
|
+
// The key itself may contain colons: git-hosted deps are only matched
|
|
859
|
+
// by a `name@git+https://…` key (#68). The anchored boolean tail makes
|
|
860
|
+
// the split land on the LAST colon, never inside a `://` — and doubles
|
|
861
|
+
// as the placeholder filter: pnpm's failed-install bug (#11535, seen
|
|
862
|
+
// in our #56) writes a literal "set this to true or false" value,
|
|
863
|
+
// which breaks every later approval until the entry is dropped.
|
|
864
|
+
const m = /^[ \t]+(\S.*?)\s*:\s*(true|false)?\s*$/.exec(line);
|
|
865
|
+
if (m === null || m[1] === '')
|
|
866
|
+
continue;
|
|
867
|
+
// Entries this fix wrote may carry single/double quotes around the key
|
|
868
|
+
// (reserved indicators like `@` cannot start a plain scalar); strip
|
|
869
|
+
// them so the map key is the bare package name and a later rewrite
|
|
870
|
+
// never nests quotes.
|
|
871
|
+
let key = m[1];
|
|
872
|
+
if (key.length >= 2
|
|
873
|
+
&& (key[0] === "'" && key[key.length - 1] === "'" || key[0] === '"' && key[key.length - 1] === '"')) {
|
|
874
|
+
key = key.slice(1, -1);
|
|
875
|
+
}
|
|
876
|
+
map[key] = m[2] ?? 'true';
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
// Bare package names, or the server-derived stable git form
|
|
880
|
+
// `name@git+https://github.com/owner/repo.git` (#68) — nothing else.
|
|
881
|
+
const GIT_KEY_RE = /^[A-Za-z0-9@/_.-]+@git\+https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/;
|
|
882
|
+
// The commit-pinned form pnpm below 11.21 matches instead (#285). Held to
|
|
883
|
+
// the same shape as the one above rather than loosened into "anything with
|
|
884
|
+
// a URL in it": this list is what stops a caller writing arbitrary text
|
|
885
|
+
// into a file pnpm parses, and a wider pattern would spend that guarantee
|
|
886
|
+
// to save a line.
|
|
887
|
+
const CODELOAD_KEY_RE = /^[A-Za-z0-9@/_.-]+@https:\/\/codeload\.github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/tar\.gz\/[0-9a-f]{40}$/;
|
|
888
|
+
for (const pkg of packages) {
|
|
889
|
+
if (/^[A-Za-z0-9@/_.-]+$/.test(pkg) || GIT_KEY_RE.test(pkg) || CODELOAD_KEY_RE.test(pkg))
|
|
890
|
+
map[pkg] = 'true';
|
|
891
|
+
}
|
|
892
|
+
// Write back in the file's OWN line ending. Rewriting a CRLF workspace
|
|
893
|
+
// file with LF would leave it mixed, which is the same class of mess this
|
|
894
|
+
// fix exists to clean up.
|
|
895
|
+
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
|
|
896
|
+
const block = Object.entries(map).map(([k, v]) => ` ${quoteYamlKey(k)}: ${v}`).join(eol);
|
|
897
|
+
const blockText = `allowBuilds:${eol}${block}${eol}`;
|
|
898
|
+
let next;
|
|
899
|
+
if (blockMatch === null) {
|
|
900
|
+
next = `${yaml.replace(/\r?\n?$/, eol)}${blockText}`;
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
// The merged block replaces the first occurrence; any further ones are
|
|
904
|
+
// the duplicates this bug created and are dropped — their entries are
|
|
905
|
+
// already folded into `map` above, so nothing is lost.
|
|
906
|
+
let seen = 0;
|
|
907
|
+
next = yaml.replace(blockRe, () => (seen++ === 0 ? blockText : ''));
|
|
908
|
+
}
|
|
909
|
+
writeFileSync(file, next);
|
|
910
|
+
return Object.keys(map);
|
|
911
|
+
}
|