@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/routes.js
ADDED
|
@@ -0,0 +1,4372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP routes bridging the browser market UI to the host. This layer only
|
|
3
|
+
* parses requests, calls the service modules, and serializes responses —
|
|
4
|
+
* process spawning lives in dsh-cli.ts, filesystem reads in profile.ts,
|
|
5
|
+
* orchestration in install.ts / themes.ts / updates.ts.
|
|
6
|
+
*
|
|
7
|
+
* Security: the install route executes a shell command, so it accepts only
|
|
8
|
+
* same-origin POSTs and only sources present in the curated registry.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { Readable } from 'node:stream';
|
|
13
|
+
import { load as loadYaml } from 'js-yaml';
|
|
14
|
+
import { forgetCatalog, loadRegistry, pluginCategories } from './registry.js';
|
|
15
|
+
import { cleanHotDir, hotMount, hotUnmount, listHotMounts, MAX_FAVORITES, MAX_NOTE, mountClientOnlyDeps, purgeMarketState, readMarketState, writeMarketState, } from './hot.js';
|
|
16
|
+
import { createGroup, deleteGroup, removeFromGroups, renameGroup, setGroupMembers } from './groups.js';
|
|
17
|
+
import { dshHostInfo } from './dsh-install.js';
|
|
18
|
+
import { deriveHostCompatibility, DiscoveryManifestIndex } from './discovery-compatibility.js';
|
|
19
|
+
import { configurePersistentLog, exportLogs, logEvent, readPersistentLog } from './log.js';
|
|
20
|
+
import { marketFetch } from './net.js';
|
|
21
|
+
import { diagnosePackageManifests } from './diagnostics.js';
|
|
22
|
+
import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin, TARGET_RE, } from './dsh-cli.js';
|
|
23
|
+
import { addProfileBundle, dropFromManifest, hasLoadableEntry, holdsNativeAddon, INBOX_BUNDLES, isDshProfileName, profileDir, readInstalled, readInstalledManifest, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, restoreProfileManifest, setAllowBuilds } from './profile.js';
|
|
24
|
+
import { assessProfile, classifyPeer, introducedDuplicateNames, introducedRisks } from './compatibility.js';
|
|
25
|
+
import { runningAgentIds } from './agents.js';
|
|
26
|
+
import { analyzeProfile, corePackageNames } from './check.js';
|
|
27
|
+
import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validateOrder } from './order.js';
|
|
28
|
+
import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.js';
|
|
29
|
+
import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
|
|
30
|
+
import { trialValidate } from './trial.js';
|
|
31
|
+
import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
|
|
32
|
+
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareNotAllowed, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
33
|
+
import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
|
|
34
|
+
import { asRegion, githubProxyManaged, normalizeGithubProxy, REGIONS, routesFor, setActiveRegion, setCustomGithubProxy, } from './regions.js';
|
|
35
|
+
import { resolveRegion } from './region-probe.js';
|
|
36
|
+
import { acceleratedTarget, resolveHeadCommit } from './accelerate.js';
|
|
37
|
+
import { updateNotesFor } from './changelog.js';
|
|
38
|
+
import { checkUpdates, compareVersions, fetchNpmLatest, invalidateUpdates, isUpgrade, latestPublishedRecently, setUpdateRegistry, versionOnChannel } from './updates.js';
|
|
39
|
+
import { createThemeManager } from './themes.js';
|
|
40
|
+
import { readJsonBody, sameOrigin, sendJson } from './http.js';
|
|
41
|
+
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest } from './restart.js';
|
|
42
|
+
import { activationAfterReplace, brokenClientBundles, checkClientBundle, hasHostHalf, newlyBrokenBundles, verifyActivation } from './verify.js';
|
|
43
|
+
import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
|
|
44
|
+
import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
|
|
45
|
+
import { createGist, fitsGistLimit, GistError, gistErrorCode, parseGistId, readGist, resolveGistTokenSource, updateGist, verifyGistToken, } from './gist.js';
|
|
46
|
+
import { MAX_UPDATE_OPERATIONS_V1, UpdateOperationStoreV1, UPDATE_API_V1_SCHEMA } from './update-api-v1.js';
|
|
47
|
+
import { findGitToNpmMigration } from './source-migration.js';
|
|
48
|
+
/**
|
|
49
|
+
* Recognize the documented GitHub Release-download target shape. This does
|
|
50
|
+
* not authorize a new URL: install trust remains catalog-bound in sources.ts,
|
|
51
|
+
* while rollback only re-adds the exact direct URL already present in this
|
|
52
|
+
* profile before the update. Keep it route-local and independent of any
|
|
53
|
+
* unverified Desktop sidecar.
|
|
54
|
+
*/
|
|
55
|
+
function isGitHubReleaseTarballSpec(spec) {
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(spec);
|
|
58
|
+
if (url.protocol !== 'https:' || url.hostname !== 'github.com')
|
|
59
|
+
return false;
|
|
60
|
+
const segments = url.pathname.split('/').filter(segment => segment !== '');
|
|
61
|
+
return segments.length >= 6
|
|
62
|
+
&& segments[2] === 'releases'
|
|
63
|
+
&& segments[3] === 'download'
|
|
64
|
+
&& (url.pathname.endsWith('.tgz') || url.pathname.endsWith('.tar.gz'));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The market's own version, read once from its installed package.json.
|
|
72
|
+
*
|
|
73
|
+
* The UI puts this in the page heading so a user's screenshot carries it:
|
|
74
|
+
* most bug reports arrive as a photo of the screen, and without a version
|
|
75
|
+
* in frame the first reply always has to ask which one it was.
|
|
76
|
+
*/
|
|
77
|
+
let cachedVersion = null;
|
|
78
|
+
export function marketVersion() {
|
|
79
|
+
if (cachedVersion !== null)
|
|
80
|
+
return cachedVersion;
|
|
81
|
+
try {
|
|
82
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
83
|
+
cachedVersion = manifest.version ?? 'unknown';
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
cachedVersion = 'unknown';
|
|
87
|
+
}
|
|
88
|
+
return cachedVersion;
|
|
89
|
+
}
|
|
90
|
+
/** The current package name plus legacy names accepted from existing profiles. */
|
|
91
|
+
const SELF_NAME_ORDER = ['@chatcode/cco-market', 'dshmarket', 'dsh-market'];
|
|
92
|
+
const SELF_NAMES = new Set(SELF_NAME_ORDER);
|
|
93
|
+
const isSelfName = (name) => SELF_NAMES.has(name);
|
|
94
|
+
/**
|
|
95
|
+
* Rebuild a GitHub target for an update.
|
|
96
|
+
*
|
|
97
|
+
* A commit pin is dropped so pnpm resolves the repository again — that is the
|
|
98
|
+
* whole point of asking for an update. One valid `path:` selector is kept
|
|
99
|
+
* because it identifies the package inside a monorepo; pnpm permits both in
|
|
100
|
+
* one fragment (`#main&path:/packages/plugin`).
|
|
101
|
+
*
|
|
102
|
+
* A BRANCH or tag is kept, which used to be the same case as a commit and
|
|
103
|
+
* was not (#446 by @Dave-12138). `github:owner/repo#publish` names the line
|
|
104
|
+
* of development the user installed from; dropping it silently moved them to
|
|
105
|
+
* the default branch on the next update — a source change wearing the word
|
|
106
|
+
* "update". A 40-character hex selector is a pin worth discarding, and
|
|
107
|
+
* anything else is a choice worth preserving. A short hex string stays too:
|
|
108
|
+
* it is indistinguishable from a branch named `abc1234`, and keeping a pin
|
|
109
|
+
* by mistake only means the update is a no-op, while dropping a branch by
|
|
110
|
+
* mistake reinstalls different code.
|
|
111
|
+
*/
|
|
112
|
+
function githubUpdateTarget(spec) {
|
|
113
|
+
const fragmentAt = spec.indexOf('#');
|
|
114
|
+
if (fragmentAt === -1)
|
|
115
|
+
return spec;
|
|
116
|
+
const repo = spec.slice(0, fragmentAt);
|
|
117
|
+
let subpath = null;
|
|
118
|
+
let ref = null;
|
|
119
|
+
for (const selector of spec.slice(fragmentAt + 1).split('&')) {
|
|
120
|
+
if (!selector.startsWith('path:/')) {
|
|
121
|
+
// `semver:<range>` selects a release line, so it is preserved for the
|
|
122
|
+
// same reason a branch is.
|
|
123
|
+
const isCommitPin = /^[0-9a-f]{40}$/i.test(selector);
|
|
124
|
+
if (selector !== '' && !isCommitPin) {
|
|
125
|
+
// Two refs in one fragment is not a shape pnpm produces; refuse to
|
|
126
|
+
// guess which one the user meant and fall back to the bare repo.
|
|
127
|
+
if (ref !== null)
|
|
128
|
+
return repo;
|
|
129
|
+
ref = selector;
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const candidate = selector.slice('path:/'.length);
|
|
134
|
+
const valid = /^[A-Za-z0-9_./-]+$/.test(candidate)
|
|
135
|
+
&& !candidate.split('/').some(segment => segment === '' || segment === '.' || segment === '..');
|
|
136
|
+
// Multiple path selectors are ambiguous; an invalid selector is never
|
|
137
|
+
// forwarded to the package manager from a hand-edited profile.
|
|
138
|
+
if (subpath !== null || !valid)
|
|
139
|
+
return repo;
|
|
140
|
+
subpath = candidate;
|
|
141
|
+
}
|
|
142
|
+
const selectors = [...(ref === null ? [] : [ref]), ...(subpath === null ? [] : [`path:/${subpath}`])];
|
|
143
|
+
return selectors.length === 0 ? repo : `${repo}#${selectors.join('&')}`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Whether an installed package declares a client part (`dsh.client`). Its UI
|
|
147
|
+
* is injected into the page, so toggling it needs a browser refresh to show
|
|
148
|
+
* the change — the install flow prompts the same way via the hot banner.
|
|
149
|
+
*/
|
|
150
|
+
function packageHasClientPart(profileDirectory, name) {
|
|
151
|
+
try {
|
|
152
|
+
const manifest = JSON.parse(readFileSync(join(profileDirectory, 'node_modules', name, 'package.json'), 'utf8'));
|
|
153
|
+
return manifest.dsh?.client !== undefined;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Packages whose build scripts pnpm refused to run, from any of its three
|
|
161
|
+
* reporting shapes: the structured ndjson event (pnpm 11), the human
|
|
162
|
+
* "Ignored build scripts:" line, or the fetcher's git-prepare rejection —
|
|
163
|
+
* which fires BEFORE the package lands in node_modules (#68). Undefined when
|
|
164
|
+
* none, so the field can be spread straight into a JSON response.
|
|
165
|
+
*/
|
|
166
|
+
function blockedBuilds(result) {
|
|
167
|
+
if (Array.isArray(result.ignoredBuilds) && result.ignoredBuilds.length > 0)
|
|
168
|
+
return result.ignoredBuilds;
|
|
169
|
+
const list = parseIgnoredBuilds(result.stdout, result.stderr);
|
|
170
|
+
if (list.length > 0)
|
|
171
|
+
return list;
|
|
172
|
+
const pending = parsePrepareNotAllowed(result.stdout, result.stderr);
|
|
173
|
+
return pending !== null ? [pending] : undefined;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Register the market's HTTP routes.
|
|
177
|
+
* @param host - Acquired webServer + shell services.
|
|
178
|
+
* @param config - Validated market configuration.
|
|
179
|
+
* @returns Disposer removing every registered route.
|
|
180
|
+
*/
|
|
181
|
+
export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
182
|
+
let disposed = false;
|
|
183
|
+
// An ordinary profile must resolve under DSH_HOME by the same rules as the
|
|
184
|
+
// DSH CLI. A host-authoritative explicit directory (DSH Desktop) does not
|
|
185
|
+
// derive a path from this display/profile name.
|
|
186
|
+
if (config.profileDirectory === undefined && !isDshProfileName(config.profile)) {
|
|
187
|
+
// Loud on the way out. This throw happens inside a cordis effect, which
|
|
188
|
+
// swallows it: the routes silently never mount and EVERY /dsh-market/*
|
|
189
|
+
// request answers 404 with nothing anywhere saying why — the market
|
|
190
|
+
// simply looks broken (#260 by @realguan). The log line is the only
|
|
191
|
+
// thing that turns that into something diagnosable, so it is written
|
|
192
|
+
// before the throw rather than left to a handler that never runs.
|
|
193
|
+
const message = `ChatCode CLI Market: invalid profile name ${JSON.stringify(config.profile)}; the market's routes were not mounted and every /dsh-market/* request will answer 404. Use the same non-empty, non-traversal profile name accepted by ChatCode CLI, or pass an explicit profile directory.`;
|
|
194
|
+
host.logger?.warn(`[ChatCode CLI Market] ${message}`);
|
|
195
|
+
logEvent('error', 'mount', message);
|
|
196
|
+
throw new Error(message);
|
|
197
|
+
}
|
|
198
|
+
const activeProfileDir = profileDir(config.profile, config.profileDirectory);
|
|
199
|
+
const persistentLogFile = join(activeProfileDir, '.dsh-market', 'log.ndjson');
|
|
200
|
+
const discoveryManifests = new DiscoveryManifestIndex(join(activeProfileDir, '.dsh-market', 'discovery-compatibility-v1.json'));
|
|
201
|
+
configurePersistentLog(persistentLogFile);
|
|
202
|
+
let agentGuardUnavailableLogged = false;
|
|
203
|
+
/** Running-agent ids for the mutation gate; logs once when the host exposes no agents service. */
|
|
204
|
+
const runningAgentsForGuard = () => {
|
|
205
|
+
const service = agentsLookup?.();
|
|
206
|
+
const ids = runningAgentIds(service);
|
|
207
|
+
if (service === undefined && !agentGuardUnavailableLogged) {
|
|
208
|
+
agentGuardUnavailableLogged = true;
|
|
209
|
+
logEvent('warn', 'agent-guard', 'host exposes no agents service — mutations are not guarded while agents run');
|
|
210
|
+
}
|
|
211
|
+
return ids;
|
|
212
|
+
};
|
|
213
|
+
/** Whether the host exposes a usable agents service (readable in /status). */
|
|
214
|
+
const agentsGuardAvailable = () => {
|
|
215
|
+
const service = agentsLookup?.();
|
|
216
|
+
if (service === undefined)
|
|
217
|
+
return false;
|
|
218
|
+
try {
|
|
219
|
+
return Array.isArray(service.list());
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
// The profile's user patch layer (cordis.patch.yml): toggles are written
|
|
226
|
+
// here so DSH's own HMR re-composes the tree (no restart) and the loader
|
|
227
|
+
// re-applies the same choice on every boot (ported from dsh-plugin-hub).
|
|
228
|
+
const userPatchPath = findUserPatchPath(host, activeProfileDir);
|
|
229
|
+
const commands = commandRuntime ?? { runPlugin: runDshPlugin, probePnpm, provisionPnpm, cancelActive };
|
|
230
|
+
const supportsExactRollbackTarget = (target) => commands.supportsExactRollbackTarget?.(target) ?? TARGET_RE.test(target);
|
|
231
|
+
// Snapshot retention cap (issue #98 supplement): a finite positive number
|
|
232
|
+
// from the market config wins; anything else falls back to the default.
|
|
233
|
+
const maxSnapshots = typeof config.maxSnapshots === 'number' && Number.isFinite(config.maxSnapshots) && config.maxSnapshots >= 1
|
|
234
|
+
? Math.floor(config.maxSnapshots)
|
|
235
|
+
: DEFAULT_MAX_SNAPSHOTS;
|
|
236
|
+
// Boot-time wipe: stale hot-mount inputs from a previous session must never
|
|
237
|
+
// survive into a composition where the bundle layer already covers them.
|
|
238
|
+
cleanHotDir(activeProfileDir);
|
|
239
|
+
// The user's persisted choices: the generic disable list (legacy
|
|
240
|
+
// disabledSkins loads transparently) plus custom groups. Every toggle,
|
|
241
|
+
// group, install and uninstall mutates this shared state and persists it.
|
|
242
|
+
const marketState = readMarketState(activeProfileDir);
|
|
243
|
+
setCustomGithubProxy(marketState.githubProxy ?? null);
|
|
244
|
+
const disabled = marketState.disabled;
|
|
245
|
+
/**
|
|
246
|
+
* Packages whose files were replaced while their host half was already
|
|
247
|
+
* running, this process only.
|
|
248
|
+
*
|
|
249
|
+
* Replacing a package on disk does not unload the module Node has already
|
|
250
|
+
* imported — measured against a real host in tests/web/update.e2e.ts, where
|
|
251
|
+
* 2.0.0 is on disk and the process keeps answering as 1.0.0. The update
|
|
252
|
+
* REPLY says so, but the installed listing recomputed activation from the
|
|
253
|
+
* loader's inventory alone, and the loader still lists the name: so the
|
|
254
|
+
* moment the page refreshed, a plugin serving its old build read as `live`
|
|
255
|
+
* and the restart notice vanished.
|
|
256
|
+
*
|
|
257
|
+
* Deliberately not persisted. What it records is a fact about THIS
|
|
258
|
+
* process, and a restart — the thing that resolves it — ends the process.
|
|
259
|
+
*/
|
|
260
|
+
const replacedWhileLive = new Set();
|
|
261
|
+
const groups = marketState.groups;
|
|
262
|
+
const groupOrder = marketState.groupOrder;
|
|
263
|
+
// A choice made in a previous session outranks whatever the entry layer
|
|
264
|
+
// composed, which is only ever a default.
|
|
265
|
+
if (marketState.channel !== undefined)
|
|
266
|
+
config.channel = marketState.channel;
|
|
267
|
+
const activeChannel = () => resolveChannel(config.channel, marketVersion());
|
|
268
|
+
// The download region: which mirrors every outbound request uses.
|
|
269
|
+
//
|
|
270
|
+
// `global` until something decides otherwise, so nothing waits on the
|
|
271
|
+
// network to start serving. A machine with no region on record gets one
|
|
272
|
+
// probed in the background below; a machine that already has one is
|
|
273
|
+
// routed immediately.
|
|
274
|
+
if (marketState.region !== undefined)
|
|
275
|
+
config.region = marketState.region;
|
|
276
|
+
let region = config.region ?? 'global';
|
|
277
|
+
let regionAuto = marketState.regionAuto === true;
|
|
278
|
+
const applyRegion = (next) => {
|
|
279
|
+
region = next;
|
|
280
|
+
// The shared holder every reader consults, plus the one consumer that
|
|
281
|
+
// must also DROP state on a change: update answers gathered from the
|
|
282
|
+
// other registry are not this registry's answers.
|
|
283
|
+
setActiveRegion(next);
|
|
284
|
+
setUpdateRegistry(routesFor(next).npmRegistry);
|
|
285
|
+
};
|
|
286
|
+
applyRegion(region);
|
|
287
|
+
// Probe only when NOTHING has decided a region — not the saved state, and
|
|
288
|
+
// not the composition either. An operator who wrote `region:` into their
|
|
289
|
+
// profile has answered the question the probe exists to ask, and measuring
|
|
290
|
+
// over the top of that answer would quietly override a deliberate choice a
|
|
291
|
+
// few seconds after boot.
|
|
292
|
+
if (config.region === undefined) {
|
|
293
|
+
void resolveRegion(undefined).then(({ region: probed }) => {
|
|
294
|
+
// A manual choice made while the probe was pending, or a replacement
|
|
295
|
+
// mount created after this one was disposed, owns the region now.
|
|
296
|
+
if (disposed || config.region !== undefined)
|
|
297
|
+
return;
|
|
298
|
+
applyRegion(probed);
|
|
299
|
+
regionAuto = true;
|
|
300
|
+
// Persisted as the decision, not re-probed each boot: a market that
|
|
301
|
+
// silently changes routes between runs makes "it was fast yesterday"
|
|
302
|
+
// impossible to investigate.
|
|
303
|
+
marketState.region = probed;
|
|
304
|
+
marketState.regionAuto = true;
|
|
305
|
+
config.region = probed;
|
|
306
|
+
writeMarketState(activeProfileDir, marketState);
|
|
307
|
+
// The listing was fetched before the region was known.
|
|
308
|
+
forgetCatalog();
|
|
309
|
+
invalidateUpdates();
|
|
310
|
+
}).catch(() => { });
|
|
311
|
+
}
|
|
312
|
+
const themes = createThemeManager(host, config.profile, disabled, activeProfileDir);
|
|
313
|
+
/**
|
|
314
|
+
* Re-sync the live closure state from disk. Snapshot restore writes
|
|
315
|
+
* state.json directly (it must, to survive the next boot), which would
|
|
316
|
+
* leave this in-memory `disabled`/`groups`/`groupOrder` stale —
|
|
317
|
+
* the next toggle/groups write would then overwrite the restored values.
|
|
318
|
+
* The objects are mutated in place (clear + refill) so every captured
|
|
319
|
+
* reference (themes manager, live handlers) sees the fresh state (issue
|
|
320
|
+
* #98 review M2).
|
|
321
|
+
*/
|
|
322
|
+
function refreshMarketState() {
|
|
323
|
+
const fresh = readMarketState(activeProfileDir);
|
|
324
|
+
disabled.clear();
|
|
325
|
+
for (const name of fresh.disabled)
|
|
326
|
+
disabled.add(name);
|
|
327
|
+
for (const key of Object.keys(groups))
|
|
328
|
+
delete groups[key];
|
|
329
|
+
Object.assign(groups, fresh.groups);
|
|
330
|
+
groupOrder.length = 0;
|
|
331
|
+
groupOrder.push(...fresh.groupOrder);
|
|
332
|
+
// The three above are aliased objects other closures hold, so they are
|
|
333
|
+
// mutated in place. These three are read off `marketState` itself and
|
|
334
|
+
// were not being refreshed at all — which is #435: a note written
|
|
335
|
+
// through this route reached disk, but `marketState.notes` still held
|
|
336
|
+
// the empty object from boot, and the next write from that object put
|
|
337
|
+
// the empty one back. The note survived a page reload (disk was right)
|
|
338
|
+
// and vanished later, which is exactly what the reporter described.
|
|
339
|
+
marketState.notes = fresh.notes;
|
|
340
|
+
marketState.channel = fresh.channel;
|
|
341
|
+
marketState.region = fresh.region;
|
|
342
|
+
marketState.regionAuto = fresh.regionAuto;
|
|
343
|
+
marketState.favorites = fresh.favorites;
|
|
344
|
+
marketState.githubProxy = fresh.githubProxy;
|
|
345
|
+
setCustomGithubProxy(fresh.githubProxy ?? null);
|
|
346
|
+
}
|
|
347
|
+
// Client-only packages (dsh.client without dsh.bundle) are invisible to the
|
|
348
|
+
// bundle layer in every boot; the market shim-mounts them so their client
|
|
349
|
+
// bundles are actually served.
|
|
350
|
+
void mountClientOnlyDeps(host, activeProfileDir).then(async (mounted) => {
|
|
351
|
+
if (mounted.length > 0)
|
|
352
|
+
logEvent('info', 'boot', `client-only shims mounted: ${mounted.join(', ')}`);
|
|
353
|
+
// Replay the persisted disable list: bundle-layer plugins the user
|
|
354
|
+
// switched away from get live-disabled again (bundle trees are
|
|
355
|
+
// in-memory, so the disable never persists on its own). Client-only
|
|
356
|
+
// shims for disabled plugins were already skipped by mountClientOnlyDeps.
|
|
357
|
+
for (const name of disabled) {
|
|
358
|
+
if (await themes.setEntryDisabled(name, true))
|
|
359
|
+
logEvent('info', 'boot', `plugin kept off: ${name}`);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
// Self-healing guard: dsh's own patch overlay can re-update entries during
|
|
363
|
+
// activation and wipe the runtime disabled flag — whenever a fiber comes
|
|
364
|
+
// up for a plugin the user switched off, put it back down.
|
|
365
|
+
host.on?.('internal/plugin', (fiber) => {
|
|
366
|
+
const name = fiber.entry?.options?.name;
|
|
367
|
+
if (name !== undefined && disabled.has(name))
|
|
368
|
+
void themes.setEntryDisabled(name, true);
|
|
369
|
+
});
|
|
370
|
+
let installing = false;
|
|
371
|
+
let restarting = false;
|
|
372
|
+
// UI-state flags ONLY: mutual exclusion is enforced by withMutationLock
|
|
373
|
+
// below (one promise chain every mutating route appends to), never by
|
|
374
|
+
// these booleans — a promise-chain serialization cannot be raced by
|
|
375
|
+
// interleaved awaits, and a second mutating request answers 409
|
|
376
|
+
// immediately instead of queueing (issue #125 review).
|
|
377
|
+
let writing = false;
|
|
378
|
+
let mutationBusy = false;
|
|
379
|
+
/** The shared mutation chain: every mutating operation appends to it. */
|
|
380
|
+
let mutationChain = Promise.resolve();
|
|
381
|
+
/**
|
|
382
|
+
* Append a lightweight state write to the mutation chain without answering
|
|
383
|
+
* 409 when another operation is in flight. Favorites are catalog bookmarks
|
|
384
|
+
* only — they must stay editable while an install runs (#414).
|
|
385
|
+
*/
|
|
386
|
+
async function withMutationQueued(fn) {
|
|
387
|
+
const run = mutationChain.then(async () => fn());
|
|
388
|
+
mutationChain = run.catch(() => undefined);
|
|
389
|
+
return await run;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Run a mutating operation under the shared mutation lock. `kind` selects
|
|
393
|
+
* the UI busy flag (`install` = pnpm operation, `write` = direct profile
|
|
394
|
+
* write) and the 409 message. The operation runs only after every earlier
|
|
395
|
+
* mutation settled (promise chain); while one is in flight a second
|
|
396
|
+
* mutating request answers 409 immediately — the UI polls /status for the
|
|
397
|
+
* busy flag instead of queueing (issue #125 review).
|
|
398
|
+
* @returns the operation's value, or null when the lock was busy (409 sent).
|
|
399
|
+
*/
|
|
400
|
+
async function withMutationLock(response, kind, fn) {
|
|
401
|
+
if (mutationBusy) {
|
|
402
|
+
sendJson(response, 409, {
|
|
403
|
+
error: kind === 'install' ? 'another install is already running' : 'another plugin operation is running',
|
|
404
|
+
});
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
mutationBusy = true;
|
|
408
|
+
if (kind === 'install')
|
|
409
|
+
installing = true;
|
|
410
|
+
else
|
|
411
|
+
writing = true;
|
|
412
|
+
try {
|
|
413
|
+
const run = mutationChain.then(async () => fn());
|
|
414
|
+
mutationChain = run.catch(() => undefined);
|
|
415
|
+
return await run;
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
mutationBusy = false;
|
|
419
|
+
if (kind === 'install')
|
|
420
|
+
installing = false;
|
|
421
|
+
else
|
|
422
|
+
writing = false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/** Dependency diff vs. a pre-operation snapshot (cancel aftermath). */
|
|
426
|
+
function changedSince(before) {
|
|
427
|
+
const now = readInstalled(config.profile, activeProfileDir);
|
|
428
|
+
const changed = new Set();
|
|
429
|
+
for (const [name, spec] of Object.entries(now))
|
|
430
|
+
if (before[name] !== spec)
|
|
431
|
+
changed.add(name);
|
|
432
|
+
for (const name of Object.keys(before))
|
|
433
|
+
if (now[name] === undefined)
|
|
434
|
+
changed.add(name);
|
|
435
|
+
return { changed: [...changed], partial: changed.size > 0 };
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Apply one enable/disable request: persist the choice in state.json, then
|
|
439
|
+
* drive the live composition. Covers every mount form — hot mounts and
|
|
440
|
+
* client-only shims go through hotUnmount/hotMount, bundle-layer entries
|
|
441
|
+
* through setEntryDisabled. Enabling a THEME goes through the caller's
|
|
442
|
+
* activateTheme instead so the Themes tab's exclusivity stays intact.
|
|
443
|
+
*/
|
|
444
|
+
async function setPluginEnabled(name, enabled) {
|
|
445
|
+
const dir = activeProfileDir;
|
|
446
|
+
if (enabled)
|
|
447
|
+
disabled.delete(name);
|
|
448
|
+
else
|
|
449
|
+
disabled.add(name);
|
|
450
|
+
let ok;
|
|
451
|
+
let reason;
|
|
452
|
+
if (enabled) {
|
|
453
|
+
if (listHotMounts().includes(name)) {
|
|
454
|
+
ok = true;
|
|
455
|
+
}
|
|
456
|
+
else if (await themes.setEntryDisabled(name, false)) {
|
|
457
|
+
ok = true;
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
const result = await hotMount(host, dir, name);
|
|
461
|
+
ok = result.ok;
|
|
462
|
+
reason = result.reason ?? undefined;
|
|
463
|
+
// A mount that succeeded imported the module as it is on disk NOW,
|
|
464
|
+
// so whatever was replaced under the old instance is no longer what
|
|
465
|
+
// this process is serving. Off-and-on is a real way out of the
|
|
466
|
+
// restart notice, and holding it after that would be wrong.
|
|
467
|
+
if (result.ok)
|
|
468
|
+
replacedWhileLive.delete(name);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
ok = await hotUnmount(name) || await themes.setEntryDisabled(name, true);
|
|
473
|
+
if (!ok) {
|
|
474
|
+
// Nothing was live (boot-skipped client shim, user-patch-managed
|
|
475
|
+
// entry, or already off): the persisted flag is the contract.
|
|
476
|
+
ok = true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
writeMarketState(dir, { disabled, groups, groupOrder });
|
|
480
|
+
return { ok, reason };
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Everything live in the running composition: market hot mounts plus
|
|
484
|
+
* bundle-layer loader entries whose fiber is up (loaded at boot). This is
|
|
485
|
+
* the source of truth for verifyActivation's `live` state — without the
|
|
486
|
+
* loader side, every boot-loaded bundle plugin would read as "restart".
|
|
487
|
+
*/
|
|
488
|
+
function liveNames() {
|
|
489
|
+
const live = new Set(listHotMounts());
|
|
490
|
+
for (const entry of host.loader.entries()) {
|
|
491
|
+
if (entry.fiber === undefined)
|
|
492
|
+
continue;
|
|
493
|
+
if (entry.options.name !== undefined)
|
|
494
|
+
live.add(entry.options.name);
|
|
495
|
+
// Entry IDS too, under a `#` prefix that cannot collide with a package
|
|
496
|
+
// name. A CARRIER bundle's row names the package it mounts, not
|
|
497
|
+
// itself (#156: @tt-a1i/archify-dsh inserts an entry named
|
|
498
|
+
// @deepseek-ai/dsh-skill-filesystem), so its own name never appears
|
|
499
|
+
// here — but the id it created does, and that id is unique to its
|
|
500
|
+
// patch. Verification needs both, and putting them in one set means
|
|
501
|
+
// no call site can pass the names and forget the ids.
|
|
502
|
+
if (entry.options.id !== undefined && entry.options.id !== '') {
|
|
503
|
+
live.add(`#${entry.options.id}`);
|
|
504
|
+
// Loader ids may carry an include prefix (`include:archify-…`).
|
|
505
|
+
const bare = entry.options.id.split(':').pop();
|
|
506
|
+
if (bare !== undefined && bare !== entry.options.id)
|
|
507
|
+
live.add(`#${bare}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return live;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Drop live hot mounts whose package was removed outside the market
|
|
514
|
+
* (e.g. `dsh plugin remove` in a terminal): the stale mount would keep
|
|
515
|
+
* serving a client bundle that 404s after refresh, wedging the page
|
|
516
|
+
* until a restart (#29 by @SunYanbox).
|
|
517
|
+
*/
|
|
518
|
+
async function dropStaleHotMounts() {
|
|
519
|
+
for (const name of listHotMounts()) {
|
|
520
|
+
if (existsSync(join(activeProfileDir, 'node_modules', name, 'package.json')))
|
|
521
|
+
continue;
|
|
522
|
+
await hotUnmount(name);
|
|
523
|
+
logEvent('warn', 'hot-sweep', `${name}: package removed outside the market — live mount dropped`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
|
|
527
|
+
const runPlugin = (profile, args) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir);
|
|
528
|
+
/**
|
|
529
|
+
* Undo a clean-exit update whose new build cannot boot. Restoring only the
|
|
530
|
+
* manifest pin (the original #159 behavior) leaves the bad package files
|
|
531
|
+
* on disk, and the boot resolves bundle patches from node_modules — the
|
|
532
|
+
* next start still fails. Re-run pnpm install against the restored
|
|
533
|
+
* manifest to rematerialize the previous build's files.
|
|
534
|
+
*/
|
|
535
|
+
async function rollbackUpdateBuild(name, manifestBefore, rematerializeWhenManifestUnchanged = false) {
|
|
536
|
+
const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
537
|
+
if (rolledBack.length === 0 && !rematerializeWhenManifestUnchanged)
|
|
538
|
+
return { ok: true, detail: null };
|
|
539
|
+
// CI=true (the market always runs pnpm that way) turns frozen-lockfile
|
|
540
|
+
// on, and the restored manifest pin now disagrees with the lockfile the
|
|
541
|
+
// bad add just wrote — without the flag this restore run fails with
|
|
542
|
+
// ERR_PNPM_OUTDATED_LOCKFILE (measured). The age override lets pnpm
|
|
543
|
+
// re-resolve a previous release that is still inside its fresh window.
|
|
544
|
+
// Flags come BEFORE the command: preparePluginArgs treats the last arg as
|
|
545
|
+
// the package target and rejects a trailing flag, while pnpm accepts the
|
|
546
|
+
// same flags in front of `install`.
|
|
547
|
+
const reinstall = await runPlugin(config.profile, ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install']);
|
|
548
|
+
const ok = reinstall.exitCode === 0 && !reinstall.timedOut && !reinstall.cancelled;
|
|
549
|
+
if (ok)
|
|
550
|
+
logEvent('info', 'update', `${name}: previous build rematerialized (${rolledBack.join(', ')})`);
|
|
551
|
+
return { ok, detail: ok ? null : failureDetail(reinstall) };
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Discriminated preflight for rollback state. readProfileManifestSnapshot
|
|
555
|
+
* intentionally degrades read/parse failures to an empty profile for
|
|
556
|
+
* diagnostics callers; an update must never mistake that fabricated empty
|
|
557
|
+
* value for a rollback snapshot and erase real dependencies.
|
|
558
|
+
*/
|
|
559
|
+
function captureUpdateManifest() {
|
|
560
|
+
const file = join(activeProfileDir, 'package.json');
|
|
561
|
+
try {
|
|
562
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
563
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
564
|
+
return { ok: false, detail: 'the profile package.json root is not an object' };
|
|
565
|
+
}
|
|
566
|
+
const manifest = value;
|
|
567
|
+
if (manifest.dependencies !== undefined && (typeof manifest.dependencies !== 'object'
|
|
568
|
+
|| manifest.dependencies === null
|
|
569
|
+
|| Array.isArray(manifest.dependencies)
|
|
570
|
+
|| Object.values(manifest.dependencies).some(spec => typeof spec !== 'string'))) {
|
|
571
|
+
return { ok: false, detail: 'the profile package.json dependency map is malformed' };
|
|
572
|
+
}
|
|
573
|
+
if (manifest.dsh !== undefined && (typeof manifest.dsh !== 'object'
|
|
574
|
+
|| manifest.dsh === null
|
|
575
|
+
|| Array.isArray(manifest.dsh))) {
|
|
576
|
+
return { ok: false, detail: 'the profile package.json dsh field is malformed' };
|
|
577
|
+
}
|
|
578
|
+
const dsh = typeof manifest.dsh === 'object' && manifest.dsh !== null && !Array.isArray(manifest.dsh)
|
|
579
|
+
? manifest.dsh
|
|
580
|
+
: undefined;
|
|
581
|
+
if (dsh?.profile !== undefined && (typeof dsh.profile !== 'object'
|
|
582
|
+
|| dsh.profile === null
|
|
583
|
+
|| Array.isArray(dsh.profile))) {
|
|
584
|
+
return { ok: false, detail: 'the profile package.json dsh.profile field is malformed' };
|
|
585
|
+
}
|
|
586
|
+
const profile = typeof dsh?.profile === 'object' && dsh.profile !== null && !Array.isArray(dsh.profile)
|
|
587
|
+
? dsh.profile
|
|
588
|
+
: undefined;
|
|
589
|
+
return {
|
|
590
|
+
ok: true,
|
|
591
|
+
snapshot: {
|
|
592
|
+
dependencies: { ...manifest.dependencies },
|
|
593
|
+
profileBundles: profile !== undefined && Object.hasOwn(profile, 'bundles')
|
|
594
|
+
? { present: true, value: structuredClone(profile.bundles) }
|
|
595
|
+
: { present: false },
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
return { ok: false, detail: `the profile package.json could not be read: ${error instanceof Error ? error.message : String(error)}` };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/** Exact pnpm importer state paired with one pre-update manifest snapshot. */
|
|
604
|
+
function captureProfileLockfile() {
|
|
605
|
+
const file = join(activeProfileDir, 'pnpm-lock.yaml');
|
|
606
|
+
try {
|
|
607
|
+
return { ok: true, snapshot: { present: true, contents: readFileSync(file) } };
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
if (error.code === 'ENOENT') {
|
|
611
|
+
return { ok: true, snapshot: { present: false } };
|
|
612
|
+
}
|
|
613
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
614
|
+
return {
|
|
615
|
+
ok: false,
|
|
616
|
+
detail: `更新前无法读取 pnpm-lock.yaml,因此自动回滚不可用:${detail} / The pre-update pnpm-lock.yaml could not be read, so automatic rollback is unavailable: ${detail}`,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** Resolved npm version for one dependency in a captured pnpm v9 importer. */
|
|
621
|
+
function capturedNpmVersion(snapshot, name) {
|
|
622
|
+
if (!snapshot.present)
|
|
623
|
+
return null;
|
|
624
|
+
try {
|
|
625
|
+
const parsed = loadYaml(snapshot.contents.toString('utf8'));
|
|
626
|
+
const dependency = parsed?.importers?.['.']?.dependencies?.[name];
|
|
627
|
+
const raw = typeof dependency === 'string' ? dependency : dependency?.version;
|
|
628
|
+
if (typeof raw !== 'string')
|
|
629
|
+
return null;
|
|
630
|
+
return /^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\(|$)/.exec(raw)?.[1] ?? null;
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/** Restore lockfile bytes atomically, or restore the fact it was absent. */
|
|
637
|
+
function restoreProfileLockfile(snapshot) {
|
|
638
|
+
const file = join(activeProfileDir, 'pnpm-lock.yaml');
|
|
639
|
+
if (!snapshot.present) {
|
|
640
|
+
try {
|
|
641
|
+
rmSync(file, { force: true });
|
|
642
|
+
return { ok: true, detail: null };
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
return { ok: false, detail: `the newly created lockfile could not be removed: ${error instanceof Error ? error.message : String(error)}` };
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const temp = `${file}.dsh-market-rollback-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
649
|
+
try {
|
|
650
|
+
writeFileSync(temp, snapshot.contents);
|
|
651
|
+
renameSync(temp, file);
|
|
652
|
+
return { ok: true, detail: null };
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
try {
|
|
656
|
+
rmSync(temp, { force: true });
|
|
657
|
+
}
|
|
658
|
+
catch { /* best-effort temp cleanup */ }
|
|
659
|
+
return { ok: false, detail: `the pre-update lockfile could not be restored: ${error instanceof Error ? error.message : String(error)}` };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Re-add one immutable source while restoring both durable manifest spelling
|
|
664
|
+
* and the exact pre-update importer/lock resolution around the command.
|
|
665
|
+
* The second lock restore is load-bearing for floating tags: real pnpm 11
|
|
666
|
+
* rewrites `latest` to an exact specifier during the add, and putting only
|
|
667
|
+
* package.json back makes the next frozen install reject the profile.
|
|
668
|
+
*/
|
|
669
|
+
async function rollbackExactTarget(name, manifestBefore, lockfileBefore, target, keepRepairedLock = false) {
|
|
670
|
+
restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
671
|
+
const preparedLock = restoreProfileLockfile(lockfileBefore);
|
|
672
|
+
if (!preparedLock.ok)
|
|
673
|
+
return preparedLock;
|
|
674
|
+
let finalLockError = null;
|
|
675
|
+
// The lock can already name the old identity while pnpm's package bytes
|
|
676
|
+
// were replaced before the rejected update failed. A normal exact add is
|
|
677
|
+
// then an "already up to date" no-op; --force is what rematerializes the
|
|
678
|
+
// captured version/commit/archive instead of blessing corrupted bytes.
|
|
679
|
+
const add = await runPlugin(config.profile, ['add', '--force', RELEASE_AGE_OVERRIDE, target]);
|
|
680
|
+
// Exact recovery targets deliberately pin versions/commits. Keep the
|
|
681
|
+
// user's durable range, tag, floating github shortcut, or release URL.
|
|
682
|
+
restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
683
|
+
// When an independently authoritative old identity (an installed npm
|
|
684
|
+
// version or pinned Git manifest) disagrees with a missing/stale lock,
|
|
685
|
+
// keep the exact add's repaired OLD resolution. Floating sources instead
|
|
686
|
+
// need their captured importer/lock bytes back.
|
|
687
|
+
if (!keepRepairedLock || add.exitCode !== 0 || add.timedOut || add.cancelled) {
|
|
688
|
+
const restoredLock = restoreProfileLockfile(lockfileBefore);
|
|
689
|
+
finalLockError = restoredLock.detail;
|
|
690
|
+
}
|
|
691
|
+
if (finalLockError !== null)
|
|
692
|
+
return { ok: false, detail: finalLockError };
|
|
693
|
+
if (add.exitCode !== 0 || add.timedOut || add.cancelled) {
|
|
694
|
+
return { ok: false, detail: failureDetail(add) };
|
|
695
|
+
}
|
|
696
|
+
if (!hasLoadableEntry(activeProfileDir, name)) {
|
|
697
|
+
return { ok: false, detail: 'the previous source was reinstalled without a loadable entry' };
|
|
698
|
+
}
|
|
699
|
+
return { ok: true, detail: null };
|
|
700
|
+
}
|
|
701
|
+
/** Restore the exact npm build that was on disk before the update. */
|
|
702
|
+
async function rollbackNpmBuild(name, manifestBefore, beforeVersion, lockfileBefore) {
|
|
703
|
+
const rollback = await rollbackExactTarget(name, manifestBefore, lockfileBefore, `${name}@${beforeVersion}`);
|
|
704
|
+
if (!rollback.ok)
|
|
705
|
+
return rollback;
|
|
706
|
+
const restoredVersion = readInstalledVersion(config.profile, name, activeProfileDir);
|
|
707
|
+
if (restoredVersion !== beforeVersion) {
|
|
708
|
+
return { ok: false, detail: `expected v${beforeVersion} after rollback, found v${restoredVersion ?? 'unknown'}` };
|
|
709
|
+
}
|
|
710
|
+
logEvent('info', 'update-rollback', `${name}: restored npm build v${beforeVersion}`);
|
|
711
|
+
return { ok: true, detail: null };
|
|
712
|
+
}
|
|
713
|
+
function exactGitRollbackTarget(target, beforeCommit) {
|
|
714
|
+
return githubCommitOfTarget(target) === beforeCommit
|
|
715
|
+
? target
|
|
716
|
+
: githubTargetAtCommit(target, beforeCommit);
|
|
717
|
+
}
|
|
718
|
+
/** Restore a github: update by re-adding the commit captured before it. */
|
|
719
|
+
async function rollbackGitBuild(name, manifestBefore, target, beforeCommit, lockfileBefore, keepRepairedLock) {
|
|
720
|
+
// Preserve an already immutable durable spelling (including a pinned
|
|
721
|
+
// codeload URL). Rewriting that source to github: while keeping the
|
|
722
|
+
// repaired lock would make its importer disagree with package.json.
|
|
723
|
+
// Floating shortcuts still need to be converted to an exact commit.
|
|
724
|
+
const rollbackTarget = exactGitRollbackTarget(target, beforeCommit);
|
|
725
|
+
if (rollbackTarget === null) {
|
|
726
|
+
return { ok: false, detail: 'the previous github target is invalid; nothing to roll back to' };
|
|
727
|
+
}
|
|
728
|
+
const rollback = await rollbackExactTarget(name, manifestBefore, lockfileBefore, rollbackTarget, keepRepairedLock);
|
|
729
|
+
if (!rollback.ok)
|
|
730
|
+
return rollback;
|
|
731
|
+
const repoKey = repoOfTarget(rollbackTarget)?.split('#')[0] ?? null;
|
|
732
|
+
const restoredCommit = repoKey === null
|
|
733
|
+
? null
|
|
734
|
+
: readLockCommits(config.profile, activeProfileDir).get(repoKey.toLowerCase()) ?? null;
|
|
735
|
+
if (restoredCommit !== beforeCommit) {
|
|
736
|
+
return { ok: false, detail: `expected commit ${beforeCommit} after rollback, found ${restoredCommit ?? 'unknown'}` };
|
|
737
|
+
}
|
|
738
|
+
logEvent('info', 'update-rollback', `${name}: restored github build at ${beforeCommit}`);
|
|
739
|
+
return { ok: true, detail: null };
|
|
740
|
+
}
|
|
741
|
+
async function executeUpdateRollback(name, manifestBefore, source) {
|
|
742
|
+
if (source.kind === 'npm') {
|
|
743
|
+
return rollbackNpmBuild(name, manifestBefore, source.beforeVersion, source.lockfileBefore);
|
|
744
|
+
}
|
|
745
|
+
if (source.kind === 'github') {
|
|
746
|
+
return rollbackGitBuild(name, manifestBefore, source.target, source.beforeCommit, source.lockfileBefore, source.keepRepairedLock);
|
|
747
|
+
}
|
|
748
|
+
return rollbackUpdateBuild(name, manifestBefore, true);
|
|
749
|
+
}
|
|
750
|
+
const pendingRollbacks = new Map();
|
|
751
|
+
let rollbackSequence = 0;
|
|
752
|
+
function captureProfileStateFingerprint() {
|
|
753
|
+
try {
|
|
754
|
+
const packageJson = readFileSync(join(activeProfileDir, 'package.json'));
|
|
755
|
+
const lockfile = captureProfileLockfile();
|
|
756
|
+
return lockfile.ok ? { packageJson, lockfile: lockfile.snapshot } : null;
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
function sameProfileLockfile(left, right) {
|
|
763
|
+
if (left.present !== right.present)
|
|
764
|
+
return false;
|
|
765
|
+
return !left.present || (right.present && left.contents.equals(right.contents));
|
|
766
|
+
}
|
|
767
|
+
function profileStateMatches(expected) {
|
|
768
|
+
const current = captureProfileStateFingerprint();
|
|
769
|
+
return current !== null
|
|
770
|
+
&& current.packageJson.equals(expected.packageJson)
|
|
771
|
+
&& sameProfileLockfile(current.lockfile, expected.lockfile);
|
|
772
|
+
}
|
|
773
|
+
function savePendingRollback(record) {
|
|
774
|
+
const expectedState = captureProfileStateFingerprint();
|
|
775
|
+
if (expectedState === null)
|
|
776
|
+
return null;
|
|
777
|
+
const id = `rollback-${String(rollbackSequence++)}`;
|
|
778
|
+
pendingRollbacks.set(id, { ...record, id, expectedState });
|
|
779
|
+
return id;
|
|
780
|
+
}
|
|
781
|
+
async function removeInstalledPackage(name) {
|
|
782
|
+
// Asked BEFORE the removal, while the files are still there to look at.
|
|
783
|
+
// A native addon is never released by unloading (#441): Node has no
|
|
784
|
+
// dlclose, so the process keeps the `.node` open until it exits, and on
|
|
785
|
+
// Windows the next install of the same plugin fails renaming over it.
|
|
786
|
+
// Reporting this uninstall as `hot` would be claiming it took effect
|
|
787
|
+
// without a restart, which for these is exactly what did not happen —
|
|
788
|
+
// and the page then tells the user to refresh, which is the one thing
|
|
789
|
+
// that cannot help.
|
|
790
|
+
const native = holdsNativeAddon(config.profile, name, activeProfileDir);
|
|
791
|
+
const result = await runPlugin(config.profile, ['remove', name]);
|
|
792
|
+
if (result.exitCode !== 0 || result.timedOut || result.cancelled) {
|
|
793
|
+
return { ok: false, hot: false, detail: failureDetail(result) };
|
|
794
|
+
}
|
|
795
|
+
// Both cleanups run — see the uninstall route's note on #213: a package
|
|
796
|
+
// with two activation sources must not have the second one skipped
|
|
797
|
+
// because the first succeeded.
|
|
798
|
+
const unmounted = await hotUnmount(name);
|
|
799
|
+
const entryDisabled = await themes.setEntryDisabled(name, true);
|
|
800
|
+
const hot = (unmounted || entryDisabled) && !native;
|
|
801
|
+
if (native) {
|
|
802
|
+
logEvent('info', 'uninstall', `${name} ships or depends on a native addon; a restart is needed before it can be installed again`);
|
|
803
|
+
}
|
|
804
|
+
removeRowBlocks(userPatchPath, rowIdsForPackage(host, activeProfileDir, name));
|
|
805
|
+
disabled.delete(name);
|
|
806
|
+
replacedWhileLive.delete(name);
|
|
807
|
+
removeFromGroups({ groups, groupOrder }, name);
|
|
808
|
+
writeMarketState(activeProfileDir, { disabled, groups, groupOrder });
|
|
809
|
+
return { ok: true, hot, detail: null };
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Errors the profile analysis reports about the restored composition —
|
|
813
|
+
* a bundle or a user-patch insert naming a package that is not in
|
|
814
|
+
* node_modules. #205: those surfaced only at the NEXT boot, as a Loader
|
|
815
|
+
* ERR_MODULE_NOT_FOUND with nothing tying it to the restore that caused it.
|
|
816
|
+
*
|
|
817
|
+
* Reported, never rolled back. A restore undone halfway can leave someone
|
|
818
|
+
* worse off than the state they were trying to leave, and after a
|
|
819
|
+
* cross-machine restore they still have the old machine to compare against.
|
|
820
|
+
* Naming the packages is what they cannot do for themselves.
|
|
821
|
+
*
|
|
822
|
+
* An analysis that throws is not allowed to fail a restore that already
|
|
823
|
+
* succeeded — the profile is on disk either way.
|
|
824
|
+
*/
|
|
825
|
+
function restoredBootErrors() {
|
|
826
|
+
try {
|
|
827
|
+
return analyzeProfile(activeProfileDir).summary.errors;
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
logEvent('warn', 'restore', `post-restore analysis failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
831
|
+
return [];
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Bundles the profile declares that will not resolve at boot.
|
|
836
|
+
*
|
|
837
|
+
* The boot loader reads `dsh.profile.bundles` and dies on the first name it
|
|
838
|
+
* cannot resolve — the whole profile, not just that plugin (#339). The
|
|
839
|
+
* rollback that leaves such a row behind is fixed, but the market issues
|
|
840
|
+
* one call and the host owns both writes, so this is the net under any
|
|
841
|
+
* write path that does the same thing next: check at the end of an
|
|
842
|
+
* operation instead of letting the next restart be the one to find out.
|
|
843
|
+
*
|
|
844
|
+
* Judged by the SAME analysis the diagnostics page uses, deliberately. A
|
|
845
|
+
* bundle can legitimately live in the dsh installation rather than the
|
|
846
|
+
* profile's node_modules (#316), and reimplementing that resolution here
|
|
847
|
+
* would call those orphans.
|
|
848
|
+
*/
|
|
849
|
+
function orphanBundles() {
|
|
850
|
+
try {
|
|
851
|
+
return analyzeProfile(activeProfileDir).bundles
|
|
852
|
+
// Not an in-box bundle we merely could not locate (#369): those are
|
|
853
|
+
// supplied by the dsh installation, and failing to find one is a gap
|
|
854
|
+
// in what this process can see rather than a profile that will not
|
|
855
|
+
// start. Reporting them here rolled back a good update.
|
|
856
|
+
.filter(layer => layer.directory === null && layer.unresolvedInbox !== true)
|
|
857
|
+
.map(layer => layer.name);
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
logEvent('warn', 'install', `bundle resolution check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
861
|
+
return [];
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Whether a `#path:` target still points at a directory that exists.
|
|
866
|
+
*
|
|
867
|
+
* A catalog entry can name a monorepo subpackage that the author has since
|
|
868
|
+
* moved or renamed. pnpm's failure for that is unrecognisable — the user
|
|
869
|
+
* sees a resolver error and no reason to suspect the entry rather than
|
|
870
|
+
* their own machine (#346). Audited the live catalog while looking into
|
|
871
|
+
* it: 8 of the 224 subpath entries point at a directory that is gone.
|
|
872
|
+
*
|
|
873
|
+
* Only ever called AFTER an install has already failed, so the happy path
|
|
874
|
+
* pays nothing, and a network problem here just means no extra sentence.
|
|
875
|
+
*/
|
|
876
|
+
async function staleSubpath(target) {
|
|
877
|
+
const match = /^github:([^#]+)#path:\/(.+)$/.exec(target);
|
|
878
|
+
if (match === null)
|
|
879
|
+
return null;
|
|
880
|
+
const [, repo, subpath] = match;
|
|
881
|
+
try {
|
|
882
|
+
const res = await marketFetch(`https://raw.githubusercontent.com/${repo}/HEAD/${subpath}/package.json`, { signal: AbortSignal.timeout(6000) });
|
|
883
|
+
if (res.ok)
|
|
884
|
+
return null;
|
|
885
|
+
if (res.status !== 404)
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
return `目录条目指向的子目录在仓库里已不存在(${repo} 的 ${subpath}),多半是作者改名或移动了它——这不是你的环境的问题。请到 awesome-dsh-plugin 反馈这条收录已失效。 / This catalog entry points at a subdirectory that no longer exists in the repository (${subpath} in ${repo}); the author most likely renamed or moved it. Nothing is wrong with your setup — please report the stale entry to awesome-dsh-plugin.`;
|
|
892
|
+
}
|
|
893
|
+
async function restoreBackup(value) {
|
|
894
|
+
if (!await probePnpm())
|
|
895
|
+
throw new Error('pnpm is required to restore plugins');
|
|
896
|
+
// Snapshot the target's manifest BEFORE the backup files overwrite it, so
|
|
897
|
+
// the restore can merge rather than replace: plugins the target already
|
|
898
|
+
// has that are NOT in the backup stay installed instead of silently
|
|
899
|
+
// dropping off the manifest (partial exports, issue #89). The mutation
|
|
900
|
+
// lock is owned by withMutationLock now, so no `installing` flag here.
|
|
901
|
+
const manifestBefore = JSON.parse(readFileSync(join(activeProfileDir, 'package.json'), 'utf8'));
|
|
902
|
+
const restored = restoreProfileBackup(config.profile, value, activeProfileDir);
|
|
903
|
+
try {
|
|
904
|
+
// Merge: current deps stay, backup specs win on name conflicts; bundle
|
|
905
|
+
// lists are unioned. Full exports merge to the backup view unchanged.
|
|
906
|
+
const mergedManifest = mergeRestoreManifest(JSON.parse(readFileSync(join(activeProfileDir, 'package.json'), 'utf8')), manifestBefore);
|
|
907
|
+
writeFileSync(join(activeProfileDir, 'package.json'), `${JSON.stringify(mergedManifest, null, 2)}\n`);
|
|
908
|
+
// Named BEFORE the install runs, because that is the install this
|
|
909
|
+
// will make fail: a `link:/Users/…` spec from another machine points
|
|
910
|
+
// at a path that does not exist here (#205). Reported rather than
|
|
911
|
+
// rewritten — where those files should live is the operator's call.
|
|
912
|
+
const unportable = unportableDeps(mergedManifest.dependencies);
|
|
913
|
+
if (unportable.length > 0) {
|
|
914
|
+
logEvent('warn', 'restore', `machine-specific dependency paths in the restored manifest — ${unportable.map(dep => `${dep.name}: ${dep.spec}`).join('; ')}`);
|
|
915
|
+
}
|
|
916
|
+
const result = await runPlugin(config.profile, ['install']);
|
|
917
|
+
if (result.exitCode === 0 && !result.timedOut && !result.cancelled) {
|
|
918
|
+
invalidateUpdates();
|
|
919
|
+
const bootErrors = restoredBootErrors();
|
|
920
|
+
if (bootErrors.length > 0) {
|
|
921
|
+
logEvent('warn', 'restore', `restored profile will not boot as-is — ${bootErrors.join('; ')}`);
|
|
922
|
+
}
|
|
923
|
+
return { files: restored.files, errors: [], unportable, ...(bootErrors.length > 0 ? { bootErrors } : {}) };
|
|
924
|
+
}
|
|
925
|
+
// A bad dependency makes pnpm abort the whole install. Retry from an
|
|
926
|
+
// empty dependency list so one broken plugin cannot block the rest.
|
|
927
|
+
// activeProfileDir, NOT profileDir(config.profile): in DSH Desktop the
|
|
928
|
+
// profile directory is host-authoritative (#72) and the ambient
|
|
929
|
+
// derivation would edit the WRONG profile's manifest.
|
|
930
|
+
const manifestFile = join(activeProfileDir, 'package.json');
|
|
931
|
+
const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
|
932
|
+
const dependencies = Object.entries(manifest.dependencies ?? {});
|
|
933
|
+
const desiredBundles = [...(manifest.dsh?.profile?.bundles ?? [])];
|
|
934
|
+
const dependencyNames = new Set(dependencies.map(([name]) => name));
|
|
935
|
+
manifest.dependencies = {};
|
|
936
|
+
if (Array.isArray(manifest.dsh?.profile?.bundles)) {
|
|
937
|
+
manifest.dsh.profile.bundles = desiredBundles.filter(bundle => !dependencyNames.has(bundle));
|
|
938
|
+
}
|
|
939
|
+
writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
940
|
+
const errors = [];
|
|
941
|
+
let installed = 0;
|
|
942
|
+
for (const [name, spec] of dependencies) {
|
|
943
|
+
const target = /^(?:file|link|github|git\+|https?):/.test(spec) ? spec : `${name}@${spec}`;
|
|
944
|
+
try {
|
|
945
|
+
const item = await runPlugin(config.profile, ['add', target]);
|
|
946
|
+
if (item.exitCode === 0 && !item.timedOut && !item.cancelled
|
|
947
|
+
&& existsSync(join(activeProfileDir, 'node_modules', name, 'package.json'))) {
|
|
948
|
+
installed += 1;
|
|
949
|
+
if (desiredBundles.includes(name)) {
|
|
950
|
+
const current = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
|
951
|
+
current.dsh ??= {};
|
|
952
|
+
current.dsh.profile ??= {};
|
|
953
|
+
current.dsh.profile.bundles ??= [];
|
|
954
|
+
if (!current.dsh.profile.bundles.includes(name))
|
|
955
|
+
current.dsh.profile.bundles.push(name);
|
|
956
|
+
writeFileSync(manifestFile, `${JSON.stringify(current, null, 2)}\n`);
|
|
957
|
+
}
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
errors.push({ name, error: failureDetail(item).trim() || 'pnpm failed' });
|
|
961
|
+
}
|
|
962
|
+
catch (error) {
|
|
963
|
+
errors.push({ name, error: error instanceof Error ? error.message : String(error) });
|
|
964
|
+
}
|
|
965
|
+
const current = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
|
966
|
+
if (current.dependencies !== undefined)
|
|
967
|
+
delete current.dependencies[name];
|
|
968
|
+
writeFileSync(manifestFile, `${JSON.stringify(current, null, 2)}\n`);
|
|
969
|
+
}
|
|
970
|
+
if (installed === 0 && dependencies.length > 0) {
|
|
971
|
+
restored.rollback();
|
|
972
|
+
}
|
|
973
|
+
invalidateUpdates();
|
|
974
|
+
const bootErrors = restoredBootErrors();
|
|
975
|
+
if (bootErrors.length > 0) {
|
|
976
|
+
logEvent('warn', 'restore', `restored profile will not boot as-is — ${bootErrors.join('; ')}`);
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
files: restored.files,
|
|
980
|
+
errors,
|
|
981
|
+
unportable: unportableDeps(manifest.dependencies),
|
|
982
|
+
...(bootErrors.length > 0 ? { bootErrors } : {}),
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
catch (error) {
|
|
986
|
+
restored.rollback();
|
|
987
|
+
throw error;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const legacyHandlers = new Map();
|
|
991
|
+
const captureLegacy = (path, route) => {
|
|
992
|
+
legacyHandlers.set(path, route.handler);
|
|
993
|
+
return route;
|
|
994
|
+
};
|
|
995
|
+
const operationsV1 = new UpdateOperationStoreV1(BOOT_ID);
|
|
996
|
+
/** Invoke one existing route in memory so v1 reuses the battle-tested executor. */
|
|
997
|
+
async function invokeLegacy(path, source, method, body, url = path) {
|
|
998
|
+
const handler = legacyHandlers.get(path);
|
|
999
|
+
if (handler === undefined)
|
|
1000
|
+
throw new Error(`legacy route is unavailable: ${path}`);
|
|
1001
|
+
const chunks = body === undefined ? [] : [Buffer.from(JSON.stringify(body))];
|
|
1002
|
+
const replay = Readable.from(chunks);
|
|
1003
|
+
Object.assign(replay, {
|
|
1004
|
+
method,
|
|
1005
|
+
url,
|
|
1006
|
+
headers: { ...source.headers },
|
|
1007
|
+
socket: source.socket,
|
|
1008
|
+
});
|
|
1009
|
+
let status = 200;
|
|
1010
|
+
let text = '';
|
|
1011
|
+
const captured = {
|
|
1012
|
+
writeHead(code) { status = code; return this; },
|
|
1013
|
+
end(chunk) {
|
|
1014
|
+
if (chunk !== undefined)
|
|
1015
|
+
text += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
|
|
1016
|
+
return this;
|
|
1017
|
+
},
|
|
1018
|
+
};
|
|
1019
|
+
await handler(replay, captured);
|
|
1020
|
+
let payload = null;
|
|
1021
|
+
try {
|
|
1022
|
+
payload = text === '' ? null : JSON.parse(text);
|
|
1023
|
+
}
|
|
1024
|
+
catch {
|
|
1025
|
+
payload = { error: text };
|
|
1026
|
+
}
|
|
1027
|
+
return { status, payload };
|
|
1028
|
+
}
|
|
1029
|
+
const packageNameFrom = (request) => {
|
|
1030
|
+
try {
|
|
1031
|
+
return new URL(request.url ?? '', 'http://localhost').searchParams.get('name') ?? '';
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
return '';
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
const operationIdFrom = (request) => {
|
|
1038
|
+
try {
|
|
1039
|
+
return new URL(request.url ?? '', 'http://localhost').searchParams.get('operationId') ?? '';
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
return '';
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
const forceCheckFrom = (request) => {
|
|
1046
|
+
try {
|
|
1047
|
+
return new URL(request.url ?? '', 'http://localhost').searchParams.get('force') === '1';
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
const disposers = [
|
|
1054
|
+
host.webServer.register({
|
|
1055
|
+
kind: 'exact',
|
|
1056
|
+
path: '/dsh-market/api/v1/capabilities',
|
|
1057
|
+
handler: (request, response) => {
|
|
1058
|
+
if (request.method !== 'GET') {
|
|
1059
|
+
response.writeHead(405, { allow: 'GET' });
|
|
1060
|
+
response.end();
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const canRestart = restartAllowed(config);
|
|
1064
|
+
sendJson(response, 200, {
|
|
1065
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
1066
|
+
apiVersion: 1,
|
|
1067
|
+
// Machine-readable, because a policy that lives only in a markdown
|
|
1068
|
+
// file is one a client never reads. `beta` says the shape may still
|
|
1069
|
+
// change; it becomes `stable` once a release stops moving it, and
|
|
1070
|
+
// that is the point at which the compatibility promise starts.
|
|
1071
|
+
stability: 'beta',
|
|
1072
|
+
marketVersion: marketVersion(),
|
|
1073
|
+
profile: config.profile,
|
|
1074
|
+
bootId: BOOT_ID,
|
|
1075
|
+
runtime: config.profileDirectory === undefined ? 'web' : 'desktop',
|
|
1076
|
+
features: {
|
|
1077
|
+
check: true,
|
|
1078
|
+
update: true,
|
|
1079
|
+
progress: true,
|
|
1080
|
+
rollback: true,
|
|
1081
|
+
restart: canRestart,
|
|
1082
|
+
},
|
|
1083
|
+
restart: {
|
|
1084
|
+
supported: canRestart,
|
|
1085
|
+
managedBy: canRestart ? 'market' : config.profileDirectory === undefined ? 'operator' : 'desktop-host',
|
|
1086
|
+
supervisor: detectedSupervisor(),
|
|
1087
|
+
debugger: detectedDebugger(),
|
|
1088
|
+
},
|
|
1089
|
+
operationRetention: 'current-process',
|
|
1090
|
+
operationLimit: MAX_UPDATE_OPERATIONS_V1,
|
|
1091
|
+
endpoints: {
|
|
1092
|
+
updates: '/dsh-market/api/v1/updates',
|
|
1093
|
+
operations: '/dsh-market/api/v1/operations',
|
|
1094
|
+
rollback: '/dsh-market/api/v1/rollback',
|
|
1095
|
+
restart: '/dsh-market/api/v1/restart',
|
|
1096
|
+
},
|
|
1097
|
+
});
|
|
1098
|
+
},
|
|
1099
|
+
}),
|
|
1100
|
+
host.webServer.register({
|
|
1101
|
+
kind: 'exact',
|
|
1102
|
+
path: '/dsh-market/api/v1/updates',
|
|
1103
|
+
handler: async (request, response) => {
|
|
1104
|
+
if (request.method === 'GET') {
|
|
1105
|
+
const name = packageNameFrom(request);
|
|
1106
|
+
if (!NPM_NAME_RE.test(name)) {
|
|
1107
|
+
sendJson(response, 400, { schema: UPDATE_API_V1_SCHEMA, error: 'a valid package name is required' });
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
const force = forceCheckFrom(request);
|
|
1112
|
+
const channel = activeChannel();
|
|
1113
|
+
const channelFor = SELF_NAMES.has(name) ? new Map([[name, channel]]) : undefined;
|
|
1114
|
+
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor))[name];
|
|
1115
|
+
if (update === undefined) {
|
|
1116
|
+
sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' });
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
sendJson(response, 200, {
|
|
1120
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
1121
|
+
package: {
|
|
1122
|
+
name,
|
|
1123
|
+
source: update.kind,
|
|
1124
|
+
installedVersion: update.current ?? update.version,
|
|
1125
|
+
latestVersion: update.latest,
|
|
1126
|
+
updateAvailable: update.updateAvailable,
|
|
1127
|
+
channelSwitch: update.channelSwitch ?? null,
|
|
1128
|
+
},
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
catch (error) {
|
|
1132
|
+
sendJson(response, 500, {
|
|
1133
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
1134
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (request.method !== 'POST') {
|
|
1140
|
+
response.writeHead(405, { allow: 'GET, POST' });
|
|
1141
|
+
response.end();
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (!sameOrigin(request)) {
|
|
1145
|
+
sendJson(response, 403, { schema: UPDATE_API_V1_SCHEMA, error: 'untrusted origin' });
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
const body = (await readJsonBody(request));
|
|
1150
|
+
const packageName = typeof body.packageName === 'string' ? body.packageName : '';
|
|
1151
|
+
if (!NPM_NAME_RE.test(packageName)) {
|
|
1152
|
+
sendJson(response, 400, { schema: UPDATE_API_V1_SCHEMA, error: 'a valid package name is required' });
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (operationsV1.hasActive()) {
|
|
1156
|
+
sendJson(response, 409, {
|
|
1157
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
1158
|
+
error: 'another public update operation is already running',
|
|
1159
|
+
failure: {
|
|
1160
|
+
code: 'OPERATION_BUSY',
|
|
1161
|
+
message: 'another public update operation is already running',
|
|
1162
|
+
retryable: true,
|
|
1163
|
+
},
|
|
1164
|
+
});
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const installedVersion = readInstalledVersion(config.profile, packageName, activeProfileDir);
|
|
1168
|
+
if (installedVersion === null) {
|
|
1169
|
+
sendJson(response, 404, {
|
|
1170
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
1171
|
+
error: 'plugin is not installed',
|
|
1172
|
+
failure: {
|
|
1173
|
+
code: 'PLUGIN_NOT_INSTALLED',
|
|
1174
|
+
message: 'plugin is not installed in this profile',
|
|
1175
|
+
retryable: false,
|
|
1176
|
+
},
|
|
1177
|
+
});
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const operation = operationsV1.create(packageName, installedVersion);
|
|
1181
|
+
operationsV1.start(operation.operationId);
|
|
1182
|
+
void invokeLegacy('/dsh-market/update', request, 'POST', {
|
|
1183
|
+
name: packageName,
|
|
1184
|
+
...(body.force === true ? { force: true } : {}),
|
|
1185
|
+
}).then(({ status, payload }) => {
|
|
1186
|
+
operationsV1.finish(operation.operationId, status, payload, readInstalledVersion(config.profile, packageName, activeProfileDir));
|
|
1187
|
+
}).catch((error) => {
|
|
1188
|
+
operationsV1.finish(operation.operationId, 500, { error: error instanceof Error ? error.message : String(error) }, readInstalledVersion(config.profile, packageName, activeProfileDir));
|
|
1189
|
+
});
|
|
1190
|
+
sendJson(response, 202, {
|
|
1191
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
1192
|
+
operation: operationsV1.get(operation.operationId),
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
catch (error) {
|
|
1196
|
+
sendJson(response, 400, {
|
|
1197
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
1198
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
}),
|
|
1203
|
+
host.webServer.register({
|
|
1204
|
+
kind: 'exact',
|
|
1205
|
+
path: '/dsh-market/api/v1/operations',
|
|
1206
|
+
handler: (request, response) => {
|
|
1207
|
+
if (request.method !== 'GET') {
|
|
1208
|
+
response.writeHead(405, { allow: 'GET' });
|
|
1209
|
+
response.end();
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const operation = operationsV1.get(operationIdFrom(request), progress);
|
|
1213
|
+
if (operation === null) {
|
|
1214
|
+
sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'operation not found in this host process' });
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
sendJson(response, 200, { schema: UPDATE_API_V1_SCHEMA, operation });
|
|
1218
|
+
},
|
|
1219
|
+
}),
|
|
1220
|
+
host.webServer.register({
|
|
1221
|
+
kind: 'exact',
|
|
1222
|
+
path: '/dsh-market/api/v1/rollback',
|
|
1223
|
+
handler: async (request, response) => {
|
|
1224
|
+
if (request.method !== 'POST') {
|
|
1225
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1226
|
+
response.end();
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (!sameOrigin(request)) {
|
|
1230
|
+
sendJson(response, 403, { schema: UPDATE_API_V1_SCHEMA, error: 'untrusted origin' });
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
try {
|
|
1234
|
+
const body = (await readJsonBody(request));
|
|
1235
|
+
const operationId = typeof body.operationId === 'string' ? body.operationId : '';
|
|
1236
|
+
const trackedOperation = operationsV1.get(operationId);
|
|
1237
|
+
const legacyRollbackId = operationsV1.beginRollback(operationId);
|
|
1238
|
+
if (legacyRollbackId === null || trackedOperation === null) {
|
|
1239
|
+
sendJson(response, 409, { schema: UPDATE_API_V1_SCHEMA, error: 'rollback is not available for this operation' });
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
const result = await invokeLegacy('/dsh-market/rollback', request, 'POST', { rollbackId: legacyRollbackId });
|
|
1243
|
+
const installedVersion = readInstalledVersion(config.profile, trackedOperation.packageName, activeProfileDir);
|
|
1244
|
+
const operation = operationsV1.finishRollback(operationId, result.status, result.payload, installedVersion);
|
|
1245
|
+
sendJson(response, 200, { schema: UPDATE_API_V1_SCHEMA, operation });
|
|
1246
|
+
}
|
|
1247
|
+
catch (error) {
|
|
1248
|
+
sendJson(response, 400, {
|
|
1249
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
1250
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1254
|
+
}),
|
|
1255
|
+
host.webServer.register({
|
|
1256
|
+
kind: 'exact',
|
|
1257
|
+
path: '/dsh-market/api/v1/restart',
|
|
1258
|
+
handler: async (request, response) => {
|
|
1259
|
+
if (request.method !== 'POST') {
|
|
1260
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1261
|
+
response.end();
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
const result = await invokeLegacy('/dsh-market/restart', request, 'POST', {});
|
|
1265
|
+
sendJson(response, result.status, { schema: UPDATE_API_V1_SCHEMA, result: result.payload });
|
|
1266
|
+
},
|
|
1267
|
+
}),
|
|
1268
|
+
host.webServer.register({
|
|
1269
|
+
kind: 'exact',
|
|
1270
|
+
path: '/dsh-market/backup',
|
|
1271
|
+
handler: (request, response) => {
|
|
1272
|
+
if (request.method !== 'GET') {
|
|
1273
|
+
response.writeHead(405, { allow: 'GET' });
|
|
1274
|
+
response.end();
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
// Profile exports carry configuration that may include credentials
|
|
1278
|
+
// (config.toml, .env, …), so they stay limited to loopback peers
|
|
1279
|
+
// without proxy forwarding (review #63). Unlike process control,
|
|
1280
|
+
// browsers omit the Origin header on `<a download>` GET navigations,
|
|
1281
|
+
// so a missing Origin passes; a present one must still match Host.
|
|
1282
|
+
if (!trustedDownloadRequest(request)) {
|
|
1283
|
+
sendJson(response, 403, { error: 'backup export is limited to same-origin loopback requests' });
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
try {
|
|
1287
|
+
const data = createProfileBackup(config.profile, activeProfileDir);
|
|
1288
|
+
const backup = JSON.stringify(data, null, 2);
|
|
1289
|
+
const timestamp = new Date(data.createdAt).toLocaleString('sv-SE').replace(/\D/g, '');
|
|
1290
|
+
response.writeHead(200, {
|
|
1291
|
+
'cache-control': 'no-store',
|
|
1292
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1293
|
+
'content-disposition': `attachment; filename="chatcode-cli-market-backup-${timestamp}.json"`,
|
|
1294
|
+
});
|
|
1295
|
+
response.end(backup);
|
|
1296
|
+
}
|
|
1297
|
+
catch (error) {
|
|
1298
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1299
|
+
}
|
|
1300
|
+
},
|
|
1301
|
+
}),
|
|
1302
|
+
host.webServer.register({
|
|
1303
|
+
kind: 'exact',
|
|
1304
|
+
path: '/dsh-market/restore',
|
|
1305
|
+
handler: async (request, response) => {
|
|
1306
|
+
if (request.method !== 'POST') {
|
|
1307
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1308
|
+
response.end();
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (!sameOrigin(request))
|
|
1312
|
+
return sendJson(response, 403, { error: 'untrusted origin' });
|
|
1313
|
+
try {
|
|
1314
|
+
const body = await readJsonBody(request, MAX_BACKUP_BYTES + 4096);
|
|
1315
|
+
await withMutationLock(response, 'install', async () => {
|
|
1316
|
+
pendingRollbacks.clear();
|
|
1317
|
+
sendJson(response, 200, { ok: true, ...await restoreBackup(body.backup) });
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
catch (error) {
|
|
1321
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1322
|
+
}
|
|
1323
|
+
},
|
|
1324
|
+
}),
|
|
1325
|
+
host.webServer.register({
|
|
1326
|
+
kind: 'exact',
|
|
1327
|
+
path: '/dsh-market/webdav',
|
|
1328
|
+
handler: async (request, response) => {
|
|
1329
|
+
if (request.method !== 'POST') {
|
|
1330
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1331
|
+
response.end();
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (!sameOrigin(request))
|
|
1335
|
+
return sendJson(response, 403, { error: 'untrusted origin' });
|
|
1336
|
+
try {
|
|
1337
|
+
const body = await readJsonBody(request);
|
|
1338
|
+
const url = typeof body.url === 'string' ? body.url : '';
|
|
1339
|
+
const username = typeof body.username === 'string' ? body.username : '';
|
|
1340
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
1341
|
+
if (body.action === 'backup') {
|
|
1342
|
+
await uploadWebdav(url, username, password, createProfileBackup(config.profile, activeProfileDir));
|
|
1343
|
+
sendJson(response, 200, { ok: true });
|
|
1344
|
+
}
|
|
1345
|
+
else if (body.action === 'restore') {
|
|
1346
|
+
// The preview flow first returns the downloaded backup so the
|
|
1347
|
+
// client can show what will be restored; the real restore then
|
|
1348
|
+
// posts it to /dsh-market/restore, where downloadWebdav's strict
|
|
1349
|
+
// validation guarantees the fetch result is never blindly echoed
|
|
1350
|
+
// (review #63).
|
|
1351
|
+
sendJson(response, 200, { ok: true, backup: await downloadWebdav(url, username, password) });
|
|
1352
|
+
}
|
|
1353
|
+
else
|
|
1354
|
+
sendJson(response, 400, { error: 'invalid WebDAV action' });
|
|
1355
|
+
}
|
|
1356
|
+
catch (error) {
|
|
1357
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1358
|
+
}
|
|
1359
|
+
},
|
|
1360
|
+
}),
|
|
1361
|
+
host.webServer.register({
|
|
1362
|
+
kind: 'exact',
|
|
1363
|
+
path: '/dsh-market/gist',
|
|
1364
|
+
handler: async (request, response) => {
|
|
1365
|
+
if (request.method !== 'POST') {
|
|
1366
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1367
|
+
response.end();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (!sameOrigin(request))
|
|
1371
|
+
return sendJson(response, 403, { error: 'untrusted origin' });
|
|
1372
|
+
// 25 s route-level ceiling: abort the underlying GitHub request too,
|
|
1373
|
+
// so the client always gets a definite, structured answer and a
|
|
1374
|
+
// wedged gh CLI / slow network can never leave a request running in
|
|
1375
|
+
// the background (issue #89; the error carries a code for the UI).
|
|
1376
|
+
const controller = new AbortController();
|
|
1377
|
+
const timer = setTimeout(() => controller.abort(new GistError('Gist operation timed out', 'timeout')), 25_000);
|
|
1378
|
+
try {
|
|
1379
|
+
const body = await readJsonBody(request);
|
|
1380
|
+
const { token, source } = await resolveGistTokenSource(body.token);
|
|
1381
|
+
if (body.action === 'export') {
|
|
1382
|
+
const gistIdInput = typeof body.gistId === 'string' ? body.gistId.trim() : '';
|
|
1383
|
+
const includeDeps = Array.isArray(body.includeDeps)
|
|
1384
|
+
? body.includeDeps.filter((name) => typeof name === 'string' && name !== '')
|
|
1385
|
+
: undefined;
|
|
1386
|
+
const backup = createProfileBackup(config.profile, activeProfileDir, includeDeps !== undefined
|
|
1387
|
+
? { includeDeps, includeConfig: body.includeConfig === true }
|
|
1388
|
+
: undefined);
|
|
1389
|
+
const content = JSON.stringify(backup, null, 2);
|
|
1390
|
+
if (!fitsGistLimit(content))
|
|
1391
|
+
throw new Error('backup exceeds the GitHub Gist 1 MB limit');
|
|
1392
|
+
const ref = gistIdInput === ''
|
|
1393
|
+
? await createGist(token, content, controller.signal)
|
|
1394
|
+
: await updateGist(token, parseGistId(gistIdInput), content, controller.signal);
|
|
1395
|
+
sendJson(response, 200, { ok: true, gistId: ref.id, gistUrl: ref.htmlUrl });
|
|
1396
|
+
}
|
|
1397
|
+
else if (body.action === 'import') {
|
|
1398
|
+
if (typeof body.gistId !== 'string' || body.gistId.trim() === '')
|
|
1399
|
+
throw new Error('gist id is required');
|
|
1400
|
+
const backup = await readGist(token, parseGistId(body.gistId), controller.signal);
|
|
1401
|
+
// Preview flow, same as WebDAV: the client reviews the backup and
|
|
1402
|
+
// posts it to /dsh-market/restore; readGist's strict validation
|
|
1403
|
+
// guarantees the fetch result is never blindly echoed.
|
|
1404
|
+
sendJson(response, 200, { ok: true, backup });
|
|
1405
|
+
}
|
|
1406
|
+
else if (body.action === 'verify') {
|
|
1407
|
+
await verifyGistToken(token, controller.signal);
|
|
1408
|
+
sendJson(response, 200, { ok: true, source });
|
|
1409
|
+
}
|
|
1410
|
+
else
|
|
1411
|
+
sendJson(response, 400, { error: 'invalid Gist action' });
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error), code: gistErrorCode(error) });
|
|
1415
|
+
}
|
|
1416
|
+
finally {
|
|
1417
|
+
clearTimeout(timer);
|
|
1418
|
+
}
|
|
1419
|
+
},
|
|
1420
|
+
}),
|
|
1421
|
+
host.webServer.register({
|
|
1422
|
+
kind: 'exact',
|
|
1423
|
+
path: '/dsh-market/registry',
|
|
1424
|
+
handler: async (request, response) => {
|
|
1425
|
+
if (request.method !== 'GET') {
|
|
1426
|
+
response.writeHead(405, { allow: 'GET' });
|
|
1427
|
+
response.end();
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
try {
|
|
1431
|
+
try {
|
|
1432
|
+
const registry = await loadRegistry();
|
|
1433
|
+
sendJson(response, 200, {
|
|
1434
|
+
registry,
|
|
1435
|
+
hostVersion: dshHostInfo()?.version ?? null,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
catch (error) {
|
|
1439
|
+
// Say what went wrong. The market used to substitute a bundled
|
|
1440
|
+
// copy here, so an unreachable registry looked exactly like a
|
|
1441
|
+
// reachable one with fewer plugins in it.
|
|
1442
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1443
|
+
logEvent('warn', 'registry', `catalog fetch failed: ${message}`);
|
|
1444
|
+
sendJson(response, 502, { error: message });
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
catch (error) {
|
|
1448
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1449
|
+
}
|
|
1450
|
+
},
|
|
1451
|
+
}),
|
|
1452
|
+
host.webServer.register({
|
|
1453
|
+
kind: 'exact',
|
|
1454
|
+
path: '/dsh-market/discovery-compatibility',
|
|
1455
|
+
handler: async (request, response) => {
|
|
1456
|
+
if (request.method !== 'POST') {
|
|
1457
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1458
|
+
response.end();
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (!sameOrigin(request)) {
|
|
1462
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
let body;
|
|
1466
|
+
try {
|
|
1467
|
+
body = await readJsonBody(request, 32 * 1024);
|
|
1468
|
+
}
|
|
1469
|
+
catch (error) {
|
|
1470
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
const requested = body !== null && typeof body === 'object' && !Array.isArray(body)
|
|
1474
|
+
? body.packages
|
|
1475
|
+
: undefined;
|
|
1476
|
+
if (!Array.isArray(requested) || requested.length > 64
|
|
1477
|
+
|| !requested.every(name => typeof name === 'string' && NPM_NAME_RE.test(name))) {
|
|
1478
|
+
sendJson(response, 400, { error: 'packages must be an array of at most 64 npm package names' });
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const packages = [...new Set(requested)];
|
|
1482
|
+
try {
|
|
1483
|
+
const host = dshHostInfo();
|
|
1484
|
+
const hostVersion = host?.version ?? null;
|
|
1485
|
+
const hostPackages = corePackageNames(host?.directory ?? null);
|
|
1486
|
+
const facts = await discoveryManifests.lookup(packages, routesFor(region).npmRegistry);
|
|
1487
|
+
const plugins = Object.fromEntries(packages.map(name => [
|
|
1488
|
+
name,
|
|
1489
|
+
deriveHostCompatibility(facts[name] ?? null, hostVersion, hostPackages),
|
|
1490
|
+
]));
|
|
1491
|
+
sendJson(response, 200, { hostVersion, plugins });
|
|
1492
|
+
}
|
|
1493
|
+
catch (error) {
|
|
1494
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1495
|
+
}
|
|
1496
|
+
},
|
|
1497
|
+
}),
|
|
1498
|
+
host.webServer.register({
|
|
1499
|
+
kind: 'exact',
|
|
1500
|
+
path: '/dsh-market/installed',
|
|
1501
|
+
handler: async (request, response) => {
|
|
1502
|
+
if (request.method !== 'GET') {
|
|
1503
|
+
response.writeHead(405, { allow: 'GET' });
|
|
1504
|
+
response.end();
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
await dropStaleHotMounts();
|
|
1508
|
+
const installed = readInstalled(config.profile, activeProfileDir);
|
|
1509
|
+
const repoIdentities = {};
|
|
1510
|
+
const repoHints = {};
|
|
1511
|
+
for (const [name, spec] of Object.entries(installed)) {
|
|
1512
|
+
const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir);
|
|
1513
|
+
if (evidence.identities.length > 0)
|
|
1514
|
+
repoIdentities[name] = evidence.identities;
|
|
1515
|
+
if (evidence.hints.length > 0)
|
|
1516
|
+
repoHints[name] = evidence.hints;
|
|
1517
|
+
}
|
|
1518
|
+
const present = Object.keys(installed).filter(name => readInstalledVersion(config.profile, name, activeProfileDir) !== null);
|
|
1519
|
+
// User-patch-layer state (port of dsh-plugin-hub): rows the user
|
|
1520
|
+
// patch disables/force-enables, plus per-package flags so the UI can
|
|
1521
|
+
// show toggles made OUTSIDE the market (hand-edited cordis.patch.yml,
|
|
1522
|
+
// the dsh CLI) that state.json never sees.
|
|
1523
|
+
const patch = readUserPatchState(userPatchPath);
|
|
1524
|
+
const patchFlags = packagePatchFlags(host, activeProfileDir, Object.keys(installed), patch);
|
|
1525
|
+
const activation = {};
|
|
1526
|
+
const live = liveNames();
|
|
1527
|
+
for (const name of Object.keys(installed)) {
|
|
1528
|
+
activation[name] = activationAfterReplace(verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name) || patchFlags.disabled.includes(name)), replacedWhileLive.has(name));
|
|
1529
|
+
}
|
|
1530
|
+
const diagnostics = diagnosePackageManifests(Object.keys(installed).map(packageName => ({
|
|
1531
|
+
packageName,
|
|
1532
|
+
manifest: readInstalledManifest(config.profile, packageName, activeProfileDir),
|
|
1533
|
+
})));
|
|
1534
|
+
sendJson(response, 200, {
|
|
1535
|
+
profile: config.profile,
|
|
1536
|
+
installed,
|
|
1537
|
+
repoIdentities,
|
|
1538
|
+
repoHints,
|
|
1539
|
+
present,
|
|
1540
|
+
activation,
|
|
1541
|
+
diagnostics,
|
|
1542
|
+
live: listHotMounts(),
|
|
1543
|
+
disabled: [...disabled],
|
|
1544
|
+
groups,
|
|
1545
|
+
groupOrder,
|
|
1546
|
+
notes: readMarketState(activeProfileDir).notes ?? {},
|
|
1547
|
+
favorites: readMarketState(activeProfileDir).favorites ?? [],
|
|
1548
|
+
patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts },
|
|
1549
|
+
patchDisabled: patchFlags.disabled,
|
|
1550
|
+
patchForced: patchFlags.forced,
|
|
1551
|
+
bundles: readProfileBundles(activeProfileDir).filter(name => !INBOX_BUNDLES.has(name)),
|
|
1552
|
+
});
|
|
1553
|
+
},
|
|
1554
|
+
}),
|
|
1555
|
+
host.webServer.register({
|
|
1556
|
+
kind: 'exact',
|
|
1557
|
+
path: '/dsh-market/check',
|
|
1558
|
+
handler: (request, response) => {
|
|
1559
|
+
if (request.method !== 'GET') {
|
|
1560
|
+
response.writeHead(405, { allow: 'GET' });
|
|
1561
|
+
response.end();
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
try {
|
|
1565
|
+
const report = analyzeProfile(activeProfileDir);
|
|
1566
|
+
// #201: attach the #200 directional verdict to every peer row so the
|
|
1567
|
+
// diagnostics UI can tier risk / warning / info without recomputing
|
|
1568
|
+
// (the client cannot see peerDependenciesMeta on disk).
|
|
1569
|
+
for (const row of report.peerMismatches) {
|
|
1570
|
+
row.verdict = row.satisfied === false
|
|
1571
|
+
// `optional` is absent rather than false on a row the plugin
|
|
1572
|
+
// did not mark (#275), and absent means not optional.
|
|
1573
|
+
? classifyPeer(row.plugin, row.name, row.range, row.resolved, row.optional === true)
|
|
1574
|
+
: { kind: 'none' };
|
|
1575
|
+
}
|
|
1576
|
+
sendJson(response, 200, report);
|
|
1577
|
+
}
|
|
1578
|
+
catch (error) {
|
|
1579
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1580
|
+
}
|
|
1581
|
+
},
|
|
1582
|
+
}),
|
|
1583
|
+
// Issue #98 phase 2: reorder the community bundles. Official bundles are
|
|
1584
|
+
// fixed; the candidate is trial-validated (dry-run composition replay)
|
|
1585
|
+
// before the manifest is written — a broken order is refused and the
|
|
1586
|
+
// profile is never touched.
|
|
1587
|
+
host.webServer.register({
|
|
1588
|
+
kind: 'exact',
|
|
1589
|
+
path: '/dsh-market/bundle-order',
|
|
1590
|
+
handler: async (request, response) => {
|
|
1591
|
+
if (request.method !== 'POST') {
|
|
1592
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1593
|
+
response.end();
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (!sameOrigin(request)) {
|
|
1597
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
// Mutex with pnpm operations AND other direct writes (issue #98
|
|
1601
|
+
// analysis): reordering writes package.json directly; racing an
|
|
1602
|
+
// install/update/uninstall — or another direct write — would
|
|
1603
|
+
// corrupt the manifest (backup restore uses the same guard). The
|
|
1604
|
+
// lock is taken BEFORE the body is read so a slow/pending request
|
|
1605
|
+
// cannot interleave with another write either.
|
|
1606
|
+
// #125 hardening (lesson from #122: a bad order write can stop DSH
|
|
1607
|
+
// from starting): keep a pre-write profile backup and restore it
|
|
1608
|
+
// automatically if the write throws mid-flight, and persist a profile
|
|
1609
|
+
// snapshot before the write (issue #126) so the change is recoverable
|
|
1610
|
+
// from the snapshots tab — the backup is the immediate rollback net.
|
|
1611
|
+
let backup = null;
|
|
1612
|
+
try {
|
|
1613
|
+
await withMutationLock(response, 'write', async () => {
|
|
1614
|
+
const body = (await readJsonBody(request));
|
|
1615
|
+
if (body === null || typeof body !== 'object') {
|
|
1616
|
+
sendJson(response, 400, { error: 'JSON body is required / 需要 JSON body' });
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
if (!Array.isArray(body.order) || !body.order.every(item => typeof item === 'string')) {
|
|
1620
|
+
sendJson(response, 400, { error: 'order must be an array of bundle names / order 必须是 bundle 名称数组' });
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
const order = body.order;
|
|
1624
|
+
// Before/after rules (issue #98 phase 2): the merged stack must
|
|
1625
|
+
// satisfy every rule the bundles declare. Enforced BEFORE the
|
|
1626
|
+
// trial/write so a rule-breaking order is refused outright.
|
|
1627
|
+
const stack = readBundleStack(activeProfileDir);
|
|
1628
|
+
const merged = mergeOrder(stack.bundles, order);
|
|
1629
|
+
if (merged.ok) {
|
|
1630
|
+
const conflicts = validateOrder(merged.bundles, readBundleRules(activeProfileDir));
|
|
1631
|
+
if (conflicts.length > 0) {
|
|
1632
|
+
logEvent('warn', 'bundle-order', `rejected by before/after rules: ${conflicts.map(c => c.reason).join('; ')}`);
|
|
1633
|
+
sendJson(response, 422, {
|
|
1634
|
+
error: 'the order violates declared before/after rules / 该顺序违反了插件声明的 before/after 规则',
|
|
1635
|
+
conflicts,
|
|
1636
|
+
});
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
const trial = trialValidate(activeProfileDir, order);
|
|
1641
|
+
if (!trial.ok) {
|
|
1642
|
+
const first = trial.errors[0];
|
|
1643
|
+
logEvent('warn', 'bundle-order', `rejected by trial validation: ${first?.message ?? 'unknown'}`);
|
|
1644
|
+
sendJson(response, 422, {
|
|
1645
|
+
error: `trial validation failed — ${first?.message ?? 'this order would not boot'} / 试启动校验失败:${first?.message ?? '该顺序无法启动'}`,
|
|
1646
|
+
trial: { errors: trial.errors, warnings: trial.warnings, diff: trial.diff },
|
|
1647
|
+
});
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
backup = createProfileBackup(config.profile, activeProfileDir);
|
|
1651
|
+
// yzke review point 4 (issue #126): persist a profile snapshot BEFORE
|
|
1652
|
+
// the write (subject to the maxSnapshots quota), so the change is
|
|
1653
|
+
// recoverable from the snapshots tab; the in-process backup above
|
|
1654
|
+
// stays as the immediate rollback net (double protection).
|
|
1655
|
+
const captured = createProfileSnapshot(activeProfileDir, maxSnapshots);
|
|
1656
|
+
if (!captured.ok) {
|
|
1657
|
+
sendJson(response, 400, { error: captured.error });
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
const snapshot = captured.snapshot;
|
|
1661
|
+
pendingRollbacks.clear();
|
|
1662
|
+
const applied = applyBundleOrder(activeProfileDir, order);
|
|
1663
|
+
if (!applied.ok) {
|
|
1664
|
+
sendJson(response, 400, { error: applied.error });
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
invalidateUpdates();
|
|
1668
|
+
logEvent('info', 'bundle-order', `applied new community order (snapshot ${snapshot.id})`);
|
|
1669
|
+
sendJson(response, 200, { ok: true, bundles: applied.bundles, snapshot: snapshot.id });
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
catch (error) {
|
|
1673
|
+
// The write threw mid-flight: restore the pre-write profile so a
|
|
1674
|
+
// broken manifest can never stop DSH from starting (issue #125,
|
|
1675
|
+
// lesson from #122). Best-effort — a failing restore must not mask
|
|
1676
|
+
// the original error.
|
|
1677
|
+
if (backup !== null) {
|
|
1678
|
+
try {
|
|
1679
|
+
restoreProfileBackup(config.profile, backup, activeProfileDir);
|
|
1680
|
+
logEvent('error', 'bundle-order', `write failed — profile restored from pre-write backup: ${error instanceof Error ? error.message : String(error)}`);
|
|
1681
|
+
}
|
|
1682
|
+
catch {
|
|
1683
|
+
logEvent('error', 'bundle-order', 'write failed AND automatic rollback failed');
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1687
|
+
}
|
|
1688
|
+
},
|
|
1689
|
+
}),
|
|
1690
|
+
// Issue #98 phase 3: named plugin presets (bundle order + disable list).
|
|
1691
|
+
host.webServer.register({
|
|
1692
|
+
kind: 'exact',
|
|
1693
|
+
path: '/dsh-market/presets',
|
|
1694
|
+
handler: async (request, response) => {
|
|
1695
|
+
if (request.method === 'GET') {
|
|
1696
|
+
sendJson(response, 200, { presets: listPresets(activeProfileDir) });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (request.method !== 'POST') {
|
|
1700
|
+
response.writeHead(405, { allow: 'GET, POST' });
|
|
1701
|
+
response.end();
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
if (!sameOrigin(request)) {
|
|
1705
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
// save carries the FULL community order + disabled list, which can
|
|
1710
|
+
// exceed the 4KiB default (CJK names are 3 bytes/char).
|
|
1711
|
+
const body = (await readJsonBody(request, 256 * 1024));
|
|
1712
|
+
if (body === null || typeof body !== 'object') {
|
|
1713
|
+
sendJson(response, 400, { error: 'JSON body is required / 需要 JSON body' });
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const name = body.name;
|
|
1717
|
+
// Preview is a pure read; save/apply/delete write presets.json,
|
|
1718
|
+
// package.json and state.json, so they take the direct-write lock —
|
|
1719
|
+
// a concurrent pnpm run or another direct write must not interleave
|
|
1720
|
+
// (issue #98 analysis: write-route mutual exclusion).
|
|
1721
|
+
if (body.action === 'preview') {
|
|
1722
|
+
const previewed = previewPreset(activeProfileDir, name);
|
|
1723
|
+
sendJson(response, previewed.ok ? 200 : 422, previewed);
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
await withMutationLock(response, 'write', async () => {
|
|
1727
|
+
switch (body.action) {
|
|
1728
|
+
case 'save': {
|
|
1729
|
+
const saved = savePreset(activeProfileDir, name, body.bundleOrder, body.disabled);
|
|
1730
|
+
sendJson(response, saved.ok ? 200 : 400, saved);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
case 'apply': {
|
|
1734
|
+
pendingRollbacks.clear();
|
|
1735
|
+
const applied = applyPreset(activeProfileDir, name, maxSnapshots);
|
|
1736
|
+
if (applied.ok) {
|
|
1737
|
+
invalidateUpdates();
|
|
1738
|
+
refreshMarketState();
|
|
1739
|
+
}
|
|
1740
|
+
sendJson(response, applied.ok ? 200 : 422, applied);
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
case 'delete': {
|
|
1744
|
+
const deleted = deletePreset(activeProfileDir, name);
|
|
1745
|
+
sendJson(response, deleted.ok ? 200 : 400, deleted);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
default:
|
|
1749
|
+
sendJson(response, 400, { error: 'action must be save | preview | apply | delete / action 必须是 save | preview | apply | delete' });
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
catch (error) {
|
|
1754
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1755
|
+
}
|
|
1756
|
+
},
|
|
1757
|
+
}),
|
|
1758
|
+
// Issue #98 phase 3 (#19): profile snapshots — list, create, restore.
|
|
1759
|
+
host.webServer.register({
|
|
1760
|
+
kind: 'exact',
|
|
1761
|
+
path: '/dsh-market/snapshots',
|
|
1762
|
+
handler: async (request, response) => {
|
|
1763
|
+
if (request.method === 'GET') {
|
|
1764
|
+
sendJson(response, 200, { snapshots: listSnapshots(activeProfileDir) });
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
if (request.method === 'POST') {
|
|
1768
|
+
if (!sameOrigin(request)) {
|
|
1769
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
try {
|
|
1773
|
+
await withMutationLock(response, 'write', async () => {
|
|
1774
|
+
const captured = createProfileSnapshot(activeProfileDir, maxSnapshots);
|
|
1775
|
+
if (captured.ok)
|
|
1776
|
+
sendJson(response, 200, { ok: true, snapshot: captured.snapshot });
|
|
1777
|
+
else
|
|
1778
|
+
sendJson(response, 400, captured);
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
catch (error) {
|
|
1782
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1783
|
+
}
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
response.writeHead(405, { allow: 'GET, POST' });
|
|
1787
|
+
response.end();
|
|
1788
|
+
},
|
|
1789
|
+
}),
|
|
1790
|
+
host.webServer.register({
|
|
1791
|
+
kind: 'exact',
|
|
1792
|
+
path: '/dsh-market/restore-snapshot',
|
|
1793
|
+
handler: async (request, response) => {
|
|
1794
|
+
if (request.method !== 'POST') {
|
|
1795
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1796
|
+
response.end();
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (!sameOrigin(request)) {
|
|
1800
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
try {
|
|
1804
|
+
await withMutationLock(response, 'write', async () => {
|
|
1805
|
+
const body = (await readJsonBody(request));
|
|
1806
|
+
if (body === null || typeof body !== 'object' || typeof body.snapshot !== 'string' || body.snapshot === '') {
|
|
1807
|
+
sendJson(response, 400, { error: 'snapshot id is required / 需要快照 id' });
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
pendingRollbacks.clear();
|
|
1811
|
+
const restored = restoreSnapshot(activeProfileDir, body.snapshot);
|
|
1812
|
+
if (restored.ok) {
|
|
1813
|
+
invalidateUpdates();
|
|
1814
|
+
refreshMarketState();
|
|
1815
|
+
}
|
|
1816
|
+
sendJson(response, restored.ok ? 200 : 400, restored);
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
catch (error) {
|
|
1820
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1821
|
+
}
|
|
1822
|
+
},
|
|
1823
|
+
}),
|
|
1824
|
+
// Issue #98 supplement: delete one snapshot (the cap also prunes old ones
|
|
1825
|
+
// automatically, but the user may want to drop a specific snapshot).
|
|
1826
|
+
host.webServer.register({
|
|
1827
|
+
kind: 'exact',
|
|
1828
|
+
path: '/dsh-market/delete-snapshot',
|
|
1829
|
+
handler: async (request, response) => {
|
|
1830
|
+
if (request.method !== 'POST') {
|
|
1831
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1832
|
+
response.end();
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
if (!sameOrigin(request)) {
|
|
1836
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
try {
|
|
1840
|
+
await withMutationLock(response, 'write', async () => {
|
|
1841
|
+
const body = (await readJsonBody(request));
|
|
1842
|
+
if (body === null || typeof body !== 'object' || typeof body.snapshot !== 'string' || body.snapshot === '') {
|
|
1843
|
+
sendJson(response, 400, { error: 'snapshot id is required / 需要快照 id' });
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
// deleteSnapshot refuses traversal-shaped ids before touching the
|
|
1847
|
+
// filesystem (same discipline as restore); a false result means the
|
|
1848
|
+
// id is malformed or no such snapshot exists.
|
|
1849
|
+
const deleted = deleteSnapshot(activeProfileDir, body.snapshot);
|
|
1850
|
+
if (!deleted) {
|
|
1851
|
+
sendJson(response, 400, { ok: false, error: 'snapshot not found / 快照不存在' });
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
logEvent('info', 'snapshot', `deleted ${body.snapshot}`);
|
|
1855
|
+
sendJson(response, 200, { ok: true, snapshot: body.snapshot });
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
catch (error) {
|
|
1859
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1860
|
+
}
|
|
1861
|
+
},
|
|
1862
|
+
}),
|
|
1863
|
+
host.webServer.register({
|
|
1864
|
+
kind: 'exact',
|
|
1865
|
+
path: '/dsh-market/use-skin',
|
|
1866
|
+
handler: async (request, response) => {
|
|
1867
|
+
if (request.method !== 'POST') {
|
|
1868
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1869
|
+
response.end();
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (!sameOrigin(request)) {
|
|
1873
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
try {
|
|
1877
|
+
const body = (await readJsonBody(request));
|
|
1878
|
+
const name = typeof body.name === 'string' ? body.name : '';
|
|
1879
|
+
const installed = readInstalled(config.profile, activeProfileDir);
|
|
1880
|
+
const themeNames = await themes.installedThemeNames();
|
|
1881
|
+
if (installed[name] === undefined || !themeNames.has(name)) {
|
|
1882
|
+
sendJson(response, 400, { error: 'not an installed theme' });
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
pendingRollbacks.clear();
|
|
1886
|
+
const activated = await themes.activateTheme(name);
|
|
1887
|
+
logEvent(activated ? 'info' : 'error', 'use-skin', `${name}: ${activated ? 'active' : 'failed'}`);
|
|
1888
|
+
sendJson(response, activated ? 200 : 502, { ok: activated, live: listHotMounts() });
|
|
1889
|
+
}
|
|
1890
|
+
catch (error) {
|
|
1891
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1892
|
+
logEvent('error', 'use-skin', `route error: ${message}`);
|
|
1893
|
+
sendJson(response, 500, { error: message });
|
|
1894
|
+
}
|
|
1895
|
+
},
|
|
1896
|
+
}),
|
|
1897
|
+
host.webServer.register({
|
|
1898
|
+
kind: 'exact',
|
|
1899
|
+
path: '/dsh-market/toggle',
|
|
1900
|
+
handler: async (request, response) => {
|
|
1901
|
+
if (request.method !== 'POST') {
|
|
1902
|
+
response.writeHead(405, { allow: 'POST' });
|
|
1903
|
+
response.end();
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
if (!sameOrigin(request)) {
|
|
1907
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
try {
|
|
1911
|
+
await withMutationLock(response, 'write', async () => {
|
|
1912
|
+
const body = (await readJsonBody(request));
|
|
1913
|
+
const name = typeof body.name === 'string' ? body.name : '';
|
|
1914
|
+
const enabled = body.enabled === true;
|
|
1915
|
+
if (isSelfName(name)) {
|
|
1916
|
+
sendJson(response, 400, { error: 'the market cannot be disabled from its own page; use ChatCode CLI' });
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
if (readInstalled(config.profile, activeProfileDir)[name] === undefined) {
|
|
1920
|
+
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
// Host infrastructure (port of dsh-plugin-hub): switching off the
|
|
1924
|
+
// timer/hmr/webserver/storage chain would break the very HMR the
|
|
1925
|
+
// patch layer relies on, so those rows refuse to toggle.
|
|
1926
|
+
if (isProtectedModule(name)) {
|
|
1927
|
+
sendJson(response, 403, {
|
|
1928
|
+
error: `${name} 属于宿主基础设施,禁止开关(会破坏热加载/传输/存储链) / ${name} is host infrastructure and cannot be toggled (it would break the hot-reload/transport/storage chain)`,
|
|
1929
|
+
});
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
pendingRollbacks.clear();
|
|
1933
|
+
let ok;
|
|
1934
|
+
let reason;
|
|
1935
|
+
if (enabled && (await themes.installedThemeNames()).has(name)) {
|
|
1936
|
+
// Theme exclusivity stays a Themes-page concern: enabling a theme
|
|
1937
|
+
// deactivates the previously active one, so only the last-enabled
|
|
1938
|
+
// theme is live (same semantics as use-skin).
|
|
1939
|
+
ok = await themes.activateTheme(name);
|
|
1940
|
+
if (!ok)
|
|
1941
|
+
reason = 'theme activation failed — restart required / 主题启用失败,需要重启';
|
|
1942
|
+
}
|
|
1943
|
+
else {
|
|
1944
|
+
const result = await setPluginEnabled(name, enabled);
|
|
1945
|
+
ok = result.ok;
|
|
1946
|
+
reason = result.reason;
|
|
1947
|
+
}
|
|
1948
|
+
// Durable patch-layer write (port of dsh-plugin-hub): the package's
|
|
1949
|
+
// bundle rows get 'disabled: true|false' in the user patch layer,
|
|
1950
|
+
// which DSH's HMR applies within ~1s AND the loader re-applies on
|
|
1951
|
+
// every boot. Client-only packages have no bundle rows — the
|
|
1952
|
+
// market's own state.json replay covers those.
|
|
1953
|
+
const patchRows = rowIdsForPackage(host, activeProfileDir, name);
|
|
1954
|
+
// Disable-carrier (#224): a bundle whose patch DISABLES a plugin it
|
|
1955
|
+
// does not own (dsh-postgres-backends disables session-persistence-jsonl).
|
|
1956
|
+
// Disabling only its inserted rows leaves that foreign disable applying
|
|
1957
|
+
// on every boot — the bundle stays in the stack — so drop it from
|
|
1958
|
+
// dsh.profile.bundles entirely, which stops its whole patch at once
|
|
1959
|
+
// (including any config side effects it carries). Enabling re-adds it.
|
|
1960
|
+
// A bundle that merely reconfigures a neighbour (config without
|
|
1961
|
+
// disabled) is NOT dropped: #147 requires disabling it to leave the
|
|
1962
|
+
// neighbour live, and the e2e fixture-cross re-enable breaks otherwise.
|
|
1963
|
+
const disablesOthers = carrierDisableIds(activeProfileDir, name);
|
|
1964
|
+
const isCarrier = disablesOthers.length > 0;
|
|
1965
|
+
let bundleSwitch = { ok: true, reason: null };
|
|
1966
|
+
if (isCarrier) {
|
|
1967
|
+
try {
|
|
1968
|
+
if (enabled)
|
|
1969
|
+
addProfileBundle(activeProfileDir, name);
|
|
1970
|
+
else
|
|
1971
|
+
removeProfileBundle(activeProfileDir, name);
|
|
1972
|
+
logEvent('info', 'toggle', `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (disables: ${disablesOthers.join(', ')})`);
|
|
1973
|
+
}
|
|
1974
|
+
catch (error) {
|
|
1975
|
+
bundleSwitch = { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
1976
|
+
logEvent('warn', 'toggle', `${name}: carrier bundle switch failed — ${bundleSwitch.reason}`);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
let patchWrite = null;
|
|
1980
|
+
if (patchRows.length > 0) {
|
|
1981
|
+
for (const rowId of patchRows) {
|
|
1982
|
+
const result = enabled ? await enableRow(userPatchPath, rowId) : await disableRow(userPatchPath, rowId);
|
|
1983
|
+
if (!result.ok && patchWrite === null)
|
|
1984
|
+
patchWrite = result;
|
|
1985
|
+
}
|
|
1986
|
+
if (patchWrite === null) {
|
|
1987
|
+
logEvent('info', 'toggle', `${name}: patch layer ${enabled ? 'enabled' : 'disabled'} rows ${patchRows.join(', ')}`);
|
|
1988
|
+
}
|
|
1989
|
+
else {
|
|
1990
|
+
logEvent('warn', 'toggle', `${name}: patch layer write refused — ${patchWrite.reason}`);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
logEvent(ok ? 'info' : 'error', 'toggle', `${name}: ${enabled ? 'on' : 'off'} ok=${String(ok)}`);
|
|
1994
|
+
// Activation reads the post-write truth: the switch state OR the
|
|
1995
|
+
// patch layer, so a disabled plugin never reports "restart to
|
|
1996
|
+
// apply".
|
|
1997
|
+
const patchNow = readUserPatchState(userPatchPath);
|
|
1998
|
+
const offNow = disabled.has(name) || patchRows.some(id => patchNow.disables.includes(id));
|
|
1999
|
+
// When the live composition does not match the requested state
|
|
2000
|
+
// (enable failed to hot-mount / disable left the fiber up), the
|
|
2001
|
+
// change lands on the next boot via the patch layer + state.json —
|
|
2002
|
+
// the client reuses the market's pending-restart banner for it.
|
|
2003
|
+
const liveAfter = liveNames().has(name);
|
|
2004
|
+
// A carrier toggle moves the bundle in/out of dsh.profile.bundles,
|
|
2005
|
+
// which only takes effect on the next composition — always a restart.
|
|
2006
|
+
// Non-carrier plugins keep the live-mount based decision.
|
|
2007
|
+
const restart = isCarrier ? true : enabled ? !liveAfter : liveAfter;
|
|
2008
|
+
// A client-part plugin's UI is in the page already — toggling it
|
|
2009
|
+
// needs a browser refresh to show the change (same signal the
|
|
2010
|
+
// install flow uses for the hot banner).
|
|
2011
|
+
const refresh = packageHasClientPart(activeProfileDir, name);
|
|
2012
|
+
sendJson(response, ok ? 200 : 502, {
|
|
2013
|
+
ok,
|
|
2014
|
+
name,
|
|
2015
|
+
enabled,
|
|
2016
|
+
disabled: [...disabled],
|
|
2017
|
+
live: listHotMounts(),
|
|
2018
|
+
activation: { [name]: verifyActivation(config.profile, name, liveNames(), activeProfileDir, offNow) },
|
|
2019
|
+
reason,
|
|
2020
|
+
patchRows,
|
|
2021
|
+
patchWrite: patchWrite ?? { ok: true, reason: null },
|
|
2022
|
+
carrier: disablesOthers,
|
|
2023
|
+
bundleSwitch,
|
|
2024
|
+
restart,
|
|
2025
|
+
refresh,
|
|
2026
|
+
});
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
catch (error) {
|
|
2030
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2031
|
+
logEvent('error', 'toggle', `route error: ${message}`);
|
|
2032
|
+
sendJson(response, 500, { error: message });
|
|
2033
|
+
}
|
|
2034
|
+
},
|
|
2035
|
+
}),
|
|
2036
|
+
host.webServer.register({
|
|
2037
|
+
kind: 'exact',
|
|
2038
|
+
path: '/dsh-market/note',
|
|
2039
|
+
handler: async (request, response) => {
|
|
2040
|
+
if (request.method !== 'POST') {
|
|
2041
|
+
response.writeHead(405, { allow: 'POST' });
|
|
2042
|
+
response.end();
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
if (!sameOrigin(request)) {
|
|
2046
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
try {
|
|
2050
|
+
await withMutationLock(response, 'write', async () => {
|
|
2051
|
+
const body = (await readJsonBody(request));
|
|
2052
|
+
const name = typeof body?.name === 'string' ? body.name : '';
|
|
2053
|
+
if (name === '') {
|
|
2054
|
+
sendJson(response, 400, { error: 'name is required / 需要 name' });
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
const state = readMarketState(activeProfileDir);
|
|
2058
|
+
const notes = { ...state.notes };
|
|
2059
|
+
const text = typeof body?.text === 'string' ? body.text.trim().slice(0, MAX_NOTE) : '';
|
|
2060
|
+
// Empty clears rather than storing a blank: a row must not claim
|
|
2061
|
+
// to carry a note the user just erased.
|
|
2062
|
+
if (text === '')
|
|
2063
|
+
delete notes[name];
|
|
2064
|
+
else
|
|
2065
|
+
notes[name] = text;
|
|
2066
|
+
writeMarketState(activeProfileDir, { ...state, notes });
|
|
2067
|
+
refreshMarketState();
|
|
2068
|
+
sendJson(response, 200, { ok: true, notes });
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
catch (error) {
|
|
2072
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2073
|
+
}
|
|
2074
|
+
},
|
|
2075
|
+
}),
|
|
2076
|
+
host.webServer.register({
|
|
2077
|
+
kind: 'exact',
|
|
2078
|
+
path: '/dsh-market/favorite',
|
|
2079
|
+
handler: async (request, response) => {
|
|
2080
|
+
if (request.method !== 'POST') {
|
|
2081
|
+
response.writeHead(405, { allow: 'POST' });
|
|
2082
|
+
response.end();
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
if (!sameOrigin(request)) {
|
|
2086
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
try {
|
|
2090
|
+
await withMutationQueued(async () => {
|
|
2091
|
+
const body = (await readJsonBody(request));
|
|
2092
|
+
const url = typeof body?.url === 'string' ? body.url.trim() : '';
|
|
2093
|
+
if (url === '' || (!url.startsWith('http://') && !url.startsWith('https://'))) {
|
|
2094
|
+
sendJson(response, 400, { error: 'url is required / 需要有效的 http(s) url' });
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
const state = readMarketState(activeProfileDir);
|
|
2098
|
+
const favorites = [...(state.favorites ?? [])];
|
|
2099
|
+
const favorited = body?.favorited === true;
|
|
2100
|
+
if (favorited) {
|
|
2101
|
+
if (favorites.includes(url)) {
|
|
2102
|
+
sendJson(response, 200, { ok: true, favorites });
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
if (favorites.length >= MAX_FAVORITES) {
|
|
2106
|
+
sendJson(response, 400, {
|
|
2107
|
+
error: `favorites limit reached (${String(MAX_FAVORITES)}) / 收藏已达上限(${String(MAX_FAVORITES)})`,
|
|
2108
|
+
});
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
favorites.push(url);
|
|
2112
|
+
}
|
|
2113
|
+
else {
|
|
2114
|
+
const index = favorites.indexOf(url);
|
|
2115
|
+
if (index !== -1)
|
|
2116
|
+
favorites.splice(index, 1);
|
|
2117
|
+
}
|
|
2118
|
+
// Re-read immediately before write so a concurrent install cannot
|
|
2119
|
+
// leave us holding a stale disabled/groups snapshot (#414).
|
|
2120
|
+
const fresh = readMarketState(activeProfileDir);
|
|
2121
|
+
writeMarketState(activeProfileDir, { ...fresh, favorites });
|
|
2122
|
+
refreshMarketState();
|
|
2123
|
+
sendJson(response, 200, { ok: true, favorites });
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
catch (error) {
|
|
2127
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2128
|
+
}
|
|
2129
|
+
},
|
|
2130
|
+
}),
|
|
2131
|
+
host.webServer.register({
|
|
2132
|
+
kind: 'exact',
|
|
2133
|
+
path: '/dsh-market/groups',
|
|
2134
|
+
handler: async (request, response) => {
|
|
2135
|
+
if (request.method !== 'POST') {
|
|
2136
|
+
response.writeHead(405, { allow: 'POST' });
|
|
2137
|
+
response.end();
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
if (!sameOrigin(request)) {
|
|
2141
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
try {
|
|
2145
|
+
const body = (await readJsonBody(request));
|
|
2146
|
+
const action = typeof body.action === 'string' ? body.action : '';
|
|
2147
|
+
const known = action === 'create' || action === 'rename' || action === 'delete'
|
|
2148
|
+
|| action === 'set-members' || action === 'toggle';
|
|
2149
|
+
if (!known) {
|
|
2150
|
+
sendJson(response, 400, { ok: false, error: 'unknown group action' });
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
const installed = new Set(Object.keys(readInstalled(config.profile, activeProfileDir)));
|
|
2154
|
+
// Theme members follow the global one-active-theme rule: a group
|
|
2155
|
+
// holds at most one, and enabling one deactivates every other.
|
|
2156
|
+
const themeNames = await themes.installedThemeNames();
|
|
2157
|
+
let ok = true;
|
|
2158
|
+
let error;
|
|
2159
|
+
let restartMembers = [];
|
|
2160
|
+
let refreshMembers = [];
|
|
2161
|
+
if (action === 'toggle') {
|
|
2162
|
+
const name = typeof body.name === 'string' ? body.name : '';
|
|
2163
|
+
const enabled = body.enabled === true;
|
|
2164
|
+
if (groups[name] === undefined) {
|
|
2165
|
+
sendJson(response, 400, { ok: false, error: 'group not found / 分组不存在' });
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
pendingRollbacks.clear();
|
|
2169
|
+
// Batch toggle: on = every installed member enabled, off = every
|
|
2170
|
+
// member disabled. Each member keeps its own persisted flag, so
|
|
2171
|
+
// later individual toggles still work (the group switch itself is
|
|
2172
|
+
// derived state and never stored).
|
|
2173
|
+
const failures = [];
|
|
2174
|
+
for (const member of groups[name]) {
|
|
2175
|
+
if (!installed.has(member))
|
|
2176
|
+
continue;
|
|
2177
|
+
const result = enabled && themeNames.has(member)
|
|
2178
|
+
? { ok: await themes.activateTheme(member), reason: undefined }
|
|
2179
|
+
: await setPluginEnabled(member, enabled);
|
|
2180
|
+
if (!result.ok)
|
|
2181
|
+
failures.push(member);
|
|
2182
|
+
// Same live-mismatch signal as the single toggle: a member
|
|
2183
|
+
// whose fiber did not follow the switch needs a boot.
|
|
2184
|
+
const liveAfter = liveNames().has(member);
|
|
2185
|
+
if ((enabled && !liveAfter) || (!enabled && liveAfter))
|
|
2186
|
+
restartMembers.push(member);
|
|
2187
|
+
// Client-part members need a page refresh to show the change.
|
|
2188
|
+
if (packageHasClientPart(activeProfileDir, member))
|
|
2189
|
+
refreshMembers.push(member);
|
|
2190
|
+
}
|
|
2191
|
+
ok = failures.length === 0;
|
|
2192
|
+
if (!ok)
|
|
2193
|
+
error = `failed to ${enabled ? 'enable' : 'disable'}: ${failures.join(', ')}`;
|
|
2194
|
+
}
|
|
2195
|
+
else {
|
|
2196
|
+
const state = { groups, groupOrder };
|
|
2197
|
+
const result = action === 'create' ? createGroup(state, body.name)
|
|
2198
|
+
: action === 'rename' ? renameGroup(state, body.name, body.newName)
|
|
2199
|
+
: action === 'delete' ? deleteGroup(state, body.name)
|
|
2200
|
+
: setGroupMembers(state, body.name, body.members, installed, themeNames);
|
|
2201
|
+
ok = result.ok;
|
|
2202
|
+
error = result.error;
|
|
2203
|
+
}
|
|
2204
|
+
if (ok)
|
|
2205
|
+
writeMarketState(activeProfileDir, { disabled, groups, groupOrder });
|
|
2206
|
+
logEvent(ok ? 'info' : 'warn', 'groups', `${action}${typeof body.name === 'string' ? ' ' + body.name : ''}${ok ? '' : ` — ${error ?? ''}`}`);
|
|
2207
|
+
sendJson(response, ok ? 200 : 400, {
|
|
2208
|
+
ok,
|
|
2209
|
+
error,
|
|
2210
|
+
groups,
|
|
2211
|
+
groupOrder,
|
|
2212
|
+
disabled: [...disabled],
|
|
2213
|
+
restartMembers,
|
|
2214
|
+
refreshMembers,
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
catch (error) {
|
|
2218
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2219
|
+
logEvent('error', 'groups', `route error: ${message}`);
|
|
2220
|
+
sendJson(response, 500, { error: message });
|
|
2221
|
+
}
|
|
2222
|
+
},
|
|
2223
|
+
}),
|
|
2224
|
+
host.webServer.register({
|
|
2225
|
+
kind: 'exact',
|
|
2226
|
+
path: '/dsh-market/status',
|
|
2227
|
+
handler: async (request, response) => {
|
|
2228
|
+
if (request.method !== 'GET') {
|
|
2229
|
+
response.writeHead(405, { allow: 'GET' });
|
|
2230
|
+
response.end();
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
await dropStaleHotMounts();
|
|
2234
|
+
const installed = readInstalled(config.profile, activeProfileDir);
|
|
2235
|
+
sendJson(response, 200, {
|
|
2236
|
+
active: progress.active,
|
|
2237
|
+
target: progress.target,
|
|
2238
|
+
seconds: progress.active ? Math.round((Date.now() - progress.startedAt) / 1000) : 0,
|
|
2239
|
+
lastLine: progress.lastLine,
|
|
2240
|
+
phase: progress.phase,
|
|
2241
|
+
done: progress.done,
|
|
2242
|
+
total: progress.total,
|
|
2243
|
+
currentPackage: progress.currentPackage,
|
|
2244
|
+
downloaded: progress.downloaded,
|
|
2245
|
+
size: progress.size,
|
|
2246
|
+
ndjson: progress.ndjson,
|
|
2247
|
+
error: progress.error,
|
|
2248
|
+
cancelling: progress.cancelling,
|
|
2249
|
+
// The route-level operation flag, NOT progress.active: after pnpm
|
|
2250
|
+
// exits, install post-processing (retarget, validation, hot-mount)
|
|
2251
|
+
// still holds the operation lock for a moment — the exact window
|
|
2252
|
+
// where clicking the restart banner used to bounce off a 409 (#91).
|
|
2253
|
+
busy: installing,
|
|
2254
|
+
pnpm: await commands.probePnpm(),
|
|
2255
|
+
boot: BOOT_ID,
|
|
2256
|
+
agentGuardAvailable: agentsGuardAvailable(),
|
|
2257
|
+
// Shown in the page heading so screenshots carry it (#159).
|
|
2258
|
+
version: marketVersion(),
|
|
2259
|
+
channel: activeChannel(),
|
|
2260
|
+
channels: CHANNELS,
|
|
2261
|
+
region,
|
|
2262
|
+
regions: REGIONS,
|
|
2263
|
+
// The prefix the BROWSER should put in front of github.com URLs
|
|
2264
|
+
// (avatars, README images). Sent resolved rather than derived from
|
|
2265
|
+
// `region` on the client, so the routing table has one home and a
|
|
2266
|
+
// change to it cannot leave the two halves disagreeing.
|
|
2267
|
+
githubProxy: routesFor(region).githubProxy,
|
|
2268
|
+
// New clients use per-service candidates. Keep githubProxy above
|
|
2269
|
+
// for older bundles that understand only one prefix.
|
|
2270
|
+
githubRoutes: routesFor(region).githubRoutes,
|
|
2271
|
+
githubProxyCustom: marketState.githubProxy ?? null,
|
|
2272
|
+
githubProxyManaged: githubProxyManaged(),
|
|
2273
|
+
// Whether the region was decided by the network check rather than
|
|
2274
|
+
// by the user — the card explains a choice it made on their behalf
|
|
2275
|
+
// exactly once, so nobody has to wonder why downloads moved.
|
|
2276
|
+
regionAuto,
|
|
2277
|
+
restart: restartAllowed(config),
|
|
2278
|
+
// Named so the UI can say WHY the button is gone. A blank
|
|
2279
|
+
// "no restart button" is the state #229 reported as broken.
|
|
2280
|
+
supervisor: detectedSupervisor(),
|
|
2281
|
+
debugger: detectedDebugger(),
|
|
2282
|
+
selfManaged: SELF_NAME_ORDER.some(name => installed[name] !== undefined),
|
|
2283
|
+
installed,
|
|
2284
|
+
});
|
|
2285
|
+
},
|
|
2286
|
+
}),
|
|
2287
|
+
host.webServer.register({
|
|
2288
|
+
kind: 'exact',
|
|
2289
|
+
path: '/dsh-market/logs',
|
|
2290
|
+
handler: (request, response) => {
|
|
2291
|
+
if (request.method !== 'GET') {
|
|
2292
|
+
response.writeHead(405, { allow: 'GET' });
|
|
2293
|
+
response.end();
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
const version = marketVersion();
|
|
2297
|
+
response.writeHead(200, {
|
|
2298
|
+
'cache-control': 'no-store',
|
|
2299
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
2300
|
+
'content-disposition': 'attachment; filename="dsh-market-log.txt"',
|
|
2301
|
+
});
|
|
2302
|
+
// Built here, not in log.ts: this is the composition, and the log
|
|
2303
|
+
// module deliberately knows nothing about profiles. Each declared
|
|
2304
|
+
// bundle is marked with whether it actually resolves, because an
|
|
2305
|
+
// unresolvable one is what stops the next boot (#339, #341) and it
|
|
2306
|
+
// is invisible in a manifest listing on its own.
|
|
2307
|
+
const snapshot = [];
|
|
2308
|
+
try {
|
|
2309
|
+
const report = analyzeProfile(activeProfileDir);
|
|
2310
|
+
const installed = readInstalled(config.profile, activeProfileDir);
|
|
2311
|
+
snapshot.push(`dependencies (${String(Object.keys(installed).length)}):`);
|
|
2312
|
+
for (const [name, spec] of Object.entries(installed))
|
|
2313
|
+
snapshot.push(` ${name}: ${spec}`);
|
|
2314
|
+
snapshot.push(`bundles (${String(report.bundles.length)}):`);
|
|
2315
|
+
for (const layer of report.bundles) {
|
|
2316
|
+
const state = layer.directory !== null
|
|
2317
|
+
? 'ok'
|
|
2318
|
+
: layer.unresolvedInbox === true
|
|
2319
|
+
? 'supplied by the dsh installation (not locatable from here)'
|
|
2320
|
+
: 'NOT RESOLVED — the next start fails here';
|
|
2321
|
+
snapshot.push(` ${layer.name}: ${state}`);
|
|
2322
|
+
}
|
|
2323
|
+
if (report.summary.errors.length > 0) {
|
|
2324
|
+
snapshot.push('errors:');
|
|
2325
|
+
for (const line of report.summary.errors)
|
|
2326
|
+
snapshot.push(` ${line}`);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
catch (error) {
|
|
2330
|
+
snapshot.push(`profile state unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
2331
|
+
}
|
|
2332
|
+
// The host version, and where it was found. Absent until now, and
|
|
2333
|
+
// it is the field investigations kept stalling on: #293 spent three
|
|
2334
|
+
// rounds before it emerged that the reporter's host was newer than
|
|
2335
|
+
// every attempt to reproduce, and a path under Electron's resources
|
|
2336
|
+
// is how a Desktop-bundled (possibly older, #139) host announces
|
|
2337
|
+
// itself. sanitize() rewrites the home prefix in the value.
|
|
2338
|
+
const host = dshHostInfo();
|
|
2339
|
+
response.end(exportLogs({
|
|
2340
|
+
'ChatCode CLI Market': version,
|
|
2341
|
+
'ChatCode CLI host': host === null
|
|
2342
|
+
? 'not locatable from this process'
|
|
2343
|
+
: `${host.version} (${host.directory})`,
|
|
2344
|
+
platform: `${process.platform} ${process.arch}`,
|
|
2345
|
+
node: process.version,
|
|
2346
|
+
profile: config.profile,
|
|
2347
|
+
}, snapshot, readPersistentLog(persistentLogFile)));
|
|
2348
|
+
},
|
|
2349
|
+
}),
|
|
2350
|
+
host.webServer.register({
|
|
2351
|
+
kind: 'exact',
|
|
2352
|
+
path: '/dsh-market/updates',
|
|
2353
|
+
handler: async (request, response) => {
|
|
2354
|
+
if (request.method !== 'GET') {
|
|
2355
|
+
response.writeHead(405, { allow: 'GET' });
|
|
2356
|
+
response.end();
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
try {
|
|
2360
|
+
const force = (request.url ?? '').includes('force=1');
|
|
2361
|
+
// Only the market itself follows the channel setting (see
|
|
2362
|
+
// MarketSettings.channel): a user opting into betas is volunteering
|
|
2363
|
+
// to try THIS plugin early, not to be handed every other author's
|
|
2364
|
+
// unreleased work.
|
|
2365
|
+
const channel = activeChannel();
|
|
2366
|
+
const installed = readInstalled(config.profile, activeProfileDir);
|
|
2367
|
+
const channelFor = new Map(Object.keys(installed)
|
|
2368
|
+
.filter(name => SELF_NAMES.has(name))
|
|
2369
|
+
.map(name => [name, channel]));
|
|
2370
|
+
const onlineSourceFor = new Map();
|
|
2371
|
+
const sourceMigrationFor = new Map();
|
|
2372
|
+
try {
|
|
2373
|
+
const registry = await loadRegistry();
|
|
2374
|
+
for (const [name, spec] of Object.entries(installed)) {
|
|
2375
|
+
const migration = findGitToNpmMigration(registry.plugins, spec);
|
|
2376
|
+
if (migration !== null)
|
|
2377
|
+
sourceMigrationFor.set(name, migration);
|
|
2378
|
+
if (!spec.toLowerCase().startsWith('file:'))
|
|
2379
|
+
continue;
|
|
2380
|
+
const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir);
|
|
2381
|
+
const entry = findCatalogEntryForLocal(registry.plugins, name, evidence.identities, evidence.hints);
|
|
2382
|
+
const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities);
|
|
2383
|
+
if (target !== null && NPM_NAME_RE.test(target))
|
|
2384
|
+
onlineSourceFor.set(name, target);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
catch (error) {
|
|
2388
|
+
logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
2389
|
+
}
|
|
2390
|
+
const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor);
|
|
2391
|
+
for (const [name, migration] of sourceMigrationFor) {
|
|
2392
|
+
const status = updates[name];
|
|
2393
|
+
if (status !== undefined)
|
|
2394
|
+
updates[name] = { ...status, sourceMigration: migration };
|
|
2395
|
+
}
|
|
2396
|
+
sendJson(response, 200, { updates });
|
|
2397
|
+
}
|
|
2398
|
+
catch (error) {
|
|
2399
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2400
|
+
}
|
|
2401
|
+
},
|
|
2402
|
+
}),
|
|
2403
|
+
// Read-only notes for the update dialog (#294): release body and/or a
|
|
2404
|
+
// commit tail sliced at the installed sha, resolved from the catalog
|
|
2405
|
+
// side's daily probe — no GitHub API call is made here, ever. The handler
|
|
2406
|
+
// itself does not throw (every failure degrades to `kind: 'none'`), so a
|
|
2407
|
+
// dialog that cannot load its data shows a neutral statement rather than
|
|
2408
|
+
// an error banner.
|
|
2409
|
+
host.webServer.register({
|
|
2410
|
+
kind: 'exact',
|
|
2411
|
+
path: '/dsh-market/changelog',
|
|
2412
|
+
handler: async (request, response) => {
|
|
2413
|
+
if (request.method !== 'GET') {
|
|
2414
|
+
response.writeHead(405, { allow: 'GET' });
|
|
2415
|
+
response.end();
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
try {
|
|
2419
|
+
const name = new URL(request.url ?? '/', 'http://localhost').searchParams.get('name') ?? '';
|
|
2420
|
+
if (name === '') {
|
|
2421
|
+
sendJson(response, 400, { error: 'name query parameter is required' });
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
sendJson(response, 200, await updateNotesFor(config.profile, activeProfileDir, name));
|
|
2425
|
+
}
|
|
2426
|
+
catch (error) {
|
|
2427
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2428
|
+
}
|
|
2429
|
+
},
|
|
2430
|
+
}),
|
|
2431
|
+
host.webServer.register({
|
|
2432
|
+
kind: 'exact',
|
|
2433
|
+
path: '/dsh-market/migrate-source',
|
|
2434
|
+
handler: async (request, response) => {
|
|
2435
|
+
if (request.method !== 'POST') {
|
|
2436
|
+
response.writeHead(405, { allow: 'POST' });
|
|
2437
|
+
response.end();
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
if (!sameOrigin(request)) {
|
|
2441
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
try {
|
|
2445
|
+
await withMutationLock(response, 'install', async () => {
|
|
2446
|
+
const body = (await readJsonBody(request));
|
|
2447
|
+
const name = typeof body.name === 'string' ? body.name : '';
|
|
2448
|
+
if (!NPM_NAME_RE.test(name) || INBOX_BUNDLES.has(name)) {
|
|
2449
|
+
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
const manifestCapture = captureUpdateManifest();
|
|
2453
|
+
if (!manifestCapture.ok) {
|
|
2454
|
+
sendJson(response, 500, {
|
|
2455
|
+
error: `迁移前无法安全读取 profile package.json,未执行任何修改(${manifestCapture.detail})。 / The profile package.json could not be captured safely before migration; nothing was changed (${manifestCapture.detail}).`,
|
|
2456
|
+
});
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
const spec = manifestCapture.snapshot.dependencies[name];
|
|
2460
|
+
if (spec === undefined) {
|
|
2461
|
+
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
let migration = null;
|
|
2465
|
+
try {
|
|
2466
|
+
const registry = await loadRegistry();
|
|
2467
|
+
migration = findGitToNpmMigration(registry.plugins, spec);
|
|
2468
|
+
}
|
|
2469
|
+
catch (error) {
|
|
2470
|
+
logEvent('warn', 'source-migration', `${name}: catalog lookup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
2471
|
+
}
|
|
2472
|
+
if (migration === null) {
|
|
2473
|
+
sendJson(response, 400, {
|
|
2474
|
+
error: '当前 Git 来源无法唯一核验到一个 npm 包,或包含显式 branch/tag/commit/ref;未执行迁移。 / This Git source cannot be uniquely verified to one npm package, or it carries an explicit branch/tag/commit/ref; migration was not performed.',
|
|
2475
|
+
});
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
const targetName = migration.target;
|
|
2479
|
+
if (targetName !== name && manifestCapture.snapshot.dependencies[targetName] !== undefined) {
|
|
2480
|
+
sendJson(response, 409, {
|
|
2481
|
+
error: `目标 npm 包 ${targetName} 已经作为独立依赖安装;为避免覆盖现有安装,未执行迁移。 / The target npm package ${targetName} is already installed as a separate dependency; migration was not performed to avoid overwriting it.`,
|
|
2482
|
+
});
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
const busyAgents = runningAgentsForGuard();
|
|
2486
|
+
if (busyAgents.length > 0) {
|
|
2487
|
+
sendJson(response, 409, {
|
|
2488
|
+
error: `有 agent 正在运行(${busyAgents.join(', ')})。来源迁移会替换插件文件,请等它完成或取消后再迁移。 / ${busyAgents.length === 1 ? 'An agent is running' : 'Agents are running'} (${busyAgents.join(', ')}). Source migration replaces plugin files; wait for the running work to finish (or cancel it) before migrating.`,
|
|
2489
|
+
agentsBusy: true,
|
|
2490
|
+
runningAgents: busyAgents,
|
|
2491
|
+
});
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
const lockfileCapture = captureProfileLockfile();
|
|
2495
|
+
if (!lockfileCapture.ok) {
|
|
2496
|
+
sendJson(response, 500, { error: lockfileCapture.detail });
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
const wasLive = verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)).state === 'live'
|
|
2500
|
+
&& hasHostHalf(config.profile, name, activeProfileDir);
|
|
2501
|
+
const oldRows = rowIdsForPackage(host, activeProfileDir, name);
|
|
2502
|
+
const patchFlags = packagePatchFlags(host, activeProfileDir, [name], readUserPatchState(userPatchPath));
|
|
2503
|
+
const wasDisabled = disabled.has(name);
|
|
2504
|
+
const manifestBefore = manifestCapture.snapshot;
|
|
2505
|
+
const lockfileBefore = lockfileCapture.snapshot;
|
|
2506
|
+
const rollbackMigration = async () => {
|
|
2507
|
+
restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
2508
|
+
const prepared = restoreProfileLockfile(lockfileBefore);
|
|
2509
|
+
if (!prepared.ok)
|
|
2510
|
+
return prepared;
|
|
2511
|
+
const reinstall = await runPlugin(config.profile, ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install']);
|
|
2512
|
+
restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
2513
|
+
const finalLock = restoreProfileLockfile(lockfileBefore);
|
|
2514
|
+
if (!finalLock.ok)
|
|
2515
|
+
return finalLock;
|
|
2516
|
+
if (reinstall.exitCode !== 0 || reinstall.timedOut || reinstall.cancelled) {
|
|
2517
|
+
return { ok: false, detail: failureDetail(reinstall) };
|
|
2518
|
+
}
|
|
2519
|
+
return hasLoadableEntry(activeProfileDir, name)
|
|
2520
|
+
? { ok: true, detail: null }
|
|
2521
|
+
: { ok: false, detail: 'the previous Git source was restored without a loadable entry' };
|
|
2522
|
+
};
|
|
2523
|
+
const failWithRollback = async (detail, extra = {}) => {
|
|
2524
|
+
const rollback = await rollbackMigration();
|
|
2525
|
+
logEvent(rollback.ok ? 'warn' : 'error', 'source-migration', `${name}: migration failed — ${detail}; ${rollback.ok ? 'previous Git source restored' : `rollback failed: ${rollback.detail ?? 'unknown'}`}`);
|
|
2526
|
+
sendJson(response, 500, {
|
|
2527
|
+
ok: false,
|
|
2528
|
+
error: rollback.ok
|
|
2529
|
+
? `${detail};已恢复原 Git 来源。 / ${detail}; the previous Git source was restored.`
|
|
2530
|
+
: `${detail};且原 Git 来源未能验证恢复(${rollback.detail ?? 'unknown'})。 / ${detail}; restoration of the previous Git source could not be verified (${rollback.detail ?? 'unknown'}).`,
|
|
2531
|
+
rollback: rollback.ok,
|
|
2532
|
+
...extra,
|
|
2533
|
+
});
|
|
2534
|
+
};
|
|
2535
|
+
const remove = await runPlugin(config.profile, ['remove', name]);
|
|
2536
|
+
if (remove.exitCode !== 0 || remove.timedOut || remove.cancelled) {
|
|
2537
|
+
await failWithRollback(failureDetail(remove));
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
const add = await runPlugin(config.profile, ['add', `${targetName}@latest`]);
|
|
2541
|
+
if (add.exitCode !== 0 || add.timedOut || add.cancelled) {
|
|
2542
|
+
await failWithRollback(failureDetail(add), {
|
|
2543
|
+
ignoredBuilds: blockedBuilds(add),
|
|
2544
|
+
cancelled: add.cancelled,
|
|
2545
|
+
});
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
const after = readInstalled(config.profile, activeProfileDir);
|
|
2549
|
+
if (after[targetName] === undefined || (targetName !== name && after[name] !== undefined) || !hasLoadableEntry(activeProfileDir, targetName)) {
|
|
2550
|
+
await failWithRollback('npm 目标安装完成后未形成可加载且唯一的依赖。 / The npm target did not produce one loadable replacement dependency.');
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
const stack = readBundleStack(activeProfileDir);
|
|
2554
|
+
const trial = trialValidate(activeProfileDir, stack.community);
|
|
2555
|
+
if (!trial.ok) {
|
|
2556
|
+
await failWithRollback(`迁移后的 profile 无法通过启动校验(${trial.errors[0]?.message ?? 'unknown'})。 / The migrated profile failed boot validation (${trial.errors[0]?.message ?? 'unknown'}).`);
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
if (targetName !== name) {
|
|
2560
|
+
if (wasDisabled) {
|
|
2561
|
+
disabled.delete(name);
|
|
2562
|
+
disabled.add(targetName);
|
|
2563
|
+
}
|
|
2564
|
+
else {
|
|
2565
|
+
disabled.delete(name);
|
|
2566
|
+
}
|
|
2567
|
+
for (const [group, members] of Object.entries(groups)) {
|
|
2568
|
+
const next = [];
|
|
2569
|
+
for (const member of members) {
|
|
2570
|
+
const mapped = member === name ? targetName : member;
|
|
2571
|
+
if (!next.includes(mapped))
|
|
2572
|
+
next.push(mapped);
|
|
2573
|
+
}
|
|
2574
|
+
groups[group] = next;
|
|
2575
|
+
}
|
|
2576
|
+
const marketNotes = marketState.notes ?? (marketState.notes = {});
|
|
2577
|
+
if (marketNotes[name] !== undefined) {
|
|
2578
|
+
if (marketNotes[targetName] === undefined)
|
|
2579
|
+
marketNotes[targetName] = marketNotes[name];
|
|
2580
|
+
delete marketNotes[name];
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
let stateWarning = null;
|
|
2584
|
+
try {
|
|
2585
|
+
writeMarketState(activeProfileDir, marketState);
|
|
2586
|
+
}
|
|
2587
|
+
catch (error) {
|
|
2588
|
+
stateWarning = `市场状态未能持久化:${error instanceof Error ? error.message : String(error)} / Market state could not be persisted: ${error instanceof Error ? error.message : String(error)}`;
|
|
2589
|
+
logEvent('warn', 'source-migration', `${name}: ${stateWarning}`);
|
|
2590
|
+
}
|
|
2591
|
+
removeRowBlocks(userPatchPath, oldRows);
|
|
2592
|
+
const patchDisabled = wasDisabled || patchFlags.disabled.includes(name);
|
|
2593
|
+
const patchForced = !patchDisabled && patchFlags.forced.includes(name);
|
|
2594
|
+
const patchWarnings = [];
|
|
2595
|
+
for (const rowId of rowIdsForPackage(host, activeProfileDir, targetName)) {
|
|
2596
|
+
const changed = patchDisabled
|
|
2597
|
+
? await disableRow(userPatchPath, rowId)
|
|
2598
|
+
: patchForced
|
|
2599
|
+
? await enableRow(userPatchPath, rowId)
|
|
2600
|
+
: { ok: true, reason: null };
|
|
2601
|
+
if (!changed.ok && changed.reason !== null)
|
|
2602
|
+
patchWarnings.push(changed.reason);
|
|
2603
|
+
}
|
|
2604
|
+
if (patchDisabled)
|
|
2605
|
+
await themes.setEntryDisabled(targetName, true);
|
|
2606
|
+
invalidateUpdates();
|
|
2607
|
+
if (wasLive)
|
|
2608
|
+
replacedWhileLive.add(targetName);
|
|
2609
|
+
const activation = {
|
|
2610
|
+
[targetName]: activationAfterReplace(verifyActivation(config.profile, targetName, liveNames(), activeProfileDir, disabled.has(targetName)), wasLive),
|
|
2611
|
+
};
|
|
2612
|
+
logEvent('info', 'source-migration', `${name}: ${spec} -> ${targetName}`);
|
|
2613
|
+
sendJson(response, 200, {
|
|
2614
|
+
ok: true,
|
|
2615
|
+
from: { name, source: spec },
|
|
2616
|
+
to: { name: targetName, source: 'npm' },
|
|
2617
|
+
activation,
|
|
2618
|
+
warnings: [stateWarning, ...patchWarnings].filter((value) => value !== null && value !== ''),
|
|
2619
|
+
});
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
catch (error) {
|
|
2623
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2624
|
+
}
|
|
2625
|
+
},
|
|
2626
|
+
}),
|
|
2627
|
+
host.webServer.register(captureLegacy('/dsh-market/update', {
|
|
2628
|
+
kind: 'exact',
|
|
2629
|
+
path: '/dsh-market/update',
|
|
2630
|
+
handler: async (request, response) => {
|
|
2631
|
+
if (request.method !== 'POST') {
|
|
2632
|
+
response.writeHead(405, { allow: 'POST' });
|
|
2633
|
+
response.end();
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
if (!sameOrigin(request)) {
|
|
2637
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
try {
|
|
2641
|
+
await withMutationLock(response, 'install', async () => {
|
|
2642
|
+
const body = (await readJsonBody(request));
|
|
2643
|
+
const name = typeof body.name === 'string' ? body.name : '';
|
|
2644
|
+
const force = body.force === true;
|
|
2645
|
+
const restore = body.restore === true;
|
|
2646
|
+
const manifestCapture = captureUpdateManifest();
|
|
2647
|
+
if (!manifestCapture.ok) {
|
|
2648
|
+
sendJson(response, 500, {
|
|
2649
|
+
error: `更新前无法安全读取 profile package.json,未执行任何修改(${manifestCapture.detail})。 / The profile package.json could not be captured safely before update; nothing was changed (${manifestCapture.detail}).`,
|
|
2650
|
+
});
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
let spec = manifestCapture.snapshot.dependencies[name];
|
|
2654
|
+
if (spec === undefined || INBOX_BUNDLES.has(name)) {
|
|
2655
|
+
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
if (restore && !isLocalSpec(spec)) {
|
|
2659
|
+
sendJson(response, 400, { error: 'restore 只适用于 link:/file: 的本地开发安装。 / Restore only applies to locally developed link:/file: installs.' });
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
if (restore && SELF_NAMES.has(name) && spec.toLowerCase().startsWith('link:')) {
|
|
2663
|
+
sendJson(response, 400, { error: '市场的本地开发链接不会被线上版本替换。 / The market\'s local development link is never replaced by an online release.' });
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
if (isLocalSpec(spec)) {
|
|
2667
|
+
if (!restore) {
|
|
2668
|
+
sendJson(response, 400, { error: 'locally linked plugins update from their checkout' });
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
// Restore replaces the local checkout with the curated source
|
|
2672
|
+
// so the ordinary update check can see it again. Same add path
|
|
2673
|
+
// as a registry update — only the resolved target changes.
|
|
2674
|
+
let catalogTarget = null;
|
|
2675
|
+
try {
|
|
2676
|
+
const registry = await loadRegistry();
|
|
2677
|
+
const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir);
|
|
2678
|
+
const entry = findCatalogEntryForLocal(registry.plugins, name, evidence.identities, evidence.hints);
|
|
2679
|
+
catalogTarget = entry === null ? null : restoreTargetForLocal(entry, evidence.identities);
|
|
2680
|
+
const workspaceDeps = workspaceProtocolDeps(readInstalledManifest(config.profile, name, activeProfileDir));
|
|
2681
|
+
if (catalogTarget !== null && restoreBlockedByWorkspace(catalogTarget, workspaceDeps)) {
|
|
2682
|
+
sendJson(response, 400, {
|
|
2683
|
+
error: `该插件依赖 monorepo workspace 包(${workspaceDeps.join(', ')}),无法从 Git 子目录单独恢复。请继续用本地开发,或等作者发布 npm 后再恢复。 / This plugin depends on monorepo workspace packages (${workspaceDeps.join(', ')}); a git subdirectory install cannot resolve workspace: protocol. Keep the local checkout, or restore after the author publishes to npm.`,
|
|
2684
|
+
});
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
catch (error) {
|
|
2689
|
+
logEvent('warn', 'update', `${name}: restore catalog lookup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
2690
|
+
}
|
|
2691
|
+
if (catalogTarget === null) {
|
|
2692
|
+
sendJson(response, 400, {
|
|
2693
|
+
error: '目录里找不到对应的线上版本,无法从本地开发恢复。 / No catalog entry matches this local plugin, so it cannot be restored to a registry install.',
|
|
2694
|
+
});
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
spec = catalogTarget;
|
|
2698
|
+
}
|
|
2699
|
+
// Replacing a package on disk under a live agent is a mixed-state
|
|
2700
|
+
// hazard the "restart" verdict cannot fix: the running module keeps
|
|
2701
|
+
// executing while its files change under it, so lazily imported
|
|
2702
|
+
// assets and data reads can fail or change version mid-turn.
|
|
2703
|
+
// No bypass is offered — the user can wait or cancel the agent.
|
|
2704
|
+
const busyAgents = runningAgentsForGuard();
|
|
2705
|
+
if (busyAgents.length > 0) {
|
|
2706
|
+
logEvent('warn', 'update-blocked', `${name}: refused while agents are running — ${busyAgents.join(', ')}`);
|
|
2707
|
+
sendJson(response, 409, {
|
|
2708
|
+
error: `有 agent 正在运行(${busyAgents.join(', ')})。更新会直接替换插件文件,正在工作的 agent 可能在执行中途读到缺失或新版本的文件而报错;请等它完成或取消后再更新。 / ${busyAgents.length === 1 ? 'An agent is running' : 'Agents are running'} (${busyAgents.join(', ')}). Updating replaces plugin files in place, so a working agent can fail or mix versions mid-turn; wait for it to finish (or cancel it) before updating.`,
|
|
2709
|
+
agentsBusy: true,
|
|
2710
|
+
runningAgents: busyAgents,
|
|
2711
|
+
});
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
const beforeInstalled = readInstalled(config.profile, activeProfileDir);
|
|
2715
|
+
// Re-running add re-resolves the source: git HEAD for github specs,
|
|
2716
|
+
// dist-tag latest for registry installs.
|
|
2717
|
+
// A GitHub source in EITHER spelling. A legacy regional install
|
|
2718
|
+
// can carry a proxied codeload URL rather than the `github:`
|
|
2719
|
+
// shortcut, and asking only about the shortcut sent those down
|
|
2720
|
+
// the npm path below —
|
|
2721
|
+
// where `name@latest` either fails or, far worse, installs an
|
|
2722
|
+
// unrelated package that happens to share the plugin's name.
|
|
2723
|
+
// The `github:` shortcut keeps its own handling, fragments and
|
|
2724
|
+
// all — `githubUpdateTarget` is what preserves a monorepo
|
|
2725
|
+
// `#path:` while dropping revision selectors (#281).
|
|
2726
|
+
//
|
|
2727
|
+
// A proxied codeload URL is the legacy spelling of the same
|
|
2728
|
+
// source. It has no fragment to preserve (subpath entries
|
|
2729
|
+
// are never accelerated), so the canonical shortcut is rebuilt
|
|
2730
|
+
// from it. Without this branch these fell through to the npm path
|
|
2731
|
+
// below, where `name@latest` either fails or — far worse —
|
|
2732
|
+
// installs an unrelated package that shares the plugin's name.
|
|
2733
|
+
const codeloadRepo = spec.startsWith('github:') ? null : repoOfTarget(spec);
|
|
2734
|
+
const gitSpec = spec.startsWith('github:')
|
|
2735
|
+
? githubUpdateTarget(spec)
|
|
2736
|
+
: codeloadRepo === null ? null : `github:${codeloadRepo}`;
|
|
2737
|
+
// Non-GitHub git remotes (Gitea / git+https): keep the remote URL
|
|
2738
|
+
// as the add target. Falling through to name@latest is what #525
|
|
2739
|
+
// reported — a colliding npm package replaced the private install.
|
|
2740
|
+
const genericGitTarget = gitSpec === null ? gitUpdateTarget(spec) : null;
|
|
2741
|
+
const isGit = gitSpec !== null || genericGitTarget !== null;
|
|
2742
|
+
const isReleaseTarball = !restore && isGitHubReleaseTarballSpec(spec);
|
|
2743
|
+
const isNpmRollbackSource = !restore && !isGit && !isReleaseTarball
|
|
2744
|
+
// Market-managed npm installs persist only a range, version, or
|
|
2745
|
+
// dist-tag. Any protocol/path/manual shorthand needs its own
|
|
2746
|
+
// proven source-preserving rollback rather than being guessed
|
|
2747
|
+
// into name@<installed-version>.
|
|
2748
|
+
&& !/[:/\\]/.test(spec);
|
|
2749
|
+
// Every ordinary non-Git update still installs name@<tag>, even
|
|
2750
|
+
// when its PREVIOUS source was a release URL. Source
|
|
2751
|
+
// classification chooses rollback mechanics; it must not weaken
|
|
2752
|
+
// the existing registry target/downgrade validation.
|
|
2753
|
+
const usesNpmUpdateTarget = !restore && !isGit;
|
|
2754
|
+
// `@latest` was hardcoded, so a beta subscriber would have been
|
|
2755
|
+
// told an update existed and then handed the stable build. The
|
|
2756
|
+
// dist-tag has to follow the same setting the offer came from.
|
|
2757
|
+
// The market follows its channel; everything else is `latest`.
|
|
2758
|
+
const selfChannel = SELF_NAMES.has(name) ? activeChannel() : null;
|
|
2759
|
+
const tag = selfChannel === null ? 'latest' : DIST_TAG[selfChannel];
|
|
2760
|
+
let expectedNpmVersion = null;
|
|
2761
|
+
// Resolve the registry pin BEFORE building the add target (#496).
|
|
2762
|
+
// Desktop's install boundary otherwise fetches `latest` again to
|
|
2763
|
+
// rewrite `@latest` into `name@x.y.z`; when the two views drift,
|
|
2764
|
+
// verification against the first fetch rolls back a correct
|
|
2765
|
+
// install. Pinning the already-resolved version here makes the
|
|
2766
|
+
// boundary a no-op rewrite and keeps one source of truth.
|
|
2767
|
+
//
|
|
2768
|
+
// The pin also decides what pnpm 11's fresh-release hold does,
|
|
2769
|
+
// which is the whole of #531 (@Astro-Han). Measured against real
|
|
2770
|
+
// pnpm 11.7.0 with `minimumReleaseAge: 1440` and a registry whose
|
|
2771
|
+
// publish times the measurement controlled — `latest` moved to a
|
|
2772
|
+
// version published five minutes earlier:
|
|
2773
|
+
//
|
|
2774
|
+
// add pkg@latest → exit 0, installs the OLDER version, writes
|
|
2775
|
+
// ^older into the manifest, and says NOTHING
|
|
2776
|
+
// about having skipped one.
|
|
2777
|
+
// add pkg@2.0.0 → ERR_PNPM_NO_MATURE_MATCHING_VERSION,
|
|
2778
|
+
// nothing installed.
|
|
2779
|
+
//
|
|
2780
|
+
// The first is a silent downgrade the market can only notice
|
|
2781
|
+
// afterwards, by which point its own verification calls the
|
|
2782
|
+
// result a RESOLVED_VERSION_MISMATCH and rolls a real upgrade
|
|
2783
|
+
// back to where the user started — every day, for as long as
|
|
2784
|
+
// releases are daily. The second is an error the market already
|
|
2785
|
+
// recovers from: classifyPnpmFailure reads it as
|
|
2786
|
+
// release-age-violation and withHoistRecovery retries once with
|
|
2787
|
+
// --config.minimumReleaseAge=0 (#39).
|
|
2788
|
+
//
|
|
2789
|
+
// So a version resolved BEFORE the add is not only about the
|
|
2790
|
+
// Desktop boundary; it is what turns a silent skip into a
|
|
2791
|
+
// failure with a name.
|
|
2792
|
+
if (usesNpmUpdateTarget) {
|
|
2793
|
+
const installedVersion = readInstalledVersion(config.profile, name, activeProfileDir);
|
|
2794
|
+
const registryLatest = selfChannel === null
|
|
2795
|
+
? await fetchNpmLatest(name)
|
|
2796
|
+
: await versionOnChannel(name, selfChannel, await fetchNpmLatest(name));
|
|
2797
|
+
expectedNpmVersion = registryLatest;
|
|
2798
|
+
// Never let `@latest` walk a profile BACKWARDS (#64 by @ZeroOrigin64):
|
|
2799
|
+
// a package whose latest dist-tag was left on an older release turns
|
|
2800
|
+
// this update into a downgrade that also rewrites an exact pin to
|
|
2801
|
+
// `@latest`. Detection already hides the button; this guards the
|
|
2802
|
+
// route itself. Unreadable versions fall through and update as before.
|
|
2803
|
+
//
|
|
2804
|
+
// A channel-following package is exempt from the DIRECTION, not
|
|
2805
|
+
// from the check. Going backwards is exactly what "put me back on
|
|
2806
|
+
// stable" means, and #64 is about a downgrade nobody asked for —
|
|
2807
|
+
// so here the guard only refuses when the channel already points
|
|
2808
|
+
// at what is installed, and it compares against the target tag
|
|
2809
|
+
// rather than `latest`, which is not the tag being installed.
|
|
2810
|
+
const refuse = selfChannel === null
|
|
2811
|
+
? installedVersion !== null && registryLatest !== null && !isUpgrade(installedVersion, registryLatest)
|
|
2812
|
+
: installedVersion !== null && registryLatest !== null && installedVersion === registryLatest;
|
|
2813
|
+
// "Already there" is not a failure (#495 by @Ztyss). The two
|
|
2814
|
+
// halves of this guard are different events wearing one reply:
|
|
2815
|
+
// the registry pointing at an OLDER release is a downgrade the
|
|
2816
|
+
// user must see, while it pointing at exactly what is installed
|
|
2817
|
+
// means the request is a no-op — usually because the page's
|
|
2818
|
+
// updatable list was snapshotted before an earlier round of the
|
|
2819
|
+
// same batch updated this plugin. Answering that with a 400 made
|
|
2820
|
+
// a batch that did everything right report "update failed" for
|
|
2821
|
+
// three plugins that were already on the version they asked for.
|
|
2822
|
+
//
|
|
2823
|
+
// 200 with `skipped`, so the page can drop the row and re-read
|
|
2824
|
+
// the list rather than counting a failure; no restart is owed,
|
|
2825
|
+
// because nothing on disk changed.
|
|
2826
|
+
if (refuse && installedVersion === registryLatest) {
|
|
2827
|
+
logEvent('info', 'update', `${name} already current at ${installedVersion}; nothing to do`);
|
|
2828
|
+
// The listing that offered this update is provably behind the
|
|
2829
|
+
// profile, so drop it rather than serve the same wrong row for
|
|
2830
|
+
// the rest of the TTL.
|
|
2831
|
+
invalidateUpdates();
|
|
2832
|
+
sendJson(response, 200, { ok: true, skipped: 'current', name, version: installedVersion });
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
if (refuse) {
|
|
2836
|
+
logEvent('info', 'update', `${name} refused: latest=${registryLatest} is not newer than installed=${installedVersion}`);
|
|
2837
|
+
sendJson(response, 400, {
|
|
2838
|
+
error: `更新会降级:registry 上的最新版是 ${registryLatest},比已装的 ${installedVersion} 还旧,已停止,插件保持不变。 / Updating would downgrade this plugin: the registry's latest (${registryLatest}) is older than the installed ${installedVersion}, so nothing was changed.`,
|
|
2839
|
+
});
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
// Ask BEFORE installing whether this release says it needs a
|
|
2843
|
+
// newer host than the one running (#404 by @Ztyss).
|
|
2844
|
+
//
|
|
2845
|
+
// The report: a caret range floated a plugin onto a release
|
|
2846
|
+
// that had migrated to a host API the user's bundled runtime
|
|
2847
|
+
// did not have. It installed silently and broke the panel; the
|
|
2848
|
+
// only way back was a manual downgrade. The market noticed
|
|
2849
|
+
// nothing, because at the time it only compared what a plugin
|
|
2850
|
+
// DECLARED, and almost nothing declared anything — the reason
|
|
2851
|
+
// this was left open rather than built.
|
|
2852
|
+
//
|
|
2853
|
+
// #473 changed that premise. Reading `engines.dsh` together
|
|
2854
|
+
// with the lockstep `@deepseek-ai/dsh-*` peers took catalog
|
|
2855
|
+
// coverage from about 2% to about 60%, so for most plugins
|
|
2856
|
+
// there is now a claim to check. Discover already shows it;
|
|
2857
|
+
// this is the same derivation applied at the moment it can
|
|
2858
|
+
// still prevent something.
|
|
2859
|
+
//
|
|
2860
|
+
// Only a CONFIRMED mismatch stops here: `incompatible` means a
|
|
2861
|
+
// declaration was read and is not satisfied. Undeclared,
|
|
2862
|
+
// unreadable, and unknown host versions all go through, which
|
|
2863
|
+
// is most of the ecosystem and the same stance Discover takes
|
|
2864
|
+
// — absence of a claim is not a verdict.
|
|
2865
|
+
//
|
|
2866
|
+
// `force` is the way past it, because the market can be the
|
|
2867
|
+
// wrong one here: a bundled host that misreports its version
|
|
2868
|
+
// makes a satisfiable requirement look unsatisfied, and the
|
|
2869
|
+
// user who knows that must not be locked out of their own
|
|
2870
|
+
// profile. Refused with 400 and the facts, so the page can ask
|
|
2871
|
+
// rather than dead-end.
|
|
2872
|
+
if (selfChannel === null && !force && registryLatest !== null) {
|
|
2873
|
+
const host = dshHostInfo();
|
|
2874
|
+
const verdict = deriveHostCompatibility((await discoveryManifests.lookup([name], routesFor(region).npmRegistry))[name] ?? null, host?.version ?? null, corePackageNames(host?.directory ?? null));
|
|
2875
|
+
if (verdict.status === 'incompatible') {
|
|
2876
|
+
logEvent('warn', 'update-compat', `${name}@${registryLatest} declares ${verdict.requirement ?? 'a host requirement'}; this host is ${host?.version ?? 'unknown'} — refused before installing`);
|
|
2877
|
+
sendJson(response, 400, {
|
|
2878
|
+
hostIncompatible: {
|
|
2879
|
+
name,
|
|
2880
|
+
version: registryLatest,
|
|
2881
|
+
requirement: verdict.requirement,
|
|
2882
|
+
hostVersion: host?.version ?? null,
|
|
2883
|
+
},
|
|
2884
|
+
error: `${name} ${registryLatest} 要求的 DSH 版本是 ${verdict.requirement ?? '未知'},而当前运行的是 ${host?.version ?? '未知版本'},装上多半会直接报错。已停止,插件保持在原来的版本。 / ${name} ${registryLatest} declares it needs DSH ${verdict.requirement ?? '(unknown)'}, and this host is ${host?.version ?? 'unknown'}; installing it would most likely break the plugin. Nothing was changed.`,
|
|
2885
|
+
});
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
// Re-accelerated from the unpinned shortcut, never from the
|
|
2891
|
+
// installed URL: that one names the commit already on disk, so
|
|
2892
|
+
// reusing it would be an update that can never move.
|
|
2893
|
+
//
|
|
2894
|
+
// A restore is not an update. `spec` by now IS the catalog target
|
|
2895
|
+
// the checkout is being put back onto, and it is already exact: a
|
|
2896
|
+
// `#path:` on it selects which package, not which version, and a
|
|
2897
|
+
// prebuilt Release tarball (#250) is a URL that `@latest` must not
|
|
2898
|
+
// be glued onto — only a bare npm name wants the dist-tag.
|
|
2899
|
+
// acceleratedTarget returns anything that is not a bare
|
|
2900
|
+
// `github:owner/repo` untouched, so passing a restore through it
|
|
2901
|
+
// still gets a China-region mirror where one applies and changes
|
|
2902
|
+
// nothing where one does not.
|
|
2903
|
+
const target = restore
|
|
2904
|
+
? (NPM_NAME_RE.test(spec) ? `${spec}@${tag}` : await acceleratedTarget(spec, region))
|
|
2905
|
+
: usesNpmUpdateTarget
|
|
2906
|
+
? (expectedNpmVersion !== null ? `${name}@${expectedNpmVersion}` : `${name}@${tag}`)
|
|
2907
|
+
: genericGitTarget !== null
|
|
2908
|
+
? genericGitTarget
|
|
2909
|
+
: await acceleratedTarget(gitSpec, region);
|
|
2910
|
+
const repoIdentity = isGit ? repoOfTarget(spec) : null;
|
|
2911
|
+
const repoKey = repoIdentity?.split('#')[0] ?? null;
|
|
2912
|
+
// dsh-cli's deliberately narrow target grammar rejects the `&`
|
|
2913
|
+
// required to combine an exact commit and a monorepo path. Do not
|
|
2914
|
+
// weaken that command boundary or offer a rollback action that
|
|
2915
|
+
// the real host can never execute.
|
|
2916
|
+
const hasGitSubpath = repoIdentity?.includes('#path:/') ?? false;
|
|
2917
|
+
// Captured BEFORE pnpm replaces the files: afterwards the loader
|
|
2918
|
+
// inventory reads exactly the same, because replacing a package
|
|
2919
|
+
// on disk does not unload the module the process already imported.
|
|
2920
|
+
// A client-only package has no host half to go stale: its bundle
|
|
2921
|
+
// is re-fetched from disk on the next page load, so an update to
|
|
2922
|
+
// one needs a refresh, not a restart.
|
|
2923
|
+
const wasLive = verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)).state === 'live'
|
|
2924
|
+
&& hasHostHalf(config.profile, name, activeProfileDir);
|
|
2925
|
+
const beforeVersion = readInstalledVersion(config.profile, name, activeProfileDir);
|
|
2926
|
+
// A durable manifest pin is independently authoritative. When its
|
|
2927
|
+
// captured lock is missing or stale, the exact OLD re-add repairs
|
|
2928
|
+
// that lock and rollback must keep the repair. Floating Git specs
|
|
2929
|
+
// still derive identity from the captured lock, so their exact
|
|
2930
|
+
// importer bytes remain the authority after rematerialization.
|
|
2931
|
+
const manifestPinnedCommit = repoKey !== null ? githubCommitOfTarget(spec) : null;
|
|
2932
|
+
const capturedLockCommit = repoKey !== null
|
|
2933
|
+
? readLockCommits(config.profile, activeProfileDir).get(repoKey) ?? null
|
|
2934
|
+
: null;
|
|
2935
|
+
const beforeCommit = manifestPinnedCommit ?? capturedLockCommit;
|
|
2936
|
+
const keepRepairedGitLock = manifestPinnedCommit !== null
|
|
2937
|
+
&& capturedLockCommit !== manifestPinnedCommit;
|
|
2938
|
+
const gitRollbackTarget = beforeCommit === null
|
|
2939
|
+
? null
|
|
2940
|
+
: exactGitRollbackTarget(spec, beforeCommit);
|
|
2941
|
+
// force: the user chose to install a fresh release without the
|
|
2942
|
+
// default one-day safety wait; scoped to this single command.
|
|
2943
|
+
const addArgs = force ? ['add', RELEASE_AGE_OVERRIDE, target] : ['add', target];
|
|
2944
|
+
// Exact manifest snapshot for failure rollback (#65, #339) — the
|
|
2945
|
+
// host can write dependencies AND dsh.profile.bundles before a
|
|
2946
|
+
// hard-failed add, leaving residue that breaks the next boot.
|
|
2947
|
+
pendingRollbacks.clear();
|
|
2948
|
+
const compatibilityBefore = assessProfile(config.profile, activeProfileDir);
|
|
2949
|
+
// pnpm re-extracts the whole tree on any operation, so a plugin
|
|
2950
|
+
// nobody touched can come back pristine-and-broken, or lose a
|
|
2951
|
+
// patch that was holding it together (#222). Only what THIS run
|
|
2952
|
+
// broke is attributable to it, so the profile is swept before as
|
|
2953
|
+
// well as after.
|
|
2954
|
+
const bundlesBefore = brokenClientBundles(config.profile, activeProfileDir);
|
|
2955
|
+
const manifestBefore = manifestCapture.snapshot;
|
|
2956
|
+
const lockfileCapture = captureProfileLockfile();
|
|
2957
|
+
const previousVersionZh = beforeVersion === null ? '更新前版本未知' : `更新前版本为 v${beforeVersion}`;
|
|
2958
|
+
const previousVersionEn = beforeVersion === null ? 'the previous version is unknown' : `the previous version was v${beforeVersion}`;
|
|
2959
|
+
const rollbackPlan = restore
|
|
2960
|
+
? { available: true, source: { kind: 'manifest' } }
|
|
2961
|
+
: !lockfileCapture.ok
|
|
2962
|
+
? { available: false, detail: lockfileCapture.detail }
|
|
2963
|
+
: isGit
|
|
2964
|
+
? hasGitSubpath
|
|
2965
|
+
? {
|
|
2966
|
+
available: false,
|
|
2967
|
+
detail: `更新前的 GitHub 来源使用 monorepo 子目录${beforeCommit === null ? '' : `(提交 ${beforeCommit})`},当前 ChatCode CLI 命令无法表达该精确目标,因此自动回滚不可用;需要时请手工重新安装该提交。 / The previous GitHub source uses a monorepo subpath${beforeCommit === null ? '' : ` at commit ${beforeCommit}`}; the current ChatCode CLI command cannot express that exact target, so automatic rollback is unavailable. Reinstall that commit manually if needed.`,
|
|
2968
|
+
lockfileBefore: lockfileCapture.snapshot,
|
|
2969
|
+
}
|
|
2970
|
+
: beforeCommit === null
|
|
2971
|
+
? {
|
|
2972
|
+
available: false,
|
|
2973
|
+
detail: '未能确认更新前的 GitHub 提交,因此自动回滚不可用;需要时请从可信来源手工重新安装先前版本。 / The previous GitHub commit could not be verified, so automatic rollback is unavailable. Reinstall the prior version manually from a trusted source if needed.',
|
|
2974
|
+
lockfileBefore: lockfileCapture.snapshot,
|
|
2975
|
+
}
|
|
2976
|
+
: gitRollbackTarget === null || !supportsExactRollbackTarget(gitRollbackTarget)
|
|
2977
|
+
? {
|
|
2978
|
+
available: false,
|
|
2979
|
+
detail: `当前宿主无法安装更新前的精确 GitHub 提交 ${beforeCommit},因此自动回滚不可用;需要时请手工重新安装该提交。 / This host cannot install the exact previous GitHub commit ${beforeCommit}, so automatic rollback is unavailable. Reinstall that commit manually if needed.`,
|
|
2980
|
+
lockfileBefore: lockfileCapture.snapshot,
|
|
2981
|
+
}
|
|
2982
|
+
: {
|
|
2983
|
+
available: true,
|
|
2984
|
+
source: {
|
|
2985
|
+
kind: 'github',
|
|
2986
|
+
target: spec,
|
|
2987
|
+
beforeCommit,
|
|
2988
|
+
lockfileBefore: lockfileCapture.snapshot,
|
|
2989
|
+
keepRepairedLock: keepRepairedGitLock,
|
|
2990
|
+
},
|
|
2991
|
+
}
|
|
2992
|
+
: isReleaseTarball
|
|
2993
|
+
// A release download URL is not a content identity: GitHub
|
|
2994
|
+
// assets can be replaced unless immutable releases are
|
|
2995
|
+
// enabled. Re-adding the same URL could bless different
|
|
2996
|
+
// bytes, so restore durable state but never claim an exact
|
|
2997
|
+
// build rollback without a captured content binding.
|
|
2998
|
+
? {
|
|
2999
|
+
available: false,
|
|
3000
|
+
detail: `${previousVersionZh},但先前的 Release 归档没有经过验证的不可变内容标识;同一链接以后可能返回不同文件,因此自动回滚不可用。需要时请从可信来源手工重新安装${beforeVersion === null ? '先前版本' : ` v${beforeVersion}`}。 / ${previousVersionEn}, but the previous Release archive has no verified immutable content identity; the same URL may later return different bytes, so automatic rollback is unavailable. Reinstall ${beforeVersion === null ? 'the prior version' : `v${beforeVersion}`} manually from a trusted source if needed.`,
|
|
3001
|
+
lockfileBefore: lockfileCapture.snapshot,
|
|
3002
|
+
}
|
|
3003
|
+
: isNpmRollbackSource
|
|
3004
|
+
? beforeVersion === null
|
|
3005
|
+
? {
|
|
3006
|
+
available: false,
|
|
3007
|
+
detail: '未能确认更新前安装的 npm 版本,因此自动回滚不可用;需要时请从可信来源手工重新安装先前版本。 / The previously installed npm version could not be verified, so automatic rollback is unavailable. Reinstall the prior version manually from a trusted source if needed.',
|
|
3008
|
+
lockfileBefore: lockfileCapture.snapshot,
|
|
3009
|
+
}
|
|
3010
|
+
: lockfileCapture.snapshot.present
|
|
3011
|
+
&& capturedNpmVersion(lockfileCapture.snapshot, name) !== beforeVersion
|
|
3012
|
+
? {
|
|
3013
|
+
available: false,
|
|
3014
|
+
detail: `更新前安装的是 v${beforeVersion},但 pnpm-lock.yaml 中的版本与它不一致,因此无法证明精确来源,自动回滚不可用。需要时请手工重新安装 ${name}@${beforeVersion}。 / The installed version before the update was v${beforeVersion}, but pnpm-lock.yaml does not match it, so the exact source cannot be proven and automatic rollback is unavailable. Reinstall ${name}@${beforeVersion} manually if needed.`,
|
|
3015
|
+
lockfileBefore: lockfileCapture.snapshot,
|
|
3016
|
+
}
|
|
3017
|
+
: !supportsExactRollbackTarget(`${name}@${beforeVersion}`)
|
|
3018
|
+
? {
|
|
3019
|
+
available: false,
|
|
3020
|
+
detail: `当前宿主无法安装更新前的精确 npm 目标 ${name}@${beforeVersion}(v${beforeVersion}),因此自动回滚不可用;需要时请手工重新安装该版本。 / This host cannot install the exact previous npm target ${name}@${beforeVersion} (v${beforeVersion}), so automatic rollback is unavailable. Reinstall that version manually if needed.`,
|
|
3021
|
+
lockfileBefore: lockfileCapture.snapshot,
|
|
3022
|
+
}
|
|
3023
|
+
: { available: true, source: { kind: 'npm', beforeVersion, lockfileBefore: lockfileCapture.snapshot } }
|
|
3024
|
+
: {
|
|
3025
|
+
available: false,
|
|
3026
|
+
detail: `更新前的来源 ${spec} 不是受支持的精确回滚目标(${previousVersionZh}),因此自动回滚不可用;需要时请从可信来源手工重新安装先前版本。 / The previous source ${spec} is not a supported exact rollback target (${previousVersionEn}), so automatic rollback is unavailable. Reinstall the prior version manually from a trusted source if needed.`,
|
|
3027
|
+
lockfileBefore: lockfileCapture.snapshot,
|
|
3028
|
+
};
|
|
3029
|
+
const result = await runPlugin(config.profile, addArgs);
|
|
3030
|
+
const cancelled = result.cancelled;
|
|
3031
|
+
const rollbackAttemptBuild = async () => {
|
|
3032
|
+
if (rollbackPlan.available) {
|
|
3033
|
+
return executeUpdateRollback(name, manifestBefore, rollbackPlan.source);
|
|
3034
|
+
}
|
|
3035
|
+
restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
3036
|
+
const lockRestore = rollbackPlan.lockfileBefore === undefined
|
|
3037
|
+
? { ok: true, detail: null }
|
|
3038
|
+
: restoreProfileLockfile(rollbackPlan.lockfileBefore);
|
|
3039
|
+
return {
|
|
3040
|
+
ok: false,
|
|
3041
|
+
detail: lockRestore.ok ? rollbackPlan.detail : `${rollbackPlan.detail}; ${lockRestore.detail ?? 'the lockfile could not be restored'}`,
|
|
3042
|
+
};
|
|
3043
|
+
};
|
|
3044
|
+
let rollbackOk = true;
|
|
3045
|
+
let rollbackDetail = null;
|
|
3046
|
+
let hardFailureRollbackError = null;
|
|
3047
|
+
// A non-zero exit or timeout can happen after pnpm has replaced
|
|
3048
|
+
// both package.json and node_modules. Restoring the manifest alone
|
|
3049
|
+
// leaves the rejected build running after restart. Reinstall the
|
|
3050
|
+
// exact prior source identity unless the host rejected the start
|
|
3051
|
+
// as busy or the user deliberately cancelled and chose to inspect
|
|
3052
|
+
// the resulting partial state.
|
|
3053
|
+
// pnpm never launched (#502): nothing was written, so there is
|
|
3054
|
+
// nothing to restore — and the notice this block produces when a
|
|
3055
|
+
// rollback cannot be verified ("inspect this profile before
|
|
3056
|
+
// restarting") would be alarm over an untouched profile, on top
|
|
3057
|
+
// of a failure the user already cannot act on from here.
|
|
3058
|
+
if ((result.exitCode !== 0 || result.timedOut) && !cancelled && result.busy !== true
|
|
3059
|
+
&& !pnpmNeverStarted(result)) {
|
|
3060
|
+
const rollback = await rollbackAttemptBuild();
|
|
3061
|
+
rollbackOk = rollback.ok;
|
|
3062
|
+
rollbackDetail = rollback.detail;
|
|
3063
|
+
if (rollback.ok) {
|
|
3064
|
+
logEvent('warn', 'update', `${name}: failed update command; previous build restored and verified`);
|
|
3065
|
+
}
|
|
3066
|
+
else {
|
|
3067
|
+
hardFailureRollbackError = `${name} 更新失败,且更新前的构建未能验证恢复(${rollback.detail ?? 'unknown'});请先检查该 profile,再重新启动。 / ${name} update failed and restoration of the previous build could not be verified (${rollback.detail ?? 'unknown'}); inspect this profile before restarting.`;
|
|
3068
|
+
logEvent('error', 'update-rollback', `${name}: failed update command and restoration of the previous build could not be verified — ${rollback.detail ?? 'unknown'}`);
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
3072
|
+
let stale = false;
|
|
3073
|
+
let versionFailureCode = null;
|
|
3074
|
+
let versionFailureError = null;
|
|
3075
|
+
let activation;
|
|
3076
|
+
if (ok) {
|
|
3077
|
+
if (restore) {
|
|
3078
|
+
// Restore succeeds when the spec is no longer local, even if
|
|
3079
|
+
// the checkout already sat on the same version as latest.
|
|
3080
|
+
const afterSpec = readInstalled(config.profile, activeProfileDir)[name];
|
|
3081
|
+
const stillLocal = afterSpec !== undefined && isLocalSpec(afterSpec);
|
|
3082
|
+
if (stillLocal)
|
|
3083
|
+
ok = false;
|
|
3084
|
+
}
|
|
3085
|
+
else {
|
|
3086
|
+
stale = isStaleUpdate({
|
|
3087
|
+
isGit,
|
|
3088
|
+
beforeVersion,
|
|
3089
|
+
afterVersion: readInstalledVersion(config.profile, name, activeProfileDir),
|
|
3090
|
+
beforeCommit,
|
|
3091
|
+
afterCommit: repoKey !== null
|
|
3092
|
+
? readLockCommits(config.profile, activeProfileDir).get(repoKey) ?? null
|
|
3093
|
+
: null,
|
|
3094
|
+
});
|
|
3095
|
+
if (stale)
|
|
3096
|
+
ok = false;
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
// Verify the bytes that landed against the pin this run asked for.
|
|
3100
|
+
// When the route already sent an exact `name@x.y.z` (#496), that
|
|
3101
|
+
// pin is authoritative: Desktop's install boundary must not be
|
|
3102
|
+
// allowed to lower the bar by reporting a different
|
|
3103
|
+
// `resolvedNpmVersion`. Only a floating dist-tag target (registry
|
|
3104
|
+
// metadata unavailable, so the add still says `@latest`/`@beta`)
|
|
3105
|
+
// adopts the boundary's reported pin — that is the version the
|
|
3106
|
+
// host actually handed to pnpm.
|
|
3107
|
+
//
|
|
3108
|
+
// Getting LESS than the pin is still a mismatch (including the
|
|
3109
|
+
// historical `@latest` + minimumReleaseAge silent-hold shape,
|
|
3110
|
+
// when a floating tag is what was sent). A version above the pin
|
|
3111
|
+
// is only possible on a floating target whose resolver moved
|
|
3112
|
+
// forward mid-download; that stays accepted.
|
|
3113
|
+
if (ok && usesNpmUpdateTarget) {
|
|
3114
|
+
const floatingDistTag = expectedNpmVersion === null;
|
|
3115
|
+
if (floatingDistTag
|
|
3116
|
+
&& typeof result.resolvedNpmVersion === 'string'
|
|
3117
|
+
&& result.resolvedNpmVersion !== '') {
|
|
3118
|
+
expectedNpmVersion = result.resolvedNpmVersion;
|
|
3119
|
+
}
|
|
3120
|
+
const afterVersion = readInstalledVersion(config.profile, name, activeProfileDir);
|
|
3121
|
+
const direction = beforeVersion !== null && afterVersion !== null
|
|
3122
|
+
? compareVersions(afterVersion, beforeVersion)
|
|
3123
|
+
: null;
|
|
3124
|
+
const unexpectedDowngrade = selfChannel === null && direction !== null && direction < 0;
|
|
3125
|
+
// Only a version BELOW the target is a mismatch. `latest` can move
|
|
3126
|
+
// forward while pnpm is still downloading — a large plugin gives
|
|
3127
|
+
// the author minutes of window — and rejecting the newer release
|
|
3128
|
+
// that arrives would roll back a good update and report it as a
|
|
3129
|
+
// failure. Getting less than we asked for is the actual symptom.
|
|
3130
|
+
const target = expectedNpmVersion;
|
|
3131
|
+
const targetOrder = target !== null && afterVersion !== null
|
|
3132
|
+
? compareVersions(afterVersion, target)
|
|
3133
|
+
: null;
|
|
3134
|
+
const targetMismatch = target !== null && (afterVersion === null
|
|
3135
|
+
|| (targetOrder !== null
|
|
3136
|
+
// Comparable: getting LESS than we asked for is the symptom.
|
|
3137
|
+
// A version above the target is `latest` moving forward while
|
|
3138
|
+
// pnpm was still downloading, which is a good update.
|
|
3139
|
+
? targetOrder < 0
|
|
3140
|
+
// Not comparable as semver. With no way to tell forward from
|
|
3141
|
+
// back, keep the exact check this replaced.
|
|
3142
|
+
: afterVersion !== target));
|
|
3143
|
+
if (unexpectedDowngrade || targetMismatch) {
|
|
3144
|
+
versionFailureCode = unexpectedDowngrade ? 'DOWNGRADE_DETECTED' : 'RESOLVED_VERSION_MISMATCH';
|
|
3145
|
+
ok = false;
|
|
3146
|
+
const rollback = await rollbackAttemptBuild();
|
|
3147
|
+
rollbackOk = rollback.ok;
|
|
3148
|
+
rollbackDetail = rollback.detail;
|
|
3149
|
+
const mismatchZh = unexpectedDowngrade
|
|
3150
|
+
? `${name} 更新实际解析为 v${afterVersion ?? 'unknown'},低于更新前的 v${beforeVersion ?? 'unknown'};已拒绝降级`
|
|
3151
|
+
: `${name} 更新目标为 v${expectedNpmVersion ?? 'unknown'},但实际安装为 v${afterVersion ?? 'unknown'}`;
|
|
3152
|
+
const mismatchEn = unexpectedDowngrade
|
|
3153
|
+
? `${name} resolved to v${afterVersion ?? 'unknown'}, below the installed v${beforeVersion ?? 'unknown'}; the downgrade was rejected`
|
|
3154
|
+
: `${name} targeted v${expectedNpmVersion ?? 'unknown'} but installed v${afterVersion ?? 'unknown'}`;
|
|
3155
|
+
versionFailureError = rollback.ok
|
|
3156
|
+
? `${mismatchZh};已自动恢复原版本。 / ${mismatchEn}; the previous build was restored.`
|
|
3157
|
+
: `${mismatchZh};回滚未能验证恢复原版本(${rollback.detail ?? 'unknown'})。 / ${mismatchEn}; restoration of the previous build could not be verified (${rollback.detail ?? 'unknown'}).`;
|
|
3158
|
+
logEvent('error', 'update-version', `${name}: ${versionFailureCode} before=${beforeVersion ?? 'unknown'} expected=${expectedNpmVersion ?? 'unknown'} actual=${afterVersion ?? 'unknown'}${rollback.ok ? '; previous build restored' : `; rollback failed: ${rollback.detail ?? 'unknown'}`}`);
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
// The new build has to be loadable (#159). pnpm exits 0 for any
|
|
3162
|
+
// tarball it can extract, and the version really did change, so
|
|
3163
|
+
// nothing above notices a package that arrived without its entry
|
|
3164
|
+
// artifact — a registry mirror serving a source-only tarball for
|
|
3165
|
+
// a just-published version is the reported case, a plugin author
|
|
3166
|
+
// shipping a broken `files` list is the other one.
|
|
3167
|
+
//
|
|
3168
|
+
// Activation cannot stand in for this check: a package updating
|
|
3169
|
+
// ITSELF still reports live, because the running fiber belongs to
|
|
3170
|
+
// the OLD code that is already in memory. The failure only
|
|
3171
|
+
// surfaces on the next boot, as a profile that will not start.
|
|
3172
|
+
let brokenEntry = false;
|
|
3173
|
+
if (ok && !hasLoadableEntry(activeProfileDir, name)) {
|
|
3174
|
+
brokenEntry = true;
|
|
3175
|
+
ok = false;
|
|
3176
|
+
const rollback = await rollbackAttemptBuild();
|
|
3177
|
+
rollbackOk = rollback.ok;
|
|
3178
|
+
rollbackDetail = rollback.detail;
|
|
3179
|
+
logEvent('error', 'update', `${name}: updated build has no loadable entry — ${rollback.ok ? 'previous build restored' : `could not restore previous files: ${rollback.detail ?? 'unknown'}`}`);
|
|
3180
|
+
}
|
|
3181
|
+
// Composition-level boot check for the remaining brick shapes:
|
|
3182
|
+
// duplicate loader entry ids, unparseable bundle patches, or
|
|
3183
|
+
// bundle layers that no longer resolve. hasLoadableEntry cannot
|
|
3184
|
+
// see these because the entry file exists — the profile still
|
|
3185
|
+
// cannot boot until the next start.
|
|
3186
|
+
let trialError = null;
|
|
3187
|
+
if (ok) {
|
|
3188
|
+
const stack = readBundleStack(activeProfileDir);
|
|
3189
|
+
const trial = trialValidate(activeProfileDir, stack.community);
|
|
3190
|
+
if (!trial.ok) {
|
|
3191
|
+
ok = false;
|
|
3192
|
+
const first = trial.errors[0]?.message ?? 'the composition would not boot';
|
|
3193
|
+
const rollback = await rollbackAttemptBuild();
|
|
3194
|
+
rollbackOk = rollback.ok;
|
|
3195
|
+
rollbackDetail = rollback.detail;
|
|
3196
|
+
trialError = rollback.ok
|
|
3197
|
+
? `${name} 更新后的组合无法启动(${first}),已自动回滚并恢复原版本文件。 / ${name} updated to a composition that cannot boot (${first}); the previous build was restored.`
|
|
3198
|
+
: `${name} 更新后的组合无法启动(${first}),回滚未能恢复原版本文件(${rollback.detail ?? 'unknown'});请运行 chatcode-cli plugin --profile ${config.profile} install 手工恢复。 / ${name} updated to a composition that cannot boot (${first}); the previous files could not be restored (${rollback.detail ?? 'unknown'}) — run 'chatcode-cli plugin --profile ${config.profile} install' to recover manually.`;
|
|
3199
|
+
logEvent('error', 'update', `${name}: trial validation failed — ${first}${rollback.ok ? '; previous build restored' : `; could not restore previous files: ${rollback.detail ?? 'unknown'}`}`);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
let compatibility;
|
|
3203
|
+
if (ok) {
|
|
3204
|
+
invalidateUpdates();
|
|
3205
|
+
// Remembered, not just reported: the listing recomputes
|
|
3206
|
+
// activation on every page load and would otherwise call this
|
|
3207
|
+
// live again the moment the user refreshed.
|
|
3208
|
+
if (wasLive)
|
|
3209
|
+
replacedWhileLive.add(name);
|
|
3210
|
+
activation = {
|
|
3211
|
+
[name]: activationAfterReplace(verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)), wasLive),
|
|
3212
|
+
};
|
|
3213
|
+
const after = assessProfile(config.profile, activeProfileDir);
|
|
3214
|
+
const risks = introducedRisks(compatibilityBefore, after);
|
|
3215
|
+
// An update can introduce shadowing too: a bundle migration
|
|
3216
|
+
// moves a plugin between layers, which is exactly the shape
|
|
3217
|
+
// #230 reported (bundle layer vs user patch layer).
|
|
3218
|
+
const shadowed = introducedDuplicateNames(compatibilityBefore, after);
|
|
3219
|
+
// See the install route: an update is the operation the #222
|
|
3220
|
+
// report actually hit.
|
|
3221
|
+
const bundleCheck = checkClientBundle(config.profile, name, activeProfileDir);
|
|
3222
|
+
const brokenBundles = newlyBrokenBundles(bundlesBefore, [
|
|
3223
|
+
...(bundleCheck.ok ? [] : [{ name, reason: bundleCheck.reason ?? 'parse failed' }]),
|
|
3224
|
+
...brokenClientBundles(config.profile, activeProfileDir),
|
|
3225
|
+
].filter((entry, index, all) => all.findIndex(other => other.name === entry.name) === index));
|
|
3226
|
+
if (risks.length > 0 || shadowed.length > 0 || brokenBundles.length > 0) {
|
|
3227
|
+
const rollbackId = rollbackPlan.available
|
|
3228
|
+
? savePendingRollback({
|
|
3229
|
+
kind: 'update',
|
|
3230
|
+
names: [name],
|
|
3231
|
+
manifestBefore,
|
|
3232
|
+
updateSource: rollbackPlan.source,
|
|
3233
|
+
})
|
|
3234
|
+
: null;
|
|
3235
|
+
compatibility = {
|
|
3236
|
+
code: 'soft-incompatible',
|
|
3237
|
+
risks,
|
|
3238
|
+
shadowedNames: shadowed.length > 0 ? shadowed : undefined,
|
|
3239
|
+
brokenBundles: brokenBundles.length > 0 ? brokenBundles : undefined,
|
|
3240
|
+
...(rollbackId !== null
|
|
3241
|
+
? { rollbackId }
|
|
3242
|
+
: {
|
|
3243
|
+
rollbackUnavailable: rollbackPlan.available
|
|
3244
|
+
? `更新完成后无法安全捕获 profile 状态(${previousVersionZh}),因此自动回滚不可用;需要时请从可信来源手工重新安装先前版本。 / The post-update profile state could not be captured safely (${previousVersionEn}), so automatic rollback is unavailable. Reinstall the prior version manually from a trusted source if needed.`
|
|
3245
|
+
: rollbackPlan.detail,
|
|
3246
|
+
}),
|
|
3247
|
+
};
|
|
3248
|
+
if (brokenBundles.length > 0) {
|
|
3249
|
+
logEvent('error', 'update-bundle', `${brokenBundles.map(entry => `${entry.name}: ${entry.reason}`).join('; ')}`);
|
|
3250
|
+
}
|
|
3251
|
+
if (risks.length > 0) {
|
|
3252
|
+
logEvent('warn', 'update-compat', `${name}: introduced host-compatibility risks — ${risks.map(risk => `${risk.peer}@${risk.range} vs ${risk.resolved}`).join('; ')}`);
|
|
3253
|
+
}
|
|
3254
|
+
if (shadowed.length > 0) {
|
|
3255
|
+
logEvent('warn', 'update-shadow', `${name}: introduced cross-layer duplicate loader names — ${shadowed.map(entry => `${entry.name} (${entry.layers.join(' + ')})`).join('; ')}`);
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
// Diagnose the stale outcome with EVIDENCE (#45 by @ayingQAQ):
|
|
3260
|
+
// only blame pnpm's fresh-release wait when the target's latest
|
|
3261
|
+
// release really is young; otherwise be honest that the cause is
|
|
3262
|
+
// unconfirmed. Git installs never hit the age gate.
|
|
3263
|
+
const youngRelease = stale && !isGit ? await latestPublishedRecently(name) : false;
|
|
3264
|
+
const staleReason = stale ? (youngRelease === true ? 'release-age' : 'unknown') : null;
|
|
3265
|
+
const staleError = !stale
|
|
3266
|
+
? null
|
|
3267
|
+
: staleReason === 'release-age'
|
|
3268
|
+
? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / This version was just released; for safety, installs normally wait about a day after a release. Try again tomorrow, or click "Update now" to install it right away.'
|
|
3269
|
+
: '更新命令执行完成,但版本没有变化,原因未能确认。点「立即更新」重试通常能解决;若仍不行,请导出日志反馈。 / The update command completed but the version did not change; the cause could not be confirmed. Clicking "Update now" to retry usually resolves it — if not, export the log and report it.';
|
|
3270
|
+
// Actionable, because the user's own recovery is the right one:
|
|
3271
|
+
// the bad artifact is cached under its integrity hash, so a plain
|
|
3272
|
+
// re-add reuses it — the package has to be removed first.
|
|
3273
|
+
const brokenEntryError = !brokenEntry ? null
|
|
3274
|
+
: rollbackOk
|
|
3275
|
+
? `${name} 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),已自动回滚并重新安装原版本文件,下次启动不受影响。这通常是镜像源在新版本刚发布时同步不完整;若仍需这个版本,请先卸载再从官方源重装。 / ${name} arrived without the entry file its package.json points at; the previous build was restored, so the next boot is unaffected. A registry mirror serving an incomplete tarball for a just-published version is the usual cause — remove the package and reinstall from the official registry if you still want this version.`
|
|
3276
|
+
: `${name} 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件(${rollbackDetail ?? 'unknown'});请先检查该 profile,再重新启动。 / ${name} arrived without the entry file its package.json points at, and restoration of the previous build could not be verified (${rollbackDetail ?? 'unknown'}); inspect this profile before restarting.`;
|
|
3277
|
+
const cancelDiff = cancelled ? changedSince(beforeInstalled) : null;
|
|
3278
|
+
// Build-script blocks hit updates too (#69): a leftover invalid
|
|
3279
|
+
// allowBuilds entry (pnpm's placeholder bug, #56) or a newly
|
|
3280
|
+
// build-required dep fails the add with ERR_PNPM_IGNORED_BUILDS.
|
|
3281
|
+
// Reporting the blocked packages here gives the client the same
|
|
3282
|
+
// approve-and-retry banner the install flow has had since #6.
|
|
3283
|
+
const ignoredBuilds = ok || cancelled ? undefined : blockedBuilds(result);
|
|
3284
|
+
logEvent(ok || cancelled ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ` STALE(${staleReason ?? 'unknown'})` : ''}${ok || cancelled ? '' : ` err=${failureDetail(result)}`}`);
|
|
3285
|
+
// A user-cancelled run is a quiet outcome, not an error.
|
|
3286
|
+
sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
|
|
3287
|
+
ok,
|
|
3288
|
+
cancelled: cancelled || undefined,
|
|
3289
|
+
busy: result.busy || undefined,
|
|
3290
|
+
stale: stale || undefined,
|
|
3291
|
+
partial: cancelDiff?.partial,
|
|
3292
|
+
changed: cancelDiff?.changed,
|
|
3293
|
+
activation,
|
|
3294
|
+
compatibility,
|
|
3295
|
+
ignoredBuilds,
|
|
3296
|
+
// Named here rather than left for the next restart to find
|
|
3297
|
+
// (#339). Empty on every healthy operation, so the client only
|
|
3298
|
+
// ever sees this when something really is unbootable.
|
|
3299
|
+
...(() => { const orphans = orphanBundles(); return orphans.length > 0 ? { orphanBundles: orphans } : {}; })(),
|
|
3300
|
+
staleReason: staleReason ?? undefined,
|
|
3301
|
+
failureCode: versionFailureCode ?? undefined,
|
|
3302
|
+
error: versionFailureError ?? trialError ?? brokenEntryError ?? hardFailureRollbackError ?? staleError ?? undefined,
|
|
3303
|
+
exitCode: result.exitCode,
|
|
3304
|
+
timedOut: result.timedOut,
|
|
3305
|
+
stdout: result.stdout,
|
|
3306
|
+
stderr: result.stderr,
|
|
3307
|
+
installed: readInstalled(config.profile, activeProfileDir),
|
|
3308
|
+
});
|
|
3309
|
+
});
|
|
3310
|
+
}
|
|
3311
|
+
catch (error) {
|
|
3312
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3313
|
+
host.logger?.warn(`[ChatCode CLI Market] update failed: ${message}`);
|
|
3314
|
+
logEvent('error', 'update', `route error: ${message}`);
|
|
3315
|
+
sendJson(response, 500, { error: message });
|
|
3316
|
+
}
|
|
3317
|
+
},
|
|
3318
|
+
})),
|
|
3319
|
+
host.webServer.register({
|
|
3320
|
+
kind: 'exact',
|
|
3321
|
+
path: '/dsh-market/setup-pnpm',
|
|
3322
|
+
handler: async (request, response) => {
|
|
3323
|
+
if (request.method !== 'POST') {
|
|
3324
|
+
response.writeHead(405, { allow: 'POST' });
|
|
3325
|
+
response.end();
|
|
3326
|
+
return;
|
|
3327
|
+
}
|
|
3328
|
+
if (!sameOrigin(request)) {
|
|
3329
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
try {
|
|
3333
|
+
const result = await commands.provisionPnpm();
|
|
3334
|
+
sendJson(response, 200, { ok: result.ok, error: result.hint });
|
|
3335
|
+
}
|
|
3336
|
+
catch (error) {
|
|
3337
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
3338
|
+
}
|
|
3339
|
+
},
|
|
3340
|
+
}),
|
|
3341
|
+
/**
|
|
3342
|
+
* Remove the market itself, from its card on the plugin configuration
|
|
3343
|
+
* page. Deliberately NOT the generic uninstall route, which keeps
|
|
3344
|
+
* refusing the market: a destructive action on the thing serving the
|
|
3345
|
+
* request should be reachable only from the surface built for it, and
|
|
3346
|
+
* never as a stray `{ name: "dshmarket" }` on the ordinary path.
|
|
3347
|
+
*
|
|
3348
|
+
* Removing itself is safe, which is not obvious and was measured before
|
|
3349
|
+
* this was written: an already-imported module does not vanish with its
|
|
3350
|
+
* files, so the process keeps serving and the response completes
|
|
3351
|
+
* normally. The profile boots clean afterwards, with the market's rows
|
|
3352
|
+
* gone from `dependencies` and `dsh.profile.bundles`.
|
|
3353
|
+
*/
|
|
3354
|
+
/**
|
|
3355
|
+
* Which release channel the market offers ITSELF from.
|
|
3356
|
+
*
|
|
3357
|
+
* Writable from the card because the settings scope is host-mode only —
|
|
3358
|
+
* a browser that is not on loopback never gets one, and the choice would
|
|
3359
|
+
* be unreachable there. Same-origin POST, like every other mutation.
|
|
3360
|
+
*/
|
|
3361
|
+
host.webServer.register({
|
|
3362
|
+
kind: 'exact',
|
|
3363
|
+
path: '/dsh-market/channel',
|
|
3364
|
+
handler: async (request, response) => {
|
|
3365
|
+
if (request.method !== 'POST') {
|
|
3366
|
+
response.writeHead(405, { allow: 'POST' });
|
|
3367
|
+
response.end();
|
|
3368
|
+
return;
|
|
3369
|
+
}
|
|
3370
|
+
if (!sameOrigin(request)) {
|
|
3371
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
try {
|
|
3375
|
+
const body = (await readJsonBody(request));
|
|
3376
|
+
const wanted = asChannel(body.channel);
|
|
3377
|
+
if (wanted === null) {
|
|
3378
|
+
sendJson(response, 400, { error: 'channel must be "stable", "beta" or "dev"' });
|
|
3379
|
+
return;
|
|
3380
|
+
}
|
|
3381
|
+
config.channel = wanted;
|
|
3382
|
+
// Persisted with the market's own durable state, so the choice
|
|
3383
|
+
// survives a restart — a setting that forgets is a setting the
|
|
3384
|
+
// user has to make again every boot.
|
|
3385
|
+
marketState.channel = wanted;
|
|
3386
|
+
writeMarketState(activeProfileDir, marketState);
|
|
3387
|
+
// The cached listing was computed for the old channel, so the very
|
|
3388
|
+
// next check would answer for a setting that no longer applies.
|
|
3389
|
+
invalidateUpdates();
|
|
3390
|
+
logEvent('info', 'channel', `release channel set to ${wanted}`);
|
|
3391
|
+
sendJson(response, 200, { ok: true, channel: wanted });
|
|
3392
|
+
}
|
|
3393
|
+
catch (error) {
|
|
3394
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
3395
|
+
}
|
|
3396
|
+
},
|
|
3397
|
+
}),
|
|
3398
|
+
/**
|
|
3399
|
+
* Which mirrors every outbound request uses.
|
|
3400
|
+
*
|
|
3401
|
+
* Beside the channel route rather than in the settings namespace, and
|
|
3402
|
+
* for the reason recorded there: a value the market stores in its own
|
|
3403
|
+
* state.json cannot also be owned by the settings schema without the two
|
|
3404
|
+
* writing over each other.
|
|
3405
|
+
*/
|
|
3406
|
+
host.webServer.register({
|
|
3407
|
+
kind: 'exact',
|
|
3408
|
+
path: '/dsh-market/region',
|
|
3409
|
+
handler: async (request, response) => {
|
|
3410
|
+
if (request.method !== 'POST') {
|
|
3411
|
+
response.writeHead(405, { allow: 'POST' });
|
|
3412
|
+
response.end();
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
if (!sameOrigin(request)) {
|
|
3416
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
try {
|
|
3420
|
+
const body = (await readJsonBody(request));
|
|
3421
|
+
const wanted = asRegion(body.region);
|
|
3422
|
+
if (wanted === null) {
|
|
3423
|
+
sendJson(response, 400, { error: 'region must be "global" or "china"' });
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
applyRegion(wanted);
|
|
3427
|
+
config.region = wanted;
|
|
3428
|
+
marketState.region = wanted;
|
|
3429
|
+
// A choice made by hand is no longer the probe's choice, so the
|
|
3430
|
+
// one-time explanation stops being offered.
|
|
3431
|
+
marketState.regionAuto = undefined;
|
|
3432
|
+
regionAuto = false;
|
|
3433
|
+
writeMarketState(activeProfileDir, marketState);
|
|
3434
|
+
// Both caches were filled from the other region's origins. The
|
|
3435
|
+
// catalog validator in particular is scoped to the URL that issued
|
|
3436
|
+
// it and would be meaningless against the new one.
|
|
3437
|
+
forgetCatalog();
|
|
3438
|
+
invalidateUpdates();
|
|
3439
|
+
logEvent('info', 'region', `download region set to ${wanted}`);
|
|
3440
|
+
sendJson(response, 200, { ok: true, region: wanted });
|
|
3441
|
+
}
|
|
3442
|
+
catch (error) {
|
|
3443
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
3444
|
+
}
|
|
3445
|
+
},
|
|
3446
|
+
}),
|
|
3447
|
+
/**
|
|
3448
|
+
* Last-resort GitHub prefix for networks where every built-in route is
|
|
3449
|
+
* unavailable. It is one escape hatch, not three service-level knobs;
|
|
3450
|
+
* the service ordering itself remains maintained by the routing table.
|
|
3451
|
+
*/
|
|
3452
|
+
host.webServer.register({
|
|
3453
|
+
kind: 'exact',
|
|
3454
|
+
path: '/dsh-market/github-proxy',
|
|
3455
|
+
handler: async (request, response) => {
|
|
3456
|
+
if (request.method !== 'POST') {
|
|
3457
|
+
response.writeHead(405, { allow: 'POST' });
|
|
3458
|
+
response.end();
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
if (!sameOrigin(request)) {
|
|
3462
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
3463
|
+
return;
|
|
3464
|
+
}
|
|
3465
|
+
if (githubProxyManaged()) {
|
|
3466
|
+
sendJson(response, 409, { error: 'GitHub proxy is managed by CHATCODE_CLI_MARKET_GITHUB_PROXY' });
|
|
3467
|
+
return;
|
|
3468
|
+
}
|
|
3469
|
+
try {
|
|
3470
|
+
const body = (await readJsonBody(request));
|
|
3471
|
+
const wanted = body.proxy === null ? null : normalizeGithubProxy(body.proxy);
|
|
3472
|
+
if (body.proxy !== null && wanted === null) {
|
|
3473
|
+
sendJson(response, 400, {
|
|
3474
|
+
error: 'proxy must be an HTTPS prefix without credentials, query parameters, or a fragment',
|
|
3475
|
+
});
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
setCustomGithubProxy(wanted);
|
|
3479
|
+
marketState.githubProxy = wanted ?? undefined;
|
|
3480
|
+
writeMarketState(activeProfileDir, marketState);
|
|
3481
|
+
invalidateUpdates();
|
|
3482
|
+
logEvent('info', 'region', wanted === null
|
|
3483
|
+
? 'custom GitHub route cleared; automatic routing restored'
|
|
3484
|
+
: 'custom GitHub route updated');
|
|
3485
|
+
sendJson(response, 200, { ok: true, githubProxyCustom: wanted });
|
|
3486
|
+
}
|
|
3487
|
+
catch (error) {
|
|
3488
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
3489
|
+
}
|
|
3490
|
+
},
|
|
3491
|
+
}),
|
|
3492
|
+
host.webServer.register({
|
|
3493
|
+
kind: 'exact',
|
|
3494
|
+
path: '/dsh-market/self-uninstall',
|
|
3495
|
+
handler: async (request, response) => {
|
|
3496
|
+
if (request.method !== 'POST') {
|
|
3497
|
+
response.writeHead(405, { allow: 'POST' });
|
|
3498
|
+
response.end();
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
// The same single door the restart route uses — and only it. Both
|
|
3502
|
+
// end the market's life in this process, so neither may be driven by
|
|
3503
|
+
// a remote or forwarded client. A separate `sameOrigin` call would
|
|
3504
|
+
// read as an extra guard while testing nothing: origin-matches-host
|
|
3505
|
+
// is already part of what this checks, so no request can fail one
|
|
3506
|
+
// and pass the other.
|
|
3507
|
+
if (!trustedRestartRequest(request)) {
|
|
3508
|
+
sendJson(response, 403, { error: 'self-uninstall is limited to same-origin loopback requests' });
|
|
3509
|
+
return;
|
|
3510
|
+
}
|
|
3511
|
+
try {
|
|
3512
|
+
await withMutationLock(response, 'install', async () => {
|
|
3513
|
+
const body = (await readJsonBody(request));
|
|
3514
|
+
// An explicit flag, not merely reaching the endpoint: this is the
|
|
3515
|
+
// one route whose accidental success cannot be undone from the UI
|
|
3516
|
+
// that would have undone it.
|
|
3517
|
+
if (body.confirm !== true) {
|
|
3518
|
+
sendJson(response, 400, { error: 'self-uninstall requires an explicit confirmation' });
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
const installed = readInstalled(config.profile, activeProfileDir);
|
|
3522
|
+
const selfName = SELF_NAME_ORDER.find(candidate => installed[candidate] !== undefined);
|
|
3523
|
+
if (selfName === undefined) {
|
|
3524
|
+
sendJson(response, 400, { error: 'the market is not an installed dependency of this profile' });
|
|
3525
|
+
return;
|
|
3526
|
+
}
|
|
3527
|
+
pendingRollbacks.clear();
|
|
3528
|
+
const result = await runPlugin(config.profile, ['remove', selfName]);
|
|
3529
|
+
const ok = result.exitCode === 0 && !result.timedOut && !result.cancelled;
|
|
3530
|
+
if (!ok) {
|
|
3531
|
+
// Report what pnpm actually said. A bare "removal failed" on
|
|
3532
|
+
// the one action the user cannot retry from a UI that is
|
|
3533
|
+
// still there would leave them with nothing to act on.
|
|
3534
|
+
const said = (result.stderr.trim() || result.stdout.trim()).slice(-800);
|
|
3535
|
+
sendJson(response, 502, {
|
|
3536
|
+
ok: false,
|
|
3537
|
+
error: said === '' ? 'removing the market failed' : said,
|
|
3538
|
+
timedOut: result.timedOut,
|
|
3539
|
+
cancelled: result.cancelled,
|
|
3540
|
+
});
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
// Opt-in cleanup. Rows the market wrote to the USER patch layer
|
|
3544
|
+
// outlive it: a plugin switched off here stays off after the
|
|
3545
|
+
// market is gone, and the only UI that could switch it back on
|
|
3546
|
+
// has just been removed. Only rows belonging to packages on the
|
|
3547
|
+
// market's own disable list are touched — a hand-written row is
|
|
3548
|
+
// the user's, not ours.
|
|
3549
|
+
const purge = body.purge === true;
|
|
3550
|
+
const restored = [];
|
|
3551
|
+
if (purge) {
|
|
3552
|
+
for (const name of disabled) {
|
|
3553
|
+
const ids = rowIdsForPackage(host, activeProfileDir, name);
|
|
3554
|
+
if (ids.length > 0) {
|
|
3555
|
+
removeRowBlocks(userPatchPath, ids);
|
|
3556
|
+
restored.push(name);
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
purgeMarketState(activeProfileDir);
|
|
3560
|
+
}
|
|
3561
|
+
logEvent('info', 'self-uninstall', `removed ${selfName}${purge ? `; purged state, restored ${String(restored.length)} disabled plugin(s)` : '; state kept'}`);
|
|
3562
|
+
sendJson(response, 200, {
|
|
3563
|
+
ok: true,
|
|
3564
|
+
removed: selfName,
|
|
3565
|
+
purged: purge,
|
|
3566
|
+
restored,
|
|
3567
|
+
restart: restartAllowed(config),
|
|
3568
|
+
});
|
|
3569
|
+
// AFTER the response. The package is gone from disk, so the host
|
|
3570
|
+
// now 404s on this plugin's client bundle while the loader entry
|
|
3571
|
+
// is still live — the shape that wedges the whole page on the
|
|
3572
|
+
// next refresh (#37). Disabling our own entry composes the page
|
|
3573
|
+
// without the market instead. Deferred because it disposes the
|
|
3574
|
+
// context this handler runs in.
|
|
3575
|
+
//
|
|
3576
|
+
// This is also why nothing here schedules a restart. An earlier
|
|
3577
|
+
// version offered one, first as a button in the end state (which
|
|
3578
|
+
// could only answer 405, since the disable takes the restart
|
|
3579
|
+
// route with it) and then as a checkbox in the confirmation. Both
|
|
3580
|
+
// were asking the user to arrange a consequence rather than
|
|
3581
|
+
// stating it: the browser drops the market the moment this runs,
|
|
3582
|
+
// and the leftover disabled entry is cleared by whatever restart
|
|
3583
|
+
// happens next. There is no decision to offer.
|
|
3584
|
+
setTimeout(() => {
|
|
3585
|
+
void themes.setEntryDisabled(selfName, true).catch(() => { });
|
|
3586
|
+
}, 0);
|
|
3587
|
+
});
|
|
3588
|
+
}
|
|
3589
|
+
catch (error) {
|
|
3590
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
3591
|
+
}
|
|
3592
|
+
},
|
|
3593
|
+
}),
|
|
3594
|
+
host.webServer.register(captureLegacy('/dsh-market/restart', {
|
|
3595
|
+
kind: 'exact',
|
|
3596
|
+
path: '/dsh-market/restart',
|
|
3597
|
+
handler: (request, response) => {
|
|
3598
|
+
if (request.method !== 'POST') {
|
|
3599
|
+
response.writeHead(405, { allow: 'POST' });
|
|
3600
|
+
response.end();
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3603
|
+
// One-click restart contributed in #14 by @ysyyhhh.
|
|
3604
|
+
if (!restartAllowed(config)) {
|
|
3605
|
+
sendJson(response, 403, { error: 'self-restart is disabled for this host' });
|
|
3606
|
+
return;
|
|
3607
|
+
}
|
|
3608
|
+
if (detectedDebugger() !== null) {
|
|
3609
|
+
sendJson(response, 403, { error: 'self-restart is disabled while the host is under a debugger' });
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
if (!trustedRestartRequest(request)) {
|
|
3613
|
+
sendJson(response, 403, { error: 'restart is limited to same-origin loopback requests' });
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3616
|
+
if (writing || installing) {
|
|
3617
|
+
sendJson(response, 409, { error: 'cannot restart while a plugin operation is running' });
|
|
3618
|
+
return;
|
|
3619
|
+
}
|
|
3620
|
+
if (restarting) {
|
|
3621
|
+
sendJson(response, 409, { error: 'restart already scheduled' });
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
restarting = true;
|
|
3625
|
+
try {
|
|
3626
|
+
const result = scheduleRestart(servingPort(request));
|
|
3627
|
+
logEvent('info', 'restart', `scheduled pid=${String(result.pid)} helper=${String(result.helperPid)}`);
|
|
3628
|
+
sendJson(response, 202, { ok: true, boot: BOOT_ID, ...result });
|
|
3629
|
+
}
|
|
3630
|
+
catch (error) {
|
|
3631
|
+
restarting = false;
|
|
3632
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3633
|
+
logEvent('error', 'restart', message);
|
|
3634
|
+
sendJson(response, 500, { error: message });
|
|
3635
|
+
}
|
|
3636
|
+
},
|
|
3637
|
+
})),
|
|
3638
|
+
host.webServer.register({
|
|
3639
|
+
kind: 'exact',
|
|
3640
|
+
path: '/dsh-market/approve-builds',
|
|
3641
|
+
handler: async (request, response) => {
|
|
3642
|
+
if (request.method !== 'POST') {
|
|
3643
|
+
response.writeHead(405, { allow: 'POST' });
|
|
3644
|
+
response.end();
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
if (!sameOrigin(request)) {
|
|
3648
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
try {
|
|
3652
|
+
// One-click build-script approval (#6 by @qichuang321): only
|
|
3653
|
+
// packages physically present in the profile's installed tree can
|
|
3654
|
+
// be allowed — the list is not free input. Presence is checked in
|
|
3655
|
+
// node_modules, NOT the dependencies map: pnpm's blocked build
|
|
3656
|
+
// scripts are usually TRANSITIVE deps (cloudflared, ssh2,
|
|
3657
|
+
// cpu-features…), which never appear in package.json (#56 by
|
|
3658
|
+
// @walnut1218).
|
|
3659
|
+
// pnpm 11's ndjson `ignored-scripts` event reports version-qualified
|
|
3660
|
+
// names (cloudflared@0.7.3); strip the @version suffix so the
|
|
3661
|
+
// allowlist keys and node_modules lookups use bare package names.
|
|
3662
|
+
const stripVersion = (name) => {
|
|
3663
|
+
const at = name.lastIndexOf('@');
|
|
3664
|
+
return at > 0 ? name.slice(0, at) : name;
|
|
3665
|
+
};
|
|
3666
|
+
const PKG_RE = /^(@[A-Za-z0-9-~][A-Za-z0-9._~-]*\/)?[A-Za-z0-9-~][A-Za-z0-9._~-]*$/;
|
|
3667
|
+
const body = (await readJsonBody(request));
|
|
3668
|
+
const requested = (Array.isArray(body.packages) ? body.packages.map(String).map(stripVersion) : [])
|
|
3669
|
+
.filter(name => PKG_RE.test(name));
|
|
3670
|
+
const installed = requested
|
|
3671
|
+
.filter(name => existsSync(join(activeProfileDir, 'node_modules', name, 'package.json')));
|
|
3672
|
+
// Git-hosted plugins rejected by pnpm's FETCHER (#68) exist in
|
|
3673
|
+
// neither node_modules nor package.json — the only trusted anchor
|
|
3674
|
+
// left is the curated registry itself: a name that resolves to a
|
|
3675
|
+
// github-sourced catalog entry may be approved pre-materialization.
|
|
3676
|
+
//
|
|
3677
|
+
// pnpm only matches a git-hosted dep's allowBuilds entry under its
|
|
3678
|
+
// stable `name@git+https://…` key (#68/#69) — a bare name entry is
|
|
3679
|
+
// ignored (verified against pnpm 11.21). Derive that key wherever
|
|
3680
|
+
// the github source is known: from the profile spec for installed
|
|
3681
|
+
// deps, from the curated registry for pending ones. The bare name
|
|
3682
|
+
// is kept alongside — it authorizes the npm-sourced case.
|
|
3683
|
+
const specs = readInstalled(config.profile, activeProfileDir);
|
|
3684
|
+
const packages = [];
|
|
3685
|
+
/**
|
|
3686
|
+
* Both key forms for one github source (#285).
|
|
3687
|
+
*
|
|
3688
|
+
* pnpm 11.21+ matches the stable `git+https://…` key; 11.8.0 — what
|
|
3689
|
+
* DSH Desktop bundles — matches only a commit-pinned codeload URL,
|
|
3690
|
+
* so on those versions the approval button wrote a key pnpm would
|
|
3691
|
+
* never read and could never work. The pin is resolved here rather
|
|
3692
|
+
* than assumed: `github:owner/repo` names no commit, and the one
|
|
3693
|
+
* pnpm will fetch is whatever HEAD is at install time.
|
|
3694
|
+
*
|
|
3695
|
+
* A pin that cannot be resolved is simply omitted. The stable key
|
|
3696
|
+
* still covers modern pnpm, and an approval that authorizes less
|
|
3697
|
+
* than hoped is better than one that fails.
|
|
3698
|
+
*/
|
|
3699
|
+
const buildKeys = async (name, spec) => {
|
|
3700
|
+
const stable = gitAllowBuildsKey(name, spec);
|
|
3701
|
+
if (stable === null)
|
|
3702
|
+
return [];
|
|
3703
|
+
const repo = repoOfTarget(spec)?.split('#')[0] ?? null;
|
|
3704
|
+
// A proxied legacy install and the mirror-resolved github form
|
|
3705
|
+
// both already carry their commit; only a bare shortcut asks.
|
|
3706
|
+
const pinned = githubCommitOfTarget(spec)
|
|
3707
|
+
?? (repo === null ? null : await resolveHeadCommit(repo, region));
|
|
3708
|
+
const codeload = pinned === null || pinned === undefined
|
|
3709
|
+
? null
|
|
3710
|
+
: codeloadAllowBuildsKey(name, spec, pinned);
|
|
3711
|
+
return codeload === null ? [stable] : [stable, codeload];
|
|
3712
|
+
};
|
|
3713
|
+
for (const name of requested) {
|
|
3714
|
+
if (installed.includes(name)) {
|
|
3715
|
+
packages.push(name, ...await buildKeys(name, String(specs[name] ?? '')));
|
|
3716
|
+
continue;
|
|
3717
|
+
}
|
|
3718
|
+
if (specs[name] !== undefined)
|
|
3719
|
+
continue;
|
|
3720
|
+
// The catalog can now FAIL rather than quietly serving a bundled
|
|
3721
|
+
// copy, and this key is an optimisation, not a requirement: the
|
|
3722
|
+
// bare name already authorizes the npm-sourced case, and a git
|
|
3723
|
+
// source that misses its key simply prompts again. Losing the
|
|
3724
|
+
// catalog must not turn "allow this build" into a 500.
|
|
3725
|
+
let entry;
|
|
3726
|
+
try {
|
|
3727
|
+
entry = (await loadRegistry()).plugins.find(p => p.name === name || p.npm === name);
|
|
3728
|
+
}
|
|
3729
|
+
catch (error) {
|
|
3730
|
+
logEvent('warn', 'approve-builds', `catalog unavailable, authorizing ${name} by name only: ${error instanceof Error ? error.message : String(error)}`);
|
|
3731
|
+
packages.push(name);
|
|
3732
|
+
continue;
|
|
3733
|
+
}
|
|
3734
|
+
const target = entry === undefined ? null : installTargetFor(entry);
|
|
3735
|
+
const keys = target === null ? [] : await buildKeys(name, target);
|
|
3736
|
+
if (keys.length > 0) {
|
|
3737
|
+
packages.push(name, ...keys);
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
if (packages.length === 0) {
|
|
3741
|
+
sendJson(response, 400, { error: 'no installed packages given' });
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
pendingRollbacks.clear();
|
|
3745
|
+
const approved = setAllowBuilds(config.profile, packages, activeProfileDir);
|
|
3746
|
+
logEvent('info', 'approve-builds', `allowed build scripts: ${approved.join(', ')}`);
|
|
3747
|
+
sendJson(response, 200, { ok: true, approved });
|
|
3748
|
+
}
|
|
3749
|
+
catch (error) {
|
|
3750
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3751
|
+
logEvent('error', 'approve-builds', `route error: ${message}`);
|
|
3752
|
+
sendJson(response, 500, { error: message });
|
|
3753
|
+
}
|
|
3754
|
+
},
|
|
3755
|
+
}),
|
|
3756
|
+
host.webServer.register({
|
|
3757
|
+
kind: 'exact',
|
|
3758
|
+
path: '/dsh-market/cancel',
|
|
3759
|
+
handler: async (request, response) => {
|
|
3760
|
+
if (request.method !== 'POST') {
|
|
3761
|
+
response.writeHead(405, { allow: 'POST' });
|
|
3762
|
+
response.end();
|
|
3763
|
+
return;
|
|
3764
|
+
}
|
|
3765
|
+
if (!sameOrigin(request)) {
|
|
3766
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
3767
|
+
return;
|
|
3768
|
+
}
|
|
3769
|
+
// Cancel flow contributed in #6 by @qichuang321.
|
|
3770
|
+
if (!commands.cancelActive()) {
|
|
3771
|
+
sendJson(response, 400, { error: 'no operation is running' });
|
|
3772
|
+
return;
|
|
3773
|
+
}
|
|
3774
|
+
logEvent('info', 'cancel', `cancelled ${progress.target || 'operation'}`);
|
|
3775
|
+
sendJson(response, 200, { ok: true, cancelled: true, target: progress.target });
|
|
3776
|
+
},
|
|
3777
|
+
}),
|
|
3778
|
+
host.webServer.register({
|
|
3779
|
+
kind: 'exact',
|
|
3780
|
+
path: '/dsh-market/uninstall',
|
|
3781
|
+
handler: async (request, response) => {
|
|
3782
|
+
if (request.method !== 'POST') {
|
|
3783
|
+
response.writeHead(405, { allow: 'POST' });
|
|
3784
|
+
response.end();
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
if (!sameOrigin(request)) {
|
|
3788
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
3789
|
+
return;
|
|
3790
|
+
}
|
|
3791
|
+
try {
|
|
3792
|
+
await withMutationLock(response, 'install', async () => {
|
|
3793
|
+
const body = (await readJsonBody(request));
|
|
3794
|
+
const name = typeof body.name === 'string' ? body.name : '';
|
|
3795
|
+
// Only the INDETERMINATE patch case is forceable, below. A patch
|
|
3796
|
+
// that definitely names the package stays refused: there the user
|
|
3797
|
+
// has a concrete thing to go fix, so an override would only help
|
|
3798
|
+
// them break their next boot.
|
|
3799
|
+
const force = body.force === true;
|
|
3800
|
+
if (isSelfName(name)) {
|
|
3801
|
+
sendJson(response, 400, { error: 'the market cannot uninstall itself; use ChatCode CLI' });
|
|
3802
|
+
return;
|
|
3803
|
+
}
|
|
3804
|
+
if (readInstalled(config.profile, activeProfileDir)[name] === undefined) {
|
|
3805
|
+
sendJson(response, 400, { error: 'plugin is not installed' });
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
const userPatchReferences = userPatchPackageReferences(userPatchPath, name);
|
|
3809
|
+
if (userPatchReferences === null && !force) {
|
|
3810
|
+
// Refusing here is right — an unreadable patch might still load
|
|
3811
|
+
// the package, and removing it would break the next boot. But
|
|
3812
|
+
// refusing with NO way through is the wrong shape: the market
|
|
3813
|
+
// cannot say which row to fix, and the moment someone wants to
|
|
3814
|
+
// uninstall is usually the moment something is already broken.
|
|
3815
|
+
// So this one is forceable, and says so.
|
|
3816
|
+
logEvent('warn', 'uninstall-blocked', `${name}: user cordis.patch.yml could not be inspected safely`);
|
|
3817
|
+
sendJson(response, 409, {
|
|
3818
|
+
error: `无法安全卸载 ${name}:当前 profile 的 cordis.patch.yml 无法读取为有效的补丁列表,因此无法排除它仍在引用该包。请先检查补丁文件;确认无关后可强制卸载。 / Cannot safely uninstall ${name}: this profile's cordis.patch.yml could not be read as a valid patch list, so the market cannot rule out a remaining package reference. Check the patch file; you can force the uninstall once you are sure it is unrelated.`,
|
|
3819
|
+
userPatchInspectionFailed: true,
|
|
3820
|
+
forceable: true,
|
|
3821
|
+
});
|
|
3822
|
+
return;
|
|
3823
|
+
}
|
|
3824
|
+
if (userPatchReferences === null) {
|
|
3825
|
+
logEvent('warn', 'uninstall', `${name}: forced past an unreadable user cordis.patch.yml`);
|
|
3826
|
+
}
|
|
3827
|
+
if (userPatchReferences !== null && userPatchReferences.length > 0) {
|
|
3828
|
+
const listed = userPatchReferences.join(', ');
|
|
3829
|
+
logEvent('warn', 'uninstall-blocked', `${name}: user cordis.patch.yml still inserts ${listed}`);
|
|
3830
|
+
sendJson(response, 409, {
|
|
3831
|
+
error: `无法卸载 ${name}:当前 profile 的 cordis.patch.yml 仍通过 insert 引用 ${listed}。请先移除这些用户补丁引用再重试;市场不会自动改写用户补丁。 / Cannot uninstall ${name}: this profile's cordis.patch.yml still inserts ${listed}. Remove those user-owned patch references first and retry; the market will not rewrite the user patch automatically.`,
|
|
3832
|
+
userPatchReferenced: true,
|
|
3833
|
+
patchReferences: userPatchReferences,
|
|
3834
|
+
});
|
|
3835
|
+
return;
|
|
3836
|
+
}
|
|
3837
|
+
const busyAgents = runningAgentsForGuard();
|
|
3838
|
+
if (busyAgents.length > 0) {
|
|
3839
|
+
logEvent('warn', 'uninstall-blocked', `${name}: refused while agents are running — ${busyAgents.join(', ')}`);
|
|
3840
|
+
sendJson(response, 409, {
|
|
3841
|
+
error: `有 agent 正在运行(${busyAgents.join(', ')})。卸载会修改插件文件,正在工作的 agent 可能在中途报错;请等它完成或取消后再卸载。 / ${busyAgents.length === 1 ? 'An agent is running' : 'Agents are running'} (${busyAgents.join(', ')}). Uninstalling changes plugin files, so a working agent can fail mid-turn; wait for it to finish (or cancel it) before uninstalling.`,
|
|
3842
|
+
agentsBusy: true,
|
|
3843
|
+
runningAgents: busyAgents,
|
|
3844
|
+
});
|
|
3845
|
+
return;
|
|
3846
|
+
}
|
|
3847
|
+
pendingRollbacks.clear();
|
|
3848
|
+
const beforeInstalled = readInstalled(config.profile, activeProfileDir);
|
|
3849
|
+
// isDisabled comes from the patch layer (#130) — keep it while the
|
|
3850
|
+
// lock moves into withMutationLock (#125).
|
|
3851
|
+
const activation = {
|
|
3852
|
+
[name]: verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)),
|
|
3853
|
+
};
|
|
3854
|
+
// Capture whether the plugin has a client part BEFORE removal — after
|
|
3855
|
+
// runPlugin the package may be gone from node_modules, so a post-hoc
|
|
3856
|
+
// check would always return false on a successful uninstall.
|
|
3857
|
+
const hadClientPart = packageHasClientPart(activeProfileDir, name);
|
|
3858
|
+
// Also captured before the removal, and for the same reason: a
|
|
3859
|
+
// native addon in this plugin or one of its dependencies is not
|
|
3860
|
+
// released by unloading it (#441). Node has no dlclose, so the
|
|
3861
|
+
// process holds the `.node` until it exits — and on Windows the
|
|
3862
|
+
// next install of the same plugin then fails renaming over the
|
|
3863
|
+
// copy this process is still holding. Calling such an uninstall
|
|
3864
|
+
// `hot` would send the user to a page refresh, which is the one
|
|
3865
|
+
// thing that cannot help.
|
|
3866
|
+
const heldNativeAddon = holdsNativeAddon(config.profile, name, activeProfileDir);
|
|
3867
|
+
const result = await runPlugin(config.profile, ['remove', name]);
|
|
3868
|
+
const cancelled = result.cancelled;
|
|
3869
|
+
const ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
3870
|
+
const cancelDiff = cancelled ? changedSince(beforeInstalled) : null;
|
|
3871
|
+
// Half-uninstall guard: pnpm can fail a remove AFTER deleting
|
|
3872
|
+
// node_modules but BEFORE saving package.json (#65's write-order
|
|
3873
|
+
// mirror image — a file locked mid-unlink aborts the run). The
|
|
3874
|
+
// manifest would then reference a package that no longer exists,
|
|
3875
|
+
// and the next boot fails to activate the ghost dependency.
|
|
3876
|
+
// Reconcile from disk truth: when the package is gone, finish
|
|
3877
|
+
// the removal the CLI could not; when it is intact, keep the
|
|
3878
|
+
// manifest so the user can simply retry.
|
|
3879
|
+
const halfGone = !ok && !cancelled
|
|
3880
|
+
&& !existsSync(join(activeProfileDir, 'node_modules', name, 'package.json'));
|
|
3881
|
+
let reconciled = false;
|
|
3882
|
+
if (halfGone) {
|
|
3883
|
+
reconciled = dropFromManifest(config.profile, name, activeProfileDir);
|
|
3884
|
+
logEvent('warn', 'uninstall', `${name}: remove failed (exit ${String(result.exitCode)}) but the package is gone from disk; ${reconciled ? 'reconciled manifest lists to match' : 'manifest lists already clean'}`);
|
|
3885
|
+
}
|
|
3886
|
+
let hot = false;
|
|
3887
|
+
if (ok || halfGone) {
|
|
3888
|
+
invalidateUpdates();
|
|
3889
|
+
hot = await hotUnmount(name);
|
|
3890
|
+
// Bundle-layer plugins never hot-mount, but their loader entry
|
|
3891
|
+
// is still LIVE in this process — after the remove deleted the
|
|
3892
|
+
// package, the next refresh would 404 on its client bundle and
|
|
3893
|
+
// wedge the whole page until a dsh restart (#37 by
|
|
3894
|
+
// @1123762794). Live-disable the entry so the refresh composes
|
|
3895
|
+
// without it; after a real restart the entry is gone anyway.
|
|
3896
|
+
//
|
|
3897
|
+
// Both run, unconditionally. This used to short-circuit on the
|
|
3898
|
+
// hot unmount, which is right only while a package has ONE
|
|
3899
|
+
// activation source — a package that is both hot-mounted AND
|
|
3900
|
+
// reachable through the bundle layer got half its cleanup, and
|
|
3901
|
+
// the surviving half is exactly the 404-on-refresh wedge above
|
|
3902
|
+
// (#213). setEntryDisabled just scans entries by name and
|
|
3903
|
+
// returns false when none match, so calling it after a
|
|
3904
|
+
// successful unmount costs a lookup and nothing else.
|
|
3905
|
+
const entryDisabled = await themes.setEntryDisabled(name, true);
|
|
3906
|
+
hot = hot || entryDisabled;
|
|
3907
|
+
if (heldNativeAddon && hot) {
|
|
3908
|
+
logEvent('info', 'uninstall', `${name} ships or depends on a native addon, which this process cannot release — reporting the uninstall as needing a restart`);
|
|
3909
|
+
}
|
|
3910
|
+
hot = hot && !heldNativeAddon;
|
|
3911
|
+
// Patch-layer rows must not survive the remove either: a
|
|
3912
|
+
// `- id: X` + `disabled: true` row for a package that no longer
|
|
3913
|
+
// mounts is a boot-time orphan (port of dsh-plugin-hub).
|
|
3914
|
+
removeRowBlocks(userPatchPath, rowIdsForPackage(host, activeProfileDir, name));
|
|
3915
|
+
// The disable list must not keep a removed plugin: a later
|
|
3916
|
+
// reinstall starts enabled. Group memberships follow the same
|
|
3917
|
+
// rule so no group toggle ever targets a ghost member.
|
|
3918
|
+
disabled.delete(name);
|
|
3919
|
+
removeFromGroups({ groups, groupOrder }, name);
|
|
3920
|
+
writeMarketState(activeProfileDir, { disabled, groups, groupOrder });
|
|
3921
|
+
}
|
|
3922
|
+
logEvent(ok || cancelled ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${cancelled ? ' CANCELLED' : ''}${ok ? ` live-removed=${String(hot)}` : cancelled ? '' : ` err=${failureDetail(result)}`}`);
|
|
3923
|
+
sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
|
|
3924
|
+
ok,
|
|
3925
|
+
cancelled: cancelled || undefined,
|
|
3926
|
+
busy: result.busy || undefined,
|
|
3927
|
+
// A failed remove whose package vanished from disk was
|
|
3928
|
+
// reconciled: the manifest lists match disk truth again, the
|
|
3929
|
+
// removal is final (a retry would 400 on "not installed").
|
|
3930
|
+
reconciled: reconciled || undefined,
|
|
3931
|
+
hot,
|
|
3932
|
+
// A client-part plugin's UI is already injected into the page; after
|
|
3933
|
+
// uninstall the injected bundle stays live until a refresh, so the
|
|
3934
|
+
// same banner as enable/disable prompts the user to reload.
|
|
3935
|
+
// Gate on hot: non-hot uninstalls already show the restart banner,
|
|
3936
|
+
// and adding a refresh banner there would double-banner (#213's
|
|
3937
|
+
// pendingRefreshNames merge exists specifically to avoid that).
|
|
3938
|
+
refresh: ok && hot && hadClientPart,
|
|
3939
|
+
partial: cancelDiff?.partial,
|
|
3940
|
+
changed: cancelDiff?.changed,
|
|
3941
|
+
// The state of the package that was just removed (captured pre-op).
|
|
3942
|
+
activation,
|
|
3943
|
+
exitCode: result.exitCode,
|
|
3944
|
+
stdout: result.stdout,
|
|
3945
|
+
stderr: result.stderr,
|
|
3946
|
+
installed: readInstalled(config.profile, activeProfileDir),
|
|
3947
|
+
});
|
|
3948
|
+
});
|
|
3949
|
+
}
|
|
3950
|
+
catch (error) {
|
|
3951
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3952
|
+
host.logger?.warn(`[ChatCode CLI Market] uninstall failed: ${message}`);
|
|
3953
|
+
logEvent('error', 'uninstall', `route error: ${message}`);
|
|
3954
|
+
sendJson(response, 500, { error: message });
|
|
3955
|
+
}
|
|
3956
|
+
},
|
|
3957
|
+
}),
|
|
3958
|
+
host.webServer.register(captureLegacy('/dsh-market/rollback', {
|
|
3959
|
+
kind: 'exact',
|
|
3960
|
+
path: '/dsh-market/rollback',
|
|
3961
|
+
handler: async (request, response) => {
|
|
3962
|
+
if (request.method !== 'POST') {
|
|
3963
|
+
response.writeHead(405, { allow: 'POST' });
|
|
3964
|
+
response.end();
|
|
3965
|
+
return;
|
|
3966
|
+
}
|
|
3967
|
+
if (!sameOrigin(request)) {
|
|
3968
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
3969
|
+
return;
|
|
3970
|
+
}
|
|
3971
|
+
try {
|
|
3972
|
+
await withMutationLock(response, 'install', async () => {
|
|
3973
|
+
const body = (await readJsonBody(request));
|
|
3974
|
+
const id = typeof body.rollbackId === 'string' ? body.rollbackId : '';
|
|
3975
|
+
const pending = pendingRollbacks.get(id);
|
|
3976
|
+
if (pending === undefined) {
|
|
3977
|
+
sendJson(response, 400, { error: 'rollback is not available (it may have been superseded by another operation) / 回滚已不可用(可能已被后续操作覆盖)' });
|
|
3978
|
+
return;
|
|
3979
|
+
}
|
|
3980
|
+
// The token captures whole-profile manifest and lock state. A
|
|
3981
|
+
// terminal-side pnpm/dsh command is outside this route's mutation
|
|
3982
|
+
// lock, so internal invalidation alone cannot prevent an old token
|
|
3983
|
+
// from overwriting a newer external edit. Refuse unless the exact
|
|
3984
|
+
// post-operation state that the user was shown is still current.
|
|
3985
|
+
if (!profileStateMatches(pending.expectedState)) {
|
|
3986
|
+
pendingRollbacks.delete(id);
|
|
3987
|
+
sendJson(response, 400, {
|
|
3988
|
+
error: 'rollback is not available because the profile changed after this operation / 操作后配置已发生变化,回滚不可用',
|
|
3989
|
+
});
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
let ok = true;
|
|
3993
|
+
let hot = false;
|
|
3994
|
+
let detail = null;
|
|
3995
|
+
if (pending.kind === 'update') {
|
|
3996
|
+
const name = pending.names[0];
|
|
3997
|
+
const result = pending.updateSource === undefined
|
|
3998
|
+
? { ok: false, detail: 'the saved update rollback source is unavailable' }
|
|
3999
|
+
: await executeUpdateRollback(name, pending.manifestBefore, pending.updateSource);
|
|
4000
|
+
ok = result.ok;
|
|
4001
|
+
detail = result.detail;
|
|
4002
|
+
}
|
|
4003
|
+
else {
|
|
4004
|
+
for (const name of pending.names) {
|
|
4005
|
+
const result = await removeInstalledPackage(name);
|
|
4006
|
+
hot ||= result.hot;
|
|
4007
|
+
if (!result.ok) {
|
|
4008
|
+
ok = false;
|
|
4009
|
+
detail = result.detail;
|
|
4010
|
+
break;
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
if (ok) {
|
|
4015
|
+
pendingRollbacks.delete(id);
|
|
4016
|
+
invalidateUpdates();
|
|
4017
|
+
logEvent('info', 'rollback', `${pending.kind}: ${pending.names.join(', ')} restored`);
|
|
4018
|
+
}
|
|
4019
|
+
else {
|
|
4020
|
+
logEvent('error', 'rollback', `${pending.kind}: ${pending.names.join(', ')} failed — ${detail ?? 'unknown'}`);
|
|
4021
|
+
}
|
|
4022
|
+
sendJson(response, ok ? 200 : 502, {
|
|
4023
|
+
ok,
|
|
4024
|
+
rolledBack: ok,
|
|
4025
|
+
hot,
|
|
4026
|
+
detail: detail ?? undefined,
|
|
4027
|
+
installed: readInstalled(config.profile, activeProfileDir),
|
|
4028
|
+
});
|
|
4029
|
+
});
|
|
4030
|
+
}
|
|
4031
|
+
catch (error) {
|
|
4032
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4033
|
+
host.logger?.warn(`[ChatCode CLI Market] rollback failed: ${message}`);
|
|
4034
|
+
logEvent('error', 'rollback', `route error: ${message}`);
|
|
4035
|
+
sendJson(response, 500, { error: message });
|
|
4036
|
+
}
|
|
4037
|
+
},
|
|
4038
|
+
})),
|
|
4039
|
+
host.webServer.register({
|
|
4040
|
+
kind: 'exact',
|
|
4041
|
+
path: '/dsh-market/install',
|
|
4042
|
+
handler: async (request, response) => {
|
|
4043
|
+
if (request.method !== 'POST') {
|
|
4044
|
+
response.writeHead(405, { allow: 'POST' });
|
|
4045
|
+
response.end();
|
|
4046
|
+
return;
|
|
4047
|
+
}
|
|
4048
|
+
if (!sameOrigin(request)) {
|
|
4049
|
+
sendJson(response, 403, { error: 'untrusted origin' });
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
try {
|
|
4053
|
+
await withMutationLock(response, 'install', async () => {
|
|
4054
|
+
const body = (await readJsonBody(request));
|
|
4055
|
+
const busyAgents = runningAgentsForGuard();
|
|
4056
|
+
if (busyAgents.length > 0) {
|
|
4057
|
+
logEvent('warn', 'install-blocked', `refused while agents are running — ${busyAgents.join(', ')}`);
|
|
4058
|
+
sendJson(response, 409, {
|
|
4059
|
+
error: `有 agent 正在运行(${busyAgents.join(', ')})。安装会修改插件文件,正在工作的 agent 可能在中途报错;请等它完成或取消后再安装。 / ${busyAgents.length === 1 ? 'An agent is running' : 'Agents are running'} (${busyAgents.join(', ')}). Installing changes plugin files, so a working agent can fail mid-turn; wait for it to finish (or cancel it) before installing.`,
|
|
4060
|
+
agentsBusy: true,
|
|
4061
|
+
runningAgents: busyAgents,
|
|
4062
|
+
});
|
|
4063
|
+
return;
|
|
4064
|
+
}
|
|
4065
|
+
const url = typeof body.url === 'string' ? body.url : '';
|
|
4066
|
+
const registry = await loadRegistry();
|
|
4067
|
+
const entry = registry.plugins.find(p => p.url.toLowerCase() === url.toLowerCase());
|
|
4068
|
+
if (entry === undefined) {
|
|
4069
|
+
logEvent('warn', 'install-rejected', `not in curated registry: ${url.slice(0, 120)}`);
|
|
4070
|
+
sendJson(response, 400, { error: 'plugin is not in the curated registry' });
|
|
4071
|
+
return;
|
|
4072
|
+
}
|
|
4073
|
+
const plainTarget = installTargetFor(entry);
|
|
4074
|
+
if (plainTarget === null) {
|
|
4075
|
+
sendJson(response, 400, { error: 'unsupported source url' });
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
// Resolve GitHub HEAD through the region's available routes, then
|
|
4079
|
+
// let pnpm fetch the canonical commit-pinned target.
|
|
4080
|
+
// Applied HERE, before the guards below, so every step downstream
|
|
4081
|
+
// reasons about the exact spec that will be installed. Returns
|
|
4082
|
+
// the original on any lookup failure (see accelerate.ts).
|
|
4083
|
+
const target = await acceleratedTarget(plainTarget, region);
|
|
4084
|
+
if (target !== plainTarget) {
|
|
4085
|
+
logEvent('info', 'region', `${entry.name}: resolved HEAD through an available ${region} route; downloading the commit-pinned GitHub target directly for pnpm integrity`);
|
|
4086
|
+
}
|
|
4087
|
+
// Duplicate guard (#27): the same plugin listed under another name
|
|
4088
|
+
// (an alias entry pointing at the same repo) must never install
|
|
4089
|
+
// twice — two loader entries with one id brick the next boot.
|
|
4090
|
+
// Monorepo subpath entries (distinct plugins in one repo) pass:
|
|
4091
|
+
// their entry urls differ by subpath and identity is name-based.
|
|
4092
|
+
// A dependency left in package.json by a FAILED install (blocked
|
|
4093
|
+
// build scripts: pnpm writes the manifest, then exits 1) is NOT a
|
|
4094
|
+
// duplicate — it was never activated. Blocking the retry would
|
|
4095
|
+
// make the approve-builds flow dead-end, so a leftover that is the
|
|
4096
|
+
// SAME package/source (not a repo-only alias of a different entry)
|
|
4097
|
+
// and is not yet active (bundle layer or live mount) may be retried.
|
|
4098
|
+
const installedNow = readInstalled(config.profile, activeProfileDir);
|
|
4099
|
+
const aliasOf = findInstalledAlias(entry, installedNow);
|
|
4100
|
+
// When the duplicate guard allows a retry of a leftover dep, that
|
|
4101
|
+
// name must be treated as "newly added" by the post-install
|
|
4102
|
+
// validation and hot-mount below (it IS in package.json from the
|
|
4103
|
+
// failed attempt, so the plain before/after diff would miss it).
|
|
4104
|
+
let retryAlias = null;
|
|
4105
|
+
if (aliasOf !== null) {
|
|
4106
|
+
// Same install? The leftover's own name/spec must match what we
|
|
4107
|
+
// are about to add — an npm entry retries under its npm name; a
|
|
4108
|
+
// github entry's package.json spec equals the target.
|
|
4109
|
+
// Compared as IDENTITIES, not as strings. One GitHub plugin has
|
|
4110
|
+
// several historical spellings — mutable or pinned `github:`
|
|
4111
|
+
// shortcuts and legacy proxied codeload tarballs — so a literal
|
|
4112
|
+
// comparison would call a leftover from before an upgrade or
|
|
4113
|
+
// region switch "a different source" and refuse the retry it
|
|
4114
|
+
// exists to allow. `repoOfTarget` returns null for npm names and file
|
|
4115
|
+
// links, which fall through to the string comparison below.
|
|
4116
|
+
const installedSpec = String(installedNow[aliasOf] ?? '').replace(/^file:/, '');
|
|
4117
|
+
const wantedSpec = String(target).replace(/^file:/, '');
|
|
4118
|
+
const installedRepo = repoOfTarget(installedSpec);
|
|
4119
|
+
const wantedRepo = repoOfTarget(wantedSpec);
|
|
4120
|
+
const sameSource = aliasOf.toLowerCase() === (entry.npm ?? '').toLowerCase()
|
|
4121
|
+
|| (installedRepo !== null && installedRepo === wantedRepo)
|
|
4122
|
+
|| installedSpec.toLowerCase() === wantedSpec.toLowerCase();
|
|
4123
|
+
let active = false;
|
|
4124
|
+
try {
|
|
4125
|
+
const manifest = JSON.parse(readFileSync(join(activeProfileDir, 'package.json'), 'utf8'));
|
|
4126
|
+
active = (manifest.dsh?.profile?.bundles ?? []).includes(aliasOf) || liveNames().has(aliasOf);
|
|
4127
|
+
}
|
|
4128
|
+
catch {
|
|
4129
|
+
// unreadable manifest — treat as active to stay safe
|
|
4130
|
+
active = true;
|
|
4131
|
+
}
|
|
4132
|
+
if (active || !sameSource) {
|
|
4133
|
+
logEvent('warn', 'install-rejected', `${entry.name}: same plugin already installed as ${aliasOf}`);
|
|
4134
|
+
sendJson(response, 400, { error: `已以「${aliasOf}」安装过同一个插件,无需重复安装 / this plugin is already installed as "${aliasOf}"` });
|
|
4135
|
+
return;
|
|
4136
|
+
}
|
|
4137
|
+
retryAlias = aliasOf;
|
|
4138
|
+
logEvent('info', 'install', `${entry.name}: ${aliasOf} present but inactive (leftover of a failed install) — retrying`);
|
|
4139
|
+
}
|
|
4140
|
+
// Name-collision guard (#66): the curated registry lists DISTINCT
|
|
4141
|
+
// plugins sharing one name (both dsh-usage-stats, four dsh-memory…).
|
|
4142
|
+
// The alias guard above no longer cross-matches them (repo evidence
|
|
4143
|
+
// decides), but two packages with one name still cannot coexist —
|
|
4144
|
+
// pnpm would silently REPLACE the installed one's dependency entry.
|
|
4145
|
+
// Refuse with the honest reason instead.
|
|
4146
|
+
if (aliasOf === null) {
|
|
4147
|
+
const clashName = [entry.npm, entry.name].find((n) => typeof n === 'string' && n !== '' && installedNow[n] !== undefined);
|
|
4148
|
+
if (clashName !== undefined) {
|
|
4149
|
+
logEvent('warn', 'install-rejected', `${entry.name}: name collision with installed ${clashName} (${installedNow[clashName]}) from a different source`);
|
|
4150
|
+
sendJson(response, 400, {
|
|
4151
|
+
error: `同名冲突:已安装的「${clashName}」来自其他来源,两个同名插件无法共存于一个 profile,请先卸载再安装 / name conflict: an installed plugin already uses the name "${clashName}" but comes from a different source; two plugins with the same name cannot coexist in one profile — uninstall it first`,
|
|
4152
|
+
});
|
|
4153
|
+
return;
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
const beforeSpecs = readInstalled(config.profile, activeProfileDir);
|
|
4157
|
+
const before = new Set(Object.keys(beforeSpecs));
|
|
4158
|
+
if (retryAlias !== null)
|
|
4159
|
+
before.delete(retryAlias);
|
|
4160
|
+
pendingRollbacks.clear();
|
|
4161
|
+
const compatibilityBefore = assessProfile(config.profile, activeProfileDir);
|
|
4162
|
+
// pnpm re-extracts the whole tree on any operation, so a plugin
|
|
4163
|
+
// nobody touched can come back pristine-and-broken, or lose a
|
|
4164
|
+
// patch that was holding it together (#222). Only what THIS run
|
|
4165
|
+
// broke is attributable to it, so the profile is swept before as
|
|
4166
|
+
// well as after.
|
|
4167
|
+
const bundlesBefore = brokenClientBundles(config.profile, activeProfileDir);
|
|
4168
|
+
// Exact manifest snapshot for failure rollback (#65, #339): the
|
|
4169
|
+
// host writes dependencies and dsh.profile.bundles before the
|
|
4170
|
+
// build-script check / registry fetches run. Either residue can
|
|
4171
|
+
// break every later operation or the next boot. Cancelled runs
|
|
4172
|
+
// keep their partial state on purpose (the user sees the diff
|
|
4173
|
+
// and decides).
|
|
4174
|
+
const manifestBefore = readProfileManifestSnapshot(config.profile, activeProfileDir);
|
|
4175
|
+
const result = await runPlugin(config.profile, ['add', target]);
|
|
4176
|
+
const cancelled = result.cancelled;
|
|
4177
|
+
if ((result.exitCode !== 0 || result.timedOut) && !cancelled) {
|
|
4178
|
+
const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
4179
|
+
if (rolledBack.length > 0)
|
|
4180
|
+
logEvent('warn', 'install', `${target}: rolled back manifest residue of the failed run: ${rolledBack.join(', ')}`);
|
|
4181
|
+
}
|
|
4182
|
+
let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
4183
|
+
const cancelDiff = cancelled ? changedSince(beforeSpecs) : null;
|
|
4184
|
+
if (ok)
|
|
4185
|
+
invalidateUpdates();
|
|
4186
|
+
if (ok) {
|
|
4187
|
+
// Collection repos (e.g. skin monorepos) install as a junk
|
|
4188
|
+
// fileset with no root package.json; retarget to the real
|
|
4189
|
+
// plugin subdirectories via pnpm's #path: selector.
|
|
4190
|
+
ok = await retargetCollections(runPlugin, config.profile, before, target, activeProfileDir);
|
|
4191
|
+
}
|
|
4192
|
+
// Fake-success guard (#18): a clean exit that added nothing
|
|
4193
|
+
// installable must not read as success. Runs even when
|
|
4194
|
+
// retargeting partially failed — a broken piece that slipped in
|
|
4195
|
+
// must never survive to brick the next boot.
|
|
4196
|
+
let notAPlugin = false;
|
|
4197
|
+
// pnpm exited 0 and the profile did not change at all — a
|
|
4198
|
+
// different failure from "what it added was unusable" (#258).
|
|
4199
|
+
let addedNothing = false;
|
|
4200
|
+
let removedBroken = [];
|
|
4201
|
+
let conflicts = [];
|
|
4202
|
+
if (result.exitCode === 0 && !result.timedOut && !cancelled) {
|
|
4203
|
+
const validated = await validateAddedPlugins(runPlugin, config.profile, before, activeProfileDir);
|
|
4204
|
+
removedBroken = validated.removedBroken;
|
|
4205
|
+
conflicts = validated.conflicts;
|
|
4206
|
+
if (removedBroken.length > 0) {
|
|
4207
|
+
logEvent('warn', 'install', `${target}: removed uninstallable pieces (no dsh manifest or missing build artifacts): ${removedBroken.join(', ')}`);
|
|
4208
|
+
}
|
|
4209
|
+
if (validated.keep.length === 0) {
|
|
4210
|
+
ok = false;
|
|
4211
|
+
notAPlugin = true;
|
|
4212
|
+
addedNothing = validated.added.length === 0;
|
|
4213
|
+
logEvent('error', 'install', addedNothing
|
|
4214
|
+
? `${target}: the plugin command reported success but added nothing to the profile`
|
|
4215
|
+
: `${target}: nothing installable survived validation (added: ${validated.added.join(', ')})`);
|
|
4216
|
+
}
|
|
4217
|
+
else {
|
|
4218
|
+
// Partial success across a collection still counts as success.
|
|
4219
|
+
ok = true;
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
const conflictGroups = groupConflictsByOwner(conflicts);
|
|
4223
|
+
const installed = readInstalled(config.profile, activeProfileDir);
|
|
4224
|
+
let hot = false;
|
|
4225
|
+
let activation;
|
|
4226
|
+
let compatibility;
|
|
4227
|
+
let addedPackages = [];
|
|
4228
|
+
if (ok) {
|
|
4229
|
+
const added = Object.keys(installed).filter(name => !before.has(name));
|
|
4230
|
+
addedPackages = added;
|
|
4231
|
+
if (added.length > 0) {
|
|
4232
|
+
// Fresh installs start enabled: drop any stale disable flag
|
|
4233
|
+
// (e.g. reinstall after an uninstall while this process kept
|
|
4234
|
+
// running) and persist before the activation loop.
|
|
4235
|
+
for (const name of added)
|
|
4236
|
+
disabled.delete(name);
|
|
4237
|
+
writeMarketState(activeProfileDir, { disabled, groups, groupOrder });
|
|
4238
|
+
// Theme installs auto-activate (and deactivate the previous
|
|
4239
|
+
// theme) so the result is visible right after the refresh.
|
|
4240
|
+
hot = true;
|
|
4241
|
+
for (const name of added) {
|
|
4242
|
+
const live = pluginCategories(entry).includes('theme')
|
|
4243
|
+
? await themes.activateTheme(name)
|
|
4244
|
+
: (await hotMount(host, activeProfileDir, name)).ok;
|
|
4245
|
+
if (!live)
|
|
4246
|
+
hot = false;
|
|
4247
|
+
}
|
|
4248
|
+
activation = {};
|
|
4249
|
+
const live = liveNames();
|
|
4250
|
+
for (const name of added) {
|
|
4251
|
+
activation[name] = verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name));
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
if (ok && addedPackages.length > 0) {
|
|
4256
|
+
const after = assessProfile(config.profile, activeProfileDir);
|
|
4257
|
+
const risks = introducedRisks(compatibilityBefore, after);
|
|
4258
|
+
// Cross-layer name shadowing this install introduced (#230).
|
|
4259
|
+
// Shares the rollback id with the peer risks when both fire:
|
|
4260
|
+
// one operation, one thing to undo.
|
|
4261
|
+
const shadowed = introducedDuplicateNames(compatibilityBefore, after);
|
|
4262
|
+
// A client bundle that no longer parses (#222): pnpm can leave
|
|
4263
|
+
// one half-written or patch-mangled, and today that surfaces
|
|
4264
|
+
// as a blank settings page long after the install reported
|
|
4265
|
+
// success, with nothing connecting the two.
|
|
4266
|
+
const brokenBundles = newlyBrokenBundles(bundlesBefore, [
|
|
4267
|
+
...addedPackages
|
|
4268
|
+
.map(pkg => ({ name: pkg, check: checkClientBundle(config.profile, pkg, activeProfileDir) }))
|
|
4269
|
+
.filter(entry => !entry.check.ok)
|
|
4270
|
+
.map(entry => ({ name: entry.name, reason: entry.check.reason ?? 'parse failed' })),
|
|
4271
|
+
...brokenClientBundles(config.profile, activeProfileDir),
|
|
4272
|
+
].filter((entry, index, all) => all.findIndex(other => other.name === entry.name) === index));
|
|
4273
|
+
if (risks.length > 0 || shadowed.length > 0 || brokenBundles.length > 0) {
|
|
4274
|
+
const rollbackId = savePendingRollback({ kind: 'install', names: addedPackages });
|
|
4275
|
+
compatibility = {
|
|
4276
|
+
code: 'soft-incompatible',
|
|
4277
|
+
risks,
|
|
4278
|
+
shadowedNames: shadowed.length > 0 ? shadowed : undefined,
|
|
4279
|
+
brokenBundles: brokenBundles.length > 0 ? brokenBundles : undefined,
|
|
4280
|
+
...(rollbackId !== null
|
|
4281
|
+
? { rollbackId }
|
|
4282
|
+
: { rollbackUnavailable: '安装完成后无法安全捕获 profile 状态,因此自动回滚不可用;需要时请手工卸载新安装的插件。 / The post-install profile state could not be captured safely, so automatic rollback is unavailable. Remove the newly installed plugin manually if needed.' }),
|
|
4283
|
+
};
|
|
4284
|
+
if (brokenBundles.length > 0) {
|
|
4285
|
+
logEvent('error', 'install-bundle', `${brokenBundles.map(entry => `${entry.name}: ${entry.reason}`).join('; ')}`);
|
|
4286
|
+
}
|
|
4287
|
+
if (risks.length > 0) {
|
|
4288
|
+
logEvent('warn', 'install-compat', `${addedPackages.join(', ')}: introduced host-compatibility risks — ${risks.map(risk => `${risk.peer}@${risk.range} vs ${risk.resolved}`).join('; ')}`);
|
|
4289
|
+
}
|
|
4290
|
+
if (shadowed.length > 0) {
|
|
4291
|
+
logEvent('warn', 'install-shadow', `${addedPackages.join(', ')}: introduced cross-layer duplicate loader names — ${shadowed.map(entry => `${entry.name} (${entry.layers.join(' + ')})`).join('; ')}`);
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
logEvent(ok || cancelled ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${ok ? ` hot=${String(hot)}` : cancelled ? '' : ` err=${failureDetail(result)}`}`);
|
|
4296
|
+
const ignoredBuilds = blockedBuilds(result);
|
|
4297
|
+
sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
|
|
4298
|
+
ok,
|
|
4299
|
+
cancelled: cancelled || undefined,
|
|
4300
|
+
busy: result.busy || undefined,
|
|
4301
|
+
hot,
|
|
4302
|
+
partial: cancelDiff?.partial,
|
|
4303
|
+
changed: cancelDiff?.changed,
|
|
4304
|
+
activation,
|
|
4305
|
+
compatibility,
|
|
4306
|
+
ignoredBuilds,
|
|
4307
|
+
// Named here rather than left for the next restart to find
|
|
4308
|
+
// (#339). Empty on every healthy operation, so the client only
|
|
4309
|
+
// ever sees this when something really is unbootable.
|
|
4310
|
+
...(() => { const orphans = orphanBundles(); return orphans.length > 0 ? { orphanBundles: orphans } : {}; })(),
|
|
4311
|
+
// Only on a failure, and only for a subpath target: a stale
|
|
4312
|
+
// catalog entry produces a pnpm error that reads like the
|
|
4313
|
+
// user's fault (#346).
|
|
4314
|
+
...(ok || cancelled ? {} : await (async () => {
|
|
4315
|
+
const stale = await staleSubpath(plainTarget ?? '');
|
|
4316
|
+
return stale === null ? {} : { staleEntry: stale };
|
|
4317
|
+
})()),
|
|
4318
|
+
// Blocked build scripts are expected (pnpm >= 10 blocks them by
|
|
4319
|
+
// default): surface the approve-builds banner instead of scaring
|
|
4320
|
+
// the user with pnpm's raw stack.
|
|
4321
|
+
// A loader-id clash is the most actionable failure of all: the
|
|
4322
|
+
// plugin is fine, it just cannot coexist with this profile (#122).
|
|
4323
|
+
// The UI renders `conflictGroups`; this string is the fallback
|
|
4324
|
+
// for logs and non-UI callers. It attributes each id to the
|
|
4325
|
+
// owner that actually declares it — a candidate can clash with
|
|
4326
|
+
// several installed plugins at once, and naming only the first
|
|
4327
|
+
// owner while listing every id blamed one plugin for another's
|
|
4328
|
+
// ids.
|
|
4329
|
+
conflictGroups: conflictGroups.length > 0 ? conflictGroups : undefined,
|
|
4330
|
+
error: conflictGroups.length > 0
|
|
4331
|
+
? `「${conflicts[0].name}」与已安装的 ${conflictGroups.map(group => `「${group.owner}」(${group.ids.join('、')})`).join('、')} 占用相同的 loader 条目 id,无法在同一环境中共存——保留会导致 ChatCode CLI 下次启动失败,因此已自动移除。 / "${conflicts[0].name}" declares the same loader entry id(s) as the installed ${conflictGroups.map(group => `"${group.owner}" (${group.ids.join(', ')})`).join(', ')}; they cannot coexist in one environment — keeping it would stop ChatCode CLI from starting, so it was removed.`
|
|
4332
|
+
: addedNothing
|
|
4333
|
+
// Blaming allowBuilds here sent a reporter chasing a build
|
|
4334
|
+
// step for a plugin that ships a complete lib/ (#258). If
|
|
4335
|
+
// the profile did not change, the plugin is not the thing
|
|
4336
|
+
// that failed — the command that should have installed it
|
|
4337
|
+
// is.
|
|
4338
|
+
? '安装命令报告成功,但 profile 没有任何变化——插件本身没问题,是执行安装的通道没有真正运行。若使用桌面端,请改用命令行 chatcode-cli plugin add 验证,并把导出日志附在 issue 中 / the install command reported success but the profile did not change — the plugin is not at fault, the channel that should have installed it did not actually run. On a desktop build, verify with `chatcode-cli plugin add` from a terminal and attach the exported log'
|
|
4339
|
+
: notAPlugin
|
|
4340
|
+
? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志'
|
|
4341
|
+
: Array.isArray(ignoredBuilds) && ignoredBuilds.length > 0
|
|
4342
|
+
// Names the button but NOT where it is: it was "above",
|
|
4343
|
+
// and this sentence is read inside the operations panel
|
|
4344
|
+
// where the button is not (#314). The panel now carries
|
|
4345
|
+
// the action on this very row, so the text can just say
|
|
4346
|
+
// what to press.
|
|
4347
|
+
? `构建脚本被 pnpm 默认拦截(${ignoredBuilds.join(', ')}),点击「放行构建脚本并重试」即可放行并重装 / build scripts are blocked by pnpm by default (${ignoredBuilds.join(', ')}); use "Allow build scripts and retry" to approve and reinstall`
|
|
4348
|
+
: undefined,
|
|
4349
|
+
exitCode: result.exitCode,
|
|
4350
|
+
timedOut: result.timedOut,
|
|
4351
|
+
stdout: result.stdout,
|
|
4352
|
+
stderr: result.stderr,
|
|
4353
|
+
installed,
|
|
4354
|
+
});
|
|
4355
|
+
});
|
|
4356
|
+
}
|
|
4357
|
+
catch (error) {
|
|
4358
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4359
|
+
host.logger?.warn(`[ChatCode CLI Market] install failed: ${message}`);
|
|
4360
|
+
logEvent('error', 'install', `route error: ${message}`);
|
|
4361
|
+
sendJson(response, 500, { error: message });
|
|
4362
|
+
}
|
|
4363
|
+
},
|
|
4364
|
+
}),
|
|
4365
|
+
];
|
|
4366
|
+
return () => {
|
|
4367
|
+
disposed = true;
|
|
4368
|
+
configurePersistentLog(null);
|
|
4369
|
+
for (const dispose of disposers)
|
|
4370
|
+
dispose();
|
|
4371
|
+
};
|
|
4372
|
+
}
|