@indigoai-us/hq-cli 5.297.0 → 5.299.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.
@@ -131,6 +131,236 @@ function isPathInside(candidate, parent) {
131
131
  const relative = path.relative(path.resolve(parent), path.resolve(candidate));
132
132
  return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
133
133
  }
134
+ function readNpmBinMap(packageRoot) {
135
+ try {
136
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
137
+ if (manifest.bin === undefined)
138
+ return {};
139
+ const entries = typeof manifest.bin === "string"
140
+ ? [["hq", manifest.bin]]
141
+ : manifest.bin && typeof manifest.bin === "object" && !Array.isArray(manifest.bin)
142
+ ? Object.entries(manifest.bin)
143
+ : null;
144
+ if (!entries)
145
+ return null;
146
+ const bins = {};
147
+ for (const [name, target] of entries) {
148
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\") ||
149
+ typeof target !== "string" || target.length === 0 || path.isAbsolute(target) ||
150
+ !isPathInside(path.resolve(packageRoot, target), packageRoot)) {
151
+ return null;
152
+ }
153
+ bins[name] = target;
154
+ }
155
+ return bins;
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ function symlinkTargetIsInsidePackage(linkPath, target, packageRoot) {
162
+ return isPathInside(path.resolve(path.dirname(linkPath), target), packageRoot);
163
+ }
164
+ function snapshotNpmBinLinks(prefix, packageRoot) {
165
+ if (process.platform === "win32")
166
+ return [];
167
+ const binMap = readNpmBinMap(packageRoot);
168
+ if (!binMap)
169
+ return [];
170
+ const binDirectory = path.join(prefix, "bin");
171
+ const links = [];
172
+ for (const name of Object.keys(binMap)) {
173
+ const linkPath = path.join(binDirectory, name);
174
+ try {
175
+ if (!fs.lstatSync(linkPath).isSymbolicLink())
176
+ continue;
177
+ const target = fs.readlinkSync(linkPath);
178
+ const resolved = fs.realpathSync(linkPath);
179
+ if (!isPathInside(resolved, packageRoot))
180
+ continue;
181
+ links.push({ path: linkPath, target });
182
+ }
183
+ catch {
184
+ // A missing or dangling entry was not a working npm bin link to snapshot.
185
+ }
186
+ }
187
+ return links;
188
+ }
189
+ function restoreNpmBinLinks(snapshot) {
190
+ if (!snapshot.npmBinDirectory || !snapshot.npmBinPackageRoot || !snapshot.npmBinLinks?.length)
191
+ return true;
192
+ if (process.platform === "win32")
193
+ return true;
194
+ let restored = true;
195
+ for (const bin of snapshot.npmBinLinks) {
196
+ if (path.dirname(bin.path) !== snapshot.npmBinDirectory) {
197
+ reportBinLinkConflict(bin.path, "the saved path is outside the npm bin directory");
198
+ restored = false;
199
+ continue;
200
+ }
201
+ let existing;
202
+ let existingTarget;
203
+ try {
204
+ existing = fs.lstatSync(bin.path);
205
+ }
206
+ catch (error) {
207
+ if (error.code !== "ENOENT") {
208
+ reportBinLinkConflict(bin.path, errorMessage(error));
209
+ restored = false;
210
+ continue;
211
+ }
212
+ }
213
+ if (existing) {
214
+ if (!existing.isSymbolicLink()) {
215
+ reportBinLinkConflict(bin.path, "a regular file owns this command name");
216
+ restored = false;
217
+ continue;
218
+ }
219
+ const currentTarget = fs.readlinkSync(bin.path);
220
+ existingTarget = currentTarget;
221
+ if (!symlinkTargetIsInsidePackage(bin.path, currentTarget, snapshot.npmBinPackageRoot)) {
222
+ reportBinLinkConflict(bin.path, "the existing symlink points outside hq-cli");
223
+ restored = false;
224
+ continue;
225
+ }
226
+ try {
227
+ if (currentTarget === bin.target &&
228
+ isPathInside(fs.realpathSync(bin.path), snapshot.npmBinPackageRoot))
229
+ continue;
230
+ }
231
+ catch {
232
+ // A dangling link into the package is safe to replace with the snapshot.
233
+ }
234
+ }
235
+ if (!symlinkTargetIsInsidePackage(bin.path, bin.target, snapshot.npmBinPackageRoot)) {
236
+ reportBinLinkConflict(bin.path, "the saved npm link target is outside the restored hq-cli package");
237
+ restored = false;
238
+ continue;
239
+ }
240
+ const temporary = `${bin.path}.hq-cli-restore-${process.pid}-${randomBytes(4).toString("hex")}`;
241
+ try {
242
+ fs.mkdirSync(snapshot.npmBinDirectory, { recursive: true });
243
+ if (!existing) {
244
+ // Creating the link directly is deliberately no-clobber: if another
245
+ // tool claimed the command name after lstat, EEXIST preserves it.
246
+ fs.symlinkSync(bin.target, bin.path);
247
+ }
248
+ else {
249
+ fs.symlinkSync(bin.target, temporary);
250
+ const current = fs.lstatSync(bin.path);
251
+ if (!current.isSymbolicLink() || fs.readlinkSync(bin.path) !== existingTarget) {
252
+ throw new Error("the bin entry changed while it was being restored");
253
+ }
254
+ fs.renameSync(temporary, bin.path);
255
+ }
256
+ try {
257
+ if (!isPathInside(fs.realpathSync(bin.path), snapshot.npmBinPackageRoot)) {
258
+ throw new Error("restored link does not resolve into the restored hq-cli package");
259
+ }
260
+ }
261
+ catch (error) {
262
+ fs.rmSync(bin.path, { force: true });
263
+ throw error;
264
+ }
265
+ }
266
+ catch (error) {
267
+ fs.rmSync(temporary, { force: true });
268
+ reportBinLinkConflict(bin.path, errorMessage(error));
269
+ restored = false;
270
+ }
271
+ }
272
+ return restored;
273
+ }
274
+ function reportBinLinkConflict(linkPath, reason) {
275
+ console.error(chalk.yellow(`⚠ Could not restore hq-cli bin link at ${linkPath}; left it untouched (${reason}).`));
276
+ }
277
+ function reportForeignNpmBin(linkPath, reason) {
278
+ console.error(chalk.yellow(`⚠ Leaving hq-cli bin entry at ${linkPath} untouched; another tool owns it (${reason}).`));
279
+ }
280
+ function errorMessage(error) {
281
+ return error instanceof Error ? error.message : String(error);
282
+ }
283
+ function verifyOrRepairNpmBins(prefix, packageRoot) {
284
+ if (process.platform === "win32")
285
+ return { ok: true };
286
+ let livePackageRoot;
287
+ try {
288
+ livePackageRoot = fs.realpathSync(packageRoot);
289
+ }
290
+ catch (error) {
291
+ return { ok: false, detail: `the installed hq-cli package is missing: ${errorMessage(error)}` };
292
+ }
293
+ const bins = readNpmBinMap(livePackageRoot);
294
+ if (!bins || typeof bins.hq !== "string" || bins.hq.length === 0) {
295
+ return { ok: false, detail: "the installed hq-cli package does not declare a valid hq bin" };
296
+ }
297
+ const binDirectory = path.join(prefix, "bin");
298
+ for (const [name, target] of Object.entries(bins)) {
299
+ const linkPath = path.join(binDirectory, name);
300
+ const expected = path.resolve(livePackageRoot, target);
301
+ let existing;
302
+ let existingTarget;
303
+ try {
304
+ existing = fs.lstatSync(linkPath);
305
+ }
306
+ catch (error) {
307
+ if (error.code !== "ENOENT") {
308
+ return { ok: false, detail: `could not inspect ${linkPath}: ${errorMessage(error)}` };
309
+ }
310
+ }
311
+ if (existing) {
312
+ if (!existing.isSymbolicLink()) {
313
+ if (name === "hq") {
314
+ return { ok: false, detail: `cannot repair ${linkPath}: a regular file owns this command name` };
315
+ }
316
+ reportForeignNpmBin(linkPath, "a regular file owns this command name");
317
+ continue;
318
+ }
319
+ const currentTarget = fs.readlinkSync(linkPath);
320
+ existingTarget = currentTarget;
321
+ if (!symlinkTargetIsInsidePackage(linkPath, currentTarget, livePackageRoot)) {
322
+ if (name === "hq") {
323
+ return { ok: false, detail: `cannot repair ${linkPath}: the existing symlink points outside hq-cli` };
324
+ }
325
+ reportForeignNpmBin(linkPath, "the existing symlink points outside hq-cli");
326
+ continue;
327
+ }
328
+ try {
329
+ if (fs.realpathSync(linkPath) === expected && fs.statSync(linkPath).isFile())
330
+ continue;
331
+ }
332
+ catch {
333
+ // Missing and dangling hq-cli links are repaired below.
334
+ }
335
+ }
336
+ const temporary = `${linkPath}.hq-cli-repair-${process.pid}-${randomBytes(4).toString("hex")}`;
337
+ try {
338
+ fs.mkdirSync(binDirectory, { recursive: true });
339
+ const targetPath = path.relative(binDirectory, expected);
340
+ if (!existing) {
341
+ // A direct symlink create does not replace a file that appeared after
342
+ // lstat; EEXIST is surfaced as an update failure instead.
343
+ fs.symlinkSync(targetPath, linkPath);
344
+ }
345
+ else {
346
+ fs.symlinkSync(targetPath, temporary);
347
+ const current = fs.lstatSync(linkPath);
348
+ if (!current.isSymbolicLink() || fs.readlinkSync(linkPath) !== existingTarget) {
349
+ throw new Error("the bin entry changed while it was being repaired");
350
+ }
351
+ fs.renameSync(temporary, linkPath);
352
+ }
353
+ if (fs.realpathSync(linkPath) !== expected || !fs.statSync(linkPath).isFile()) {
354
+ throw new Error("repaired link does not resolve to its declared package bin");
355
+ }
356
+ }
357
+ catch (error) {
358
+ fs.rmSync(temporary, { force: true });
359
+ return { ok: false, detail: `could not repair ${linkPath}: ${errorMessage(error)}` };
360
+ }
361
+ }
362
+ return { ok: true };
363
+ }
134
364
  function isInsideNodeModules(candidate) {
135
365
  const segments = path.resolve(candidate).replace(/\\/g, "/").split("/").filter(Boolean);
136
366
  const index = process.platform === "win32"
@@ -159,8 +389,14 @@ function snapshotPathsAreSafe(snapshot) {
159
389
  snapshot.movedPath,
160
390
  snapshot.resolvedTarget,
161
391
  snapshot.publicLinkPath,
392
+ snapshot.npmBinPackageRoot,
162
393
  ].filter((value) => value !== undefined);
163
- return paths.every(isInsideNodeModules);
394
+ if (!paths.every(isInsideNodeModules))
395
+ return false;
396
+ if (snapshot.npmBinLinks?.some((bin) => !snapshot.npmBinDirectory || path.dirname(bin.path) !== snapshot.npmBinDirectory ||
397
+ !isPathInside(bin.path, snapshot.npmBinDirectory)))
398
+ return false;
399
+ return true;
164
400
  }
165
401
  function snapshotInstall(install, renamePath = fs.renameSync) {
166
402
  const packageRoot = install.packageRoot;
@@ -200,6 +436,9 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
200
436
  const pnpmPublicLinkPath = publicLink?.path ?? (entry.isSymbolicLink() ? packageRoot : undefined);
201
437
  if (backupKind === "pnpm-public-link" && !pnpmPublicLinkPath)
202
438
  return null;
439
+ const npmBinLinks = backupKind === "package" && install.manager === "npm" && install.prefix
440
+ ? snapshotNpmBinLinks(install.prefix, sourcePath)
441
+ : undefined;
203
442
  movedPath = backupKind === "pnpm-public-link" ? pnpmPublicLinkPath : sourcePath;
204
443
  backupPath = path.join(path.dirname(movedPath), `.hq-cli-backup-${process.pid}-${randomBytes(8).toString("hex")}`);
205
444
  renamePath(movedPath, backupPath);
@@ -215,6 +454,9 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
215
454
  publicLinkTarget: backupKind === "pnpm-public-link"
216
455
  ? publicLink?.target ?? symlinkTarget
217
456
  : publicLink?.target,
457
+ npmBinDirectory: npmBinLinks ? path.join(install.prefix, "bin") : undefined,
458
+ npmBinPackageRoot: npmBinLinks ? sourcePath : undefined,
459
+ npmBinLinks,
218
460
  };
219
461
  }
220
462
  catch {
@@ -260,7 +502,7 @@ function restoreInstall(snapshot, renamePath = fs.renameSync) {
260
502
  fs.mkdirSync(path.dirname(snapshot.publicLinkPath), { recursive: true });
261
503
  fs.symlinkSync(snapshot.publicLinkTarget, snapshot.publicLinkPath, process.platform === "win32" ? "junction" : "dir");
262
504
  }
263
- return true;
505
+ return restoreNpmBinLinks(snapshot);
264
506
  }
265
507
  catch {
266
508
  reportFailedRestore(snapshot);
@@ -458,6 +700,11 @@ async function updateAndReexec(argv, flavor, known, deps) {
458
700
  else {
459
701
  result = runner(plan.cmd, plan.args, updateEnv);
460
702
  }
703
+ if (result.ok && !staged && install.manager === "npm" && install.prefix && install.packageRoot) {
704
+ const bins = verifyOrRepairNpmBins(install.prefix, install.packageRoot);
705
+ if (!bins.ok)
706
+ result = bins;
707
+ }
461
708
  if (!result.ok && installSnapshot) {
462
709
  restoredAfterFailure = restoreInstall(installSnapshot, renamePath);
463
710
  if (restoredAfterFailure)
@@ -89,7 +89,7 @@ export function stagedNpmInstall(install, version, deps = {}) {
89
89
  }
90
90
  return { ok: false, detail: `could not move hq-cli ${version} into place: ${message(error)}` };
91
91
  }
92
- if (!linkBins(prefix, live, readBins(backup), tag)) {
92
+ if (!linkBins(prefix, live, readBins(backup), tag, rename)) {
93
93
  // `hq` does not reach the new package, so put the old one back.
94
94
  try {
95
95
  rename(live, staged);
@@ -99,10 +99,10 @@ export function stagedNpmInstall(install, version, deps = {}) {
99
99
  keepBackup = true;
100
100
  return {
101
101
  ok: false,
102
- detail: `could not link hq to hq-cli ${version} or restore the previous install from ${backup} (${message(error)})`,
102
+ detail: `could not verify or relink every declared hq-cli bin for ${version}, or restore the previous install from ${backup} (${message(error)})`,
103
103
  };
104
104
  }
105
- return { ok: false, detail: `could not point ${path.join(prefix, "bin", "hq")} at hq-cli ${version}` };
105
+ return { ok: false, detail: `could not verify or relink every declared hq-cli bin for ${version}` };
106
106
  }
107
107
  try {
108
108
  postinstall(live);
@@ -136,11 +136,11 @@ export function stagedNpmInstall(install, version, deps = {}) {
136
136
  * links already point at the live path. A link into another package, or a
137
137
  * regular file (someone's wrapper), is left alone.
138
138
  *
139
- * `hq` is settled first. If it does not resolve to a file, its link is put
140
- * back as it was, no other bin is touched, and this returns false so the
141
- * caller can restore the old package.
139
+ * `hq` must resolve into the live package or the caller restores the old
140
+ * package. Other declared bins owned by another tool are left alone and warned
141
+ * about; bins we own or need to create must resolve into the live package.
142
142
  */
143
- function linkBins(prefix, live, previousBins, tag) {
143
+ function linkBins(prefix, live, previousBins, tag, rename) {
144
144
  const binDir = path.join(prefix, "bin");
145
145
  const ownedByHqCli = (link) => {
146
146
  try {
@@ -156,7 +156,7 @@ function linkBins(prefix, live, previousBins, tag) {
156
156
  try {
157
157
  fs.mkdirSync(binDir, { recursive: true });
158
158
  fs.symlinkSync(want, temporary);
159
- fs.renameSync(temporary, link);
159
+ rename(temporary, link);
160
160
  }
161
161
  catch {
162
162
  try {
@@ -185,30 +185,70 @@ function linkBins(prefix, live, previousBins, tag) {
185
185
  pointAt(link, want);
186
186
  };
187
187
  const bins = readBins(live);
188
- const hqLink = path.join(binDir, "hq");
189
- const hqBefore = symlinkTarget(hqLink);
190
- if (bins.hq)
191
- linkBin("hq", bins.hq);
192
- if (!resolvesToFile(hqLink)) {
193
- const hqAfter = symlinkTarget(hqLink);
194
- if (hqAfter !== hqBefore) {
195
- if (hqBefore === null) {
188
+ if (typeof bins.hq !== "string" || bins.hq.length === 0)
189
+ return false;
190
+ const foreignBins = new Set();
191
+ for (const [name, target] of Object.entries(bins)) {
192
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\"))
193
+ return false;
194
+ const targetPath = path.resolve(live, target);
195
+ if (!targetPath.startsWith(`${path.resolve(live)}${path.sep}`) || !resolvesToFile(targetPath))
196
+ return false;
197
+ const link = path.join(binDir, name);
198
+ try {
199
+ const existing = fs.lstatSync(link, { throwIfNoEntry: false });
200
+ if (existing && (!existing.isSymbolicLink() || !ownedByHqCli(link))) {
201
+ if (name === "hq")
202
+ return false;
203
+ foreignBins.add(name);
204
+ console.error(`⚠ Leaving hq-cli bin entry at ${link} untouched; another tool owns it.`);
205
+ }
206
+ }
207
+ catch {
208
+ return false;
209
+ }
210
+ }
211
+ const changedBins = [];
212
+ const linkAndRecord = (name, target) => {
213
+ const link = path.join(binDir, name);
214
+ const before = symlinkTarget(link);
215
+ linkBin(name, target);
216
+ const after = symlinkTarget(link);
217
+ if (after !== before)
218
+ changedBins.push({ link, before });
219
+ };
220
+ const rollbackBinChanges = () => {
221
+ for (const change of changedBins.reverse()) {
222
+ const current = symlinkTarget(change.link);
223
+ if (current === null || !ownedByHqCli(change.link))
224
+ continue;
225
+ if (change.before === null) {
196
226
  try {
197
- fs.unlinkSync(hqLink);
227
+ fs.unlinkSync(change.link);
198
228
  }
199
229
  catch {
200
- // Nothing to undo.
230
+ // The staged update still fails; preserve the best-effort rollback result.
201
231
  }
202
232
  }
203
233
  else {
204
- pointAt(hqLink, hqBefore);
234
+ pointAt(change.link, change.before);
205
235
  }
206
236
  }
237
+ };
238
+ const hqLink = path.join(binDir, "hq");
239
+ linkAndRecord("hq", bins.hq);
240
+ if (!resolvesToPackageBin(hqLink, live, bins.hq)) {
241
+ rollbackBinChanges();
207
242
  return false;
208
243
  }
209
244
  for (const [name, target] of Object.entries(bins)) {
210
- if (name !== "hq")
211
- linkBin(name, target);
245
+ if (name === "hq" || foreignBins.has(name))
246
+ continue;
247
+ linkAndRecord(name, target);
248
+ if (!resolvesToPackageBin(path.join(binDir, name), live, target)) {
249
+ rollbackBinChanges();
250
+ return false;
251
+ }
212
252
  }
213
253
  for (const name of Object.keys(previousBins)) {
214
254
  if (name in bins)
@@ -240,6 +280,16 @@ function resolvesToFile(link) {
240
280
  return false;
241
281
  }
242
282
  }
283
+ function resolvesToPackageBin(link, packageRoot, target) {
284
+ try {
285
+ return fs.lstatSync(link).isSymbolicLink() &&
286
+ fs.realpathSync(link) === fs.realpathSync(path.resolve(packageRoot, target)) &&
287
+ fs.statSync(link).isFile();
288
+ }
289
+ catch {
290
+ return false;
291
+ }
292
+ }
243
293
  function readBins(packageRoot) {
244
294
  try {
245
295
  const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.297.0",
3
+ "version": "5.299.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {