@newlandnpt/pep-cli 0.9.0-rc.4 → 0.9.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.
Files changed (2) hide show
  1. package/dist/pep.cjs +260 -91
  2. package/package.json +1 -1
package/dist/pep.cjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // src/auth-service.ts
5
5
  var import_promises2 = require("node:fs/promises");
6
6
  var import_node_path2 = require("node:path");
7
+ var import_node_crypto2 = require("node:crypto");
7
8
 
8
9
  // src/config.ts
9
10
  var import_promises = require("node:fs/promises");
@@ -53,8 +54,18 @@ function defaultSkillsDirectory() {
53
54
  function claudeSkillsDirectory() {
54
55
  return (0, import_node_path.join)((0, import_node_os.homedir)(), ".claude", "skills");
55
56
  }
57
+ function isStableDirectory(directory, api = {
58
+ isAbsolute: import_node_path.isAbsolute,
59
+ parse: import_node_path.parse,
60
+ sep: import_node_path.sep
61
+ }) {
62
+ if (!api.isAbsolute(directory)) return false;
63
+ const root = api.parse(directory).root;
64
+ if (api.sep === "/") return true;
65
+ return root.length > 1;
66
+ }
56
67
  function samePlace(a, b) {
57
- return a.toLowerCase() === b.toLowerCase();
68
+ return (0, import_node_path.resolve)(a).toLowerCase() === (0, import_node_path.resolve)(b).toLowerCase();
58
69
  }
59
70
  function userSkillsTarget() {
60
71
  return { directory: defaultSkillsDirectory(), linkInto: claudeSkillsDirectory() };
@@ -294,12 +305,12 @@ async function waitForAuthorizationCallback(args) {
294
305
  throw new Error("CLI redirect URI must be an http://localhost URL with an explicit port.");
295
306
  }
296
307
  let server;
297
- return new Promise((resolve2, reject) => {
308
+ return new Promise((resolve4, reject) => {
298
309
  const finish = (error, code) => {
299
310
  clearTimeout(timeout);
300
311
  server?.close();
301
312
  if (error) reject(error);
302
- else resolve2(code);
313
+ else resolve4(code);
303
314
  };
304
315
  const timeout = setTimeout(
305
316
  () => finish(new Error("Login timed out while waiting for the browser callback.")),
@@ -342,7 +353,7 @@ async function waitForAuthorizationCallback(args) {
342
353
  function openBrowser(url) {
343
354
  const command = process.platform === "win32" ? "rundll32.exe" : process.platform === "darwin" ? "open" : "xdg-open";
344
355
  const commandArgs = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
345
- return new Promise((resolve2, reject) => {
356
+ return new Promise((resolve4, reject) => {
346
357
  const child = (0, import_node_child_process.spawn)(command, commandArgs, {
347
358
  detached: true,
348
359
  stdio: "ignore",
@@ -351,7 +362,7 @@ function openBrowser(url) {
351
362
  child.once("error", reject);
352
363
  child.once("spawn", () => {
353
364
  child.unref();
354
- resolve2();
365
+ resolve4();
355
366
  });
356
367
  });
357
368
  }
@@ -462,7 +473,15 @@ var REFRESH_EARLY_MS = 60 * 1e3;
462
473
  var LOCK_STALE_MS = 30 * 1e3;
463
474
  var LOCK_WAIT_MS = 10 * 1e3;
464
475
  async function delay(milliseconds) {
465
- await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
476
+ await new Promise((resolve4) => setTimeout(resolve4, milliseconds));
477
+ }
478
+ async function releaseIfMine(path, stamp2) {
479
+ try {
480
+ if (await (0, import_promises2.readFile)(path, "utf8") !== stamp2) return;
481
+ } catch {
482
+ return;
483
+ }
484
+ await (0, import_promises2.unlink)(path).catch(() => void 0);
466
485
  }
467
486
  async function withAuthorizationLock(path, action) {
468
487
  await (0, import_promises2.mkdir)((0, import_node_path2.dirname)(path), { recursive: true });
@@ -470,11 +489,13 @@ async function withAuthorizationLock(path, action) {
470
489
  while (true) {
471
490
  try {
472
491
  const handle = await (0, import_promises2.open)(path, "wx", 384);
492
+ const stamp2 = `${process.pid}:${(0, import_node_crypto2.randomUUID)()}`;
493
+ await handle.writeFile(stamp2, "utf8");
473
494
  try {
474
495
  return await action();
475
496
  } finally {
476
497
  await handle.close();
477
- await (0, import_promises2.unlink)(path).catch(() => void 0);
498
+ await releaseIfMine(path, stamp2);
478
499
  }
479
500
  } catch (error) {
480
501
  if (error.code !== "EEXIST") throw error;
@@ -542,17 +563,22 @@ function createAuthService(dependencies) {
542
563
  code,
543
564
  pkce.verifier
544
565
  );
545
- const previous = await dependencies.credentialStore.read();
546
- await dependencies.credentialStore.write(authorization);
547
- await dependencies.configStore.write(normalized);
548
- if (previous) {
549
- try {
550
- const previousDiscovery = await dependencies.oauth.discover(previous.issuer);
551
- await dependencies.oauth.revoke(previousDiscovery, previous);
552
- } catch {
566
+ return withAuthorizationLock(
567
+ dependencies.authorizationLockPath ?? lockPath(),
568
+ async () => {
569
+ const previous = await dependencies.credentialStore.read();
570
+ await dependencies.credentialStore.write(authorization);
571
+ await dependencies.configStore.write(normalized);
572
+ if (previous) {
573
+ try {
574
+ const previousDiscovery = await dependencies.oauth.discover(previous.issuer);
575
+ await dependencies.oauth.revoke(previousDiscovery, previous);
576
+ } catch {
577
+ }
578
+ }
579
+ return authorization;
553
580
  }
554
- }
555
- return authorization;
581
+ );
556
582
  },
557
583
  currentAuthorization,
558
584
  async status() {
@@ -581,6 +607,9 @@ function createAuthService(dependencies) {
581
607
  };
582
608
  }
583
609
 
610
+ // src/cli.ts
611
+ var import_node_path5 = require("node:path");
612
+
584
613
  // src/credential-store.ts
585
614
  var import_node_child_process2 = require("node:child_process");
586
615
  var CREDENTIAL_TARGET = "PEP CLI OAuth Tokens";
@@ -656,7 +685,7 @@ function encodedPowerShell(script) {
656
685
  return Buffer.from(script, "utf16le").toString("base64");
657
686
  }
658
687
  async function runPowerShell(script, input) {
659
- return new Promise((resolve2, reject) => {
688
+ return new Promise((resolve4, reject) => {
660
689
  const child = (0, import_node_child_process2.spawn)(
661
690
  "powershell.exe",
662
691
  [
@@ -679,7 +708,7 @@ async function runPowerShell(script, input) {
679
708
  );
680
709
  child.once("close", (code) => {
681
710
  clearTimeout(timeout);
682
- if (code === 0) resolve2(stdout.trim());
711
+ if (code === 0) resolve4(stdout.trim());
683
712
  else
684
713
  reject(
685
714
  new Error(
@@ -700,14 +729,18 @@ function parseStoredAuthorization(raw) {
700
729
  }
701
730
  return candidate;
702
731
  }
732
+ function psLiteral(value) {
733
+ return `'${value.replaceAll("'", "''")}'`;
734
+ }
703
735
  function windowsCredentialStore(target = CREDENTIAL_TARGET) {
736
+ const quoted = psLiteral(target);
704
737
  const preamble = `Add-Type -TypeDefinition @'
705
738
  ${WINDOWS_CREDENTIAL_NATIVE}
706
739
  '@`;
707
740
  return {
708
741
  async read() {
709
742
  const script = `${preamble}
710
- $bytes = [PepCredential]::Read('${target}')
743
+ $bytes = [PepCredential]::Read(${quoted})
711
744
  if ($null -ne $bytes) { [Convert]::ToBase64String($bytes) }`;
712
745
  const encoded = await runPowerShell(script);
713
746
  if (!encoded) return null;
@@ -718,18 +751,18 @@ if ($null -ne $bytes) { [Convert]::ToBase64String($bytes) }`;
718
751
  async write(authorization) {
719
752
  const script = `${preamble}
720
753
  $raw = [Console]::In.ReadToEnd()
721
- [PepCredential]::Write('${target}', 'oauth', [Text.Encoding]::UTF8.GetBytes($raw))`;
754
+ [PepCredential]::Write(${quoted}, 'oauth', [Text.Encoding]::UTF8.GetBytes($raw))`;
722
755
  await runPowerShell(script, JSON.stringify(authorization));
723
756
  },
724
757
  async delete() {
725
758
  await runPowerShell(`${preamble}
726
- [PepCredential]::Delete('${target}')`);
759
+ [PepCredential]::Delete(${quoted})`);
727
760
  }
728
761
  };
729
762
  }
730
763
  var SECURITY_NOT_FOUND = 44;
731
764
  async function runSecurity(args) {
732
- return new Promise((resolve2, reject) => {
765
+ return new Promise((resolve4, reject) => {
733
766
  const child = (0, import_node_child_process2.spawn)("/usr/bin/security", [...args], {
734
767
  stdio: ["ignore", "pipe", "pipe"]
735
768
  });
@@ -744,7 +777,7 @@ async function runSecurity(args) {
744
777
  );
745
778
  child.once("close", (code) => {
746
779
  clearTimeout(timeout);
747
- resolve2({ code: code ?? -1, stdout, stderr });
780
+ resolve4({ code: code ?? -1, stdout, stderr });
748
781
  });
749
782
  });
750
783
  }
@@ -800,6 +833,45 @@ function systemCredentialStore() {
800
833
  );
801
834
  }
802
835
 
836
+ // src/http.ts
837
+ var HEADERS_TIMEOUT_MS = 15e3;
838
+ function explainNetworkFailure(error, what, url) {
839
+ const host = (() => {
840
+ try {
841
+ return new URL(url).host;
842
+ } catch {
843
+ return url;
844
+ }
845
+ })();
846
+ if (error instanceof Error && error.name === "TimeoutError") {
847
+ return new Error(
848
+ `${what}: ${host} did not respond within ${HEADERS_TIMEOUT_MS / 1e3}s. Check the network (VPN / proxy / firewall), then retry.`
849
+ );
850
+ }
851
+ const code = error?.cause?.code;
852
+ const hint = code === "ENOTFOUND" || code === "EAI_AGAIN" ? `${host} could not be resolved \u2014 check the address and your DNS.` : code === "ECONNREFUSED" ? `${host} refused the connection \u2014 it may be down, or you may need a VPN.` : code === "ECONNRESET" || code === "EPIPE" ? `the connection to ${host} was reset mid-flight. Retry; if it persists, suspect a proxy.` : typeof code === "string" && code.startsWith("CERT_") ? `${host} presented a certificate this machine does not trust (${code}).` : `could not reach ${host}${typeof code === "string" ? ` (${code})` : ""}.`;
853
+ return new Error(`${what}: ${hint}`);
854
+ }
855
+ async function fetchOrExplain(doFetch, url, init, what, timeoutMs = HEADERS_TIMEOUT_MS) {
856
+ const target = typeof url === "string" ? url : url.toString();
857
+ if (init.signal) {
858
+ try {
859
+ return await doFetch(url, init);
860
+ } catch (error) {
861
+ throw explainNetworkFailure(error, what, target);
862
+ }
863
+ }
864
+ const controller = new AbortController();
865
+ const timer = setTimeout(() => controller.abort(new DOMException("timed out", "TimeoutError")), timeoutMs);
866
+ try {
867
+ return await doFetch(url, { ...init, signal: controller.signal });
868
+ } catch (error) {
869
+ throw explainNetworkFailure(error, what, target);
870
+ } finally {
871
+ clearTimeout(timer);
872
+ }
873
+ }
874
+
803
875
  // src/docs-service.ts
804
876
  var ENTRY = /^-\s*\[([^\]]*)\]\(([^)]+)\)(?::\s*(.*))?$/;
805
877
  function parseDocsIndex(text) {
@@ -845,9 +917,12 @@ function describeFailure(status, what) {
845
917
  }
846
918
  async function get(dependencies, path, accept) {
847
919
  const doFetch = dependencies.fetch ?? globalThis.fetch;
848
- const response = await doFetch(`${dependencies.docsUrl.replace(/\/+$/, "")}${path}`, {
849
- headers: { Authorization: `Bearer ${dependencies.accessToken}`, Accept: accept }
850
- });
920
+ const response = await fetchOrExplain(
921
+ doFetch,
922
+ `${dependencies.docsUrl.replace(/\/+$/, "")}${path}`,
923
+ { headers: { Authorization: `Bearer ${dependencies.accessToken}`, Accept: accept } },
924
+ "Could not reach the documentation platform"
925
+ );
851
926
  if (!response.ok) {
852
927
  await response.body?.cancel();
853
928
  throw new Error(describeFailure(response.status, path));
@@ -966,7 +1041,10 @@ async function persistMavenEnv(credential, target = resolveTarget(), now = () =>
966
1041
  };
967
1042
  }
968
1043
  }
969
- await (0, import_promises3.writeFile)(target.path, plan.content, "utf8");
1044
+ const originalMode = await (0, import_promises3.stat)(target.path).then((info) => info.mode & 511).catch(() => 384);
1045
+ const temporary = `${target.path}.${process.pid}.tmp`;
1046
+ await (0, import_promises3.writeFile)(temporary, plan.content, { encoding: "utf8", mode: originalMode });
1047
+ await (0, import_promises3.rename)(temporary, target.path);
970
1048
  return plan.action === "replace" ? { status: "profile-replaced", path: target.path, backupPath } : { status: "profile-appended", path: target.path, ...backupPath ? { backupPath } : {} };
971
1049
  }
972
1050
 
@@ -1310,13 +1388,18 @@ async function errorCodeOf(response) {
1310
1388
  }
1311
1389
  async function fetchNexusCredential(dependencies) {
1312
1390
  const doFetch = dependencies.fetch ?? globalThis.fetch;
1313
- const response = await doFetch(`${dependencies.issuer.replace(/\/+$/, "")}${CREDENTIAL_PATH}`, {
1314
- method: "POST",
1315
- headers: {
1316
- Authorization: `Bearer ${dependencies.accessToken}`,
1317
- Accept: "application/json"
1318
- }
1319
- });
1391
+ const response = await fetchOrExplain(
1392
+ doFetch,
1393
+ `${dependencies.issuer.replace(/\/+$/, "")}${CREDENTIAL_PATH}`,
1394
+ {
1395
+ method: "POST",
1396
+ headers: {
1397
+ Authorization: `Bearer ${dependencies.accessToken}`,
1398
+ Accept: "application/json"
1399
+ }
1400
+ },
1401
+ "Could not reach PEP for Maven credentials"
1402
+ );
1320
1403
  if (!response.ok) {
1321
1404
  throw new Error(describeFailure2(response.status, await errorCodeOf(response)));
1322
1405
  }
@@ -1350,7 +1433,7 @@ async function setupNexusCredential(dependencies) {
1350
1433
  var import_promises4 = require("node:fs/promises");
1351
1434
  var import_node_os3 = require("node:os");
1352
1435
  var import_node_path4 = require("node:path");
1353
- var import_node_crypto2 = require("node:crypto");
1436
+ var import_node_crypto3 = require("node:crypto");
1354
1437
  var import_node_zlib = require("node:zlib");
1355
1438
 
1356
1439
  // src/tar.ts
@@ -1401,6 +1484,9 @@ function readTar(bytes) {
1401
1484
  const size = readOctal(header, SIZE_OFFSET, SIZE_LENGTH);
1402
1485
  const type = String.fromCharCode(header[TYPE_OFFSET]);
1403
1486
  const dataStart = offset + BLOCK;
1487
+ if (dataStart + size > bytes.length) {
1488
+ throw new Error("Malformed archive: an entry extends past the end of the stream.");
1489
+ }
1404
1490
  const data = bytes.subarray(dataStart, dataStart + size);
1405
1491
  offset = dataStart + Math.ceil(size / BLOCK) * BLOCK;
1406
1492
  if (type === "L") {
@@ -1438,6 +1524,11 @@ function tooBig(what, limit) {
1438
1524
  );
1439
1525
  }
1440
1526
  var SKILL_MANIFEST = "SKILL.md";
1527
+ function nameClash(owner, skill, directory) {
1528
+ return new Error(
1529
+ `${owner} already installs a skill named "${skill}" in ${directory}. Two repositories cannot both provide "${skill}" there \u2014 the second would silently overwrite the first. Install this one elsewhere (-p, or --dir <path>), or run \`pep skills remove ${owner}\` first.`
1530
+ );
1531
+ }
1441
1532
  function planSkillFiles(entries) {
1442
1533
  const roots = [];
1443
1534
  for (const entry of entries) {
@@ -1560,9 +1651,12 @@ async function listGroupRepos(dependencies) {
1560
1651
  const doFetch = dependencies.fetch ?? globalThis.fetch;
1561
1652
  const url = new URL(`${dependencies.issuer.replace(/\/+$/, "")}${GROUP_PATH}`);
1562
1653
  url.searchParams.set("path", dependencies.group);
1563
- const response = await doFetch(url, {
1564
- headers: { Authorization: `Bearer ${dependencies.accessToken}` }
1565
- });
1654
+ const response = await fetchOrExplain(
1655
+ doFetch,
1656
+ url,
1657
+ { headers: { Authorization: `Bearer ${dependencies.accessToken}` } },
1658
+ "Could not list the skills group"
1659
+ );
1566
1660
  if (!response.ok) {
1567
1661
  await response.body?.cancel();
1568
1662
  throw new Error(describeGroupFailure(response.status));
@@ -1570,8 +1664,8 @@ async function listGroupRepos(dependencies) {
1570
1664
  const raw = await readCapped(response, dependencies.maxListBytes ?? MAX_GROUP_LIST_BYTES);
1571
1665
  return parseGroupList(new TextDecoder().decode(raw));
1572
1666
  }
1573
- function groupMemberPath(ledgerKey, group) {
1574
- let path = ledgerKey.trim();
1667
+ function repoIdentity(address) {
1668
+ let path = address.trim();
1575
1669
  if (/^https?:\/\//i.test(path)) {
1576
1670
  try {
1577
1671
  path = new URL(path).pathname;
@@ -1579,13 +1673,22 @@ function groupMemberPath(ledgerKey, group) {
1579
1673
  return void 0;
1580
1674
  }
1581
1675
  }
1582
- path = (path.split("@")[0] ?? "").replace(/\.git$/i, "");
1583
- const segments = path.split("/").filter((one) => one !== "");
1584
- const groupSegments = group.split("/").filter((one) => one !== "");
1585
- if (groupSegments.length === 0 || segments.length <= groupSegments.length) return void 0;
1586
- const lower = segments.map((one) => one.toLowerCase());
1587
- if (!groupSegments.every((one, index) => one.toLowerCase() === lower[index])) return void 0;
1588
- return lower.join("/");
1676
+ path = path.split("@")[0] ?? "";
1677
+ const webRoute = path.indexOf("/-/");
1678
+ if (webRoute !== -1) path = path.slice(0, webRoute);
1679
+ path = path.replace(/\.git$/i, "");
1680
+ const segments = path.split("/").filter((one) => one !== "").map((one) => one.toLowerCase());
1681
+ return segments.length === 0 ? void 0 : segments.join("/");
1682
+ }
1683
+ function groupMemberPath(ledgerKey, group) {
1684
+ const path = repoIdentity(ledgerKey);
1685
+ const groupPath = repoIdentity(group);
1686
+ if (path === void 0 || groupPath === void 0) return void 0;
1687
+ const segments = path.split("/");
1688
+ const groupSegments = groupPath.split("/");
1689
+ if (segments.length <= groupSegments.length) return void 0;
1690
+ if (!groupSegments.every((one, index) => one === segments[index])) return void 0;
1691
+ return path;
1589
1692
  }
1590
1693
  async function physicalDir(path) {
1591
1694
  const absolute = (0, import_node_path4.resolve)(path);
@@ -1613,7 +1716,7 @@ async function linkSkill(canonicalSkillDir, linkPath) {
1613
1716
  }
1614
1717
  }
1615
1718
  function hashSkill(files) {
1616
- const hash = (0, import_node_crypto2.createHash)("sha256");
1719
+ const hash = (0, import_node_crypto3.createHash)("sha256");
1617
1720
  for (const file of [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
1618
1721
  hash.update(file.path);
1619
1722
  hash.update("\0");
@@ -1628,12 +1731,20 @@ function otherClaimant(packages, self, directory, skill) {
1628
1731
  ([other, pkg]) => other !== self && samePlace(pkg.directory, directory) && Object.keys(pkg.skills).some((one) => one.toLowerCase() === wanted)
1629
1732
  )?.[0];
1630
1733
  }
1734
+ function deletableLedgerDirectory(directory) {
1735
+ return isStableDirectory(directory) ? directory : void 0;
1736
+ }
1631
1737
  async function removeSkills(name, store) {
1632
1738
  const state = await store.read();
1633
1739
  const pkg = state?.packages[name];
1634
1740
  if (!state || !pkg) {
1635
1741
  throw new Error(`Not added: ${name}. Run \`pep skills list\` to see what is.`);
1636
1742
  }
1743
+ if (deletableLedgerDirectory(pkg.directory) === void 0) {
1744
+ throw new Error(
1745
+ `${name} was recorded with a relative install directory (${pkg.directory}) by an older pep-cli, so its real location is unknown \u2014 removing it here could delete files in the wrong project. Delete that directory yourself, then run \`pep skills add ${name}\` to re-record it with an absolute path.`
1746
+ );
1747
+ }
1637
1748
  const deleted = [];
1638
1749
  const keptForOthers = [];
1639
1750
  for (const skill of Object.keys(pkg.skills).sort()) {
@@ -1669,9 +1780,12 @@ async function updateSkills(dependencies) {
1669
1780
  const name = dependencies.source;
1670
1781
  const url = new URL(`${dependencies.issuer.replace(/\/+$/, "")}${ARCHIVE_PATH}`);
1671
1782
  url.searchParams.set("source", name);
1672
- const response = await doFetch(url, {
1673
- headers: { Authorization: `Bearer ${dependencies.accessToken}` }
1674
- });
1783
+ const response = await fetchOrExplain(
1784
+ doFetch,
1785
+ url,
1786
+ { headers: { Authorization: `Bearer ${dependencies.accessToken}` } },
1787
+ "Could not download the skills archive"
1788
+ );
1675
1789
  if (!response.ok) {
1676
1790
  await response.body?.cancel();
1677
1791
  throw new Error(describeFailure3(response.status));
@@ -1681,15 +1795,21 @@ async function updateSkills(dependencies) {
1681
1795
  const previous = state?.packages[name];
1682
1796
  const sameTarget = previous !== void 0 && samePlace(previous.directory, dependencies.directory) && (previous.linkedInto === void 0 ? dependencies.linkInto === void 0 : dependencies.linkInto !== void 0 && samePlace(previous.linkedInto, dependencies.linkInto));
1683
1797
  const claimedElsewhere = (skill) => otherClaimant(state?.packages ?? {}, name, dependencies.directory, skill);
1684
- const gone = previous !== void 0 && sameTarget ? await missingSkills(previous.directory, Object.keys(previous.skills)) : /* @__PURE__ */ new Set();
1685
- const nothingToRestore = !dependencies.restoreMissing || gone.size === 0;
1798
+ const previousSkills = Object.keys(previous?.skills ?? {});
1799
+ const gone = previous !== void 0 && sameTarget ? await missingSkills(previous.directory, previousSkills) : /* @__PURE__ */ new Set();
1800
+ const linksGone = dependencies.restoreMissing && previous !== void 0 && sameTarget && previous.linkedInto !== void 0 && dependencies.linkInto !== void 0 ? await missingSkills(previous.linkedInto, previousSkills) : /* @__PURE__ */ new Set();
1801
+ const nothingToRestore = !dependencies.restoreMissing || gone.size === 0 && linksGone.size === 0;
1686
1802
  const standing = Object.keys(previous?.skills ?? {}).flatMap((skill) => {
1687
1803
  const owner = claimedElsewhere(skill);
1688
1804
  return owner ? [{ skill, owner }] : [];
1689
1805
  });
1806
+ if (dependencies.restoreMissing && standing.length > 0) {
1807
+ const [first] = standing;
1808
+ throw nameClash(first.owner, first.skill, dependencies.directory);
1809
+ }
1690
1810
  if (commit !== void 0 && commit === previous?.commit && sameTarget && nothingToRestore) {
1691
1811
  await response.body?.cancel();
1692
- return { status: "unchanged", name, commit, conflicts: standing };
1812
+ return { status: "unchanged", name, commit, conflicts: standing, skipped: [...gone] };
1693
1813
  }
1694
1814
  const gzipped = await readCapped(response, dependencies.maxArchiveBytes ?? MAX_ARCHIVE_BYTES);
1695
1815
  let unpacked;
@@ -1721,11 +1841,7 @@ async function updateSkills(dependencies) {
1721
1841
  for (const skill of candidates) {
1722
1842
  const owner = claimedElsewhere(skill);
1723
1843
  if (owner === void 0) continue;
1724
- if (dependencies.restoreMissing) {
1725
- throw new Error(
1726
- `${owner} already installs a skill named "${skill}" in ${dependencies.directory}. Two repositories cannot both provide "${skill}" there \u2014 the second would silently overwrite the first. Install this one elsewhere (-p, or --dir <path>), or run \`pep skills remove ${owner}\` first.`
1727
- );
1728
- }
1844
+ if (dependencies.restoreMissing) throw nameClash(owner, skill, dependencies.directory);
1729
1845
  conflicts.push({ skill, owner });
1730
1846
  }
1731
1847
  const skills = candidates.filter((skill) => !conflicts.some((one) => one.skill === skill));
@@ -1745,7 +1861,9 @@ async function updateSkills(dependencies) {
1745
1861
  const stale = sameTarget ? removed : Object.keys(previous.skills);
1746
1862
  for (const skill of stale) {
1747
1863
  if (otherClaimant(state?.packages ?? {}, name, previous.directory, skill)) continue;
1748
- await (0, import_promises4.rm)((0, import_node_path4.join)(previous.directory, skill), { recursive: true, force: true });
1864
+ const stalePlace = deletableLedgerDirectory(previous.directory);
1865
+ if (stalePlace === void 0) continue;
1866
+ await (0, import_promises4.rm)((0, import_node_path4.join)(stalePlace, skill), { recursive: true, force: true });
1749
1867
  if (previous.linkedInto !== void 0) {
1750
1868
  await (0, import_promises4.rm)((0, import_node_path4.join)(previous.linkedInto, skill), { recursive: true, force: true });
1751
1869
  }
@@ -1830,11 +1948,11 @@ function driftedFromGroup(installedSources, groupRepos, group) {
1830
1948
  return member !== void 0 && !present.has(member);
1831
1949
  }).sort();
1832
1950
  }
1833
- async function installGroup(dependencies, remote) {
1951
+ async function installGroup(dependencies, remoteNow) {
1834
1952
  const group = dependencies.group;
1835
1953
  let repos;
1836
1954
  try {
1837
- repos = await dependencies.listGroupRepos(remote);
1955
+ repos = await dependencies.listGroupRepos(await remoteNow());
1838
1956
  } catch (error) {
1839
1957
  const message = messageOf(error);
1840
1958
  dependencies.onProgress(` \u2717 ${message}`);
@@ -1859,7 +1977,7 @@ async function installGroup(dependencies, remote) {
1859
1977
  for (const [index, source] of repos.entries()) {
1860
1978
  dependencies.onProgress(` [${index + 1}/${repos.length}] ${source}`);
1861
1979
  try {
1862
- installed.push({ source, ...await dependencies.installSkills(remote, source) });
1980
+ installed.push({ source, ...await dependencies.installSkills(await remoteNow(), source) });
1863
1981
  } catch (error) {
1864
1982
  const message = messageOf(error);
1865
1983
  failed.push({ source, message });
@@ -1899,15 +2017,17 @@ async function runSetup(dependencies) {
1899
2017
  login = { alreadySignedIn: false, account };
1900
2018
  dependencies.onProgress(` Signed in as ${account}.`);
1901
2019
  }
1902
- const authorization = signedIn?.authorization ?? await dependencies.auth.currentAuthorization();
1903
- const remote = { issuer: authorization.issuer, accessToken: authorization.accessToken };
2020
+ const remoteNow = async () => {
2021
+ const authorization = await dependencies.auth.currentAuthorization();
2022
+ return { issuer: authorization.issuer, accessToken: authorization.accessToken };
2023
+ };
1904
2024
  let skills;
1905
2025
  if (dependencies.skipSkills) {
1906
2026
  skills = { status: "skipped", reason: "--skip-skills" };
1907
2027
  dependencies.onProgress("\u2461 Installing skills\u2026 skipped (--skip-skills).");
1908
2028
  } else {
1909
2029
  dependencies.onProgress(`\u2461 Listing skills repositories in ${dependencies.group}\u2026`);
1910
- skills = await installGroup(dependencies, remote);
2030
+ skills = await installGroup(dependencies, remoteNow);
1911
2031
  }
1912
2032
  let nexus;
1913
2033
  if (dependencies.skipNexus) {
@@ -1916,7 +2036,7 @@ async function runSetup(dependencies) {
1916
2036
  } else {
1917
2037
  dependencies.onProgress("\u2462 Setting up Maven credentials\u2026");
1918
2038
  try {
1919
- nexus = { status: "done", result: await dependencies.setupNexus(remote) };
2039
+ nexus = { status: "done", result: await dependencies.setupNexus(await remoteNow()) };
1920
2040
  } catch (error) {
1921
2041
  nexus = { status: "failed", message: messageOf(error) };
1922
2042
  dependencies.onProgress(` \u2717 ${nexus.message}`);
@@ -1939,7 +2059,6 @@ function setupFailureSummary(report) {
1939
2059
  async function planSetup(dependencies) {
1940
2060
  const group = dependencies.group;
1941
2061
  const nexus = dependencies.skipNexus ? "skipped" : "planned";
1942
- const bySource = new Map(dependencies.installed.map((one) => [one.source, one]));
1943
2062
  let current;
1944
2063
  try {
1945
2064
  current = await dependencies.auth.status();
@@ -1988,22 +2107,28 @@ async function planSetup(dependencies) {
1988
2107
  } catch (error) {
1989
2108
  return { ...plan, skills: { status: "unavailable", reason: messageOf(error) } };
1990
2109
  }
1991
- const repositories = repos.map((path) => {
1992
- const known = bySource.get(path);
1993
- return known === void 0 ? { path, state: "new", directory: dependencies.directoryFor(path) } : {
1994
- path,
1995
- state: "installed",
1996
- directory: known.directory,
1997
- ...known.commit !== void 0 ? { commit: known.commit } : {}
1998
- };
1999
- });
2110
+ let repositories;
2111
+ try {
2112
+ repositories = repos.map((path) => {
2113
+ const known = dependencies.recordFor(path);
2114
+ const directory = dependencies.directoryFor(path);
2115
+ return known === void 0 ? { path, state: "new", directory } : {
2116
+ path,
2117
+ state: "installed",
2118
+ directory,
2119
+ ...known.commit !== void 0 ? { commit: known.commit } : {}
2120
+ };
2121
+ });
2122
+ } catch (error) {
2123
+ return { ...plan, skills: { status: "unavailable", reason: messageOf(error) } };
2124
+ }
2000
2125
  const gone = new Set(driftedFromGroup(dependencies.installed.map((one) => one.source), repos, group));
2001
2126
  const drifted = dependencies.installed.filter((one) => gone.has(one.source));
2002
2127
  return { ...plan, skills: { status: "planned", repositories, drifted } };
2003
2128
  }
2004
2129
 
2005
2130
  // src/cli.ts
2006
- var VERSION = false ? "0.0.0-dev" : "0.9.0-rc.4";
2131
+ var VERSION = false ? "0.0.0-dev" : "0.9.0";
2007
2132
  var SKILLS_ADD_DEPRECATION = "\u26A0 `pep skills add` will be removed in a future release.";
2008
2133
  function reportUpdate(result) {
2009
2134
  const conflicts = () => {
@@ -2012,8 +2137,14 @@ function reportUpdate(result) {
2012
2137
  console.error(` each other. \`pep skills remove\` one of them to settle it.`);
2013
2138
  }
2014
2139
  };
2140
+ const skipped = (list, name) => {
2141
+ if (list.length === 0) return;
2142
+ console.log(` deleted locally, left alone: ${list.join(" ")}`);
2143
+ console.log(` (\`pep skills add ${name}\` puts them back)`);
2144
+ };
2015
2145
  if (result.status === "unchanged") {
2016
2146
  console.log(`${result.name}: already at ${result.commit}. Nothing to do.`);
2147
+ skipped(result.skipped, result.name);
2017
2148
  conflicts();
2018
2149
  return;
2019
2150
  }
@@ -2023,10 +2154,7 @@ function reportUpdate(result) {
2023
2154
  if (result.unchanged.length > 0) {
2024
2155
  console.log(` unchanged: ${result.unchanged.join(" ")}`);
2025
2156
  }
2026
- if (result.skipped.length > 0) {
2027
- console.log(` deleted locally, left alone: ${result.skipped.join(" ")}`);
2028
- console.log(` (\`pep skills add ${result.name}\` puts them back)`);
2029
- }
2157
+ skipped(result.skipped, result.name);
2030
2158
  conflicts();
2031
2159
  console.log(` ${result.fileCount} file(s) in ${result.directory}`);
2032
2160
  if (result.linkedInto !== void 0) {
@@ -2117,17 +2245,52 @@ function parseSkillsArgs(command, args, cwd) {
2117
2245
  if (command !== "update" && args.length > 0) {
2118
2246
  throw new Error(`Unexpected argument: ${args[0]}`);
2119
2247
  }
2120
- const chosenTarget = explicitDirectory !== void 0 ? { directory: explicitDirectory } : project ? projectSkillsTarget(cwd) : void 0;
2248
+ const chosenTarget = explicitDirectory !== void 0 ? (
2249
+ // ⚠ **立刻转成绝对路径**,相对路径绝不能进账本。
2250
+ // 账上记的落点会在**另一个工作目录**下被重新使用(`targetForSource` 的中间那一档):
2251
+ // A 项目里 `--dir ./skills` 存下字面量 `./skills`,到 B 项目再跑,它就指向 B 的
2252
+ // `./skills` —— 写进去是覆盖别人的文件,换落点清理和 `remove` 那两条路更是直接
2253
+ // `rm -rf` 到 B 头上。`--project` 没有这个问题,因为它走 `join(cwd, …)`。
2254
+ // 基准用 `cwd` 而不是 `process.cwd()`:与同一处的 `projectSkillsTarget(cwd)` 同源,
2255
+ // 也让这条分支可测。
2256
+ { directory: (0, import_node_path5.resolve)(cwd ?? process.cwd(), explicitDirectory) }
2257
+ ) : project ? projectSkillsTarget(cwd) : void 0;
2121
2258
  return { ...requested !== void 0 ? { requested } : {}, named, ...chosenTarget ? { chosenTarget } : {} };
2122
2259
  }
2123
- function targetForSource(chosen, recorded) {
2260
+ async function readLedger(store, skipSkills, warn) {
2261
+ try {
2262
+ return await store.read();
2263
+ } catch (error) {
2264
+ if (!skipSkills) throw error;
2265
+ const detail = error instanceof Error ? error.message : String(error);
2266
+ warn(` \u26A0 Could not read the skills ledger (${detail}). Continuing because --skip-skills was given.`);
2267
+ return null;
2268
+ }
2269
+ }
2270
+ function targetForSource(chosen, recorded, source = "this repository") {
2124
2271
  if (chosen) return chosen;
2125
2272
  if (!recorded) return userSkillsTarget();
2273
+ if (!isStableDirectory(recorded.directory)) {
2274
+ throw new Error(
2275
+ `${source} was recorded with a relative install directory (${recorded.directory}) by an older pep-cli, so its real location depends on where you run this from \u2014 continuing could overwrite files in the current project. Pass --dir <absolute path> (or --project) to say where it belongs, or run \`pep skills remove\` on it first.`
2276
+ );
2277
+ }
2126
2278
  return {
2127
2279
  directory: recorded.directory,
2128
2280
  ...recorded.linkedInto !== void 0 ? { linkInto: recorded.linkedInto } : {}
2129
2281
  };
2130
2282
  }
2283
+ function recordedFor(packages, source) {
2284
+ if (!packages) return void 0;
2285
+ const exact = packages[source];
2286
+ if (exact !== void 0) return { key: source, pkg: exact };
2287
+ const wanted = repoIdentity(source);
2288
+ if (wanted === void 0) return void 0;
2289
+ for (const [key, pkg] of Object.entries(packages)) {
2290
+ if (repoIdentity(key) === wanted) return { key, pkg };
2291
+ }
2292
+ return void 0;
2293
+ }
2131
2294
  function nexusReport(result, printEnv) {
2132
2295
  const stdout = printEnv ? [result.lines] : [];
2133
2296
  const stderr = [];
@@ -2227,7 +2390,7 @@ async function main() {
2227
2390
  const skillsGroup = requested ?? DEFAULT_SKILLS_GROUP;
2228
2391
  const { configStore: configStore2, auth: auth2 } = createCliServices();
2229
2392
  const stateStore = fileSkillsStateStore();
2230
- const state = await stateStore.read();
2393
+ const state = await readLedger(stateStore, skipSkills, (line) => console.error(line));
2231
2394
  const installed = Object.entries(state?.packages ?? {}).map(([source, pkg]) => ({
2232
2395
  source,
2233
2396
  directory: pkg.directory,
@@ -2245,7 +2408,11 @@ async function main() {
2245
2408
  installed,
2246
2409
  // ⚠ 与真跑**同一条推导**(`targetForSource`):干跑报的落点要是和真跑落的地方
2247
2410
  // 不一样,那它比没有还坏。
2248
- directoryFor: (source) => targetForSource(chosenTarget, state?.packages[source]).directory
2411
+ directoryFor: (source) => targetForSource(chosenTarget, recordedFor(state?.packages, source)?.pkg, source).directory,
2412
+ // ⚠ 与真跑**共用同一个查账函数**。干跑自己再建一份索引的话,两边的重复键策略会分叉
2413
+ // (Map 是后者覆盖,`recordedFor` 是精确优先),于是预览的落点与 commit 可能来自
2414
+ // 账上两条不同的记录。
2415
+ recordFor: (source) => recordedFor(state?.packages, source)?.pkg
2249
2416
  });
2250
2417
  console.log(JSON.stringify(plan, null, 2));
2251
2418
  if (plan.skills.status === "unavailable") {
@@ -2264,10 +2431,12 @@ async function main() {
2264
2431
  skipSkills,
2265
2432
  listGroupRepos: (remote) => listGroupRepos({ ...remote, group: skillsGroup }),
2266
2433
  installSkills: async (remote, source) => {
2267
- const target = targetForSource(chosenTarget, state?.packages[source]);
2434
+ const recorded = recordedFor(state?.packages, source);
2435
+ const target = targetForSource(chosenTarget, recorded?.pkg, source);
2268
2436
  const result = await updateSkills({
2269
2437
  ...remote,
2270
- source,
2438
+ // 原键还回去 —— 更新那一行,而不是按裸路径另起一条。
2439
+ source: recorded?.key ?? source,
2271
2440
  directory: target.directory,
2272
2441
  ...target.linkInto !== void 0 ? { linkInto: target.linkInto } : {},
2273
2442
  // `add` 那一档的语义:盘上被删掉的那些由它补回来(`update` 不管)。
@@ -2423,7 +2592,7 @@ ${usage(VERSION, ["skills"])}`);
2423
2592
  console.error(SKILLS_ADD_DEPRECATION);
2424
2593
  await run2(
2425
2594
  requested,
2426
- targetForSource(chosenTarget, state?.packages[requested]),
2595
+ targetForSource(chosenTarget, state?.packages[requested], requested),
2427
2596
  true
2428
2597
  );
2429
2598
  return;
@@ -2454,7 +2623,7 @@ ${usage(VERSION, ["skills"])}`);
2454
2623
  console.error(`Not installed in ${chosenTarget?.directory}, skipped: ${skipped.join(" ")}`);
2455
2624
  }
2456
2625
  for (const source of targets) {
2457
- await run2(source, targetForSource(void 0, state?.packages[source]));
2626
+ await run2(source, targetForSource(void 0, state?.packages[source], source));
2458
2627
  }
2459
2628
  return;
2460
2629
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newlandnpt/pep-cli",
3
- "version": "0.9.0-rc.4",
3
+ "version": "0.9.0",
4
4
  "description": "PEP CLI —— 凭 PEP 身份读取 Newland 开发者文档与 agent skills",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",