@ahood/cli 0.8.1 → 0.8.2
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/dist/commands/add.js +91 -19
- package/dist/commands/snap.js +8 -0
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -370,7 +370,23 @@ function assertNoCollision(fileContents, owner, skill) {
|
|
|
370
370
|
// forward. Exported so update.ts's real mcp-update path (ahood-cli#169)
|
|
371
371
|
// re-resolves secrets the same way a fresh install does, instead of
|
|
372
372
|
// duplicating this parsing/prompting logic.
|
|
373
|
-
|
|
373
|
+
// `existingEnv` carries forward the secret values already sitting in the
|
|
374
|
+
// entry being updated, so a routine `ahood skill update` doesn't re-prompt
|
|
375
|
+
// for a credential the user has already supplied. Only ever passed by
|
|
376
|
+
// updateMcpEntry, and only from an entry whose fingerprint it has just
|
|
377
|
+
// verified -- i.e. proven to be ahood's own unmodified entry, not something
|
|
378
|
+
// hand-edited or written by another tool.
|
|
379
|
+
//
|
|
380
|
+
// `allowNonTtyPrompt` (default true, preserving `echo secret | ahood skill
|
|
381
|
+
// add ...` for a fresh install the user explicitly asked for) is false on
|
|
382
|
+
// the update path: `ahood skill update` with no arguments walks EVERY locked
|
|
383
|
+
// entry, so a prompt there is never something the caller aimed at. Left
|
|
384
|
+
// interactive, an unattended update would hang on an idle pipe with no
|
|
385
|
+
// timeout -- against this CLI's "must never hang" rule -- or, worse, silently
|
|
386
|
+
// bank the first line of unrelated piped input (`echo y | ahood skill
|
|
387
|
+
// update`) as the credential and fingerprint over it.
|
|
388
|
+
export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
389
|
+
const { existingEnv, allowNonTtyPrompt = true } = options;
|
|
374
390
|
const content = await extractSingleFileContent(buffer, "server.json");
|
|
375
391
|
const manifest = JSON.parse(content.toString("utf-8"));
|
|
376
392
|
// Validate the manifest's shape (unsupported registry_type/runtime_hint,
|
|
@@ -392,9 +408,22 @@ export async function resolveMcpServerConfig(buffer) {
|
|
|
392
408
|
// (per the design spec), which uses `if (process.env.AHOOD_TOKEN)`
|
|
393
409
|
// specifically so an empty-string env var falls through to the real
|
|
394
410
|
// source instead of silently installing an empty secret.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
411
|
+
if (fromEnv) {
|
|
412
|
+
secretEnv[variable.name] = fromEnv;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
// Ordered after the env var deliberately: exporting the variable stays
|
|
416
|
+
// the way to rotate a credential during an update, rather than being
|
|
417
|
+
// shadowed by the stale value already on disk.
|
|
418
|
+
const carriedForward = existingEnv?.[variable.name];
|
|
419
|
+
if (carriedForward) {
|
|
420
|
+
secretEnv[variable.name] = carriedForward;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (!allowNonTtyPrompt && !process.stdin.isTTY) {
|
|
424
|
+
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.`);
|
|
425
|
+
}
|
|
426
|
+
secretEnv[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
|
|
398
427
|
}
|
|
399
428
|
const serverConfig = buildMcpServerConfig(manifest, secretEnv);
|
|
400
429
|
return { manifest, serverConfig };
|
|
@@ -413,6 +442,25 @@ export async function resolveMcpServerConfig(buffer) {
|
|
|
413
442
|
export function hashMcpServerConfig(config) {
|
|
414
443
|
return createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
|
415
444
|
}
|
|
445
|
+
// Pulls the `env` map out of an .mcp.json entry read off disk, so an update
|
|
446
|
+
// can carry its secrets forward (see resolveMcpServerConfig's `existingEnv`).
|
|
447
|
+
// Written defensively -- the entry is `unknown` here, and only a string value
|
|
448
|
+
// is usable as a credential -- even though the caller has already fingerprint-
|
|
449
|
+
// verified it, so that a shape this function can't read degrades into a
|
|
450
|
+
// prompt rather than a crash.
|
|
451
|
+
function readMcpEntryEnv(entry) {
|
|
452
|
+
if (typeof entry !== "object" || entry === null)
|
|
453
|
+
return undefined;
|
|
454
|
+
const env = entry.env;
|
|
455
|
+
if (typeof env !== "object" || env === null)
|
|
456
|
+
return undefined;
|
|
457
|
+
const result = {};
|
|
458
|
+
for (const [name, value] of Object.entries(env)) {
|
|
459
|
+
if (typeof value === "string")
|
|
460
|
+
result[name] = value;
|
|
461
|
+
}
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
416
464
|
async function installMcpEntry(owner, skill, meta, buffer) {
|
|
417
465
|
// Check for an existing .mcp.json collision BEFORE resolving secrets --
|
|
418
466
|
// matches resolveMcpServerConfig's own "don't cost the user a prompt for
|
|
@@ -503,7 +551,13 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
503
551
|
}
|
|
504
552
|
}
|
|
505
553
|
const buffer = await downloadVerifiedArchive(owner, skill, meta);
|
|
506
|
-
|
|
554
|
+
// Reuse the secrets already in the entry rather than re-prompting for
|
|
555
|
+
// them. Safe to trust specifically because the fingerprint check above has
|
|
556
|
+
// just proven this is ahood's own unmodified entry; in the self-heal case
|
|
557
|
+
// (no entry on disk at all) there's nothing to carry forward and a genuine
|
|
558
|
+
// prompt is correct.
|
|
559
|
+
const existingEnv = readMcpEntryEnv(existingOnDisk);
|
|
560
|
+
const { serverConfig } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
|
|
507
561
|
// Read-modify-write under an advisory lock, mirroring installMcpEntry's
|
|
508
562
|
// own pattern. Re-checks the fingerprint here too (not just above) since
|
|
509
563
|
// arbitrary time may have passed since the pre-check -- the download, and
|
|
@@ -529,25 +583,43 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
529
583
|
});
|
|
530
584
|
}
|
|
531
585
|
catch (error) {
|
|
532
|
-
if (!(error instanceof LockfileChecksumConflictError))
|
|
533
|
-
throw error;
|
|
534
586
|
// Roll back the .mcp.json overwrite above under the same lock pattern,
|
|
535
587
|
// restoring the entry to what it was before this update touched it --
|
|
536
588
|
// not deleting it, unlike installMcpEntry's rollback: unless this was
|
|
537
589
|
// the self-heal-a-missing-entry case (previousOnDisk undefined), there
|
|
538
590
|
// WAS a working entry here before this update began.
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
591
|
+
//
|
|
592
|
+
// Runs for EVERY lockfile failure, not just a checksum conflict. A
|
|
593
|
+
// rethrow-before-rollback here left .mcp.json holding the new config
|
|
594
|
+
// while the lockfile kept the old version and old mcp_config_hash, after
|
|
595
|
+
// which the fingerprint never matches again and both `update` and
|
|
596
|
+
// `remove` permanently refuse -- the exact dead end the comments above
|
|
597
|
+
// exist to prevent, reachable from nothing worse than withLock's 5s
|
|
598
|
+
// timeout against a concurrent ahood, or an EACCES/ENOSPC on the write.
|
|
599
|
+
try {
|
|
600
|
+
withLock(MCP_CONFIG_PATH, () => {
|
|
601
|
+
const fileContents = readMcpConfig();
|
|
602
|
+
const mcpServers = fileContents.mcpServers;
|
|
603
|
+
if (previousOnDisk === undefined) {
|
|
604
|
+
delete mcpServers[skill];
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
mcpServers[skill] = previousOnDisk;
|
|
608
|
+
}
|
|
609
|
+
writeJsonFileAtomic(MCP_CONFIG_PATH, fileContents);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
// The rollback can fail for the same reason the write did (lock still
|
|
614
|
+
// contended, disk still full). Warn rather than throw: the original
|
|
615
|
+
// error below is the more actionable one, and masking it with this
|
|
616
|
+
// one would hide why the update failed in the first place.
|
|
617
|
+
console.warn(`WARNING: could not roll back ${MCP_CONFIG_PATH} after ${key}'s update failed -- its mcpServers."${skill}" entry may be on the new version while ${LOCKFILE_PATH} still pins the old one. Delete that entry by hand and run \`ahood skill add ${key}\` to reinstall.`);
|
|
618
|
+
}
|
|
619
|
+
if (error instanceof LockfileChecksumConflictError) {
|
|
620
|
+
throw new Error(checksumConflictMessage(key, meta, error.existing));
|
|
621
|
+
}
|
|
622
|
+
throw error;
|
|
551
623
|
}
|
|
552
624
|
console.log(`Updated ${key} to ${meta.version}`);
|
|
553
625
|
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
package/dist/commands/snap.js
CHANGED
|
@@ -212,6 +212,14 @@ export async function unshareSnap(args) {
|
|
|
212
212
|
export async function tagsSnap(args) {
|
|
213
213
|
const jsonOutput = args.includes("--json");
|
|
214
214
|
const positionals = args.filter((a) => a !== "--json");
|
|
215
|
+
// Reject stray "--" flags rather than folding them into the tag list
|
|
216
|
+
// (parseSearchQuery in flags.ts does the same). Without this, the natural
|
|
217
|
+
// mistake `snap tags <id> --tags a,b` -- natural because `snap create`
|
|
218
|
+
// really does spell it `--tags a,b` -- silently PATCHed the tag set to
|
|
219
|
+
// ["--tags a", "b"], and `snap tags --yes <id>` used "--yes" as the id.
|
|
220
|
+
const unknownFlag = positionals.find((a) => a.startsWith("--"));
|
|
221
|
+
if (unknownFlag)
|
|
222
|
+
throw new UsageError(`Unknown flag: ${unknownFlag}\n${TAGS_USAGE}`);
|
|
215
223
|
const id = positionals[0];
|
|
216
224
|
if (!id)
|
|
217
225
|
throw new UsageError(TAGS_USAGE);
|
package/package.json
CHANGED