@indigoai-us/hq-cli 5.77.6 → 5.77.7

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.
@@ -29,7 +29,7 @@
29
29
  */
30
30
 
31
31
  import { spawnSync } from "node:child_process";
32
- import { readFileSync } from "node:fs";
32
+ import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
33
33
  import path from "node:path";
34
34
  import { fileURLToPath } from "node:url";
35
35
  import chalk from "chalk";
@@ -104,6 +104,109 @@ export function buildPrefixedInstallArgv(prefix: string): string[] {
104
104
  return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
105
105
  }
106
106
 
107
+ /**
108
+ * Filesystem surface used by {@link cleanStalePartialInstall}. Injected so the
109
+ * cleanup logic is unit-testable without touching a real global prefix.
110
+ */
111
+ export interface StaleInstallFs {
112
+ readdirSync: (dir: string) => string[];
113
+ existsSync: (target: string) => boolean;
114
+ readFileSync: (target: string, encoding: "utf-8") => string;
115
+ rmSync: (target: string, options: { recursive: boolean; force: boolean }) => void;
116
+ }
117
+
118
+ const nodeStaleInstallFs: StaleInstallFs = {
119
+ readdirSync: (dir) => readdirSync(dir),
120
+ existsSync,
121
+ readFileSync: (target, encoding) => readFileSync(target, encoding),
122
+ rmSync,
123
+ };
124
+
125
+ function isHealthyPackageDir(pkgDir: string, fs: StaleInstallFs): boolean {
126
+ try {
127
+ const pkg = JSON.parse(
128
+ fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"),
129
+ ) as { name?: unknown };
130
+ return pkg.name === CLI_NAME;
131
+ } catch {
132
+ return false; // missing / unreadable / malformed package.json ⇒ partial
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Remove leftover artifacts from an interrupted `npm install -g` so a retry can
138
+ * succeed. npm unpacks a package into a `.<pkg>-<rand>` staging dir alongside
139
+ * the final location and then renames it into place; if a previous run was
140
+ * killed mid-rename (or a half-written package dir survives), every subsequent
141
+ * install fails with `ENOTEMPTY` because npm cannot atomically rename over the
142
+ * non-empty leftover. npm does not self-heal this — the stale dir must be
143
+ * removed first.
144
+ *
145
+ * To stay safe we only ever delete:
146
+ * - dot-prefixed npm staging dirs for THIS package (`.hq-cli-*`), and
147
+ * - a package dir whose `package.json` is missing/unreadable or whose `name`
148
+ * is not exactly {@link CLI_NAME} (i.e. a genuinely partial/foreign dir).
149
+ *
150
+ * A healthy install (valid `package.json`, `name === CLI_NAME`) is never
151
+ * touched, so an ordinary version bump still flows through npm untouched.
152
+ *
153
+ * Returns the list of removed paths — empty when there was nothing to clean, so
154
+ * callers can gate a reinstall retry on `removed.length > 0`.
155
+ */
156
+ export function cleanStalePartialInstall(
157
+ prefix: string,
158
+ fs: StaleInstallFs = nodeStaleInstallFs,
159
+ ): string[] {
160
+ const removed: string[] = [];
161
+ const slash = CLI_NAME.indexOf("/");
162
+ const scope = slash === -1 ? null : CLI_NAME.slice(0, slash);
163
+ const leaf = slash === -1 ? CLI_NAME : CLI_NAME.slice(slash + 1);
164
+ const stagingPrefix = `.${leaf}-`;
165
+
166
+ // Global npm keeps packages under `<prefix>/lib/node_modules` (unix) while a
167
+ // bare `--prefix` dir (windows / some sandboxes) uses `<prefix>/node_modules`.
168
+ const nmRoots = [
169
+ path.join(prefix, "lib", "node_modules"),
170
+ path.join(prefix, "node_modules"),
171
+ ];
172
+
173
+ for (const nmRoot of nmRoots) {
174
+ // For a scoped package the staging dir + final dir both live inside the
175
+ // scope dir (`.../@indigoai-us/.hq-cli-<rand>`, `.../@indigoai-us/hq-cli`).
176
+ const parentDir = scope ? path.join(nmRoot, scope) : nmRoot;
177
+ let entries: string[];
178
+ try {
179
+ entries = fs.readdirSync(parentDir);
180
+ } catch {
181
+ continue; // this node_modules / scope dir doesn't exist here
182
+ }
183
+
184
+ for (const entry of entries) {
185
+ if (!entry.startsWith(stagingPrefix)) continue;
186
+ const target = path.join(parentDir, entry);
187
+ try {
188
+ fs.rmSync(target, { recursive: true, force: true });
189
+ removed.push(target);
190
+ } catch {
191
+ // best-effort: a dir we can't remove (perms) just means the retry
192
+ // still fails and we fall through to the sudo / manual path.
193
+ }
194
+ }
195
+
196
+ const pkgDir = path.join(parentDir, leaf);
197
+ if (fs.existsSync(pkgDir) && !isHealthyPackageDir(pkgDir, fs)) {
198
+ try {
199
+ fs.rmSync(pkgDir, { recursive: true, force: true });
200
+ removed.push(pkgDir);
201
+ } catch {
202
+ // best-effort (see above)
203
+ }
204
+ }
205
+ }
206
+
207
+ return removed;
208
+ }
209
+
107
210
  /**
108
211
  * Hit POST /v1/client-version/check. Returns the parsed body on 200, or
109
212
  * `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
@@ -213,6 +316,7 @@ function enforceUpdateRequired(
213
316
  performUpdateString?: (command: string) => UpdateResult;
214
317
  resolvePrefix?: () => string | null;
215
318
  runner?: UpdateRunner;
319
+ cleanStale?: (prefix: string) => string[];
216
320
  } = {},
217
321
  ): never {
218
322
  const banner = chalk.red.bold(
@@ -262,6 +366,27 @@ function enforceUpdateRequired(
262
366
  : performUpdate(command!, runner);
263
367
  })();
264
368
 
369
+ // A partial/corrupt global install leaves npm unable to atomically rename its
370
+ // freshly-unpacked package over a leftover directory, so the install above
371
+ // fails with ENOTEMPTY (e.g. a prior interrupted `npm install -g` left a
372
+ // half-written `hq-cli` package dir or a `.hq-cli-<rand>` staging dir under
373
+ // the prefix's node_modules). npm cannot self-heal this. Remove the stale
374
+ // artifacts and retry the install ONCE. Guarded on `cleaned.length > 0` so a
375
+ // plain EACCES on an otherwise-healthy prefix falls straight through to the
376
+ // sudo retry below without a redundant reinstall attempt.
377
+ if (!result.ok && prefix) {
378
+ const cleaner = deps.cleanStale ?? cleanStalePartialInstall;
379
+ const cleaned = cleaner(prefix);
380
+ if (cleaned.length > 0) {
381
+ console.error(
382
+ chalk.dim(
383
+ ` Removing stale partial install artifacts and retrying: ${cleaned.join(", ")}`,
384
+ ),
385
+ );
386
+ result = performUpdateCommand("npm", primaryArgs, runner);
387
+ }
388
+ }
389
+
265
390
  // A root-owned global install (e.g. a system `/usr` install where the CLI runs
266
391
  // unprivileged — the outpost agent boxes) can't rewrite the prefix's bin dir,
267
392
  // so the install above fails with EACCES (`rename /usr/bin/hq`). Retry ONCE
@@ -341,6 +466,7 @@ export const __test__ = {
341
466
  ENDPOINT_PATH,
342
467
  FETCH_TIMEOUT_MS,
343
468
  buildPrefixedInstallArgv,
469
+ cleanStalePartialInstall,
344
470
  enforceUpdateRequired,
345
471
  npmPrefixFromPackageDir,
346
472
  performUpdate,