@ekanos/cli 0.22.0 → 0.23.0

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.
Files changed (77) hide show
  1. package/README.md +18 -4
  2. package/dist/commands/add.js +21 -10
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/contract.js +12 -2
  5. package/dist/commands/contract.js.map +1 -1
  6. package/dist/commands/dev.js +33 -2
  7. package/dist/commands/dev.js.map +1 -1
  8. package/dist/commands/publish.d.ts +6 -0
  9. package/dist/commands/publish.js +80 -4
  10. package/dist/commands/publish.js.map +1 -1
  11. package/dist/commands/status.d.ts +44 -0
  12. package/dist/commands/status.js +79 -18
  13. package/dist/commands/status.js.map +1 -1
  14. package/dist/commands/upgrade.js +287 -144
  15. package/dist/commands/upgrade.js.map +1 -1
  16. package/dist/docs-links.js +5 -1
  17. package/dist/docs-links.js.map +1 -1
  18. package/dist/errors.d.ts +2 -0
  19. package/dist/errors.js +9 -0
  20. package/dist/errors.js.map +1 -1
  21. package/dist/exit-codes.d.ts +3 -0
  22. package/dist/exit-codes.js +3 -0
  23. package/dist/exit-codes.js.map +1 -1
  24. package/dist/experience/checks.js +2 -1
  25. package/dist/experience/checks.js.map +1 -1
  26. package/dist/experience/definition-checks.d.ts +7 -0
  27. package/dist/experience/definition-checks.js +36 -4
  28. package/dist/experience/definition-checks.js.map +1 -1
  29. package/dist/experience/init.js +10 -0
  30. package/dist/experience/init.js.map +1 -1
  31. package/dist/experience/load-experience.js +33 -2
  32. package/dist/experience/load-experience.js.map +1 -1
  33. package/dist/experience/pins.d.ts +10 -0
  34. package/dist/experience/pins.js +19 -1
  35. package/dist/experience/pins.js.map +1 -1
  36. package/dist/generated/phosphor-names.d.ts +3 -0
  37. package/dist/generated/phosphor-names.js +1541 -0
  38. package/dist/generated/phosphor-names.js.map +1 -0
  39. package/dist/harness-scaffold.d.ts +13 -0
  40. package/dist/harness-scaffold.js +19 -3
  41. package/dist/harness-scaffold.js.map +1 -1
  42. package/dist/icon-findings.d.ts +12 -17
  43. package/dist/icon-findings.js +49 -74
  44. package/dist/icon-findings.js.map +1 -1
  45. package/dist/index.js +28 -5
  46. package/dist/index.js.map +1 -1
  47. package/dist/lockfile-instances.d.ts +60 -0
  48. package/dist/lockfile-instances.js +177 -0
  49. package/dist/lockfile-instances.js.map +1 -0
  50. package/dist/pack-hygiene.d.ts +8 -0
  51. package/dist/pack-hygiene.js +57 -0
  52. package/dist/pack-hygiene.js.map +1 -0
  53. package/dist/package-manager.d.ts +34 -6
  54. package/dist/package-manager.js +130 -20
  55. package/dist/package-manager.js.map +1 -1
  56. package/dist/project.js +11 -0
  57. package/dist/project.js.map +1 -1
  58. package/dist/repo/scaffold.js +4 -0
  59. package/dist/repo/scaffold.js.map +1 -1
  60. package/dist/schema-skew.js.map +1 -1
  61. package/dist/validate-findings.js +10 -1
  62. package/dist/validate-findings.js.map +1 -1
  63. package/dist/workspace-root.d.ts +16 -0
  64. package/dist/workspace-root.js +50 -0
  65. package/dist/workspace-root.js.map +1 -0
  66. package/package.json +4 -4
  67. package/templates/AGENTS.md.tmpl +11 -15
  68. package/templates/experience/package.json.tmpl +8 -6
  69. package/templates/experience/src/widgets/hello-widget.test.tsx.tmpl +49 -0
  70. package/templates/experience/vitest.config.ts.tmpl +21 -0
  71. package/templates/harness/next.config.mjs.tmpl +7 -4
  72. package/templates/package.json.tmpl +5 -5
  73. package/templates/skills/build-integration/references/widget-design.md +2 -3
  74. package/templates/skills/polish-widgets/references/patterns.md +5 -5
  75. package/dist/generated/fa-free-names.d.ts +0 -9
  76. package/dist/generated/fa-free-names.js +0 -2599
  77. package/dist/generated/fa-free-names.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { discoverRepo, isRepoRoot } from '@ekanos/integration-schema/repo';
1
2
  import { spawn } from 'node:child_process';
2
3
  import * as fs from 'node:fs';
3
4
  import * as os from 'node:os';
@@ -7,7 +8,7 @@ import { cliVersion } from '../compatibility.js';
7
8
  import { invalidStateError, networkError, preconditionError, validationError, } from '../errors.js';
8
9
  import { EXIT_CODES } from '../exit-codes.js';
9
10
  import { readInstalledPackageManifest } from '../harness-scaffold.js';
10
- import { detectPackageManager, installExactArgs, lockfileName, } from '../package-manager.js';
11
+ import { detectPackageManager, frozenInstallArgs, installExactArgs, isMultiPackageWorkspaceRoot, lockfileNames, } from '../package-manager.js';
11
12
  import { ensureBuildScriptsAnswered, skippedBuildScriptsHint, findIgnoredBuilds, ignoredBuildsHint, isIgnoredBuildsFailure, WORKSPACE_FILE, } from '../pnpm-builds.js';
12
13
  import { loadProject } from '../project.js';
13
14
  import { classifyPath, writeFileNoFollow } from '../safe-fs.js';
@@ -18,18 +19,72 @@ import { displayPath } from '../ui.js';
18
19
  import { collectAllFindings } from '../validate-findings.js';
19
20
  import { AGENT_CONTEXT_FILES, DOCS_GROUNDING_FILES, copyBundledSkillTrees, } from './init.js';
20
21
  import { promptYesNo } from './publish.js';
22
+ const TOOLCHAIN_NAMES = Object.values(TOOLCHAIN_PACKAGES);
23
+ function dependsOnToolchain(dir) {
24
+ const manifestPath = path.join(dir, 'package.json');
25
+ if (!fs.existsSync(manifestPath))
26
+ return false;
27
+ const manifest = readManifest(manifestPath);
28
+ return ['dependencies', 'devDependencies'].some((field) => {
29
+ const deps = manifest[field];
30
+ return (typeof deps === 'object' &&
31
+ deps !== null &&
32
+ TOOLCHAIN_NAMES.some((name) => name in deps));
33
+ });
34
+ }
35
+ function planUpgrade(ctx, dir) {
36
+ const absolute = path.resolve(dir);
37
+ if (!isRepoRoot(absolute)) {
38
+ const loaded = loadProject(dir);
39
+ return {
40
+ baseDir: loaded.projectDir,
41
+ repo: false,
42
+ targets: [
43
+ {
44
+ dir: loaded.projectDir,
45
+ relDir: '',
46
+ slug: loaded.primary.slug,
47
+ validate: true,
48
+ },
49
+ ],
50
+ };
51
+ }
52
+ const repo = discoverRepo(absolute);
53
+ for (const problem of repo.problems) {
54
+ ctx.warn(`${problem.where}: ${problem.message}`);
55
+ }
56
+ const targets = [
57
+ ...(dependsOnToolchain(repo.root)
58
+ ? [{ dir: repo.root, relDir: '', slug: null, validate: false }]
59
+ : []),
60
+ ...repo.packages
61
+ .filter((pkg) => dependsOnToolchain(pkg.dir))
62
+ .map((pkg) => ({
63
+ dir: pkg.dir,
64
+ relDir: pkg.relDir,
65
+ slug: pkg.kind === 'integration' ? pkg.name : null,
66
+ validate: pkg.kind === 'integration',
67
+ })),
68
+ ];
69
+ return { baseDir: repo.root, repo: true, targets };
70
+ }
21
71
  export async function runUpgrade(ctx, args, deps = {}) {
22
- var _a, _b, _c, _d, _e, _f, _g;
23
- const loaded = loadProject(args.dir);
24
- const pm = detectPackageManager(loaded.projectDir);
72
+ var _a, _b, _c, _d, _e, _f, _g, _h;
73
+ const plan = planUpgrade(ctx, args.dir);
74
+ const pm = detectPackageManager(plan.baseDir);
25
75
  // Captured BEFORE anything installs: the templates the project's CURRENT
26
76
  // (pre-upgrade) local `@ekanos/cli` ships, which is what actually
27
77
  // generated the agent-context files on disk — not necessarily this
28
78
  // process's own bundled templates, since `upgrade` is a machine-level verb
29
79
  // that never delegates and is routinely invoked via a DIFFERENT (global)
30
80
  // install. See `regenerateAgentContextFiles`.
31
- const oldAgentTemplates = captureAgentTemplates(loaded.projectDir);
32
- const current = readCurrentToolchain(loaded.projectDir);
81
+ const oldAgentTemplates = new Map(plan.targets
82
+ .filter((target) => target.slug !== null)
83
+ .map((target) => [
84
+ target.dir,
85
+ captureAgentTemplates(cliTemplatesDir(target.dir, pm.root)),
86
+ ]));
87
+ const current = readCurrentToolchain(plan.baseDir, pm.root);
33
88
  const session = resolveSessionQuietly(ctx, args);
34
89
  const resolveTarget = (_a = deps.resolveTarget) !== null && _a !== void 0 ? _a : resolveToolchainTarget;
35
90
  const resolution = await resolveTarget({
@@ -55,16 +110,19 @@ export async function runUpgrade(ctx, args, deps = {}) {
55
110
  }
56
111
  const target = resolution.target;
57
112
  const deltas = computeDeltas(current, target);
113
+ const scope = Object.assign({ packageManager: { name: pm.name, root: pm.root } }, (plan.repo
114
+ ? { packages: plan.targets.map((t) => t.relDir || '.') }
115
+ : {}));
58
116
  if (args.check) {
59
- return ctx.succeed(Object.assign({ source: resolution.source, current,
117
+ return ctx.succeed(Object.assign(Object.assign({ source: resolution.source, current,
60
118
  target,
61
- deltas }, (resolution.source === 'host'
119
+ deltas }, scope), (resolution.source === 'host'
62
120
  ? { minimum: resolution.minimum, notes: resolution.notes }
63
121
  : {})), checkSummary(ctx.ui, resolution.source, deltas));
64
122
  }
65
123
  const changedKeys = Object.keys(deltas).filter((key) => deltas[key].changed);
66
124
  if (changedKeys.length === 0) {
67
- return ctx.succeed({ source: resolution.source, current, target, deltas }, ctx.ui.result({
125
+ return ctx.succeed(Object.assign({ source: resolution.source, current, target, deltas }, scope), ctx.ui.result({
68
126
  title: 'Already up to date',
69
127
  rows: [['Target', `resolved from ${resolution.source}`]],
70
128
  }));
@@ -86,11 +144,12 @@ export async function runUpgrade(ctx, args, deps = {}) {
86
144
  'modified.');
87
145
  }
88
146
  }
89
- const packageJsonPath = path.join(loaded.projectDir, 'package.json');
90
- const manifest = readManifest(packageJsonPath);
91
- const specs = buildInstallSpecs(manifest, target, changedKeys);
92
- if (specs.length === 0) {
93
- return ctx.succeed({ source: resolution.source, current, target, deltas }, ctx.ui.result({
147
+ const work = plan.targets.map((t) => ({
148
+ target: t,
149
+ specs: buildInstallSpecs(readManifest(path.join(t.dir, 'package.json')), target, changedKeys),
150
+ }));
151
+ if (work.every((item) => item.specs.length === 0)) {
152
+ return ctx.succeed(Object.assign({ source: resolution.source, current, target, deltas }, scope), ctx.ui.result({
94
153
  title: 'Nothing to install',
95
154
  tone: 'warn',
96
155
  notes: [
@@ -99,18 +158,27 @@ export async function runUpgrade(ctx, args, deps = {}) {
99
158
  ],
100
159
  }));
101
160
  }
102
- const snapshot = snapshotProject(loaded.projectDir, pm);
161
+ const snapshot = snapshotFiles([
162
+ ...plan.targets.map((t) => path.join(t.dir, 'package.json')),
163
+ ...lockfileNames(pm).map((name) => path.join(pm.root, name)),
164
+ path.join(plan.baseDir, WORKSPACE_FILE),
165
+ path.join(pm.root, WORKSPACE_FILE),
166
+ ]);
167
+ const hadLockfile = lockfileNames(pm).some((name) => snapshot.existed(path.join(pm.root, name)));
168
+ const runInstall = (_d = deps.runInstall) !== null && _d !== void 0 ? _d : defaultRunInstall;
169
+ const rollback = (failure) => failAndRollback(ctx, snapshot, pm, hadLockfile, runInstall, failure);
103
170
  let answeredBuildScripts = [];
104
171
  let buildScriptsSkipped;
105
172
  try {
106
- const runInstall = (_d = deps.runInstall) !== null && _d !== void 0 ? _d : defaultRunInstall;
107
173
  // pnpm 10+ refuses dependency build scripts nobody has answered, and a
108
174
  // new toolchain release can grow that list (0.4.0 added @swc/core and
109
175
  // @parcel/watcher via next-intl). A project scaffolded under an older
110
176
  // CLI has no line for them, so answer the closure BEFORE `pnpm add`, or
111
- // the install writes placeholders and exits ERR_PNPM_IGNORED_BUILDS.
177
+ // the install writes placeholders and exits ERR_PNPM_IGNORED_BUILDS. A
178
+ // package inside someone else's workspace is told which file to edit
179
+ // rather than having that file edited for it.
112
180
  const buildScripts = pm.name === 'pnpm'
113
- ? ensureBuildScriptsAnswered(loaded.projectDir)
181
+ ? ensureBuildScriptsAnswered(plan.baseDir)
114
182
  : { changed: [] };
115
183
  answeredBuildScripts = buildScripts.changed;
116
184
  if (buildScripts.skipped) {
@@ -120,92 +188,147 @@ export async function runUpgrade(ctx, args, deps = {}) {
120
188
  if (answeredBuildScripts.length > 0) {
121
189
  ctx.log(ctx.ui.dim(`Answered dependency build scripts in pnpm-workspace.yaml: ${answeredBuildScripts.join(', ')}.`));
122
190
  }
123
- for (const group of [
124
- specs.filter((s) => !s.dev),
125
- specs.filter((s) => s.dev),
126
- ]) {
127
- if (group.length === 0)
128
- continue;
129
- const argv = installExactArgs(pm, group.map((s) => `${s.name}@${s.version}`), group[0].dev);
130
- ctx.log(ctx.ui.dim(`Running "${pm.bin} ${argv.join(' ')}" in ${displayPath(loaded.projectDir, process.cwd())}…`));
131
- const result = await runInstall(argv, pm.bin, loaded.projectDir);
132
- if (result.code !== 0) {
133
- return await failAndRollback(ctx, snapshot, pm, loaded.projectDir, runInstall, {
134
- step: 'install',
135
- exitCode: EXIT_CODES.PRECONDITION_FAILED,
136
- message: isIgnoredBuildsFailure(result.output)
137
- ? `"${pm.bin} ${argv.join(' ')}" exited with code ${result.code}: ${ignoredBuildsHint(findIgnoredBuilds(result.output))}`
138
- : `"${pm.bin} ${argv.join(' ')}" exited with code ${result.code}.`,
139
- output: result.output,
140
- });
191
+ const atWorkspaceRoot = isMultiPackageWorkspaceRoot(pm.root);
192
+ for (const { target: t, specs } of work) {
193
+ for (const group of [
194
+ specs.filter((s) => !s.dev),
195
+ specs.filter((s) => s.dev),
196
+ ]) {
197
+ if (group.length === 0)
198
+ continue;
199
+ const argv = installExactArgs(pm, group.map((s) => `${s.name}@${s.version}`), group[0].dev, atWorkspaceRoot && t.dir === pm.root);
200
+ ctx.log(ctx.ui.dim(`Running "${pm.bin} ${argv.join(' ')}" in ${displayPath(t.dir, process.cwd())}…`));
201
+ const result = await runInstall(argv, pm.bin, t.dir);
202
+ if (result.code !== 0) {
203
+ return await rollback({
204
+ step: 'install',
205
+ exitCode: EXIT_CODES.PRECONDITION_FAILED,
206
+ message: isIgnoredBuildsFailure(result.output)
207
+ ? `"${pm.bin} ${argv.join(' ')}" exited with code ${result.code}: ${ignoredBuildsHint(findIgnoredBuilds(result.output))}`
208
+ : `"${pm.bin} ${argv.join(' ')}" exited with code ${result.code}.`,
209
+ output: result.output,
210
+ });
211
+ }
141
212
  }
142
213
  }
143
214
  if (!args.noVerify) {
144
215
  const runValidateStep = (_e = deps.runValidateStep) !== null && _e !== void 0 ? _e : defaultRunValidateStep;
145
- const validateResult = await runValidateStep(loaded.projectDir);
146
- if (!validateResult.ok) {
147
- return await failAndRollback(ctx, snapshot, pm, loaded.projectDir, runInstall, {
148
- step: 'validate',
149
- exitCode: EXIT_CODES.VALIDATION,
150
- message: 'The upgraded toolchain fails "ekanos validate" against this ' +
151
- "project's own integration definition.",
152
- output: JSON.stringify((_f = validateResult.findings) !== null && _f !== void 0 ? _f : [], null, 2),
153
- });
216
+ for (const t of plan.targets.filter((item) => item.validate)) {
217
+ const validateResult = await runValidateStep(t.dir);
218
+ if (!validateResult.ok) {
219
+ return await rollback({
220
+ step: 'validate',
221
+ exitCode: EXIT_CODES.VALIDATION,
222
+ message: 'The upgraded toolchain fails "ekanos validate" against ' +
223
+ (plan.repo
224
+ ? `${t.relDir}'s integration definition.`
225
+ : "this project's own integration definition."),
226
+ output: JSON.stringify((_f = validateResult.findings) !== null && _f !== void 0 ? _f : [], null, 2),
227
+ });
228
+ }
154
229
  }
230
+ // A single project always runs its test script. In a repo, each
231
+ // package that declares one runs it; the root holds no tests.
232
+ const testTargets = plan.repo
233
+ ? plan.targets.filter((t) => t.relDir !== '' && hasTestScript(t.dir))
234
+ : plan.targets;
155
235
  const runTestStep = (_g = deps.runTestStep) !== null && _g !== void 0 ? _g : defaultRunTestStep;
156
- const testResult = await runTestStep(loaded.projectDir, pm);
157
- if (testResult.code !== 0) {
158
- return await failAndRollback(ctx, snapshot, pm, loaded.projectDir, runInstall, {
159
- step: 'test',
160
- exitCode: testResult.code,
161
- message: `The project's test script exited with code ${testResult.code} on the upgraded toolchain.`,
162
- output: testResult.output,
163
- });
236
+ for (const t of testTargets) {
237
+ const testResult = await runTestStep(t.dir, pm);
238
+ if (testResult.code !== 0) {
239
+ return await rollback({
240
+ step: 'test',
241
+ exitCode: testResult.code,
242
+ message: `${plan.repo ? `${t.relDir}: the` : "The project's"} test script exited with code ${testResult.code} on the upgraded toolchain.`,
243
+ output: testResult.output,
244
+ });
245
+ }
164
246
  }
165
247
  }
166
248
  }
167
249
  finally {
168
250
  snapshot.dispose();
169
251
  }
170
- const newTemplatesDir = path.join(loaded.projectDir, 'node_modules', '@ekanos', 'cli', 'templates');
171
- const regenerated = regenerateAgentContextFiles({
172
- projectDir: loaded.projectDir,
173
- slug: loaded.primary.slug,
174
- oldTemplates: oldAgentTemplates,
175
- newTemplatesDir,
176
- });
177
- // The regenerated AGENTS.md and skills may now REFERENCE artifacts this
178
- // project predates (docs/api-notes.md, .mcp.json, bundled skill trees) —
179
- // instructions pointing at files that don't exist are exactly the stale-docs
180
- // failure the docs-grounding feature closes, so create what's missing.
181
- // Create-if-missing ONLY: these files are partner/agent-authored after the
182
- // scaffold and are never regenerated.
183
- const scaffolded = scaffoldMissingDocsArtifacts({
184
- projectDir: loaded.projectDir,
185
- slug: loaded.primary.slug,
186
- newTemplatesDir,
187
- });
252
+ const regenerated = {
253
+ updated: [],
254
+ unchanged: [],
255
+ needsAttention: [],
256
+ skipped: [],
257
+ };
258
+ const scaffolded = {
259
+ created: [],
260
+ needsAttention: [],
261
+ };
262
+ const under = (relDir, file) => relDir === '' ? file : `${relDir}/${file}`;
263
+ for (const t of plan.targets) {
264
+ if (t.slug === null)
265
+ continue;
266
+ const newTemplatesDir = cliTemplatesDir(t.dir, pm.root);
267
+ const report = regenerateAgentContextFiles({
268
+ projectDir: t.dir,
269
+ slug: t.slug,
270
+ oldTemplates: (_h = oldAgentTemplates.get(t.dir)) !== null && _h !== void 0 ? _h : new Map(),
271
+ newTemplatesDir,
272
+ });
273
+ regenerated.updated.push(...report.updated.map((f) => under(t.relDir, f)));
274
+ regenerated.unchanged.push(...report.unchanged.map((f) => under(t.relDir, f)));
275
+ regenerated.needsAttention.push(...report.needsAttention.map((f) => under(t.relDir, f)));
276
+ regenerated.skipped.push(...report.skipped.map((s) => (Object.assign(Object.assign({}, s), { file: under(t.relDir, s.file) }))));
277
+ // The regenerated AGENTS.md and skills may now REFERENCE artifacts this
278
+ // project predates (docs/api-notes.md, .mcp.json, bundled skill trees) —
279
+ // instructions pointing at files that don't exist are exactly the
280
+ // stale-docs failure the docs-grounding feature closes, so create what's
281
+ // missing. Create-if-missing ONLY: these files are partner/agent-authored
282
+ // after the scaffold and are never regenerated.
283
+ const docs = scaffoldMissingDocsArtifacts({
284
+ projectDir: t.dir,
285
+ slug: t.slug,
286
+ newTemplatesDir,
287
+ });
288
+ scaffolded.created.push(...docs.created.map((f) => under(t.relDir, f)));
289
+ scaffolded.needsAttention.push(...docs.needsAttention.map((f) => under(t.relDir, f)));
290
+ }
188
291
  // `specs` only ever covers DIRECT dependencies (see `buildInstallSpecs`) —
189
292
  // a changed key with no spec is transitive and was never actually
190
293
  // installed here, so the report (and the human summary) must say so
191
294
  // explicitly rather than implying every changed key was upgraded. This
192
- // matters most for exactly the scenario this PR's own delegation feature
193
- // targets: a globally-installed `ekanos` with no local `@ekanos/cli`
194
- // devDependency, where `cli` changes in `deltas` but the running global
195
- // binary itself is untouched by this command.
196
- const installedKeys = specs.map((spec) => spec.key);
295
+ // matters most for a globally-installed `ekanos` with no local
296
+ // `@ekanos/cli` devDependency, where `cli` changes in `deltas` but the
297
+ // running global binary itself is untouched by this command.
298
+ const installedKeys = [
299
+ ...new Set(work.flatMap((item) => item.specs.map((spec) => spec.key))),
300
+ ];
197
301
  const skippedKeys = changedKeys.filter((key) => !installedKeys.includes(key));
198
- return ctx.succeed({
199
- source: resolution.source,
200
- current,
302
+ return ctx.succeed(Object.assign(Object.assign({ source: resolution.source, current,
201
303
  target,
202
- deltas,
203
- installed: installedKeys,
204
- skippedTransitive: skippedKeys,
205
- buildScripts: Object.assign({ answered: answeredBuildScripts }, (buildScriptsSkipped ? { skipped: buildScriptsSkipped } : {})),
206
- regenerated,
207
- scaffolded,
208
- }, successSummary(ctx.ui, installedKeys, skippedKeys, deltas, regenerated, scaffolded));
304
+ deltas }, scope), { installed: installedKeys, skippedTransitive: skippedKeys, buildScripts: Object.assign({ answered: answeredBuildScripts }, (buildScriptsSkipped ? { skipped: buildScriptsSkipped } : {})), regenerated,
305
+ scaffolded }), successSummary(ctx.ui, installedKeys, skippedKeys, deltas, regenerated, scaffolded));
306
+ }
307
+ function hasTestScript(dir) {
308
+ const scripts = readManifest(path.join(dir, 'package.json')).scripts;
309
+ return typeof scripts === 'object' && scripts !== null && 'test' in scripts;
310
+ }
311
+ /**
312
+ * The nearest `node_modules/@ekanos/cli/templates` from `dir` up to `top`
313
+ * (the workspace root): pnpm links it into each package, npm and yarn hoist
314
+ * it to the root. Falls back to the package's own path, where a missing
315
+ * directory just means "cannot verify" to the regeneration below.
316
+ */
317
+ function cliTemplatesDir(dir, top) {
318
+ const own = path.join(dir, 'node_modules', '@ekanos', 'cli', 'templates');
319
+ let current = path.resolve(dir);
320
+ const stop = path.resolve(top);
321
+ for (;;) {
322
+ const candidate = path.join(current, 'node_modules', '@ekanos', 'cli', 'templates');
323
+ if (fs.existsSync(candidate))
324
+ return candidate;
325
+ if (current === stop)
326
+ return own;
327
+ const parent = path.dirname(current);
328
+ if (parent === current)
329
+ return own;
330
+ current = parent;
331
+ }
209
332
  }
210
333
  /**
211
334
  * Best-effort host/session resolution — NEVER throws. `upgrade` must work for
@@ -225,11 +348,24 @@ function resolveSessionQuietly(ctx, args) {
225
348
  return null;
226
349
  }
227
350
  }
228
- function readCurrentToolchain(projectDir) {
351
+ /**
352
+ * The installed toolchain, read from the nearest `node_modules` holding each
353
+ * package between `projectDir` and `top` (the workspace root): npm and yarn
354
+ * hoist a workspace package's dependencies to the root.
355
+ */
356
+ function readCurrentToolchain(projectDir, top) {
229
357
  var _a;
230
358
  const result = {};
359
+ const dirs = [];
360
+ for (let dir = path.resolve(projectDir);; dir = path.dirname(dir)) {
361
+ dirs.push(dir);
362
+ if (dir === path.resolve(top) || path.dirname(dir) === dir)
363
+ break;
364
+ }
231
365
  for (const [key, pkg] of Object.entries(TOOLCHAIN_PACKAGES)) {
232
- const installed = readInstalledPackageManifest(projectDir, pkg);
366
+ const installed = dirs
367
+ .map((dir) => readInstalledPackageManifest(dir, pkg))
368
+ .find((manifest) => manifest !== null);
233
369
  result[key] = (_a = installed === null || installed === void 0 ? void 0 : installed.version) !== null && _a !== void 0 ? _a : (key === 'cli' ? cliVersion() : null);
234
370
  }
235
371
  return result;
@@ -289,45 +425,31 @@ function readManifest(packageJsonPath) {
289
425
  return parsed;
290
426
  }
291
427
  /**
292
- * Copy `package.json`, `pnpm-workspace.yaml` and the lockfile to a temp directory before anything
293
- * installs. `restore()` copies them back (and removes a lockfile that did not
294
- * exist before, if the failed install created one); `dispose()` cleans up the
295
- * temp copies once the run is over, success or failure.
428
+ * Copy every file in `files` that exists to a temp directory before anything
429
+ * installs: each target's `package.json`, the lockfile and
430
+ * `pnpm-workspace.yaml` at the package manager's root (the WORKSPACE root
431
+ * for a workspace package — that is where an install writes them).
432
+ * `restore()` copies them back and removes any of them the failed run
433
+ * created; `dispose()` cleans up the temp copies once the run is over,
434
+ * success or failure.
296
435
  */
297
- function snapshotProject(projectDir, pm) {
436
+ function snapshotFiles(files) {
298
437
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ekanos-upgrade-'));
299
- const packageJsonPath = path.join(projectDir, 'package.json');
300
- // bun has shipped both a text (`bun.lock`) and a legacy binary
301
- // (`bun.lockb`) lockfile; snapshot whichever is actually present.
302
- const lockCandidates = pm.name === 'bun' ? ['bun.lock', 'bun.lockb'] : [lockfileName(pm)];
303
- const lockName = lockCandidates.find((name) => fs.existsSync(path.join(projectDir, name)));
304
- const lockfilePath = lockName
305
- ? path.join(projectDir, lockName)
306
- : path.join(projectDir, lockfileName(pm));
307
- const lockfileExisted = lockName !== undefined;
308
- const workspacePath = path.join(projectDir, WORKSPACE_FILE);
309
- const workspaceExisted = fs.existsSync(workspacePath);
310
- fs.copyFileSync(packageJsonPath, path.join(tmpDir, 'package.json'));
311
- if (workspaceExisted) {
312
- fs.copyFileSync(workspacePath, path.join(tmpDir, 'workspace.snapshot'));
313
- }
314
- if (lockfileExisted) {
315
- fs.copyFileSync(lockfilePath, path.join(tmpDir, 'lockfile.snapshot'));
316
- }
438
+ const entries = [...new Set(files.map((file) => path.resolve(file)))].map((file, index) => {
439
+ const copy = path.join(tmpDir, String(index));
440
+ const existed = fs.existsSync(file);
441
+ if (existed)
442
+ fs.copyFileSync(file, copy);
443
+ return { file, copy, existed };
444
+ });
317
445
  return {
446
+ existed: (file) => { var _a; var _b; return (_b = (_a = entries.find((entry) => entry.file === path.resolve(file))) === null || _a === void 0 ? void 0 : _a.existed) !== null && _b !== void 0 ? _b : false; },
318
447
  restore: () => {
319
- fs.copyFileSync(path.join(tmpDir, 'package.json'), packageJsonPath);
320
- if (workspaceExisted) {
321
- fs.copyFileSync(path.join(tmpDir, 'workspace.snapshot'), workspacePath);
322
- }
323
- else if (fs.existsSync(workspacePath)) {
324
- fs.rmSync(workspacePath);
325
- }
326
- if (lockfileExisted) {
327
- fs.copyFileSync(path.join(tmpDir, 'lockfile.snapshot'), lockfilePath);
328
- }
329
- else if (fs.existsSync(lockfilePath)) {
330
- fs.rmSync(lockfilePath);
448
+ for (const { file, copy, existed } of entries) {
449
+ if (existed)
450
+ fs.copyFileSync(copy, file);
451
+ else if (fs.existsSync(file))
452
+ fs.rmSync(file);
331
453
  }
332
454
  },
333
455
  dispose: () => {
@@ -337,35 +459,55 @@ function snapshotProject(projectDir, pm) {
337
459
  }
338
460
  /**
339
461
  * The one rollback path every failure funnels through: restore the snapshot,
340
- * reinstall to put `node_modules` back in sync with the restored
341
- * `package.json`/lockfile, and report exactly which step failed and what it
342
- * said — with an explicit statement that the project was restored, so a
343
- * partner (or an agent) never has to wonder whether they are looking at a
344
- * half-upgraded tree.
462
+ * then reinstall with the SAME package manager, frozen to the restored
463
+ * lockfile, at its root — so `node_modules` matches the restored
464
+ * `package.json`/lockfile again. npm run inside a pnpm workspace is what
465
+ * broke a project once; it is never used for a project another manager owns.
466
+ *
467
+ * The report says exactly which step failed and what it said, and states the
468
+ * project's real state: restored when the reinstall succeeded, and when it
469
+ * did not, that `node_modules` is NOT consistent, with the command to repair
470
+ * it by hand. Never a success claim the reinstall did not earn.
345
471
  */
346
- async function failAndRollback(ctx, snapshot, pm, projectDir, runInstall, failure) {
472
+ async function failAndRollback(ctx, snapshot, pm, hadLockfile, runInstall, failure) {
347
473
  ctx.warn(`ekanos: the "${failure.step}" step failed — rolling back package.json ` +
348
- 'and the lockfile, then reinstalling to restore node_modules…');
474
+ `and the lockfile, then reinstalling with ${pm.name} to restore node_modules…`);
349
475
  snapshot.restore();
350
476
  // The SAME injectable installer seam used for the forward install — a test
351
477
  // that mocks it never shells out for the rollback path either.
352
- const restore = await runInstall(['install'], pm.bin, projectDir);
478
+ const reinstallArgs = frozenInstallArgs(pm, hadLockfile);
479
+ const reinstall = await runInstall(reinstallArgs, pm.bin, pm.root);
480
+ const reinstallCommand = `${pm.bin} ${reinstallArgs.join(' ')}`;
481
+ const manual = `cd ${pm.root} && ${reinstallCommand}`;
482
+ const restored = reinstall.code === 0;
353
483
  return ctx.failWith({
354
484
  code: `upgrade_${failure.step}_failed`,
355
- message: failure.message,
356
- hint: `The project HAS BEEN RESTORED to its pre-upgrade state (package.json, ` +
357
- `lockfile and node_modules all rolled back${restore.code === 0
358
- ? ''
359
- : ' — though the restore install itself also failed with code ' +
360
- `${restore.code}; run "${pm.bin} install" by hand to finish ` +
361
- 'repairing node_modules'}). Read the output in data.output, fix the underlying issue, and ` +
362
- 're-run "ekanos upgrade".',
485
+ message: restored
486
+ ? failure.message
487
+ : `${failure.message} The rollback reinstall ("${reinstallCommand}") ALSO failed with code ${reinstall.code}.`,
488
+ hint: restored
489
+ ? 'The project HAS BEEN RESTORED to its pre-upgrade state (package.json, ' +
490
+ 'lockfile and node_modules all rolled back). Read the output in ' +
491
+ 'data.output, fix the underlying issue, and re-run "ekanos upgrade".'
492
+ : 'package.json and the lockfile were restored, but node_modules was NOT ' +
493
+ 'reinstalled and does not match them. Repair it by hand before ' +
494
+ `anything else: ${manual}. Then read data.output and data.rollback, ` +
495
+ 'fix the underlying issue, and re-run "ekanos upgrade".',
363
496
  exitCode: failure.exitCode,
364
- data: { step: failure.step, output: failure.output },
497
+ data: {
498
+ step: failure.step,
499
+ output: failure.output,
500
+ rollback: Object.assign({ filesRestored: true, nodeModulesRestored: restored, command: reinstallCommand, cwd: pm.root, exitCode: reinstall.code }, (restored ? {} : { output: reinstall.output, manual })),
501
+ },
365
502
  });
366
503
  }
504
+ /**
505
+ * Spawn the package manager. A spawn failure (the binary is missing) resolves
506
+ * as exit 127, the shell's "command not found", rather than rejecting: the
507
+ * caller must still roll back, and a rejection would skip straight past it.
508
+ */
367
509
  async function defaultRunInstall(argv, bin, cwd) {
368
- return new Promise((resolve, reject) => {
510
+ return new Promise((resolve) => {
369
511
  var _a, _b;
370
512
  const child = spawn(bin, argv, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
371
513
  let output = '';
@@ -375,7 +517,9 @@ async function defaultRunInstall(argv, bin, cwd) {
375
517
  (_b = child.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (chunk) => {
376
518
  output += chunk.toString();
377
519
  });
378
- child.on('error', reject);
520
+ child.on('error', (error) => {
521
+ resolve({ code: 127, output: `${output}${error.message}` });
522
+ });
379
523
  child.on('close', (code) => resolve({ code: code !== null && code !== void 0 ? code : 1, output }));
380
524
  });
381
525
  }
@@ -410,8 +554,7 @@ async function defaultRunTestStep(projectDir, pm) {
410
554
  * just means every file below is treated conservatively — see
411
555
  * `regenerateAgentContextFiles`.
412
556
  */
413
- function captureAgentTemplates(projectDir) {
414
- const templatesDir = path.join(projectDir, 'node_modules', '@ekanos', 'cli', 'templates');
557
+ function captureAgentTemplates(templatesDir) {
415
558
  const captured = new Map();
416
559
  for (const { template } of AGENT_CONTEXT_FILES) {
417
560
  const templatePath = path.join(templatesDir, template);