@yemi33/minions 0.1.2423 → 0.1.2425

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 (184) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +6 -4
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/internal-install.md +190 -0
  55. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  56. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  57. package/docs/kb-sweep.md +2 -2
  58. package/docs/keep-processes.md +8 -1
  59. package/docs/live-checkout-mode.md +12 -7
  60. package/docs/managed-spawn.md +21 -3
  61. package/docs/named-agents.md +3 -2
  62. package/docs/plan-lifecycle.md +32 -32
  63. package/docs/pr-auto-fix-dispatch.md +3 -3
  64. package/docs/pr-review-fix-loop.md +6 -6
  65. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  69. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  70. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  71. package/docs/qa-runbook-lifecycle.md +67 -51
  72. package/docs/qa-runbooks.md +2 -2
  73. package/docs/runtime-adapters.md +101 -50
  74. package/docs/security.md +28 -0
  75. package/docs/self-improvement.md +47 -13
  76. package/docs/skills.md +29 -0
  77. package/docs/slim-ux/concepts.md +2 -2
  78. package/docs/specs/agent-configurability.md +43 -41
  79. package/docs/specs/agent-rename.md +13 -13
  80. package/docs/team-memory.md +283 -14
  81. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  82. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  83. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  84. package/docs/tutorials/README.md +4 -3
  85. package/docs/visual-evidence-ci.md +103 -0
  86. package/docs/watches.md +35 -30
  87. package/docs/worktree-lifecycle.md +151 -13
  88. package/engine/acp-transport.js +1 -0
  89. package/engine/ado.js +80 -33
  90. package/engine/agent-api-validation.js +571 -0
  91. package/engine/agent-worker-pool.js +7 -6
  92. package/engine/api-contracts/agent-content.js +761 -0
  93. package/engine/api-contracts/cc-ops.js +219 -0
  94. package/engine/api-contracts/config-runtime.js +419 -0
  95. package/engine/api-contracts/core.js +138 -0
  96. package/engine/api-contracts/index.js +506 -0
  97. package/engine/api-contracts/orchestration.js +602 -0
  98. package/engine/api-contracts/pull-requests.js +323 -0
  99. package/engine/api-contracts/qa-process.js +764 -0
  100. package/engine/api-contracts/work-plan-prd.js +494 -0
  101. package/engine/api-validation.js +543 -0
  102. package/engine/cc-api-validation.js +586 -0
  103. package/engine/cc-worker-pool.js +9 -1
  104. package/engine/cleanup.js +393 -271
  105. package/engine/cli.js +65 -7
  106. package/engine/comment-classifier.js +70 -5
  107. package/engine/consolidation.js +123 -5
  108. package/engine/content-api-validation.js +606 -0
  109. package/engine/create-pr-worktree.js +22 -1
  110. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  111. package/engine/dispatch.js +33 -0
  112. package/engine/distribution.js +189 -0
  113. package/engine/execution-model.js +59 -0
  114. package/engine/features.js +12 -0
  115. package/engine/github.js +67 -25
  116. package/engine/harness-context.js +313 -0
  117. package/engine/inbox-store.js +1 -1
  118. package/engine/kb-sweep.js +161 -38
  119. package/engine/keep-process-sweep.js +43 -3
  120. package/engine/lifecycle.js +1066 -165
  121. package/engine/llm.js +4 -0
  122. package/engine/managed-spawn.js +463 -48
  123. package/engine/meeting.js +197 -34
  124. package/engine/memory-retrieval.js +280 -0
  125. package/engine/memory-store.js +821 -38
  126. package/engine/model-discovery.js +17 -3
  127. package/engine/pipeline.js +802 -14
  128. package/engine/plan-prd-validation.js +745 -0
  129. package/engine/playbook.js +206 -17
  130. package/engine/pooled-agent-process.js +6 -1
  131. package/engine/pr-action.js +1 -1
  132. package/engine/pr-issue-validation.js +653 -0
  133. package/engine/pr-resolve.js +75 -13
  134. package/engine/prd-store.js +39 -6
  135. package/engine/preflight.js +7 -2
  136. package/engine/process-utils.js +79 -31
  137. package/engine/projects.js +198 -12
  138. package/engine/promotion.js +371 -0
  139. package/engine/qa-from-prd.js +3 -0
  140. package/engine/qa-process-validation.js +572 -0
  141. package/engine/qa-runbooks.js +41 -37
  142. package/engine/qa-runners/maestro.js +11 -7
  143. package/engine/qa-runners/playwright.js +37 -14
  144. package/engine/qa-runners.js +9 -10
  145. package/engine/qa-runs.js +124 -11
  146. package/engine/qa-sessions.js +139 -40
  147. package/engine/quarantine-refs.js +81 -11
  148. package/engine/queries.js +163 -9
  149. package/engine/review-learning-backfill.js +482 -0
  150. package/engine/review-learning.js +1236 -0
  151. package/engine/runtimes/claude.js +60 -2
  152. package/engine/runtimes/codex.js +57 -16
  153. package/engine/runtimes/contract.js +21 -0
  154. package/engine/runtimes/copilot.js +51 -3
  155. package/engine/runtimes/index.js +1 -0
  156. package/engine/scheduler.js +201 -32
  157. package/engine/settings-validation.js +909 -0
  158. package/engine/shared.js +705 -208
  159. package/engine/spawn-agent.js +48 -21
  160. package/engine/steering-constraints.js +31 -0
  161. package/engine/steering-store.js +10 -2
  162. package/engine/steering.js +14 -2
  163. package/engine/timeout.js +404 -111
  164. package/engine/untrusted-fence.js +24 -10
  165. package/engine/watch-actions.js +7 -2
  166. package/engine/watches.js +353 -51
  167. package/engine/work-item-validation.js +561 -0
  168. package/engine/work-items-store.js +61 -1
  169. package/engine/worktree-gc.js +656 -37
  170. package/engine/worktree-preflight.js +154 -0
  171. package/engine.js +2068 -342
  172. package/package.json +1 -1
  173. package/playbooks/_pr-description-audit.md +88 -41
  174. package/playbooks/build-fix-complex.md +3 -3
  175. package/playbooks/docs.md +2 -2
  176. package/playbooks/fix.md +18 -18
  177. package/playbooks/implement.md +18 -18
  178. package/playbooks/qa-session-draft.md +10 -12
  179. package/playbooks/qa-session-execute.md +16 -12
  180. package/playbooks/qa-validate.md +8 -7
  181. package/playbooks/shared-rules.md +45 -0
  182. package/playbooks/test.md +4 -0
  183. package/prompts/cc-system.md +1 -1
  184. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -0,0 +1,1209 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // One-command bootstrap for the internal (ISS ProjectFeed-ISS) Minions channel.
5
+ //
6
+ // Installs or upgrades `@opg-microsoft/minions` from the Azure Artifacts npm
7
+ // registry using the caller's existing `az` login, then synchronizes the
8
+ // existing runtime root with `minions init --force` and a health-verified
9
+ // `minions restart` — preserving config, SQLite state, notes, plans, knowledge,
10
+ // and project links.
11
+ //
12
+ // Deliberately standalone: it must run on a machine that has no Minions
13
+ // installed yet, so it requires nothing from `engine/` and uses Node built-ins
14
+ // only. State work is delegated to the *installed* CLI (`minions state backup`,
15
+ // `minions init --force`, `minions restart`) rather than reimplemented here —
16
+ // those are the supported entry points and already own the consistency
17
+ // contracts (`engine/state-operations.js#backupState` does
18
+ // `PRAGMA wal_checkpoint(TRUNCATE)` + `VACUUM INTO`).
19
+ //
20
+ // Token handling: the ADO access token is acquired per-run, written only into a
21
+ // 0600 temporary npm userconfig inside the OS temp dir, and that file is removed
22
+ // on success and on failure. The token never reaches argv, the process
23
+ // environment, stdout, or any file under the runtime root.
24
+
25
+ const fs = require('fs');
26
+ const os = require('os');
27
+ const path = require('path');
28
+ const { spawnSync } = require('child_process');
29
+
30
+ // ─── Channel constants ──────────────────────────────────────────────────────
31
+
32
+ /** Azure DevOps resource id used for `az account get-access-token` (mirrors engine/ado-token.js). */
33
+ const ADO_TOKEN_RESOURCE = '499b84ac-1321-427f-aa17-267ca6975798';
34
+
35
+ /** Documented default feed: the ISS ProjectFeed-ISS Azure Artifacts npm registry. */
36
+ const DEFAULT_REGISTRY = 'https://pkgs.dev.azure.com/office/ISS/_packaging/ProjectFeed-ISS/npm/registry/';
37
+
38
+ const INTERNAL_PACKAGE = '@opg-microsoft/minions';
39
+ const PUBLIC_PACKAGE = '@yemi33/minions';
40
+
41
+ /** Safe default: the feed's own `latest` dist-tag, resolved to a concrete version before install. */
42
+ const DEFAULT_VERSION_SPEC = 'latest';
43
+
44
+ const EXIT = { OK: 0, FAILED: 1, USAGE: 2, REFUSED: 3 };
45
+
46
+ const NPM_VIEW_TIMEOUT_MS = 60000;
47
+ const NPM_PACK_TIMEOUT_MS = 300000;
48
+ const NPM_INSTALL_TIMEOUT_MS = 300000;
49
+ const CLI_INIT_TIMEOUT_MS = 300000;
50
+ const CLI_RESTART_TIMEOUT_MS = 300000;
51
+ const AZ_TOKEN_TIMEOUT_MS = 60000;
52
+
53
+ // ─── Argument parsing ───────────────────────────────────────────────────────
54
+
55
+ const USAGE = `
56
+ Install or upgrade the internal Minions package from the ISS Azure Artifacts feed.
57
+
58
+ Usage:
59
+ node bin/install-internal-minions.js [options]
60
+ scripts/install-internal-minions.ps1 [options] (Windows)
61
+ scripts/install-internal-minions.sh [options] (Linux / macOS)
62
+
63
+ Options:
64
+ --version <spec> Version or dist-tag to install (default: ${DEFAULT_VERSION_SPEC})
65
+ --registry <url> Azure Artifacts npm registry (default: the ISS ProjectFeed-ISS feed)
66
+ --package <name> Package to install (default: ${INTERNAL_PACKAGE})
67
+ --backup-dir <dir> Where to write the pre-migration state backup
68
+ (default: <runtime root>/backups/internal-install-<timestamp>)
69
+ --keep-public Do not uninstall an existing ${PUBLIC_PACKAGE} global install
70
+ --no-restart Sync runtime files but skip the health-verified restart
71
+ --force Proceed even while agents are active (see docs/internal-install.md)
72
+ --dry-run Print the plan and the exact commands, change nothing
73
+ --help Show this help
74
+
75
+ Prerequisites: Azure CLI (\`az login\`), Node >= 22.5 with npm, and read access to
76
+ the ProjectFeed-ISS feed. No manually created PAT is required or accepted.
77
+ `;
78
+
79
+ const BOOLEAN_FLAGS = new Map([
80
+ ['--keep-public', 'keepPublic'],
81
+ ['--no-restart', 'restart'],
82
+ ['--force', 'force'],
83
+ ['--dry-run', 'dryRun'],
84
+ ['--help', 'help'],
85
+ ['-h', 'help'],
86
+ ]);
87
+
88
+ const VALUE_FLAGS = new Map([
89
+ ['--version', 'versionSpec'],
90
+ ['--registry', 'registry'],
91
+ ['--package', 'packageName'],
92
+ ['--backup-dir', 'backupDir'],
93
+ ]);
94
+
95
+ /**
96
+ * Parse CLI arguments into an options object. Pure — throws on malformed input
97
+ * so both the CLI and the tests observe identical validation.
98
+ */
99
+ function parseArgs(argv) {
100
+ const opts = {
101
+ versionSpec: DEFAULT_VERSION_SPEC,
102
+ registry: DEFAULT_REGISTRY,
103
+ packageName: INTERNAL_PACKAGE,
104
+ backupDir: null,
105
+ keepPublic: false,
106
+ restart: true,
107
+ force: false,
108
+ dryRun: false,
109
+ help: false,
110
+ };
111
+ for (let i = 0; i < argv.length; i++) {
112
+ const arg = argv[i];
113
+ if (BOOLEAN_FLAGS.has(arg)) {
114
+ const key = BOOLEAN_FLAGS.get(arg);
115
+ opts[key] = arg === '--no-restart' ? false : true;
116
+ continue;
117
+ }
118
+ if (VALUE_FLAGS.has(arg)) {
119
+ const value = argv[i + 1];
120
+ if (value === undefined || value.startsWith('--')) throw new Error(`${arg} requires a value`);
121
+ opts[VALUE_FLAGS.get(arg)] = value;
122
+ i++;
123
+ continue;
124
+ }
125
+ throw new Error(`unknown argument: ${arg}`);
126
+ }
127
+ if (!opts.versionSpec) throw new Error('--version requires a value');
128
+ if (!opts.packageName.startsWith('@') || !opts.packageName.includes('/')) {
129
+ throw new Error(`--package must be a scoped package name, got: ${opts.packageName}`);
130
+ }
131
+ opts.registry = normalizeRegistry(opts.registry);
132
+ return opts;
133
+ }
134
+
135
+ /** Normalize a registry URL: https only, exactly one trailing slash. */
136
+ function normalizeRegistry(url) {
137
+ if (!url) throw new Error('registry URL is required');
138
+ let parsed;
139
+ try { parsed = new URL(url); } catch { throw new Error(`invalid registry URL: ${url}`); }
140
+ if (parsed.protocol !== 'https:') throw new Error(`registry URL must use https, got: ${url}`);
141
+ return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}/`;
142
+ }
143
+
144
+ /** The npm auth-config key prefix for a registry (`//host/path/`). */
145
+ function registryAuthKey(registry) {
146
+ const parsed = new URL(normalizeRegistry(registry));
147
+ return `//${parsed.host}${parsed.pathname}`;
148
+ }
149
+
150
+ /** The npm scope key for a scoped package name (`@opg-microsoft`). */
151
+ function packageScope(packageName) {
152
+ const scope = String(packageName).split('/')[0];
153
+ if (!scope.startsWith('@')) throw new Error(`not a scoped package name: ${packageName}`);
154
+ return scope;
155
+ }
156
+
157
+ // ─── Command construction (pure) ────────────────────────────────────────────
158
+
159
+ /**
160
+ * `az` arguments for a short-lived ADO access token. `-o tsv` keeps the raw
161
+ * token off the JSON path so it is never pretty-printed by az itself.
162
+ */
163
+ function buildTokenArgs() {
164
+ return ['account', 'get-access-token', '--resource', ADO_TOKEN_RESOURCE, '--query', 'accessToken', '-o', 'tsv'];
165
+ }
166
+
167
+ /**
168
+ * Temporary npm userconfig contents. The token is injected here and nowhere
169
+ * else. Both the bearer (`_authToken`) and the legacy base64 (`_password`) forms
170
+ * are emitted because Azure Artifacts accepts either and older npm/registry
171
+ * combinations only honor the latter.
172
+ *
173
+ * `baseConfig` is the caller's existing user npmrc, carried through verbatim so
174
+ * `prefix`, proxy, and other global-install settings keep applying — replacing
175
+ * the userconfig wholesale would silently relocate a `-g` install.
176
+ */
177
+ function buildNpmrc({ registry, scope, token, baseConfig = '' }) {
178
+ const authKey = registryAuthKey(registry);
179
+ const lines = [];
180
+ const base = String(baseConfig || '').trim();
181
+ if (base) {
182
+ lines.push('; ── inherited from the caller\'s user npmrc ──', base, '');
183
+ }
184
+ lines.push(
185
+ '; ── added by install-internal-minions (temporary, deleted after the run) ──',
186
+ `${scope}:registry=${registry}`,
187
+ `${authKey}:_authToken=${token}`,
188
+ `${authKey}:username=minions`,
189
+ `${authKey}:_password=${Buffer.from(String(token), 'utf8').toString('base64')}`,
190
+ `${authKey}:email=minions@internal.invalid`,
191
+ '',
192
+ );
193
+ return lines.join('\n');
194
+ }
195
+
196
+ function buildNpmViewArgs({ packageName, versionSpec, registry, userconfig }) {
197
+ return ['view', `${packageName}@${versionSpec}`, 'version', '--registry', registry, '--userconfig', userconfig, '--json'];
198
+ }
199
+
200
+ function buildNpmInstallArgs({ packageName, version, registry, userconfig }) {
201
+ return ['install', '-g', `${packageName}@${version}`, '--registry', registry, '--userconfig', userconfig];
202
+ }
203
+
204
+ /**
205
+ * Download the resolved version from the feed as a tarball, without installing
206
+ * it. This is what makes the cutover safe: the artifact is on local disk before
207
+ * the public package is removed, so a feed/network/auth failure during the
208
+ * global install is recoverable offline instead of leaving the machine with no
209
+ * `minions` at all.
210
+ */
211
+ function buildNpmPackArgs({ packageName, version, registry, userconfig, destination }) {
212
+ return [
213
+ 'pack', `${packageName}@${version}`,
214
+ '--registry', registry,
215
+ '--userconfig', userconfig,
216
+ '--pack-destination', destination,
217
+ '--json',
218
+ ];
219
+ }
220
+
221
+ /**
222
+ * Install from the already-downloaded tarball. Deliberately carries no
223
+ * `--registry` and no `--userconfig`: the recovery path must not depend on the
224
+ * feed that just failed.
225
+ */
226
+ function buildNpmInstallTarballArgs(tarballPath) {
227
+ return ['install', '-g', tarballPath];
228
+ }
229
+
230
+ function buildNpmUninstallArgs(packageName) {
231
+ return ['uninstall', '-g', packageName];
232
+ }
233
+
234
+ /** Resolve the on-disk root of a globally installed package from `npm root -g`. */
235
+ function resolveGlobalPackageRoot(npmRootG, packageName) {
236
+ if (!npmRootG) throw new Error('could not resolve the global npm root');
237
+ return path.join(String(npmRootG).trim(), ...packageName.split('/'));
238
+ }
239
+
240
+ /**
241
+ * Locate the tarball `npm pack` just wrote. npm 9+ prints a JSON array with a
242
+ * `filename`; older npm printed the bare filename. Both are treated as hints and
243
+ * confirmed against the destination we control — and if neither parses, the
244
+ * destination directory is scanned, because the artifact's existence is what the
245
+ * cutover gate depends on, not npm's output format.
246
+ */
247
+ function resolvePackedTarball(destination, stdout, deps = {}) {
248
+ const readdir = deps.readdirSync || (p => fs.readdirSync(p));
249
+ const exists = deps.existsSync || (p => fs.existsSync(p));
250
+ const hints = [];
251
+ const text = String(stdout == null ? '' : stdout).trim();
252
+ if (text) {
253
+ let parsed = null;
254
+ try { parsed = JSON.parse(text); } catch { parsed = null; }
255
+ if (parsed) {
256
+ for (const entry of (Array.isArray(parsed) ? parsed : [parsed])) {
257
+ if (entry && entry.filename) hints.push(String(entry.filename));
258
+ }
259
+ } else {
260
+ for (const line of text.split(/\r?\n/)) {
261
+ const trimmed = line.trim();
262
+ if (trimmed.endsWith('.tgz')) hints.push(trimmed);
263
+ }
264
+ }
265
+ }
266
+ for (const hint of hints) {
267
+ const full = path.join(destination, path.basename(hint));
268
+ if (exists(full)) return full;
269
+ }
270
+ let entries;
271
+ try { entries = readdir(destination); } catch { return null; }
272
+ for (const entry of entries) {
273
+ const name = String(entry && entry.name !== undefined ? entry.name : entry);
274
+ if (name.endsWith('.tgz')) return path.join(destination, name);
275
+ }
276
+ return null;
277
+ }
278
+
279
+ // ─── `minions` bin shim verification ────────────────────────────────────────
280
+
281
+ /**
282
+ * Candidate `minions` shim paths on the global npm prefix. npm writes a
283
+ * `.cmd`/`.ps1`/sh trio directly under the prefix on Windows and a single
284
+ * symlink under `<prefix>/bin` everywhere else.
285
+ */
286
+ function resolveBinShimPaths(npmPrefixG, platform = process.platform) {
287
+ const prefix = String(npmPrefixG || '').trim();
288
+ if (!prefix) throw new Error('could not resolve the global npm prefix');
289
+ if (platform === 'win32') {
290
+ return [path.join(prefix, 'minions.cmd'), path.join(prefix, 'minions.ps1'), path.join(prefix, 'minions')];
291
+ }
292
+ return [path.join(prefix, 'bin', 'minions')];
293
+ }
294
+
295
+ /**
296
+ * Does this shim dispatch into `packageName`? The scoped name is discriminating,
297
+ * so a shim left behind by `@yemi33/minions` cannot pass as `@opg-microsoft/minions`.
298
+ * A symlink is read through `readlink`; a generated `.cmd`/`.ps1`/sh script is
299
+ * matched on its body.
300
+ */
301
+ function shimTargetsPackage({ shimPath, packageName, deps = {} }) {
302
+ const readlink = deps.readlinkSync || (p => fs.readlinkSync(p));
303
+ const readFile = deps.readFileSync || (p => fs.readFileSync(p, 'utf8'));
304
+ const parts = String(packageName).split('/');
305
+ const fragments = [parts.join('/'), parts.join('\\')];
306
+ const matches = text => fragments.some(fragment => String(text).includes(fragment));
307
+ try {
308
+ if (matches(readlink(shimPath))) return true;
309
+ } catch { /* not a symlink — fall through to the script body */ }
310
+ try {
311
+ return matches(readFile(shimPath));
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Prove that the `minions` command a user types now resolves to the internal
319
+ * package. Installing the package is not the same as owning the shim: an
320
+ * interrupted or partially rolled-back cutover can leave a working
321
+ * `@opg-microsoft/minions` on disk while `minions` still runs the public build.
322
+ */
323
+ function verifyBinShims({ npmPrefixG, packageName, platform = process.platform, deps = {} }) {
324
+ const exists = deps.existsSync || (p => fs.existsSync(p));
325
+ const candidates = resolveBinShimPaths(npmPrefixG, platform);
326
+ const shims = [];
327
+ for (const shimPath of candidates) {
328
+ if (!exists(shimPath)) continue;
329
+ shims.push({
330
+ path: shimPath,
331
+ matches: shimTargetsPackage({ shimPath, packageName, deps }),
332
+ matchesPublic: packageName === PUBLIC_PACKAGE
333
+ ? false
334
+ : shimTargetsPackage({ shimPath, packageName: PUBLIC_PACKAGE, deps }),
335
+ });
336
+ }
337
+ if (!shims.length) {
338
+ return { ok: false, shims, reason: `no \`minions\` shim was found under ${String(npmPrefixG).trim()}` };
339
+ }
340
+ const mismatched = shims.filter(shim => !shim.matches);
341
+ if (mismatched.length) {
342
+ const stale = mismatched.find(shim => shim.matchesPublic);
343
+ return {
344
+ ok: false,
345
+ shims,
346
+ reason: stale
347
+ ? `the \`minions\` shim at ${stale.path} still points at ${PUBLIC_PACKAGE}`
348
+ : `the \`minions\` shim at ${mismatched[0].path} does not point at ${packageName}`,
349
+ };
350
+ }
351
+ return { ok: true, shims, reason: `\`minions\` resolves to ${packageName} (${shims.map(s => path.basename(s.path)).join(', ')})` };
352
+ }
353
+
354
+ // ─── Portable spawning ──────────────────────────────────────────────────────
355
+ //
356
+ // On Windows both `npm` and `az` are `.cmd` shims. Node >= 18.20 refuses to
357
+ // spawn `.cmd`/`.bat` without a shell (CVE-2024-27980), and `shell: true` does
358
+ // NOT quote individual arguments — it concatenates them into one command
359
+ // string. So on Windows we quote every argument ourselves before handing the
360
+ // line to cmd.exe. Absolute executables (`process.execPath`) skip the shell
361
+ // entirely on every platform.
362
+
363
+ /** cmd.exe-safe quoting for a single argument. */
364
+ function quoteWindowsArg(arg) {
365
+ const value = String(arg);
366
+ if (value.length && /^[A-Za-z0-9_@:.,=+\-\\/]+$/.test(value)) return value;
367
+ // Double any backslashes that precede a quote or terminate the string, then
368
+ // escape embedded quotes — the standard CommandLineToArgvW round-trip.
369
+ const escaped = value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1');
370
+ return `"${escaped}"`;
371
+ }
372
+
373
+ /**
374
+ * Decide how to invoke a command on the current platform.
375
+ * Returns `{ command, args, shell }` ready for spawnSync.
376
+ */
377
+ function resolveSpawnTarget(command, args, platform = process.platform) {
378
+ if (platform !== 'win32' || path.isAbsolute(command)) {
379
+ return { command, args, shell: false };
380
+ }
381
+ return {
382
+ command: `${command} ${args.map(quoteWindowsArg).join(' ')}`.trim(),
383
+ args: [],
384
+ shell: true,
385
+ };
386
+ }
387
+
388
+ // ─── Secret redaction ───────────────────────────────────────────────────────
389
+
390
+ /**
391
+ * Strip every known secret from text before it reaches stdout/stderr. Applied to
392
+ * all child-process output and to error messages, so a failing npm command can
393
+ * never echo the token back through this script.
394
+ */
395
+ function redact(text, secrets) {
396
+ let out = String(text == null ? '' : text);
397
+ for (const secret of secrets || []) {
398
+ if (!secret || String(secret).length < 8) continue;
399
+ const raw = String(secret);
400
+ out = out.split(raw).join('***REDACTED***');
401
+ const b64 = Buffer.from(raw, 'utf8').toString('base64');
402
+ out = out.split(b64).join('***REDACTED***');
403
+ }
404
+ return out;
405
+ }
406
+
407
+ // ─── Runtime-root inspection ────────────────────────────────────────────────
408
+
409
+ function resolveRuntimeRoot(env = process.env) {
410
+ return env.MINIONS_HOME ? path.resolve(env.MINIONS_HOME) : path.join(os.homedir(), '.minions');
411
+ }
412
+
413
+ /**
414
+ * Resolve the CLI that can run `minions state backup` for the existing runtime.
415
+ *
416
+ * `minions init` deliberately excludes `bin` from the runtime-root copy (see the
417
+ * `excludeTop` set in bin/minions.js), so an installed runtime root has
418
+ * `engine.js` but NO `bin/minions.js` — the CLI lives in the globally installed
419
+ * package instead. `<runtimeRoot>/minions.js` IS copied but is the project
420
+ * launcher and has no `state` command, so it is never a candidate.
421
+ *
422
+ * Candidates, first existing one wins:
423
+ * 1. the internal package, when it is already installed (re-run / upgrade)
424
+ * 2. the public package, when it is still installed (the normal cutover)
425
+ * 3. the runtime root itself, so source-checkout runtime roots (which do carry
426
+ * bin/) still work
427
+ *
428
+ * Pure apart from the injected `exists` probe, so the contract is asserted
429
+ * directly rather than inferred from a live migration.
430
+ */
431
+ function resolveBackupCliCandidates({
432
+ npmRootG = null,
433
+ runtimeRoot = null,
434
+ packageName = null,
435
+ installedInternalVersion = null,
436
+ installedPublic = false,
437
+ } = {}) {
438
+ const candidates = [];
439
+ const pushPackageRoot = (name) => {
440
+ if (!name) return;
441
+ try { candidates.push(path.join(resolveGlobalPackageRoot(npmRootG, name), 'bin', 'minions.js')); } catch { /* npm root unresolved */ }
442
+ };
443
+ if (installedInternalVersion) pushPackageRoot(packageName);
444
+ if (installedPublic) pushPackageRoot(PUBLIC_PACKAGE);
445
+ if (runtimeRoot) candidates.push(path.join(runtimeRoot, 'bin', 'minions.js'));
446
+ return [...new Set(candidates)];
447
+ }
448
+
449
+ function resolveBackupCli(state, exists = p => fs.existsSync(p)) {
450
+ const candidates = resolveBackupCliCandidates(state);
451
+ return { path: candidates.find(exists) || null, candidates };
452
+ }
453
+
454
+ /**
455
+ * Decide how to back up the existing runtime's SQLite state, preferring the
456
+ * option that cannot go stale:
457
+ *
458
+ * 1. the runtime's OWN `engine/state-operations.js`. `minions init` copies
459
+ * `engine/*.js` into the runtime root, so it is present for every installed
460
+ * runtime and is by construction version-matched to that runtime's
461
+ * `engine/state.db`. Resolving a CLI is not sufficient on its own — the
462
+ * published public package predates the `minions state` command, so an
463
+ * existing global CLI can be present and still have no way to take a backup.
464
+ * 2. an installed CLI's `minions state backup`, for runtime roots that predate
465
+ * the SQL store layout and therefore carry no state-operations module.
466
+ */
467
+ function resolveBackupStrategy(state, exists = p => fs.existsSync(p)) {
468
+ const runtimeRoot = (state && state.runtimeRoot) || null;
469
+ const modulePath = runtimeRoot ? path.join(runtimeRoot, 'engine', 'state-operations.js') : null;
470
+ const cli = resolveBackupCli(state, exists);
471
+ const candidates = [modulePath, ...cli.candidates].filter(Boolean);
472
+ if (modulePath && exists(modulePath)) return { kind: 'runtime-module', modulePath, candidates };
473
+ if (cli.path) return { kind: 'cli', cliPath: cli.path, candidates };
474
+ return { kind: 'none', candidates };
475
+ }
476
+
477
+ /** Run the runtime's own backupState() out-of-process against its own MINIONS_HOME. */
478
+ const BACKUP_MODULE_SCRIPT = 'require(process.argv[1]).backupState(process.argv[2]);';
479
+
480
+ /** Runtime paths that MUST survive the migration. */
481
+ const PRESERVED_PATHS = [
482
+ 'config.json',
483
+ 'engine/state.db',
484
+ 'notes',
485
+ 'notes.md',
486
+ 'plans',
487
+ 'knowledge',
488
+ 'projects',
489
+ 'pinned.md',
490
+ ];
491
+
492
+ /**
493
+ * Snapshot which preserved paths exist. Compared before and after migration so
494
+ * a path that existed beforehand and vanished is a hard failure rather than a
495
+ * silent data loss.
496
+ */
497
+ function snapshotPreservedPaths(runtimeRoot, exists = p => fs.existsSync(p)) {
498
+ const present = [];
499
+ for (const rel of PRESERVED_PATHS) {
500
+ if (exists(path.join(runtimeRoot, ...rel.split('/')))) present.push(rel);
501
+ }
502
+ return present;
503
+ }
504
+
505
+ function diffPreservedPaths(before, after) {
506
+ const afterSet = new Set(after);
507
+ return before.filter(rel => !afterSet.has(rel));
508
+ }
509
+
510
+ /**
511
+ * Count live agent processes by walking the dispatch PID files the engine
512
+ * writes under `<runtime root>/engine/tmp/` — both the per-dispatch directory
513
+ * layout and the legacy flat layout (see engine/shared.js#forEachPidFile).
514
+ * Filesystem-based on purpose: it works with the dashboard down, which is
515
+ * exactly when a migration is most likely to be attempted.
516
+ */
517
+ function countActiveAgents(runtimeRoot, deps = {}) {
518
+ const readdir = deps.readdirSync || (p => fs.readdirSync(p, { withFileTypes: true }));
519
+ const readFile = deps.readFileSync || (p => fs.readFileSync(p, 'utf8'));
520
+ const isAlive = deps.isAlive || defaultIsAlive;
521
+ const tmpRoot = path.join(runtimeRoot, 'engine', 'tmp');
522
+ const pidFiles = [];
523
+ let entries;
524
+ try { entries = readdir(tmpRoot); } catch { return 0; }
525
+ for (const entry of entries) {
526
+ const full = path.join(tmpRoot, entry.name);
527
+ if (entry.isDirectory && entry.isDirectory()) {
528
+ let inner;
529
+ try { inner = readdir(full); } catch { continue; }
530
+ for (const child of inner) {
531
+ const name = child.name !== undefined ? child.name : child;
532
+ if (String(name).startsWith('pid-') && String(name).endsWith('.pid')) pidFiles.push(path.join(full, name));
533
+ }
534
+ } else if (String(entry.name).startsWith('pid-') && String(entry.name).endsWith('.pid')) {
535
+ pidFiles.push(full);
536
+ }
537
+ }
538
+ let alive = 0;
539
+ for (const file of pidFiles) {
540
+ let pid;
541
+ try { pid = Number(String(readFile(file)).trim()); } catch { continue; }
542
+ if (Number.isInteger(pid) && pid > 0 && isAlive(pid)) alive++;
543
+ }
544
+ return alive;
545
+ }
546
+
547
+ function defaultIsAlive(pid) {
548
+ try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === 'EPERM'; }
549
+ }
550
+
551
+ /**
552
+ * Decide whether the disruptive phase (package replacement + `init --force` +
553
+ * restart) may run. The engine's restart/reattachment contract covers an engine
554
+ * restart, but it does NOT cover rewriting the runtime `.js` files an active
555
+ * agent's engine is executing — so active agents block by default.
556
+ */
557
+ function assessAgentActivity({ activeAgents, force }) {
558
+ if (activeAgents <= 0) return { allowed: true, activeAgents, reason: 'no active agents' };
559
+ if (force) {
560
+ return {
561
+ allowed: true,
562
+ activeAgents,
563
+ reason: `${activeAgents} active agent(s) overridden by --force`,
564
+ warning: true,
565
+ };
566
+ }
567
+ return {
568
+ allowed: false,
569
+ activeAgents,
570
+ reason: `${activeAgents} agent process(es) are still running; migration deferred`,
571
+ };
572
+ }
573
+
574
+ // ─── Migration plan (pure) ──────────────────────────────────────────────────
575
+
576
+ const STEP_ORDER = [
577
+ 'acquire-token',
578
+ 'validate-feed',
579
+ 'download-artifact',
580
+ 'backup-state',
581
+ 'uninstall-public',
582
+ 'install-internal',
583
+ 'verify-install',
584
+ 'verify-shim',
585
+ 'sync-init',
586
+ 'restart',
587
+ 'cleanup',
588
+ ];
589
+
590
+ /**
591
+ * Build the ordered migration plan for the detected state.
592
+ *
593
+ * The ordering is the safety contract, not a formality:
594
+ * - `validate-feed` precedes `backup-state` and `uninstall-public`, so an
595
+ * unreachable feed or missing version can never remove a working install.
596
+ * - `download-artifact` precedes `uninstall-public`, so the public package is
597
+ * only removed once the internal one is resolved AND on local disk — a feed
598
+ * failure during the install is then recoverable offline.
599
+ * - `backup-state` precedes every mutation of the runtime root, and also
600
+ * precedes `uninstall-public`, so the cutover never starts without a
601
+ * restore point.
602
+ * - `uninstall-public` precedes `install-internal` so the two packages never
603
+ * race for the same `minions` bin shim.
604
+ * - `verify-shim` precedes `sync-init`, so the runtime is only synchronized
605
+ * once `minions` actually resolves to the internal package.
606
+ * - `cleanup` is always last and always present — it runs in a `finally`.
607
+ *
608
+ * `install-internal` is also the only step that (re-)links the `minions` bin
609
+ * shim, so the idempotent skip is conditioned on the shim, not on the version
610
+ * alone. Skipping it while `uninstall-public` is planned would let npm take the
611
+ * command away with the package it owns and leave nothing to put it back — and
612
+ * because the next run observes the same version match, the plan would omit the
613
+ * install again and the machine could never repair itself.
614
+ */
615
+ function planSteps(state) {
616
+ const {
617
+ hasExistingRuntime = false,
618
+ installedPublic = false,
619
+ installedInternalVersion = null,
620
+ targetVersion,
621
+ keepPublic = false,
622
+ restart = true,
623
+ shimOwned = true,
624
+ } = state || {};
625
+
626
+ const willUninstallPublic = !!installedPublic && !keepPublic;
627
+ const alreadyAtTarget = !!targetVersion
628
+ && installedInternalVersion === targetVersion
629
+ && !willUninstallPublic
630
+ && shimOwned;
631
+ const skip = new Set();
632
+ if (!hasExistingRuntime) skip.add('backup-state');
633
+ if (!willUninstallPublic) skip.add('uninstall-public');
634
+ if (alreadyAtTarget) {
635
+ skip.add('install-internal');
636
+ skip.add('download-artifact');
637
+ }
638
+ if (!restart) skip.add('restart');
639
+
640
+ return STEP_ORDER.filter(step => !skip.has(step));
641
+ }
642
+
643
+ /**
644
+ * Gate the destructive half of the cutover. Removing the global public package
645
+ * is the only irreversible-feeling step for a user, so it is allowed only when a
646
+ * usable CLI is guaranteed to be recoverable afterwards:
647
+ * - the internal artifact is already downloaded (or the internal package is
648
+ * already installed at the target version), and
649
+ * - a state backup exists whenever there was a runtime to back up.
650
+ * Pure, so the contract is asserted directly rather than inferred from logs.
651
+ */
652
+ function assessCutoverReadiness({
653
+ artifactReady = false,
654
+ internalAlreadyInstalled = false,
655
+ backupRequired = false,
656
+ backupReady = false,
657
+ } = {}) {
658
+ if (!artifactReady && !internalAlreadyInstalled) {
659
+ return { allowed: false, reason: 'the internal package artifact has not been downloaded yet' };
660
+ }
661
+ if (backupRequired && !backupReady) {
662
+ return { allowed: false, reason: 'no state backup exists yet' };
663
+ }
664
+ // The reason is echoed verbatim into the operator-facing log, so it reports
665
+ // what actually happened rather than the most common path.
666
+ const recoverable = artifactReady
667
+ ? 'artifact downloaded'
668
+ : 'the internal package is already installed at the target version';
669
+ const backup = backupRequired ? 'state backed up' : 'no runtime state to back up';
670
+ return { allowed: true, reason: `${recoverable}, ${backup}` };
671
+ }
672
+
673
+ // ─── Process helpers ────────────────────────────────────────────────────────
674
+
675
+ function makeRunner(secrets, { log = console.log, error = console.error } = {}) {
676
+ return function run(command, args, { timeout, cwd, capture = false, allowFailure = false, env } = {}) {
677
+ const target = resolveSpawnTarget(command, args);
678
+ const result = spawnSync(target.command, target.args, {
679
+ cwd: cwd || process.cwd(),
680
+ env: env || process.env,
681
+ encoding: 'utf8',
682
+ timeout,
683
+ windowsHide: true,
684
+ shell: target.shell,
685
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'pipe'],
686
+ });
687
+ const stderr = redact(result.stderr || '', secrets);
688
+ const stdout = redact(result.stdout || '', secrets);
689
+ const ok = !result.error && result.status === 0;
690
+ if (!ok && !allowFailure) {
691
+ if (!capture && stderr.trim()) error(stderr.trimEnd());
692
+ const detail = result.error
693
+ ? redact(result.error.message, secrets)
694
+ : `exit code ${result.status}`;
695
+ const err = new Error(`${command} ${redact(args.join(' '), secrets)} failed (${detail})`);
696
+ err.stdout = stdout;
697
+ err.stderr = stderr;
698
+ throw err;
699
+ }
700
+ if (capture && !ok && stderr.trim()) log(stderr.trimEnd());
701
+ return { ok, stdout, stderr, status: result.status };
702
+ };
703
+ }
704
+
705
+ /** Create the 0600 temporary npm userconfig. Returns `{ dir, file }`. */
706
+ function writeTempNpmrc({ registry, scope, token, baseConfig }) {
707
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minions-internal-npm-'));
708
+ const file = path.join(dir, '.npmrc');
709
+ fs.writeFileSync(file, buildNpmrc({ registry, scope, token, baseConfig }), { mode: 0o600 });
710
+ try { fs.chmodSync(file, 0o600); } catch { /* best-effort on filesystems without POSIX modes */ }
711
+ return { dir, file };
712
+ }
713
+
714
+ /** Remove the temporary npm config. Best-effort and idempotent. */
715
+ function cleanupTempNpmrc(dir) {
716
+ if (!dir) return false;
717
+ try { fs.rmSync(dir, { recursive: true, force: true }); return true; } catch { return false; }
718
+ }
719
+
720
+ function readUserNpmrc(env = process.env) {
721
+ const candidate = env.NPM_CONFIG_USERCONFIG || path.join(os.homedir(), '.npmrc');
722
+ try { return fs.readFileSync(candidate, 'utf8'); } catch { return ''; }
723
+ }
724
+
725
+ function readGlobalPackageVersion(npmRootG, packageName) {
726
+ try {
727
+ const pkgPath = path.join(resolveGlobalPackageRoot(npmRootG, packageName), 'package.json');
728
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || null;
729
+ } catch { return null; }
730
+ }
731
+
732
+ // ─── Main ───────────────────────────────────────────────────────────────────
733
+
734
+ function main(argv) {
735
+ let opts;
736
+ try {
737
+ opts = parseArgs(argv);
738
+ } catch (e) {
739
+ console.error(`\n ${e.message}`);
740
+ console.error(USAGE);
741
+ return EXIT.USAGE;
742
+ }
743
+ if (opts.help) {
744
+ console.log(USAGE);
745
+ return EXIT.OK;
746
+ }
747
+
748
+ const secrets = [];
749
+ const log = (...args) => console.log(...args);
750
+ const run = makeRunner(secrets, { log });
751
+ const scope = packageScope(opts.packageName);
752
+ const runtimeRoot = resolveRuntimeRoot();
753
+ const hasExistingRuntime = fs.existsSync(path.join(runtimeRoot, 'engine.js'));
754
+
755
+ log(`\n Internal Minions install`);
756
+ log(` package: ${opts.packageName}@${opts.versionSpec}`);
757
+ log(` registry: ${opts.registry}`);
758
+ log(` runtime root: ${runtimeRoot}${hasExistingRuntime ? '' : ' (fresh install)'}`);
759
+
760
+ let npmRootG = null;
761
+ try {
762
+ npmRootG = run('npm', ['root', '-g'], { timeout: NPM_VIEW_TIMEOUT_MS, capture: true }).stdout.trim();
763
+ } catch (e) {
764
+ console.error(`\n ERROR: npm is required but not usable: ${e.message}\n`);
765
+ return EXIT.FAILED;
766
+ }
767
+ const installedInternalVersion = readGlobalPackageVersion(npmRootG, opts.packageName);
768
+ const installedPublic = !!readGlobalPackageVersion(npmRootG, PUBLIC_PACKAGE);
769
+
770
+ // The global bin prefix is where the `minions` shim lives. Resolved up front so
771
+ // a prefix npm itself cannot report is a warning, not a late failure after the
772
+ // package is already installed.
773
+ let npmPrefixG = null;
774
+ try {
775
+ npmPrefixG = run('npm', ['prefix', '-g'], { timeout: NPM_VIEW_TIMEOUT_MS, capture: true }).stdout.trim() || null;
776
+ } catch {
777
+ npmPrefixG = null;
778
+ }
779
+
780
+ // Agent-activity gate — evaluated before anything is acquired or mutated.
781
+ const activity = assessAgentActivity({
782
+ activeAgents: countActiveAgents(runtimeRoot),
783
+ force: opts.force,
784
+ });
785
+ if (!activity.allowed) {
786
+ console.error(`\n REFUSED: ${activity.reason}.`);
787
+ console.error(' Replacing the runtime package rewrites the engine code those agents are executing,');
788
+ console.error(' which the restart/reattachment contract does not cover.');
789
+ console.error(' Wait for them to finish (`minions status`), or re-run with --force to override.\n');
790
+ return EXIT.REFUSED;
791
+ }
792
+ if (activity.warning) console.log(`\n WARNING: ${activity.reason}.`);
793
+
794
+ let tempDir = null;
795
+ let artifactDir = null;
796
+ let retainArtifact = false;
797
+ let backupArtifactParked = false;
798
+ try {
799
+ // 1. acquire-token ─────────────────────────────────────────────────────
800
+ let token = '';
801
+ if (opts.dryRun) {
802
+ log(`\n [dry-run] az ${buildTokenArgs().join(' ')}`);
803
+ token = 'DRY-RUN-PLACEHOLDER-TOKEN';
804
+ } else {
805
+ log('\n Acquiring a short-lived Azure DevOps token via the Azure CLI...');
806
+ let tokenResult;
807
+ try {
808
+ tokenResult = run('az', buildTokenArgs(), { timeout: AZ_TOKEN_TIMEOUT_MS, capture: true });
809
+ } catch (e) {
810
+ console.error(`\n ERROR: could not acquire an Azure DevOps token (${e.message}).`);
811
+ console.error(' Run `az login` (and `az account set --subscription <id>` if needed) and retry.\n');
812
+ return EXIT.FAILED;
813
+ }
814
+ token = tokenResult.stdout.trim();
815
+ if (!token) {
816
+ console.error('\n ERROR: the Azure CLI returned an empty access token. Run `az login` and retry.\n');
817
+ return EXIT.FAILED;
818
+ }
819
+ secrets.push(token);
820
+ }
821
+
822
+ const temp = writeTempNpmrc({
823
+ registry: opts.registry,
824
+ scope,
825
+ token,
826
+ baseConfig: readUserNpmrc(),
827
+ });
828
+ tempDir = temp.dir;
829
+ log(` npm auth staged in a temporary config (removed when this run ends).`);
830
+
831
+ // 2. validate-feed ─────────────────────────────────────────────────────
832
+ const viewArgs = buildNpmViewArgs({
833
+ packageName: opts.packageName,
834
+ versionSpec: opts.versionSpec,
835
+ registry: opts.registry,
836
+ userconfig: temp.file,
837
+ });
838
+ let targetVersion = null;
839
+ if (opts.dryRun) {
840
+ log(` [dry-run] npm ${redact(viewArgs.join(' '), secrets)}`);
841
+ targetVersion = opts.versionSpec === DEFAULT_VERSION_SPEC ? '<resolved-latest>' : opts.versionSpec;
842
+ } else {
843
+ log(` Validating feed access and resolving ${opts.packageName}@${opts.versionSpec}...`);
844
+ let viewResult;
845
+ try {
846
+ viewResult = run('npm', viewArgs, { timeout: NPM_VIEW_TIMEOUT_MS, capture: true });
847
+ } catch (e) {
848
+ console.error(`\n ERROR: could not read ${opts.packageName}@${opts.versionSpec} from the feed.`);
849
+ console.error(` ${e.message}`);
850
+ console.error(' Confirm you have read access to the feed and that the version exists.');
851
+ console.error(' Nothing was uninstalled or modified.\n');
852
+ return EXIT.FAILED;
853
+ }
854
+ try {
855
+ const parsed = JSON.parse(viewResult.stdout.trim());
856
+ targetVersion = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
857
+ } catch {
858
+ targetVersion = viewResult.stdout.trim().replace(/^"|"$/g, '');
859
+ }
860
+ if (!targetVersion) {
861
+ console.error('\n ERROR: the feed returned no version for that spec. Nothing was modified.\n');
862
+ return EXIT.FAILED;
863
+ }
864
+ log(` Feed access confirmed; target version ${targetVersion}.`);
865
+ }
866
+
867
+ // Owning the package directory is not the same as owning the `minions`
868
+ // command. Feeding real shim ownership into the plan is what makes a
869
+ // half-finished cutover repairable by simply re-running: without it, the
870
+ // version match alone would skip the one step that re-links the shim.
871
+ const shimOwned = (npmPrefixG && installedInternalVersion)
872
+ ? verifyBinShims({ npmPrefixG, packageName: opts.packageName }).ok
873
+ : true;
874
+
875
+ const steps = planSteps({
876
+ hasExistingRuntime,
877
+ installedPublic,
878
+ installedInternalVersion,
879
+ targetVersion,
880
+ keepPublic: opts.keepPublic,
881
+ restart: opts.restart,
882
+ shimOwned,
883
+ });
884
+ log(`\n Plan: ${steps.join(' → ')}`);
885
+ if (installedInternalVersion === targetVersion && !steps.includes('install-internal')) {
886
+ log(` ${opts.packageName}@${targetVersion} is already installed — install step skipped (idempotent).`);
887
+ } else if (installedInternalVersion === targetVersion && !shimOwned) {
888
+ log(` ${opts.packageName}@${targetVersion} is installed but \`minions\` does not resolve to it — reinstalling to repair the shim.`);
889
+ }
890
+
891
+ if (opts.dryRun) {
892
+ log('\n [dry-run] Commands that would run:');
893
+ if (steps.includes('download-artifact')) {
894
+ log(` npm ${redact(buildNpmPackArgs({ packageName: opts.packageName, version: targetVersion, registry: opts.registry, userconfig: temp.file, destination: '<temp>' }).join(' '), secrets)}`);
895
+ }
896
+ if (steps.includes('backup-state')) log(` minions state backup <backup-dir>/state.db`);
897
+ if (steps.includes('uninstall-public')) log(` npm ${buildNpmUninstallArgs(PUBLIC_PACKAGE).join(' ')}`);
898
+ if (steps.includes('install-internal')) {
899
+ log(` npm ${redact(buildNpmInstallArgs({ packageName: opts.packageName, version: targetVersion, registry: opts.registry, userconfig: temp.file }).join(' '), secrets)}`);
900
+ log(` (on failure) npm ${buildNpmInstallTarballArgs('<backup-dir>/<package>.tgz').join(' ')}`);
901
+ }
902
+ log(` minions init --force --skip-start`);
903
+ if (steps.includes('restart')) log(` minions restart`);
904
+ log('\n [dry-run] No changes made.\n');
905
+ return EXIT.OK;
906
+ }
907
+
908
+ const preservedBefore = snapshotPreservedPaths(runtimeRoot);
909
+
910
+ // 3. download-artifact ─────────────────────────────────────────────────
911
+ // The cutover contract: the tarball is on local disk BEFORE the public
912
+ // package is removed, so a feed, network, or auth failure during the global
913
+ // install can be recovered from without the feed.
914
+ let artifactPath = null;
915
+ if (steps.includes('download-artifact')) {
916
+ log(`\n Downloading ${opts.packageName}@${targetVersion} from the feed...`);
917
+ artifactDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minions-internal-pkg-'));
918
+ let packResult;
919
+ try {
920
+ packResult = run('npm', buildNpmPackArgs({
921
+ packageName: opts.packageName,
922
+ version: targetVersion,
923
+ registry: opts.registry,
924
+ userconfig: temp.file,
925
+ destination: artifactDir,
926
+ }), { timeout: NPM_PACK_TIMEOUT_MS, capture: true });
927
+ } catch (e) {
928
+ console.error(`\n ERROR: could not download ${opts.packageName}@${targetVersion} from the feed.`);
929
+ console.error(` ${e.message}`);
930
+ console.error(' Nothing was uninstalled or modified.\n');
931
+ return EXIT.FAILED;
932
+ }
933
+ artifactPath = resolvePackedTarball(artifactDir, packResult.stdout);
934
+ if (!artifactPath) {
935
+ console.error(`\n ERROR: npm reported success but no tarball landed in ${artifactDir}.`);
936
+ console.error(' Refusing to start the cutover without a downloaded artifact. Nothing was modified.\n');
937
+ return EXIT.FAILED;
938
+ }
939
+ log(` Artifact downloaded: ${path.basename(artifactPath)}`);
940
+ }
941
+
942
+ // 4. backup-state ──────────────────────────────────────────────────────
943
+ let backupDir = null;
944
+ let backupStatePath = null;
945
+ if (steps.includes('backup-state')) {
946
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
947
+ backupDir = opts.backupDir
948
+ ? path.resolve(opts.backupDir)
949
+ : path.join(runtimeRoot, 'backups', `internal-install-${stamp}`);
950
+ fs.mkdirSync(backupDir, { recursive: true });
951
+ const backup = resolveBackupStrategy({
952
+ npmRootG,
953
+ runtimeRoot,
954
+ packageName: opts.packageName,
955
+ installedInternalVersion,
956
+ installedPublic,
957
+ });
958
+ log(`\n Backing up runtime state to ${backupDir} ...`);
959
+ if (backup.kind === 'none') {
960
+ console.error('\n ERROR: no way to back up the existing runtime state was found.');
961
+ console.error(` Looked for: ${backup.candidates.join(', ')}`);
962
+ console.error(' Refusing to migrate without a consistent backup. Nothing was uninstalled.\n');
963
+ return EXIT.FAILED;
964
+ }
965
+ try {
966
+ backupStatePath = path.join(backupDir, 'state.db');
967
+ const [command, args] = backup.kind === 'runtime-module'
968
+ ? [process.execPath, ['-e', BACKUP_MODULE_SCRIPT, backup.modulePath, backupStatePath]]
969
+ : [process.execPath, [backup.cliPath, 'state', 'backup', backupStatePath]];
970
+ run(command, args, {
971
+ timeout: CLI_INIT_TIMEOUT_MS,
972
+ capture: true,
973
+ env: { ...process.env, MINIONS_HOME: runtimeRoot },
974
+ });
975
+ if (!fs.existsSync(backupStatePath)) throw new Error('the backup command reported success but wrote no file');
976
+ log(` SQLite state checkpointed and backed up (via ${backup.kind === 'runtime-module' ? 'the runtime\'s own engine/state-operations.js' : backup.cliPath}).`);
977
+ } catch (e) {
978
+ backupStatePath = null;
979
+ console.error(`\n ERROR: state backup failed (${e.message}).`);
980
+ console.error(' Refusing to migrate without a consistent backup. Nothing was uninstalled.\n');
981
+ return EXIT.FAILED;
982
+ }
983
+ for (const rel of ['config.json', 'routing.md', 'pinned.md']) {
984
+ const src = path.join(runtimeRoot, rel);
985
+ try { if (fs.existsSync(src)) fs.copyFileSync(src, path.join(backupDir, rel)); } catch { /* best-effort */ }
986
+ }
987
+ // Park the artifact beside the backup so the recovery source outlives the
988
+ // temp directory and stays discoverable after the process exits.
989
+ if (artifactPath) {
990
+ try {
991
+ const parked = path.join(backupDir, path.basename(artifactPath));
992
+ fs.copyFileSync(artifactPath, parked);
993
+ artifactPath = parked;
994
+ backupArtifactParked = true;
995
+ } catch { /* best-effort — the temp copy is still usable this run */ }
996
+ }
997
+ fs.writeFileSync(path.join(backupDir, 'manifest.json'), `${JSON.stringify({
998
+ createdAt: new Date().toISOString(),
999
+ runtimeRoot,
1000
+ fromPackage: installedPublic ? PUBLIC_PACKAGE : (installedInternalVersion ? opts.packageName : null),
1001
+ fromVersion: installedInternalVersion,
1002
+ toPackage: opts.packageName,
1003
+ toVersion: targetVersion,
1004
+ artifact: artifactPath ? path.basename(artifactPath) : null,
1005
+ preservedPaths: preservedBefore,
1006
+ }, null, 2)}\n`);
1007
+ log(` config/routing/pinned copied; manifest written.`);
1008
+ if (artifactPath && backupArtifactParked) log(` Recovery artifact parked at ${artifactPath}`);
1009
+ }
1010
+
1011
+ // 5. uninstall-public ──────────────────────────────────────────────────
1012
+ if (steps.includes('uninstall-public')) {
1013
+ const readiness = assessCutoverReadiness({
1014
+ artifactReady: !!artifactPath,
1015
+ internalAlreadyInstalled: installedInternalVersion === targetVersion,
1016
+ backupRequired: hasExistingRuntime,
1017
+ backupReady: !!backupStatePath && fs.existsSync(backupStatePath),
1018
+ });
1019
+ if (!readiness.allowed) {
1020
+ console.error(`\n ERROR: refusing to uninstall ${PUBLIC_PACKAGE} — ${readiness.reason}.`);
1021
+ console.error(' The public package is only removed once a usable CLI is guaranteed to be recoverable.\n');
1022
+ return EXIT.FAILED;
1023
+ }
1024
+ log(`\n Removing the public ${PUBLIC_PACKAGE} global install (${readiness.reason})...`);
1025
+ run('npm', buildNpmUninstallArgs(PUBLIC_PACKAGE), { timeout: NPM_INSTALL_TIMEOUT_MS, allowFailure: true });
1026
+ }
1027
+
1028
+ // 6. install-internal ──────────────────────────────────────────────────
1029
+ if (steps.includes('install-internal')) {
1030
+ log(`\n Installing ${opts.packageName}@${targetVersion} ...`);
1031
+ let installed = false;
1032
+ try {
1033
+ run('npm', buildNpmInstallArgs({
1034
+ packageName: opts.packageName,
1035
+ version: targetVersion,
1036
+ registry: opts.registry,
1037
+ userconfig: temp.file,
1038
+ }), { timeout: NPM_INSTALL_TIMEOUT_MS });
1039
+ installed = true;
1040
+ } catch (e) {
1041
+ console.error(`\n WARNING: the feed install failed (${e.message}).`);
1042
+ // Recovery, not a hint: install the artifact already on disk. It is the
1043
+ // same bytes the feed resolved, and the local install needs neither the
1044
+ // registry nor the token — which is exactly what just failed.
1045
+ if (artifactPath && fs.existsSync(artifactPath)) {
1046
+ log(` Recovering from the downloaded artifact (${path.basename(artifactPath)})...`);
1047
+ try {
1048
+ run('npm', buildNpmInstallTarballArgs(artifactPath), { timeout: NPM_INSTALL_TIMEOUT_MS });
1049
+ installed = true;
1050
+ log(' Recovered: the CLI was restored from the downloaded artifact.');
1051
+ } catch (recoveryError) {
1052
+ console.error(` Recovery from the artifact also failed (${recoveryError.message}).`);
1053
+ }
1054
+ }
1055
+ }
1056
+ if (!installed) {
1057
+ retainArtifact = true;
1058
+ console.error(`\n ERROR: could not install ${opts.packageName}@${targetVersion}.`);
1059
+ if (backupDir) console.error(` Your state backup is intact at ${backupDir}.`);
1060
+ if (artifactPath) {
1061
+ console.error(` Retry the offline install yourself with:`);
1062
+ console.error(` npm ${buildNpmInstallTarballArgs(artifactPath).join(' ')}`);
1063
+ }
1064
+ if (steps.includes('uninstall-public')) {
1065
+ console.error(` Or fall back to the public package with:`);
1066
+ console.error(` npm install -g ${PUBLIC_PACKAGE}`);
1067
+ }
1068
+ console.error('');
1069
+ return EXIT.FAILED;
1070
+ }
1071
+ }
1072
+
1073
+ // 7. verify-install ────────────────────────────────────────────────────
1074
+ const installedNow = readGlobalPackageVersion(npmRootG, opts.packageName);
1075
+ if (installedNow !== targetVersion) {
1076
+ console.error(`\n ERROR: expected ${opts.packageName}@${targetVersion} on disk, found ${installedNow || 'nothing'}.`);
1077
+ console.error(' npm may have resolved a stale metadata cache. Try `npm cache clean --force` and re-run.\n');
1078
+ return EXIT.FAILED;
1079
+ }
1080
+ log(` Verified ${opts.packageName}@${installedNow} on disk.`);
1081
+
1082
+ // 8. verify-shim ───────────────────────────────────────────────────────
1083
+ // Owning the package directory is not the same as owning the `minions`
1084
+ // command: a half-finished cutover can leave the shim wired to the public
1085
+ // package while the internal one sits installed and unused.
1086
+ if (npmPrefixG) {
1087
+ const shimCheck = verifyBinShims({ npmPrefixG, packageName: opts.packageName });
1088
+ if (!shimCheck.ok) {
1089
+ retainArtifact = true;
1090
+ console.error(`\n ERROR: ${shimCheck.reason}.`);
1091
+ console.error(` ${opts.packageName}@${installedNow} is installed, but \`minions\` does not run it.`);
1092
+ console.error(` Repair the shim with:`);
1093
+ console.error(` npm uninstall -g ${PUBLIC_PACKAGE}`);
1094
+ if (artifactPath) console.error(` npm ${buildNpmInstallTarballArgs(artifactPath).join(' ')}`);
1095
+ if (backupDir) console.error(` Your state backup is intact at ${backupDir}.`);
1096
+ console.error('');
1097
+ return EXIT.FAILED;
1098
+ }
1099
+ log(` Verified ${shimCheck.reason}.`);
1100
+ } else {
1101
+ log(' WARNING: npm did not report a global prefix; the `minions` shim could not be verified.');
1102
+ }
1103
+
1104
+ const installedCliPath = path.join(resolveGlobalPackageRoot(npmRootG, opts.packageName), 'bin', 'minions.js');
1105
+
1106
+ // 9. sync-init ─────────────────────────────────────────────────────────
1107
+ log('\n Synchronizing the runtime root (minions init --force)...');
1108
+ try {
1109
+ run(process.execPath, [installedCliPath, 'init', '--force', '--skip-start'], { timeout: CLI_INIT_TIMEOUT_MS });
1110
+ } catch (e) {
1111
+ console.error(`\n ERROR: runtime synchronization failed (${e.message}).`);
1112
+ console.error(` The package is installed. Finish manually with:`);
1113
+ console.error(` minions init --force`);
1114
+ console.error(` minions restart\n`);
1115
+ return EXIT.FAILED;
1116
+ }
1117
+
1118
+ const preservedAfter = snapshotPreservedPaths(runtimeRoot);
1119
+ const lost = diffPreservedPaths(preservedBefore, preservedAfter);
1120
+ if (lost.length) {
1121
+ console.error(`\n ERROR: these runtime paths existed before migration and are now missing: ${lost.join(', ')}`);
1122
+ if (backupDir) console.error(` Restore from ${backupDir} before continuing.\n`);
1123
+ return EXIT.FAILED;
1124
+ }
1125
+ if (preservedBefore.length) log(` Preserved: ${preservedBefore.join(', ')}`);
1126
+
1127
+ // 10. restart ──────────────────────────────────────────────────────────
1128
+ if (steps.includes('restart')) {
1129
+ log('\n Restarting engine and dashboard (health-verified)...');
1130
+ try {
1131
+ run(process.execPath, [installedCliPath, 'restart'], { timeout: CLI_RESTART_TIMEOUT_MS });
1132
+ } catch (e) {
1133
+ console.error(`\n ERROR: restart failed (${e.message}).`);
1134
+ console.error(' The package and runtime files are up to date; run `minions restart` to finish.\n');
1135
+ return EXIT.FAILED;
1136
+ }
1137
+ } else {
1138
+ log('\n Restart skipped (--no-restart). Run `minions restart` when ready.');
1139
+ }
1140
+
1141
+ log(`\n Done. ${opts.packageName}@${installedNow} is installed at ${runtimeRoot}.`);
1142
+ if (backupDir) log(` Pre-migration backup: ${backupDir}`);
1143
+ log('');
1144
+ return EXIT.OK;
1145
+ } catch (e) {
1146
+ console.error(`\n ERROR: ${redact(e && e.message ? e.message : String(e), secrets)}\n`);
1147
+ return EXIT.FAILED;
1148
+ } finally {
1149
+ // 11. cleanup — runs on success and on every failure path, including the
1150
+ // early `return`s above, so the token file never outlives the process. The
1151
+ // temp artifact copy goes too; when there was a runtime to back up, the
1152
+ // recovery tarball was already parked in the backup directory.
1153
+ cleanupTempNpmrc(tempDir);
1154
+ // The artifact is kept only when the run left the machine needing it and the
1155
+ // backup directory did not already receive a copy.
1156
+ if (retainArtifact && artifactDir && !backupArtifactParked) {
1157
+ console.error(` Recovery artifact retained at ${artifactDir}\n`);
1158
+ } else {
1159
+ cleanupTempNpmrc(artifactDir);
1160
+ }
1161
+ }
1162
+ }
1163
+
1164
+ module.exports = {
1165
+ ADO_TOKEN_RESOURCE,
1166
+ DEFAULT_REGISTRY,
1167
+ DEFAULT_VERSION_SPEC,
1168
+ INTERNAL_PACKAGE,
1169
+ PUBLIC_PACKAGE,
1170
+ EXIT,
1171
+ PRESERVED_PATHS,
1172
+ STEP_ORDER,
1173
+ parseArgs,
1174
+ normalizeRegistry,
1175
+ registryAuthKey,
1176
+ packageScope,
1177
+ buildTokenArgs,
1178
+ buildNpmrc,
1179
+ buildNpmViewArgs,
1180
+ buildNpmInstallArgs,
1181
+ buildNpmPackArgs,
1182
+ buildNpmInstallTarballArgs,
1183
+ buildNpmUninstallArgs,
1184
+ resolveGlobalPackageRoot,
1185
+ resolvePackedTarball,
1186
+ resolveBinShimPaths,
1187
+ shimTargetsPackage,
1188
+ verifyBinShims,
1189
+ quoteWindowsArg,
1190
+ resolveSpawnTarget,
1191
+ redact,
1192
+ resolveRuntimeRoot,
1193
+ resolveBackupCliCandidates,
1194
+ resolveBackupCli,
1195
+ resolveBackupStrategy,
1196
+ snapshotPreservedPaths,
1197
+ diffPreservedPaths,
1198
+ countActiveAgents,
1199
+ assessAgentActivity,
1200
+ assessCutoverReadiness,
1201
+ planSteps,
1202
+ writeTempNpmrc,
1203
+ cleanupTempNpmrc,
1204
+ main,
1205
+ };
1206
+
1207
+ if (require.main === module) {
1208
+ process.exit(main(process.argv.slice(2)));
1209
+ }