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 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's dependency map to a pre-operation snapshot,
83
- * leaving every other manifest field untouched. pnpm writes package.json
84
- * BEFORE it finishes installing (#65, #69: a 404/blocked-build failure lands
85
- * after the write), so a failed add leaves ghost dependencies that break
86
- * every later pnpm run and pnpm itself can no longer remove them (the same
87
- * failure re-fires on any mutation). Direct manifest surgery is the only
88
- * reliable rollback; the lockfile is left as-is (pnpm reconciles it from the
89
- * manifest on the next run).
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 restoreManifestDeps(profile, snapshot, explicitDir) {
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
- for (const name of Object.keys(snapshot))
107
- if (current[name] !== snapshot[name])
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
- writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
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 restoreManifestDeps:
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, unlike restoreManifestDeps above. This one runs only
126
- * after something already went wrong mid-uninstall, so it is the worst place
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, readManifestDeps, readProfileBundles, removeProfileBundle, restoreManifestDeps, setAllowBuilds } from './profile.js';
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 = restoreManifestDeps(config.profile, manifestBefore, activeProfileDir);
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
- restoreManifestDeps(config.profile, manifestBefore, activeProfileDir);
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
- restoreManifestDeps(config.profile, manifestBefore, activeProfileDir);
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
- // RAW manifest snapshot for failure rollback (#65) — pnpm writes
1679
- // package.json before it finishes, so a hard-failed add leaves
1680
- // ghost/bumped entries that break every later pnpm run.
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 = readManifestDeps(config.profile, activeProfileDir);
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 = restoreManifestDeps(config.profile, manifestBefore, activeProfileDir);
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
- // RAW manifest snapshot for failure rollback (#65): pnpm writes
2630
- // package.json before the build-script check / registry fetches
2631
- // run, so a hard-failed add leaves ghost dependencies that break
2632
- // every later pnpm run of anything. Cancelled runs keep their
2633
- // partial state on purpose (the user sees the diff and decides).
2634
- const manifestBefore = readManifestDeps(config.profile, activeProfileDir);
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 = restoreManifestDeps(config.profile, manifestBefore, activeProfileDir);
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
  }
@@ -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
- * Restore the profile manifest's dependency map to a pre-operation snapshot,
39
- * leaving every other manifest field untouched. pnpm writes package.json
40
- * BEFORE it finishes installing (#65, #69: a 404/blocked-build failure lands
41
- * after the write), so a failed add leaves ghost dependencies that break
42
- * every later pnpm run — and pnpm itself can no longer remove them (the same
43
- * failure re-fires on any mutation). Direct manifest surgery is the only
44
- * reliable rollback; the lockfile is left as-is (pnpm reconciles it from the
45
- * manifest on the next run).
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 restoreManifestDeps(profile: string, snapshot: Record<string, string>, explicitDir?: string): string[];
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 restoreManifestDeps:
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, unlike restoreManifestDeps above. This one runs only
60
- * after something already went wrong mid-uninstall, so it is the worst place
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.2",
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's dependency map to a pre-operation snapshot,
89
- * leaving every other manifest field untouched. pnpm writes package.json
90
- * BEFORE it finishes installing (#65, #69: a 404/blocked-build failure lands
91
- * after the write), so a failed add leaves ghost dependencies that break
92
- * every later pnpm run and pnpm itself can no longer remove them (the same
93
- * failure re-fires on any mutation). Direct manifest surgery is the only
94
- * reliable rollback; the lockfile is left as-is (pnpm reconciles it from the
95
- * manifest on the next run).
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 restoreManifestDeps(profile: string, snapshot: Record<string, string>, explicitDir?: string): string[] {
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: { dependencies?: Record<string, string> }
143
+ let manifest: {
144
+ dependencies?: Record<string, string>
145
+ dsh?: unknown
146
+ }
101
147
  try {
102
- manifest = JSON.parse(readFileSync(file, 'utf8')) as { dependencies?: Record<string, string> }
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)) if (current[name] !== snapshot[name]) touched.add(name)
109
- for (const name of Object.keys(snapshot)) if (current[name] !== snapshot[name]) touched.add(name)
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
- writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`)
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 restoreManifestDeps:
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, unlike restoreManifestDeps above. This one runs only
127
- * after something already went wrong mid-uninstall, so it is the worst place
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, readManifestDeps, readProfileBundles, removeProfileBundle, restoreManifestDeps, setAllowBuilds } from './profile.ts'
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: Record<string, string>): Promise<{ ok: boolean; detail: string | null }> {
466
- const rolledBack = restoreManifestDeps(config.profile, manifestBefore, activeProfileDir)
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?: Record<string, string>
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: Record<string, string>,
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
- restoreManifestDeps(config.profile, manifestBefore, activeProfileDir)
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
- restoreManifestDeps(config.profile, manifestBefore, activeProfileDir)
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
- // RAW manifest snapshot for failure rollback (#65) — pnpm writes
1726
- // package.json before it finishes, so a hard-failed add leaves
1727
- // ghost/bumped entries that break every later pnpm run.
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 = readManifestDeps(config.profile, activeProfileDir)
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 = restoreManifestDeps(config.profile, manifestBefore, activeProfileDir)
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
- // RAW manifest snapshot for failure rollback (#65): pnpm writes
2685
- // package.json before the build-script check / registry fetches
2686
- // run, so a hard-failed add leaves ghost dependencies that break
2687
- // every later pnpm run of anything. Cancelled runs keep their
2688
- // partial state on purpose (the user sees the diff and decides).
2689
- const manifestBefore = readManifestDeps(config.profile, activeProfileDir)
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 = restoreManifestDeps(config.profile, manifestBefore, activeProfileDir)
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