@ahood/cli 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,13 +81,15 @@ Requires Node.js 18 or later.
81
81
  | `AHOOD_API_URL` | Overrides the registry endpoint (default: `https://ahood.vercel.app`). Must be `https://`, except for `localhost`/`127.0.0.1` or a `.test`/`.invalid`/`.example`/`.localhost` host, which may use plain `http://` for local development. |
82
82
  | `XDG_CONFIG_HOME` | If set, browser-login credentials are stored under `$XDG_CONFIG_HOME/ahood/credentials.json` instead of the default `~/.config/ahood/credentials.json`. |
83
83
 
84
- Paths `ahood` reads and writes in your project, none of which need to be gitignored (the lockfile is meant to be committed, same as `package-lock.json`):
84
+ Paths `ahood` reads and writes in your project. The lockfile is meant to be committed, same as `package-lock.json`:
85
85
 
86
86
  | Path | What's in it |
87
87
  | --- | --- |
88
88
  | `.claude/skills/<owner>@<skill>/` | Installed skill files. |
89
89
  | `.claude/agents/<owner>@<skill>.md` | An installed agent, as a single file (`@` owner/skill separator, since Claude Code's own subagent loader scans `.claude/agents/*.md` flat, non-recursively). |
90
90
  | `.claude/skills.lock.json` | Exact installed version + checksum per skill or agent, written by `add`/`update`, read by every install to verify integrity. |
91
+ | `.mcp.json` | MCP server entries, for `mcp`-kind artifacts only. Merged into rather than owned — other MCP clients share this file. **May contain secrets in plaintext**, since a server manifest can declare credentials that `add` prompts for; `add` warns when it writes one. |
92
+ | `*.json.tmp-<pid>-<hrtime>` | Never, in normal operation. Writes to the two `.json` files above go through a temp file that is renamed into place, and only a hard kill (SIGKILL, OOM, power loss) mid-write can strand one. A stranded copy of `.mcp.json` carries the same plaintext secrets, so **gitignore this pattern**. The next successful write removes any left by a process that is no longer running. |
91
93
 
92
94
  ## Usage examples
93
95
 
@@ -114,7 +114,7 @@ export async function extractTarGz(buffer, destDir) {
114
114
  const fullPath = resolve(destDir, entryPath);
115
115
  if (fullPath !== resolvedDest && !fullPath.startsWith(resolvedDest + sep)) {
116
116
  stream.resume();
117
- reject(new Error(`Refusing to extract unsafe archive entry: ${header.name}`));
117
+ reject(new Error(`Refusing to extract unsafe archive entry: ${sanitizeForTerminal(header.name)}`));
118
118
  // next() is deliberately NOT called -- nothing further in this archive
119
119
  // should be processed. destroy() tears the paused extractor down
120
120
  // rather than leaving it stalled mid-entry.
@@ -237,17 +237,24 @@ export async function extractSingleFileContent(buffer, entryName) {
237
237
  throw new Error(`${entryName} not found in the downloaded archive`);
238
238
  return found;
239
239
  }
240
- // Every manifest field used below reaches the terminal (an error message, or
240
+ // Every manifest field used below -- and every tar entry name extractTarGz
241
+ // quotes back in an error above -- reaches the terminal (an error message, or
241
242
  // -- worse -- the text printed immediately before a masked secret prompt)
242
- // but comes verbatim from a third-party-published server.json: server-side
243
+ // but comes verbatim from a third-party-published archive: server-side
243
244
  // validation only checks these are strings, not that they're free of
244
245
  // control/escape characters. A malicious description could otherwise smuggle
245
246
  // a terminal control sequence (cursor movement, line-clear) that repaints
246
247
  // what the user sees at the exact moment they're about to type a credential.
247
248
  // Strips C0/C1 control characters (including ESC, \x1b) and caps length so a
248
249
  // single field can't also flood the terminal.
250
+ //
251
+ // Takes `unknown` rather than `string` because every caller's "string" is a
252
+ // declared type over an unchecked `as ServerManifest` cast of downloaded JSON,
253
+ // not a guarantee: calling .replace() directly on a `registry_type: 123` would
254
+ // turn a diagnosable message into the raw TypeError ahood-cli#121 removed from
255
+ // this same path (ahood-cli#122).
249
256
  function sanitizeForTerminal(text) {
250
- return text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
257
+ return String(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
251
258
  }
252
259
  // Only npm+npx is supported in v1 (docs/superpowers/specs/2026-09-03-mcp-server-artifacts-design.md) --
253
260
  // server-side publish validation (lib/publish/parse-server-manifest.ts) already
@@ -260,7 +267,7 @@ function buildMcpServerConfig(manifest, env) {
260
267
  if (manifest.packages && manifest.packages.length === 1) {
261
268
  const pkg = manifest.packages[0];
262
269
  if (pkg.registry_type !== "npm" || pkg.runtime_hint !== "npx") {
263
- throw new Error(`${manifestName}'s server.json uses registry_type "${pkg.registry_type}"/runtime_hint "${pkg.runtime_hint}", which this version of ahood does not know how to install (only npm+npx is supported).`);
270
+ throw new Error(`${manifestName}'s server.json uses registry_type "${sanitizeForTerminal(pkg.registry_type)}"/runtime_hint "${sanitizeForTerminal(pkg.runtime_hint)}", which this version of ahood does not know how to install (only npm+npx is supported).`);
264
271
  }
265
272
  const config = { command: "npx", args: ["-y", `${pkg.identifier}@${pkg.version}`] };
266
273
  if (Object.keys(env).length > 0)
@@ -379,8 +386,8 @@ function assertNoCollision(fileContents, owner, skill) {
379
386
  `Remove it manually first if you want to reinstall ${owner}/${skill}.`);
380
387
  }
381
388
  }
382
- // Extracts server.json, validates its shape, and resolves every secret
383
- // environment variable it declares (prompting only for ones not already
389
+ // Extracts server.json, validates its shape, and resolves every
390
+ // environment variable it declares (prompting only for secrets not already
384
391
  // set in the shell) -- the part of installing an mcp entry that's identical
385
392
  // whether this is a fresh `add` or an `update` moving an existing pin
386
393
  // forward. Exported so update.ts's real mcp-update path (ahood-cli#169)
@@ -401,10 +408,38 @@ function assertNoCollision(fileContents, owner, skill) {
401
408
  // timeout -- against this CLI's "must never hang" rule -- or, worse, silently
402
409
  // bank the first line of unrelated piped input (`echo y | ahood skill
403
410
  // update`) as the credential and fingerprint over it.
411
+ //
412
+ // `secretNames` comes back alongside the config because `env` also carries
413
+ // non-secret configuration now (ahood-cli#120): callers that need to know
414
+ // whether what they just wrote to disk is actually a credential can no longer
415
+ // infer it from `env` being non-empty.
404
416
  export async function resolveMcpServerConfig(buffer, options = {}) {
405
417
  const { existingEnv, allowNonTtyPrompt = true } = options;
406
418
  const content = await extractSingleFileContent(buffer, "server.json");
407
- const manifest = JSON.parse(content.toString("utf-8"));
419
+ // Guarded the same way readMcpConfig guards .mcp.json: an unguarded parse
420
+ // surfaced a raw V8 SyntaxError with a stack instead of this CLI's usual
421
+ // single-line error (ahood-cli#121). The parser's own message is dropped
422
+ // rather than run through sanitizeForTerminal, because V8 quotes a snippet
423
+ // of the offending source in it -- that source is third-party archive
424
+ // content, and sanitizing would still paste it into the terminal, just
425
+ // with its control characters flattened.
426
+ let parsed;
427
+ try {
428
+ parsed = JSON.parse(content.toString("utf-8"));
429
+ }
430
+ catch {
431
+ throw new Error("The downloaded archive's server.json is not valid JSON -- this published version is malformed and cannot be installed.");
432
+ }
433
+ // Checked before the cast because buildMcpServerConfig immediately reads
434
+ // properties off the result: valid JSON that isn't an object (`[]`, `"x"`,
435
+ // `3`, `null`) would otherwise reach it and surface as an unactionable raw
436
+ // TypeError (ahood-cli#121). Deliberately stops at "is it an object" -- the
437
+ // manifest's actual shape is buildMcpServerConfig's job just below, and a
438
+ // second implementation of that check here would be free to drift from it.
439
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
440
+ throw new Error("The downloaded archive's server.json is valid JSON but its top level is not an object -- this published version is malformed and cannot be installed.");
441
+ }
442
+ const manifest = parsed;
408
443
  // Validate the manifest's shape (unsupported registry_type/runtime_hint,
409
444
  // or neither a single 'packages' nor a single 'remotes' entry) BEFORE
410
445
  // prompting for any secret -- an install/update that's going to be
@@ -414,10 +449,46 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
414
449
  // secretEnv is known.
415
450
  buildMcpServerConfig(manifest, {});
416
451
  const envVars = manifest.packages?.[0]?.environment_variables ?? [];
417
- const secretEnv = {};
452
+ const env = {};
453
+ // Non-secret variables used to be skipped outright, so a server declaring
454
+ // one installed into a config that could not start it, with no diagnostic
455
+ // (ahood-cli#120). They're configuration, not credentials: read them from
456
+ // the shell, but never prompt -- a prompt here would reintroduce exactly
457
+ // the unattended-use hazard ahood-cli#169/0.8.2 removed from the update
458
+ // path, and there is nothing to mask anyway.
459
+ //
460
+ // Resolved in a first pass, ahead of any masked prompt below, for the same
461
+ // reason buildMcpServerConfig is dry-run above and assertNoCollision runs
462
+ // before installMcpEntry's resolve: an install that's going to be refused
463
+ // for a missing required variable must not first cost the user a secret
464
+ // prompt.
465
+ for (const variable of envVars) {
466
+ if (variable.is_secret)
467
+ continue;
468
+ // Same truthiness rule as the secret branch below: an empty-string env
469
+ // var reads as "not set" rather than installing an empty value.
470
+ const fromEnv = process.env[variable.name];
471
+ if (fromEnv) {
472
+ env[variable.name] = fromEnv;
473
+ continue;
474
+ }
475
+ // `is_required` was parsed and validated but never consulted at install
476
+ // time (ahood-cli#120). Optional means optional -- install without it.
477
+ // Required means the server cannot start without it, so refusing beats
478
+ // writing a config that is broken in a way nothing reports.
479
+ if (variable.is_required) {
480
+ throw new Error(`${sanitizeForTerminal(variable.name)} is required by ${sanitizeForTerminal(manifest.name)} but is not set -- export ${sanitizeForTerminal(variable.name)} and re-run.`);
481
+ }
482
+ }
483
+ // Required-ness is consulted on the secret path only in the one branch
484
+ // where a secret can still end up unresolved: the unattended update guard
485
+ // below. Every other branch either resolves a value or prompts for one, so
486
+ // an `is_required` check there would change nothing (ahood-cli#120).
487
+ const secretNames = [];
418
488
  for (const variable of envVars) {
419
489
  if (!variable.is_secret)
420
490
  continue;
491
+ secretNames.push(variable.name);
421
492
  const fromEnv = process.env[variable.name];
422
493
  // Truthiness, not `!== undefined` -- matches credentials.ts's
423
494
  // resolveToken(), the precedent this feature was explicitly modeled on
@@ -425,24 +496,56 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
425
496
  // specifically so an empty-string env var falls through to the real
426
497
  // source instead of silently installing an empty secret.
427
498
  if (fromEnv) {
428
- secretEnv[variable.name] = fromEnv;
499
+ env[variable.name] = fromEnv;
429
500
  continue;
430
501
  }
431
502
  // Ordered after the env var deliberately: exporting the variable stays
432
503
  // the way to rotate a credential during an update, rather than being
433
- // shadowed by the stale value already on disk.
504
+ // shadowed by the stale value already on disk. Deliberately not extended
505
+ // to non-secret variables (ahood-cli#120): carrying a value forward from
506
+ // .mcp.json is credential-preservation machinery, and a non-secret is
507
+ // plain configuration that the shell should stay authoritative for.
434
508
  const carriedForward = existingEnv?.[variable.name];
435
509
  if (carriedForward) {
436
- secretEnv[variable.name] = carriedForward;
510
+ env[variable.name] = carriedForward;
437
511
  continue;
438
512
  }
439
513
  if (!allowNonTtyPrompt && !process.stdin.isTTY) {
514
+ // Optional means optional here too, exactly as it does on the
515
+ // non-secret pass above (ahood-cli#126). This guard used to fire for
516
+ // ANY unresolved secret, so a single artifact declaring an optional
517
+ // token that nothing had exported aborted the whole `ahood skill
518
+ // update` walk -- and said the variable "is required" while the
519
+ // manifest that declared it said otherwise. Announced rather than
520
+ // silent because skipping and carrying forward leave different entries
521
+ // in .mcp.json and nothing else distinguishes them; the notice is
522
+ // confined to this branch, so an interactive `add` (which still prompts
523
+ // for optional secrets) and a routine update whose credential carries
524
+ // forward both stay quiet.
525
+ if (!variable.is_required) {
526
+ console.warn(`${sanitizeForTerminal(variable.name)} is optional and is not set -- updating ${sanitizeForTerminal(manifest.name)} without it.`);
527
+ continue;
528
+ }
440
529
  throw new Error(`${sanitizeForTerminal(variable.name)} is required by ${sanitizeForTerminal(manifest.name)} but is not set, and there is no terminal to prompt on -- export ${sanitizeForTerminal(variable.name)} and re-run.`);
441
530
  }
442
- secretEnv[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
531
+ env[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
443
532
  }
444
- const serverConfig = buildMcpServerConfig(manifest, secretEnv);
445
- return { manifest, serverConfig };
533
+ const serverConfig = buildMcpServerConfig(manifest, env);
534
+ return { manifest, serverConfig, secretNames };
535
+ }
536
+ // The plaintext-secret warning printed after a successful mcp install/update.
537
+ // Still derived from the config that actually landed on disk -- the property
538
+ // the old bare `serverConfig.env` check was chosen for -- but intersected with
539
+ // the manifest's declared secrets, because `env` now also holds non-secret
540
+ // configuration (ahood-cli#120) and would otherwise warn about credentials
541
+ // for a server that declares none.
542
+ function warnIfSecretsWereWritten(serverConfig, secretNames) {
543
+ const env = serverConfig.env;
544
+ if (!env)
545
+ return;
546
+ if (!secretNames.some((name) => Object.prototype.hasOwnProperty.call(env, name)))
547
+ return;
548
+ console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
446
549
  }
447
550
  // Fingerprint of exactly what ahood wrote into .mcp.json's mcpServers.<skill>
448
551
  // entry, stored in the lockfile alongside the version/checksum pin. Lets
@@ -520,7 +623,7 @@ async function installMcpEntry(owner, skill, meta, buffer) {
520
623
  // something that's going to be refused anyway" reasoning, just for the
521
624
  // collision check specifically rather than the manifest-shape check.
522
625
  assertNoCollision(readMcpConfig(), owner, skill);
523
- const { serverConfig } = await resolveMcpServerConfig(buffer);
626
+ const { serverConfig, secretNames } = await resolveMcpServerConfig(buffer);
524
627
  // Read-modify-write under an advisory lock, mirroring lockfile.ts's own
525
628
  // writeLockfileEntry: .mcp.json sits right next to the lockfile and can
526
629
  // hold other servers' secrets, so it gets the same protection against a
@@ -558,13 +661,8 @@ async function installMcpEntry(owner, skill, meta, buffer) {
558
661
  }
559
662
  console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
560
663
  // Only the npm+npx package path ever carries secrets into `env` (headers
561
- // on a remote entry are static per v1's scope, per buildMcpServerConfig) --
562
- // checking the final serverConfig's own `env` key (rather than threading
563
- // secretEnv out of resolveMcpServerConfig separately) means this warning
564
- // can never drift from what was actually written to disk.
565
- if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
566
- console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
567
- }
664
+ // on a remote entry are static per v1's scope, per buildMcpServerConfig).
665
+ warnIfSecretsWereWritten(serverConfig, secretNames);
568
666
  }
569
667
  // Re-runs the mcp install flow for an already-installed entry, moving its
570
668
  // lockfile pin forward -- update.ts's real mcp-update path (ahood-cli#169).
@@ -610,7 +708,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
610
708
  // (no entry on disk at all) there's nothing to carry forward and a genuine
611
709
  // prompt is correct.
612
710
  const existingEnv = readMcpEntryEnv(existingOnDisk);
613
- const { serverConfig } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
711
+ const { serverConfig, secretNames } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
614
712
  // Read-modify-write under an advisory lock, mirroring installMcpEntry's
615
713
  // own pattern. Re-checks the fingerprint here too (not just above) since
616
714
  // arbitrary time may have passed since the pre-check -- the download, and
@@ -675,9 +773,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
675
773
  throw error;
676
774
  }
677
775
  console.log(`Updated ${key} to ${meta.version}`);
678
- if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
679
- console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
680
- }
776
+ warnIfSecretsWereWritten(serverConfig, secretNames);
681
777
  }
682
778
  // Downloads a specific version's archive and verifies it against the
683
779
  // checksum in `meta`, WITHOUT extracting anything -- the shared first half
package/dist/http.js CHANGED
@@ -37,6 +37,30 @@ export async function apiFetch(path, init = {}) {
37
37
  }
38
38
  }
39
39
  const MAX_ERROR_MESSAGE_LENGTH = 500;
40
+ // The HTML/length checks below (ahood-cli#31) let a *short, non-HTML* body
41
+ // through verbatim, which meant an error string could still carry ANSI escapes
42
+ // straight to the terminal (ahood-cli#127). That matters here more than in the
43
+ // archive-content path ahood-cli#122 hardened, because sanitizeErrorMessage
44
+ // sits on every apiJson call in the CLI -- including `add`, which prints
45
+ // server-sourced text immediately before a masked secret prompt, the exact
46
+ // moment a cursor-movement/line-clear sequence can repaint what the user sees.
47
+ //
48
+ // Control characters are replaced with a space rather than deleted so that
49
+ // stripping can't silently glue two tokens into one misleading word.
50
+ //
51
+ // Newlines (\x0a) and carriage returns (\x0d) are inside this range and are
52
+ // deliberately flattened too: a forged prompt line ("...\n\nEnter your token:")
53
+ // needs no escape sequence at all, just a newline, so preserving them would
54
+ // leave the spoofing half of the threat model open while closing the escape
55
+ // half. The readability cost is small because anything reaching this branch is
56
+ // already capped at MAX_ERROR_MESSAGE_LENGTH -- a few lines' worth of text, not
57
+ // a stack trace.
58
+ //
59
+ // Known duplication: this is the same substitution as `sanitizeForTerminal` in
60
+ // src/commands/add.ts (ahood-cli#122). It is copied rather than shared on
61
+ // purpose -- promoting a shared helper means editing add.ts's call sites, which
62
+ // is follow-up refactor work kept out of this security fix; see ahood-cli#127.
63
+ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g;
40
64
  // Defense in depth (ahood-cli#31): the API is expected to never forward a raw
41
65
  // upstream error body (an HTML block page from a proxy/WAF, a giant stack
42
66
  // trace, etc.) into `error`, but this CLI shouldn't trust that unconditionally
@@ -46,7 +70,7 @@ const MAX_ERROR_MESSAGE_LENGTH = 500;
46
70
  export function sanitizeErrorMessage(message) {
47
71
  const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(message);
48
72
  if (!looksLikeHtml && message.length <= MAX_ERROR_MESSAGE_LENGTH)
49
- return message;
73
+ return message.replace(CONTROL_CHARACTERS, " ");
50
74
  // No preview of the raw content: an HTML-shaped body's first bytes are
51
75
  // exactly where infra details (e.g. a Cloudflare block page's title, Ray
52
76
  // ID) live, so a "helpful" excerpt would leak the same details this
package/dist/lockfile.js CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
3
  export function readLockfile(path) {
4
4
  if (!existsSync(path))
5
5
  return {};
@@ -15,6 +15,63 @@ export function readLockfile(path) {
15
15
  throw new Error(`Lockfile at ${path} is corrupted and could not be parsed as JSON. Fix or delete it before continuing.`);
16
16
  }
17
17
  }
18
+ // Signal 0 doesn't deliver anything, it just probes whether the process could
19
+ // be signaled. ESRCH means it's gone; EPERM means it's alive but owned by
20
+ // someone else, which is conservatively treated as still alive since that
21
+ // can't be disproven. Shared by the stale-lock reclaim (ahood-cli#100) and the
22
+ // stale-temp sweep (ahood-cli#125) so both answer "is the process that left
23
+ // this behind still around?" the same way, rather than growing two divergent
24
+ // staleness rules.
25
+ function isPidDead(pid) {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return false;
29
+ }
30
+ catch (error) {
31
+ return error.code === "ESRCH";
32
+ }
33
+ }
34
+ // Exactly what the temp name below produces: a decimal pid and a decimal
35
+ // hrtime, nothing else. Anything the user merely happened to name
36
+ // `<basename>.tmp-something` fails this and is left alone -- deleting a file
37
+ // that was never ours is far worse than leaving a stale temp file behind
38
+ // (ahood-cli#125).
39
+ const TEMP_SUFFIX_RE = /^([1-9][0-9]{0,9})-[0-9]{1,25}$/;
40
+ // The finally in writeJsonFileAtomic can't run on SIGKILL, an OOM kill or
41
+ // power loss, so a temp file can still be orphaned in the window between the
42
+ // write and the rename -- and for .mcp.json that orphan holds resolved MCP
43
+ // server secrets under a name nothing else ever removes (ahood-cli#125). The
44
+ // next successful write to the same destination collects them.
45
+ //
46
+ // withLock serializes the writers this CLI itself starts, but a temp file is
47
+ // only provably collectable once the process that created it is gone, so the
48
+ // pid embedded in the name is checked rather than trusting the lock alone. A
49
+ // pid recycled since the last boot can only make a dead writer look alive,
50
+ // which just defers the cleanup; it can never make a live writer look dead, so
51
+ // this never deletes a file still being written.
52
+ function sweepStaleTempFiles(path, ownTempName) {
53
+ const dir = dirname(path);
54
+ const prefix = `${basename(path)}.tmp-`;
55
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
56
+ if (!entry.isFile())
57
+ continue;
58
+ if (entry.name === ownTempName)
59
+ continue;
60
+ if (!entry.name.startsWith(prefix))
61
+ continue;
62
+ const match = TEMP_SUFFIX_RE.exec(entry.name.slice(prefix.length));
63
+ if (!match)
64
+ continue;
65
+ if (!isPidDead(Number(match[1])))
66
+ continue;
67
+ try {
68
+ rmSync(join(dir, entry.name), { force: true });
69
+ }
70
+ catch {
71
+ // One unremovable orphan must not stop the rest of the sweep.
72
+ }
73
+ }
74
+ }
18
75
  // Generalized so callers other than the lockfile itself (e.g. add.ts's
19
76
  // .mcp.json merge, which sits right next to the lockfile and can hold other
20
77
  // servers' secrets) get the same crash-safety guarantee instead of a bare
@@ -25,22 +82,70 @@ export function writeJsonFileAtomic(path, data) {
25
82
  // truncated file on disk -- a reader always sees either the old or the new
26
83
  // complete content, never a partial one.
27
84
  const tmpPath = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}`;
28
- writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
29
- renameSync(tmpPath, path);
85
+ // The mode the destination must end up with. Preserving an existing file's
86
+ // permissions matters because .mcp.json is a project-shared config other MCP
87
+ // clients read: whether it should be 0644 or tighter is its own decision
88
+ // (ahood#169), not something this write path gets to change as a side effect
89
+ // of the 0600 temp file below (ahood-cli#119). With no destination yet there
90
+ // is nothing to preserve, so fall back to what a plain writeFileSync would
91
+ // have produced -- 0666 masked by the process umask.
92
+ let finalMode;
93
+ try {
94
+ finalMode = statSync(path).mode & 0o777;
95
+ }
96
+ catch {
97
+ finalMode = 0o666 & ~process.umask();
98
+ }
99
+ try {
100
+ // 0600 from the moment of creation: for .mcp.json this temp file holds
101
+ // resolved MCP server secrets, and it must never be world-readable, not
102
+ // even for the instant before the rename (ahood-cli#119).
103
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
104
+ renameSync(tmpPath, path);
105
+ try {
106
+ chmodSync(path, finalMode);
107
+ }
108
+ catch {
109
+ // Best-effort, and deliberately after the rename rather than on the temp
110
+ // file: widening the temp file first would reopen the world-readable
111
+ // window this fix closes. A failure here leaves the file MORE restrictive
112
+ // than intended, never less, so it isn't worth failing a write that has
113
+ // already landed -- the caller would roll back a successful install.
114
+ }
115
+ try {
116
+ sweepStaleTempFiles(path, basename(tmpPath));
117
+ }
118
+ catch {
119
+ // Swallowed for the same reason as the chmod above and #119's unlink:
120
+ // the rename has already landed, so failing to tidy up somebody else's
121
+ // abandoned temp file must never turn a successful write into an error
122
+ // the caller rolls back (ahood-cli#125).
123
+ }
124
+ }
125
+ finally {
126
+ // The rename consumes the temp file on success; this only bites when the
127
+ // write or the rename threw, where an orphaned *.tmp-* copy of .mcp.json
128
+ // would strand plaintext secrets under a name no .gitignore rule matches
129
+ // (ahood-cli#119). Swallowed because it must never mask the real reason
130
+ // the write failed -- that error is what the caller has to report.
131
+ try {
132
+ rmSync(tmpPath, { force: true });
133
+ }
134
+ catch {
135
+ // best effort, see comment above
136
+ }
137
+ }
30
138
  }
31
139
  function writeLockfile(path, lockfile) {
32
140
  writeJsonFileAtomic(path, lockfile);
33
141
  }
34
142
  // A lock directory is stale if the PID recorded inside it (written by the
35
- // acquirer below) belongs to a process that's no longer running -- signal 0
36
- // doesn't actually deliver a signal, just probes whether the process could
37
- // be signaled. ESRCH means it's dead; EPERM means it's alive but owned by
38
- // someone else, which is conservatively treated as still alive since that
39
- // can't be disproven. A missing/unparseable pid file is also treated as
40
- // "not stale" -- either a lock from before this file existed, or another
41
- // process is still mid-way through acquiring (mkdirSync succeeded, the pid
42
- // write hasn't landed yet) -- safer to wait it out than to reclaim a lock
43
- // that's actually still being set up.
143
+ // acquirer below) belongs to a process that's no longer running, per
144
+ // isPidDead above. A missing/unparseable pid file is treated as "not stale"
145
+ // -- either a lock from before this file existed, or another process is
146
+ // still mid-way through acquiring (mkdirSync succeeded, the pid write hasn't
147
+ // landed yet) -- safer to wait it out than to reclaim a lock that's actually
148
+ // still being set up.
44
149
  function isLockStale(pidFile) {
45
150
  let pidText;
46
151
  try {
@@ -52,13 +157,7 @@ function isLockStale(pidFile) {
52
157
  const pid = Number(pidText);
53
158
  if (!Number.isInteger(pid) || pid <= 0)
54
159
  return false;
55
- try {
56
- process.kill(pid, 0);
57
- return false;
58
- }
59
- catch (error) {
60
- return error.code === "ESRCH";
61
- }
160
+ return isPidDead(pid);
62
161
  }
63
162
  // Simple advisory lock via mkdir's atomicity (EEXIST on a second caller),
64
163
  // so two concurrent `ahood skill add`/`remove` invocations against the same
@@ -109,7 +208,7 @@ export function withLock(path, fn) {
109
208
  continue;
110
209
  }
111
210
  if (Date.now() > deadline) {
112
- throw new Error(`Timed out waiting for the lockfile lock at ${lockDir}. If no other ahood process is running, delete that directory manually.`);
211
+ throw new Error(`Timed out waiting for the lock on ${path} at ${lockDir}. If no other ahood process is running, delete that directory manually.`);
113
212
  }
114
213
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
115
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahood/cli",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "CLI for ahood, a registry for installing and publishing Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/AlexKay28/ahood-cli",