dshmarket 1.29.2 → 1.29.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/profile.js +87 -20
- package/lib/routes.js +17 -16
- package/lib/types/profile.d.ts +26 -15
- package/package.json +1 -1
- package/src/profile.ts +106 -20
- package/src/routes.ts +20 -19
package/lib/profile.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
9
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
9
10
|
import { githubRemoteIdentities, githubRepoIdentities } from './sources.js';
|
|
10
11
|
/**
|
|
11
12
|
* Whether a profile name follows DSH's own directory-name contract.
|
|
@@ -78,18 +79,45 @@ export function readManifestDeps(profile, explicitDir) {
|
|
|
78
79
|
return {};
|
|
79
80
|
}
|
|
80
81
|
}
|
|
82
|
+
function objectRecord(value) {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
84
|
+
? value
|
|
85
|
+
: undefined;
|
|
86
|
+
}
|
|
87
|
+
/** Read dependencies and the exact `dsh.profile.bundles` field before a package operation. */
|
|
88
|
+
export function readProfileManifestSnapshot(profile, explicitDir) {
|
|
89
|
+
try {
|
|
90
|
+
const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'package.json'), 'utf8'));
|
|
91
|
+
const profileManifest = objectRecord(manifest.dsh?.profile);
|
|
92
|
+
const present = profileManifest !== undefined && Object.hasOwn(profileManifest, 'bundles');
|
|
93
|
+
return {
|
|
94
|
+
dependencies: { ...manifest.dependencies },
|
|
95
|
+
profileBundles: present
|
|
96
|
+
? { present: true, value: structuredClone(profileManifest.bundles) }
|
|
97
|
+
: { present: false },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { dependencies: {}, profileBundles: { present: false } };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** String package names carried by one valid bundle-list value. */
|
|
105
|
+
function bundleNames(value) {
|
|
106
|
+
return Array.isArray(value) ? value.filter((name) => typeof name === 'string') : [];
|
|
107
|
+
}
|
|
81
108
|
/**
|
|
82
|
-
* Restore the profile manifest
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
109
|
+
* Restore the profile manifest fields a package operation may mutate:
|
|
110
|
+
* `dependencies` and `dsh.profile.bundles`. pnpm and `dsh plugin add` can
|
|
111
|
+
* write both before a later fetch or build-script failure (#65, #69, #339),
|
|
112
|
+
* leaving either an unresolvable dependency or a bundle the next boot cannot
|
|
113
|
+
* activate. Every unrelated manifest field remains untouched. The lockfile is
|
|
114
|
+
* left as-is; pnpm reconciles it from the manifest on the next run.
|
|
115
|
+
*
|
|
116
|
+
* The write is atomic because rollback runs after another operation already
|
|
117
|
+
* failed; a partial repair must not turn a valid profile into invalid JSON.
|
|
90
118
|
* @returns names whose entries were dropped or reverted, empty when nothing changed.
|
|
91
119
|
*/
|
|
92
|
-
export function
|
|
120
|
+
export function restoreProfileManifest(profile, snapshot, explicitDir) {
|
|
93
121
|
const file = join(profileDir(profile, explicitDir), 'package.json');
|
|
94
122
|
let manifest;
|
|
95
123
|
try {
|
|
@@ -100,21 +128,62 @@ export function restoreManifestDeps(profile, snapshot, explicitDir) {
|
|
|
100
128
|
}
|
|
101
129
|
const current = manifest.dependencies ?? {};
|
|
102
130
|
const touched = new Set();
|
|
103
|
-
for (const name of Object.keys(current))
|
|
104
|
-
if (current[name] !== snapshot[name])
|
|
131
|
+
for (const name of Object.keys(current)) {
|
|
132
|
+
if (current[name] !== snapshot.dependencies[name])
|
|
105
133
|
touched.add(name);
|
|
106
|
-
|
|
107
|
-
|
|
134
|
+
}
|
|
135
|
+
for (const name of Object.keys(snapshot.dependencies)) {
|
|
136
|
+
if (current[name] !== snapshot.dependencies[name])
|
|
108
137
|
touched.add(name);
|
|
138
|
+
}
|
|
139
|
+
const currentDsh = objectRecord(manifest.dsh);
|
|
140
|
+
const currentProfile = objectRecord(currentDsh?.profile);
|
|
141
|
+
const currentBundles = currentProfile !== undefined && Object.hasOwn(currentProfile, 'bundles')
|
|
142
|
+
? { present: true, value: currentProfile.bundles }
|
|
143
|
+
: { present: false };
|
|
144
|
+
const bundlesChanged = currentBundles.present !== snapshot.profileBundles.present
|
|
145
|
+
|| (currentBundles.present && snapshot.profileBundles.present
|
|
146
|
+
&& !isDeepStrictEqual(currentBundles.value, snapshot.profileBundles.value));
|
|
147
|
+
if (bundlesChanged) {
|
|
148
|
+
const currentNames = new Set(currentBundles.present ? bundleNames(currentBundles.value) : []);
|
|
149
|
+
const snapshotNames = new Set(snapshot.profileBundles.present ? bundleNames(snapshot.profileBundles.value) : []);
|
|
150
|
+
let namedBundleChange = false;
|
|
151
|
+
for (const name of currentNames) {
|
|
152
|
+
if (!snapshotNames.has(name)) {
|
|
153
|
+
touched.add(name);
|
|
154
|
+
namedBundleChange = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const name of snapshotNames) {
|
|
158
|
+
if (!currentNames.has(name)) {
|
|
159
|
+
touched.add(name);
|
|
160
|
+
namedBundleChange = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Presence, order, duplicates, or a malformed non-array value can differ
|
|
164
|
+
// without changing the set of package names. Still report that rollback.
|
|
165
|
+
if (!namedBundleChange)
|
|
166
|
+
touched.add('dsh.profile.bundles');
|
|
167
|
+
}
|
|
109
168
|
if (touched.size === 0)
|
|
110
169
|
return [];
|
|
111
|
-
manifest.dependencies = { ...snapshot };
|
|
112
|
-
|
|
170
|
+
manifest.dependencies = { ...snapshot.dependencies };
|
|
171
|
+
if (snapshot.profileBundles.present) {
|
|
172
|
+
const dsh = currentDsh ?? {};
|
|
173
|
+
const profileManifest = currentProfile ?? {};
|
|
174
|
+
manifest.dsh = dsh;
|
|
175
|
+
dsh.profile = profileManifest;
|
|
176
|
+
profileManifest.bundles = structuredClone(snapshot.profileBundles.value);
|
|
177
|
+
}
|
|
178
|
+
else if (currentProfile !== undefined) {
|
|
179
|
+
delete currentProfile.bundles;
|
|
180
|
+
}
|
|
181
|
+
writeManifestAtomic(file, manifest);
|
|
113
182
|
return [...touched];
|
|
114
183
|
}
|
|
115
184
|
/**
|
|
116
185
|
* Remove a package from BOTH manifest lists — dependencies and
|
|
117
|
-
* dsh.profile.bundles. The uninstall counterpart of
|
|
186
|
+
* dsh.profile.bundles. The uninstall counterpart of restoreProfileManifest:
|
|
118
187
|
* pnpm can fail a remove after deleting node_modules but before saving
|
|
119
188
|
* package.json (the #65 write-order's mirror image — a file locked mid-
|
|
120
189
|
* unlink aborts the run), leaving the manifest pointing at a package that
|
|
@@ -122,10 +191,8 @@ export function restoreManifestDeps(profile, snapshot, explicitDir) {
|
|
|
122
191
|
* dependency. When disk truth says the package is gone, this finishes the
|
|
123
192
|
* removal the CLI could not. Every other manifest field is untouched.
|
|
124
193
|
*
|
|
125
|
-
* Written atomically
|
|
126
|
-
*
|
|
127
|
-
* in the codebase to leave a half-written package.json: the profile would go
|
|
128
|
-
* from "one ghost dependency" to "will not parse".
|
|
194
|
+
* Written atomically because it runs only after something already went wrong
|
|
195
|
+
* mid-uninstall, so it is the worst place to leave a half-written manifest.
|
|
129
196
|
* @returns true when either list still mentioned the package.
|
|
130
197
|
*/
|
|
131
198
|
export function dropFromManifest(profile, name, explicitDir) {
|
package/lib/routes.js
CHANGED
|
@@ -15,7 +15,7 @@ import { createGroup, deleteGroup, removeFromGroups, renameGroup, setGroupMember
|
|
|
15
15
|
import { exportLogs, logEvent } from './log.js';
|
|
16
16
|
import { diagnosePackageManifests } from './diagnostics.js';
|
|
17
17
|
import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin, } from './dsh-cli.js';
|
|
18
|
-
import { addProfileBundle, dropFromManifest, hasLoadableEntry, INBOX_BUNDLES, isDshProfileName, profileDir, readInstalled, readInstalledManifest, readInstalledRepoEvidence, readInstalledVersion, readLockCommits,
|
|
18
|
+
import { addProfileBundle, dropFromManifest, hasLoadableEntry, INBOX_BUNDLES, isDshProfileName, profileDir, readInstalled, readInstalledManifest, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, restoreProfileManifest, setAllowBuilds } from './profile.js';
|
|
19
19
|
import { assessProfile, classifyPeer, introducedDuplicateNames, introducedRisks } from './compatibility.js';
|
|
20
20
|
import { runningAgentIds } from './agents.js';
|
|
21
21
|
import { analyzeProfile } from './check.js';
|
|
@@ -420,7 +420,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
420
420
|
* manifest to rematerialize the previous build's files.
|
|
421
421
|
*/
|
|
422
422
|
async function rollbackUpdateBuild(name, manifestBefore) {
|
|
423
|
-
const rolledBack =
|
|
423
|
+
const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
424
424
|
if (rolledBack.length === 0)
|
|
425
425
|
return { ok: true, detail: null };
|
|
426
426
|
// CI=true (the market always runs pnpm that way) turns frozen-lockfile
|
|
@@ -449,7 +449,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
449
449
|
if (beforeCommit === null) {
|
|
450
450
|
return { ok: false, detail: 'the previous commit is unknown; nothing to roll back to' };
|
|
451
451
|
}
|
|
452
|
-
|
|
452
|
+
restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
453
453
|
const add = await runPlugin(config.profile, ['add', RELEASE_AGE_OVERRIDE, `${target}#${beforeCommit}`]);
|
|
454
454
|
if (add.exitCode !== 0 || add.timedOut || add.cancelled) {
|
|
455
455
|
return { ok: false, detail: failureDetail(add) };
|
|
@@ -457,7 +457,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
457
457
|
// pnpm wrote a commit-pinned spec; the profile's durable spec must stay
|
|
458
458
|
// the original `github:owner/repo` form. The lockfile keeps the restored
|
|
459
459
|
// commit resolution for the next boot.
|
|
460
|
-
|
|
460
|
+
restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
461
461
|
logEvent('info', 'update-rollback', `${name}: restored github build at ${beforeCommit}`);
|
|
462
462
|
return { ok: true, detail: null };
|
|
463
463
|
}
|
|
@@ -1675,9 +1675,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
1675
1675
|
// force: the user chose to install a fresh release without the
|
|
1676
1676
|
// default one-day safety wait; scoped to this single command.
|
|
1677
1677
|
const addArgs = force ? ['add', RELEASE_AGE_OVERRIDE, target] : ['add', target];
|
|
1678
|
-
//
|
|
1679
|
-
//
|
|
1680
|
-
//
|
|
1678
|
+
// Exact manifest snapshot for failure rollback (#65, #339) — the
|
|
1679
|
+
// host can write dependencies AND dsh.profile.bundles before a
|
|
1680
|
+
// hard-failed add, leaving residue that breaks the next boot.
|
|
1681
1681
|
pendingRollbacks.clear();
|
|
1682
1682
|
const compatibilityBefore = assessProfile(config.profile, activeProfileDir);
|
|
1683
1683
|
// pnpm re-extracts the whole tree on any operation, so a plugin
|
|
@@ -1686,11 +1686,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
1686
1686
|
// broke is attributable to it, so the profile is swept before as
|
|
1687
1687
|
// well as after.
|
|
1688
1688
|
const bundlesBefore = brokenClientBundles(config.profile, activeProfileDir);
|
|
1689
|
-
const manifestBefore =
|
|
1689
|
+
const manifestBefore = readProfileManifestSnapshot(config.profile, activeProfileDir);
|
|
1690
1690
|
const result = await runPlugin(config.profile, addArgs);
|
|
1691
1691
|
const cancelled = result.cancelled;
|
|
1692
1692
|
if ((result.exitCode !== 0 || result.timedOut) && !cancelled) {
|
|
1693
|
-
const rolledBack =
|
|
1693
|
+
const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
1694
1694
|
if (rolledBack.length > 0)
|
|
1695
1695
|
logEvent('warn', 'update', `${name}: rolled back manifest residue of the failed run: ${rolledBack.join(', ')}`);
|
|
1696
1696
|
}
|
|
@@ -2626,16 +2626,17 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
2626
2626
|
// broke is attributable to it, so the profile is swept before as
|
|
2627
2627
|
// well as after.
|
|
2628
2628
|
const bundlesBefore = brokenClientBundles(config.profile, activeProfileDir);
|
|
2629
|
-
//
|
|
2630
|
-
//
|
|
2631
|
-
//
|
|
2632
|
-
// every later
|
|
2633
|
-
// partial state on purpose (the user sees the diff
|
|
2634
|
-
|
|
2629
|
+
// Exact manifest snapshot for failure rollback (#65, #339): the
|
|
2630
|
+
// host writes dependencies and dsh.profile.bundles before the
|
|
2631
|
+
// build-script check / registry fetches run. Either residue can
|
|
2632
|
+
// break every later operation or the next boot. Cancelled runs
|
|
2633
|
+
// keep their partial state on purpose (the user sees the diff
|
|
2634
|
+
// and decides).
|
|
2635
|
+
const manifestBefore = readProfileManifestSnapshot(config.profile, activeProfileDir);
|
|
2635
2636
|
const result = await runPlugin(config.profile, ['add', target]);
|
|
2636
2637
|
const cancelled = result.cancelled;
|
|
2637
2638
|
if ((result.exitCode !== 0 || result.timedOut) && !cancelled) {
|
|
2638
|
-
const rolledBack =
|
|
2639
|
+
const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
|
|
2639
2640
|
if (rolledBack.length > 0)
|
|
2640
2641
|
logEvent('warn', 'install', `${target}: rolled back manifest residue of the failed run: ${rolledBack.join(', ')}`);
|
|
2641
2642
|
}
|
package/lib/types/profile.d.ts
CHANGED
|
@@ -34,21 +34,34 @@ export declare function readInstalled(profile: string, explicitDir?: string): Re
|
|
|
34
34
|
* a filtered view would delete @deepseek-ai/dsh-base and friends.
|
|
35
35
|
*/
|
|
36
36
|
export declare function readManifestDeps(profile: string, explicitDir?: string): Record<string, string>;
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
37
|
+
/** Exact rollback state owned by one profile package operation. */
|
|
38
|
+
export interface ProfileManifestSnapshot {
|
|
39
|
+
dependencies: Record<string, string>;
|
|
40
|
+
profileBundles: {
|
|
41
|
+
present: false;
|
|
42
|
+
} | {
|
|
43
|
+
present: true;
|
|
44
|
+
value: unknown;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Read dependencies and the exact `dsh.profile.bundles` field before a package operation. */
|
|
48
|
+
export declare function readProfileManifestSnapshot(profile: string, explicitDir?: string): ProfileManifestSnapshot;
|
|
49
|
+
/**
|
|
50
|
+
* Restore the profile manifest fields a package operation may mutate:
|
|
51
|
+
* `dependencies` and `dsh.profile.bundles`. pnpm and `dsh plugin add` can
|
|
52
|
+
* write both before a later fetch or build-script failure (#65, #69, #339),
|
|
53
|
+
* leaving either an unresolvable dependency or a bundle the next boot cannot
|
|
54
|
+
* activate. Every unrelated manifest field remains untouched. The lockfile is
|
|
55
|
+
* left as-is; pnpm reconciles it from the manifest on the next run.
|
|
56
|
+
*
|
|
57
|
+
* The write is atomic because rollback runs after another operation already
|
|
58
|
+
* failed; a partial repair must not turn a valid profile into invalid JSON.
|
|
46
59
|
* @returns names whose entries were dropped or reverted, empty when nothing changed.
|
|
47
60
|
*/
|
|
48
|
-
export declare function
|
|
61
|
+
export declare function restoreProfileManifest(profile: string, snapshot: ProfileManifestSnapshot, explicitDir?: string): string[];
|
|
49
62
|
/**
|
|
50
63
|
* Remove a package from BOTH manifest lists — dependencies and
|
|
51
|
-
* dsh.profile.bundles. The uninstall counterpart of
|
|
64
|
+
* dsh.profile.bundles. The uninstall counterpart of restoreProfileManifest:
|
|
52
65
|
* pnpm can fail a remove after deleting node_modules but before saving
|
|
53
66
|
* package.json (the #65 write-order's mirror image — a file locked mid-
|
|
54
67
|
* unlink aborts the run), leaving the manifest pointing at a package that
|
|
@@ -56,10 +69,8 @@ export declare function restoreManifestDeps(profile: string, snapshot: Record<st
|
|
|
56
69
|
* dependency. When disk truth says the package is gone, this finishes the
|
|
57
70
|
* removal the CLI could not. Every other manifest field is untouched.
|
|
58
71
|
*
|
|
59
|
-
* Written atomically
|
|
60
|
-
*
|
|
61
|
-
* in the codebase to leave a half-written package.json: the profile would go
|
|
62
|
-
* from "one ghost dependency" to "will not parse".
|
|
72
|
+
* Written atomically because it runs only after something already went wrong
|
|
73
|
+
* mid-uninstall, so it is the worst place to leave a half-written manifest.
|
|
63
74
|
* @returns true when either list still mentioned the package.
|
|
64
75
|
*/
|
|
65
76
|
export declare function dropFromManifest(profile: string, name: string, explicitDir?: string): boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.29.
|
|
4
|
+
"version": "1.29.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
package/src/profile.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
10
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
10
11
|
import { githubRemoteIdentities, githubRepoIdentities } from './sources.ts'
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -84,38 +85,125 @@ export function readManifestDeps(profile: string, explicitDir?: string): Record<
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/** Exact rollback state owned by one profile package operation. */
|
|
89
|
+
export interface ProfileManifestSnapshot {
|
|
90
|
+
dependencies: Record<string, string>
|
|
91
|
+
profileBundles: { present: false } | { present: true; value: unknown }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function objectRecord(value: unknown): Record<string, unknown> | undefined {
|
|
95
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
96
|
+
? value as Record<string, unknown>
|
|
97
|
+
: undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read dependencies and the exact `dsh.profile.bundles` field before a package operation. */
|
|
101
|
+
export function readProfileManifestSnapshot(profile: string, explicitDir?: string): ProfileManifestSnapshot {
|
|
102
|
+
try {
|
|
103
|
+
const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'package.json'), 'utf8')) as {
|
|
104
|
+
dependencies?: Record<string, string>
|
|
105
|
+
dsh?: { profile?: unknown }
|
|
106
|
+
}
|
|
107
|
+
const profileManifest = objectRecord(manifest.dsh?.profile)
|
|
108
|
+
const present = profileManifest !== undefined && Object.hasOwn(profileManifest, 'bundles')
|
|
109
|
+
return {
|
|
110
|
+
dependencies: { ...manifest.dependencies },
|
|
111
|
+
profileBundles: present
|
|
112
|
+
? { present: true, value: structuredClone(profileManifest.bundles) }
|
|
113
|
+
: { present: false },
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
return { dependencies: {}, profileBundles: { present: false } }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** String package names carried by one valid bundle-list value. */
|
|
121
|
+
function bundleNames(value: unknown): string[] {
|
|
122
|
+
return Array.isArray(value) ? value.filter((name): name is string => typeof name === 'string') : []
|
|
123
|
+
}
|
|
124
|
+
|
|
87
125
|
/**
|
|
88
|
-
* Restore the profile manifest
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
126
|
+
* Restore the profile manifest fields a package operation may mutate:
|
|
127
|
+
* `dependencies` and `dsh.profile.bundles`. pnpm and `dsh plugin add` can
|
|
128
|
+
* write both before a later fetch or build-script failure (#65, #69, #339),
|
|
129
|
+
* leaving either an unresolvable dependency or a bundle the next boot cannot
|
|
130
|
+
* activate. Every unrelated manifest field remains untouched. The lockfile is
|
|
131
|
+
* left as-is; pnpm reconciles it from the manifest on the next run.
|
|
132
|
+
*
|
|
133
|
+
* The write is atomic because rollback runs after another operation already
|
|
134
|
+
* failed; a partial repair must not turn a valid profile into invalid JSON.
|
|
96
135
|
* @returns names whose entries were dropped or reverted, empty when nothing changed.
|
|
97
136
|
*/
|
|
98
|
-
export function
|
|
137
|
+
export function restoreProfileManifest(
|
|
138
|
+
profile: string,
|
|
139
|
+
snapshot: ProfileManifestSnapshot,
|
|
140
|
+
explicitDir?: string,
|
|
141
|
+
): string[] {
|
|
99
142
|
const file = join(profileDir(profile, explicitDir), 'package.json')
|
|
100
|
-
let manifest: {
|
|
143
|
+
let manifest: {
|
|
144
|
+
dependencies?: Record<string, string>
|
|
145
|
+
dsh?: unknown
|
|
146
|
+
}
|
|
101
147
|
try {
|
|
102
|
-
manifest = JSON.parse(readFileSync(file, 'utf8')) as
|
|
148
|
+
manifest = JSON.parse(readFileSync(file, 'utf8')) as typeof manifest
|
|
103
149
|
} catch {
|
|
104
150
|
return []
|
|
105
151
|
}
|
|
106
152
|
const current = manifest.dependencies ?? {}
|
|
107
153
|
const touched = new Set<string>()
|
|
108
|
-
for (const name of Object.keys(current))
|
|
109
|
-
|
|
154
|
+
for (const name of Object.keys(current)) {
|
|
155
|
+
if (current[name] !== snapshot.dependencies[name]) touched.add(name)
|
|
156
|
+
}
|
|
157
|
+
for (const name of Object.keys(snapshot.dependencies)) {
|
|
158
|
+
if (current[name] !== snapshot.dependencies[name]) touched.add(name)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const currentDsh = objectRecord(manifest.dsh)
|
|
162
|
+
const currentProfile = objectRecord(currentDsh?.profile)
|
|
163
|
+
const currentBundles = currentProfile !== undefined && Object.hasOwn(currentProfile, 'bundles')
|
|
164
|
+
? { present: true as const, value: currentProfile.bundles }
|
|
165
|
+
: { present: false as const }
|
|
166
|
+
const bundlesChanged = currentBundles.present !== snapshot.profileBundles.present
|
|
167
|
+
|| (currentBundles.present && snapshot.profileBundles.present
|
|
168
|
+
&& !isDeepStrictEqual(currentBundles.value, snapshot.profileBundles.value))
|
|
169
|
+
if (bundlesChanged) {
|
|
170
|
+
const currentNames = new Set(currentBundles.present ? bundleNames(currentBundles.value) : [])
|
|
171
|
+
const snapshotNames = new Set(snapshot.profileBundles.present ? bundleNames(snapshot.profileBundles.value) : [])
|
|
172
|
+
let namedBundleChange = false
|
|
173
|
+
for (const name of currentNames) {
|
|
174
|
+
if (!snapshotNames.has(name)) {
|
|
175
|
+
touched.add(name)
|
|
176
|
+
namedBundleChange = true
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const name of snapshotNames) {
|
|
180
|
+
if (!currentNames.has(name)) {
|
|
181
|
+
touched.add(name)
|
|
182
|
+
namedBundleChange = true
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Presence, order, duplicates, or a malformed non-array value can differ
|
|
186
|
+
// without changing the set of package names. Still report that rollback.
|
|
187
|
+
if (!namedBundleChange) touched.add('dsh.profile.bundles')
|
|
188
|
+
}
|
|
110
189
|
if (touched.size === 0) return []
|
|
111
|
-
manifest.dependencies = { ...snapshot }
|
|
112
|
-
|
|
190
|
+
manifest.dependencies = { ...snapshot.dependencies }
|
|
191
|
+
if (snapshot.profileBundles.present) {
|
|
192
|
+
const dsh = currentDsh ?? {}
|
|
193
|
+
const profileManifest = currentProfile ?? {}
|
|
194
|
+
manifest.dsh = dsh
|
|
195
|
+
dsh.profile = profileManifest
|
|
196
|
+
profileManifest.bundles = structuredClone(snapshot.profileBundles.value)
|
|
197
|
+
} else if (currentProfile !== undefined) {
|
|
198
|
+
delete currentProfile.bundles
|
|
199
|
+
}
|
|
200
|
+
writeManifestAtomic(file, manifest)
|
|
113
201
|
return [...touched]
|
|
114
202
|
}
|
|
115
203
|
|
|
116
204
|
/**
|
|
117
205
|
* Remove a package from BOTH manifest lists — dependencies and
|
|
118
|
-
* dsh.profile.bundles. The uninstall counterpart of
|
|
206
|
+
* dsh.profile.bundles. The uninstall counterpart of restoreProfileManifest:
|
|
119
207
|
* pnpm can fail a remove after deleting node_modules but before saving
|
|
120
208
|
* package.json (the #65 write-order's mirror image — a file locked mid-
|
|
121
209
|
* unlink aborts the run), leaving the manifest pointing at a package that
|
|
@@ -123,10 +211,8 @@ export function restoreManifestDeps(profile: string, snapshot: Record<string, st
|
|
|
123
211
|
* dependency. When disk truth says the package is gone, this finishes the
|
|
124
212
|
* removal the CLI could not. Every other manifest field is untouched.
|
|
125
213
|
*
|
|
126
|
-
* Written atomically
|
|
127
|
-
*
|
|
128
|
-
* in the codebase to leave a half-written package.json: the profile would go
|
|
129
|
-
* from "one ghost dependency" to "will not parse".
|
|
214
|
+
* Written atomically because it runs only after something already went wrong
|
|
215
|
+
* mid-uninstall, so it is the worst place to leave a half-written manifest.
|
|
130
216
|
* @returns true when either list still mentioned the package.
|
|
131
217
|
*/
|
|
132
218
|
export function dropFromManifest(profile: string, name: string, explicitDir?: string): boolean {
|
package/src/routes.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin,
|
|
24
24
|
type PluginCommandRuntime,
|
|
25
25
|
} from './dsh-cli.ts'
|
|
26
|
-
import { addProfileBundle, dropFromManifest, hasLoadableEntry, INBOX_BUNDLES, isDshProfileName, profileDir, readInstalled, readInstalledManifest, readInstalledRepoEvidence, readInstalledVersion, readLockCommits,
|
|
26
|
+
import { addProfileBundle, dropFromManifest, hasLoadableEntry, INBOX_BUNDLES, isDshProfileName, profileDir, readInstalled, readInstalledManifest, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, restoreProfileManifest, setAllowBuilds, type ProfileManifestSnapshot } from './profile.ts'
|
|
27
27
|
import { assessProfile, classifyPeer, introducedDuplicateNames, introducedRisks, type CompatibilityRisk } from './compatibility.ts'
|
|
28
28
|
import { runningAgentIds, type AgentsLookup } from './agents.ts'
|
|
29
29
|
import { analyzeProfile, type DuplicateName } from './check.ts'
|
|
@@ -462,8 +462,8 @@ export function mountMarketRoutes(
|
|
|
462
462
|
* next start still fails. Re-run pnpm install against the restored
|
|
463
463
|
* manifest to rematerialize the previous build's files.
|
|
464
464
|
*/
|
|
465
|
-
async function rollbackUpdateBuild(name: string, manifestBefore:
|
|
466
|
-
const rolledBack =
|
|
465
|
+
async function rollbackUpdateBuild(name: string, manifestBefore: ProfileManifestSnapshot): Promise<{ ok: boolean; detail: string | null }> {
|
|
466
|
+
const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir)
|
|
467
467
|
if (rolledBack.length === 0) return { ok: true, detail: null }
|
|
468
468
|
// CI=true (the market always runs pnpm that way) turns frozen-lockfile
|
|
469
469
|
// on, and the restored manifest pin now disagrees with the lockfile the
|
|
@@ -483,7 +483,7 @@ export function mountMarketRoutes(
|
|
|
483
483
|
id: string
|
|
484
484
|
kind: 'update' | 'install'
|
|
485
485
|
names: string[]
|
|
486
|
-
manifestBefore?:
|
|
486
|
+
manifestBefore?: ProfileManifestSnapshot
|
|
487
487
|
/** github: updates must re-add the pre-update commit, not just reinstall. */
|
|
488
488
|
gitTarget?: string
|
|
489
489
|
beforeCommit?: string | null
|
|
@@ -501,14 +501,14 @@ export function mountMarketRoutes(
|
|
|
501
501
|
/** Restore a github: update by re-adding the commit captured before the update. */
|
|
502
502
|
async function rollbackGitBuild(
|
|
503
503
|
name: string,
|
|
504
|
-
manifestBefore:
|
|
504
|
+
manifestBefore: ProfileManifestSnapshot,
|
|
505
505
|
target: string,
|
|
506
506
|
beforeCommit: string | null,
|
|
507
507
|
): Promise<{ ok: boolean; detail: string | null }> {
|
|
508
508
|
if (beforeCommit === null) {
|
|
509
509
|
return { ok: false, detail: 'the previous commit is unknown; nothing to roll back to' }
|
|
510
510
|
}
|
|
511
|
-
|
|
511
|
+
restoreProfileManifest(config.profile, manifestBefore, activeProfileDir)
|
|
512
512
|
const add = await runPlugin(config.profile, ['add', RELEASE_AGE_OVERRIDE, `${target}#${beforeCommit}`])
|
|
513
513
|
if (add.exitCode !== 0 || add.timedOut || add.cancelled) {
|
|
514
514
|
return { ok: false, detail: failureDetail(add) }
|
|
@@ -516,7 +516,7 @@ export function mountMarketRoutes(
|
|
|
516
516
|
// pnpm wrote a commit-pinned spec; the profile's durable spec must stay
|
|
517
517
|
// the original `github:owner/repo` form. The lockfile keeps the restored
|
|
518
518
|
// commit resolution for the next boot.
|
|
519
|
-
|
|
519
|
+
restoreProfileManifest(config.profile, manifestBefore, activeProfileDir)
|
|
520
520
|
logEvent('info', 'update-rollback', `${name}: restored github build at ${beforeCommit}`)
|
|
521
521
|
return { ok: true, detail: null }
|
|
522
522
|
}
|
|
@@ -1722,9 +1722,9 @@ export function mountMarketRoutes(
|
|
|
1722
1722
|
// force: the user chose to install a fresh release without the
|
|
1723
1723
|
// default one-day safety wait; scoped to this single command.
|
|
1724
1724
|
const addArgs = force ? ['add', RELEASE_AGE_OVERRIDE, target] : ['add', target]
|
|
1725
|
-
//
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1725
|
+
// Exact manifest snapshot for failure rollback (#65, #339) — the
|
|
1726
|
+
// host can write dependencies AND dsh.profile.bundles before a
|
|
1727
|
+
// hard-failed add, leaving residue that breaks the next boot.
|
|
1728
1728
|
pendingRollbacks.clear()
|
|
1729
1729
|
const compatibilityBefore = assessProfile(config.profile, activeProfileDir)
|
|
1730
1730
|
// pnpm re-extracts the whole tree on any operation, so a plugin
|
|
@@ -1733,11 +1733,11 @@ export function mountMarketRoutes(
|
|
|
1733
1733
|
// broke is attributable to it, so the profile is swept before as
|
|
1734
1734
|
// well as after.
|
|
1735
1735
|
const bundlesBefore = brokenClientBundles(config.profile, activeProfileDir)
|
|
1736
|
-
const manifestBefore =
|
|
1736
|
+
const manifestBefore = readProfileManifestSnapshot(config.profile, activeProfileDir)
|
|
1737
1737
|
const result = await runPlugin(config.profile, addArgs)
|
|
1738
1738
|
const cancelled = result.cancelled
|
|
1739
1739
|
if ((result.exitCode !== 0 || result.timedOut) && !cancelled) {
|
|
1740
|
-
const rolledBack =
|
|
1740
|
+
const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir)
|
|
1741
1741
|
if (rolledBack.length > 0) logEvent('warn', 'update', `${name}: rolled back manifest residue of the failed run: ${rolledBack.join(', ')}`)
|
|
1742
1742
|
}
|
|
1743
1743
|
let ok = result.exitCode === 0 && !result.timedOut && !cancelled
|
|
@@ -2681,16 +2681,17 @@ export function mountMarketRoutes(
|
|
|
2681
2681
|
// broke is attributable to it, so the profile is swept before as
|
|
2682
2682
|
// well as after.
|
|
2683
2683
|
const bundlesBefore = brokenClientBundles(config.profile, activeProfileDir)
|
|
2684
|
-
//
|
|
2685
|
-
//
|
|
2686
|
-
//
|
|
2687
|
-
// every later
|
|
2688
|
-
// partial state on purpose (the user sees the diff
|
|
2689
|
-
|
|
2684
|
+
// Exact manifest snapshot for failure rollback (#65, #339): the
|
|
2685
|
+
// host writes dependencies and dsh.profile.bundles before the
|
|
2686
|
+
// build-script check / registry fetches run. Either residue can
|
|
2687
|
+
// break every later operation or the next boot. Cancelled runs
|
|
2688
|
+
// keep their partial state on purpose (the user sees the diff
|
|
2689
|
+
// and decides).
|
|
2690
|
+
const manifestBefore = readProfileManifestSnapshot(config.profile, activeProfileDir)
|
|
2690
2691
|
const result = await runPlugin(config.profile, ['add', target])
|
|
2691
2692
|
const cancelled = result.cancelled
|
|
2692
2693
|
if ((result.exitCode !== 0 || result.timedOut) && !cancelled) {
|
|
2693
|
-
const rolledBack =
|
|
2694
|
+
const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir)
|
|
2694
2695
|
if (rolledBack.length > 0) logEvent('warn', 'install', `${target}: rolled back manifest residue of the failed run: ${rolledBack.join(', ')}`)
|
|
2695
2696
|
}
|
|
2696
2697
|
let ok = result.exitCode === 0 && !result.timedOut && !cancelled
|