@newlandnpt/pep-cli 0.1.2 → 0.3.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 +480 -89
  2. package/package.json +1 -1
package/dist/pep.cjs CHANGED
@@ -11,8 +11,16 @@ var import_node_os = require("node:os");
11
11
  var import_node_path = require("node:path");
12
12
  var DEFAULT_CLIENT_ID = "pep-cli";
13
13
  var DEFAULT_REDIRECT_URI = "http://localhost:53682/callback";
14
- var DEFAULT_SCOPES = ["openid", "profile", "email", "docs:read", "skills:read"];
14
+ var DEFAULT_SCOPES = [
15
+ "openid",
16
+ "profile",
17
+ "email",
18
+ "docs:read",
19
+ "skills:read",
20
+ "nexus-credentials:write"
21
+ ];
15
22
  var DEFAULT_RESOURCES = ["urn:newland:pep:docs"];
23
+ var LEGACY_PACKAGE = "semi-integration";
16
24
  var DEFAULT_DOCS_URL = "https://pep-developer-docs.onrender.com";
17
25
  function issuerForEnvironment(environment) {
18
26
  if (environment === "production") return "https://pep.newlandnpt.us";
@@ -76,15 +84,75 @@ function fileConfigStore(path = configPath()) {
76
84
  function isSkillsState(value) {
77
85
  if (typeof value !== "object" || value === null) return false;
78
86
  const candidate = value;
79
- return candidate.version === 1 && typeof candidate.directory === "string" && Array.isArray(candidate.skills) && candidate.skills.every((one) => typeof one === "string") && (candidate.commit === void 0 || typeof candidate.commit === "string") && // 可选:0.1.1 及更早写下的 skills.json 没有这个键,读它们不该报「状态损坏」。
80
- (candidate.linkedInto === void 0 || typeof candidate.linkedInto === "string");
87
+ if (candidate.version !== 3) return false;
88
+ if (typeof candidate.directory !== "string") return false;
89
+ if (candidate.linkedInto !== void 0 && typeof candidate.linkedInto !== "string") return false;
90
+ return isPackageMap(candidate.packages, (skills) => isStringMap(skills));
91
+ }
92
+ function isSkillsStateV2(value) {
93
+ if (typeof value !== "object" || value === null) return false;
94
+ const candidate = value;
95
+ if (candidate.version !== 2) return false;
96
+ if (typeof candidate.directory !== "string") return false;
97
+ if (candidate.linkedInto !== void 0 && typeof candidate.linkedInto !== "string") return false;
98
+ return isPackageMap(
99
+ candidate.packages,
100
+ (skills) => Array.isArray(skills) && skills.every((one) => typeof one === "string")
101
+ );
102
+ }
103
+ function isSkillsStateV1(value) {
104
+ if (typeof value !== "object" || value === null) return false;
105
+ const candidate = value;
106
+ return candidate.version === 1 && typeof candidate.directory === "string" && Array.isArray(candidate.skills) && candidate.skills.every((one) => typeof one === "string") && (candidate.commit === void 0 || typeof candidate.commit === "string") && (candidate.linkedInto === void 0 || typeof candidate.linkedInto === "string");
107
+ }
108
+ function isPackageMap(value, skillsOk) {
109
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
110
+ return Object.values(value).every((one) => {
111
+ if (typeof one !== "object" || one === null || Array.isArray(one)) return false;
112
+ const pkg = one;
113
+ return skillsOk(pkg.skills) && (pkg.commit === void 0 || typeof pkg.commit === "string");
114
+ });
115
+ }
116
+ function isStringMap(value) {
117
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
118
+ return Object.values(value).every((one) => typeof one === "string");
119
+ }
120
+ function migrateSkillsState(old) {
121
+ const base = {
122
+ version: 3,
123
+ directory: old.directory,
124
+ ...old.linkedInto !== void 0 ? { linkedInto: old.linkedInto } : {}
125
+ };
126
+ const unknownHashes = (names) => Object.fromEntries(names.map((name) => [name, ""]));
127
+ if (old.version === 1) {
128
+ return {
129
+ ...base,
130
+ packages: {
131
+ [LEGACY_PACKAGE]: {
132
+ ...old.commit !== void 0 ? { commit: old.commit } : {},
133
+ skills: unknownHashes(old.skills)
134
+ }
135
+ }
136
+ };
137
+ }
138
+ return {
139
+ ...base,
140
+ packages: Object.fromEntries(
141
+ Object.entries(old.packages).map(([source, pkg]) => [
142
+ source,
143
+ { ...pkg.commit !== void 0 ? { commit: pkg.commit } : {}, skills: unknownHashes(pkg.skills) }
144
+ ])
145
+ )
146
+ };
81
147
  }
82
148
  function fileSkillsStateStore(path = skillsStatePath()) {
83
149
  return {
84
150
  async read() {
85
151
  try {
86
152
  const parsed = JSON.parse(await (0, import_promises.readFile)(path, "utf8"));
87
- return isSkillsState(parsed) ? parsed : null;
153
+ if (isSkillsState(parsed)) return parsed;
154
+ if (isSkillsStateV2(parsed) || isSkillsStateV1(parsed)) return migrateSkillsState(parsed);
155
+ return null;
88
156
  } catch (error) {
89
157
  if (error.code === "ENOENT") return null;
90
158
  if (error instanceof SyntaxError) return null;
@@ -196,12 +264,12 @@ async function waitForAuthorizationCallback(args) {
196
264
  throw new Error("CLI redirect URI must be an http://localhost URL with an explicit port.");
197
265
  }
198
266
  let server;
199
- return new Promise((resolve, reject) => {
267
+ return new Promise((resolve2, reject) => {
200
268
  const finish = (error, code) => {
201
269
  clearTimeout(timeout);
202
270
  server?.close();
203
271
  if (error) reject(error);
204
- else resolve(code);
272
+ else resolve2(code);
205
273
  };
206
274
  const timeout = setTimeout(
207
275
  () => finish(new Error("Login timed out while waiting for the browser callback.")),
@@ -244,7 +312,7 @@ async function waitForAuthorizationCallback(args) {
244
312
  function openBrowser(url) {
245
313
  const command = process.platform === "win32" ? "rundll32.exe" : process.platform === "darwin" ? "open" : "xdg-open";
246
314
  const commandArgs = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
247
- return new Promise((resolve, reject) => {
315
+ return new Promise((resolve2, reject) => {
248
316
  const child = (0, import_node_child_process.spawn)(command, commandArgs, {
249
317
  detached: true,
250
318
  stdio: "ignore",
@@ -253,7 +321,7 @@ function openBrowser(url) {
253
321
  child.once("error", reject);
254
322
  child.once("spawn", () => {
255
323
  child.unref();
256
- resolve();
324
+ resolve2();
257
325
  });
258
326
  });
259
327
  }
@@ -364,7 +432,7 @@ var REFRESH_EARLY_MS = 60 * 1e3;
364
432
  var LOCK_STALE_MS = 30 * 1e3;
365
433
  var LOCK_WAIT_MS = 10 * 1e3;
366
434
  async function delay(milliseconds) {
367
- await new Promise((resolve) => setTimeout(resolve, milliseconds));
435
+ await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
368
436
  }
369
437
  async function withAuthorizationLock(path, action) {
370
438
  await (0, import_promises2.mkdir)((0, import_node_path2.dirname)(path), { recursive: true });
@@ -558,7 +626,7 @@ function encodedPowerShell(script) {
558
626
  return Buffer.from(script, "utf16le").toString("base64");
559
627
  }
560
628
  async function runPowerShell(script, input) {
561
- return new Promise((resolve, reject) => {
629
+ return new Promise((resolve2, reject) => {
562
630
  const child = (0, import_node_child_process2.spawn)(
563
631
  "powershell.exe",
564
632
  [
@@ -581,7 +649,7 @@ async function runPowerShell(script, input) {
581
649
  );
582
650
  child.once("close", (code) => {
583
651
  clearTimeout(timeout);
584
- if (code === 0) resolve(stdout.trim());
652
+ if (code === 0) resolve2(stdout.trim());
585
653
  else
586
654
  reject(
587
655
  new Error(
@@ -631,7 +699,7 @@ $raw = [Console]::In.ReadToEnd()
631
699
  }
632
700
  var SECURITY_NOT_FOUND = 44;
633
701
  async function runSecurity(args) {
634
- return new Promise((resolve, reject) => {
702
+ return new Promise((resolve2, reject) => {
635
703
  const child = (0, import_node_child_process2.spawn)("/usr/bin/security", [...args], {
636
704
  stdio: ["ignore", "pipe", "pipe"]
637
705
  });
@@ -646,7 +714,7 @@ async function runSecurity(args) {
646
714
  );
647
715
  child.once("close", (code) => {
648
716
  clearTimeout(timeout);
649
- resolve({ code: code ?? -1, stdout, stderr });
717
+ resolve2({ code: code ?? -1, stdout, stderr });
650
718
  });
651
719
  });
652
720
  }
@@ -765,10 +833,173 @@ async function fetchDocContent(dependencies, path) {
765
833
  return response.text();
766
834
  }
767
835
 
768
- // src/skills-service.ts
836
+ // src/maven-env.ts
769
837
  var import_promises3 = require("node:fs/promises");
770
838
  var import_node_os2 = require("node:os");
771
839
  var import_node_path3 = require("node:path");
840
+ var import_node_child_process3 = require("node:child_process");
841
+ var import_node_util = require("node:util");
842
+ var run = (0, import_node_util.promisify)(import_node_child_process3.execFile);
843
+ var USERNAME_VAR = "NEWLAND_MAVEN_USERNAME";
844
+ var PASSWORD_VAR = "NEWLAND_MAVEN_PASSWORD";
845
+ var BEGIN = "# >>> pep-cli: Newland Maven credentials >>>";
846
+ var END = "# <<< pep-cli: Newland Maven credentials <<<";
847
+ function resolveTarget(os = (0, import_node_os2.platform)(), shell = process.env.SHELL) {
848
+ if (os === "win32") return { kind: "windows" };
849
+ const file = shell?.endsWith("/bash") ? ".bash_profile" : ".zshrc";
850
+ return { kind: "profile", path: (0, import_node_path3.join)((0, import_node_os2.homedir)(), file) };
851
+ }
852
+ function shellQuote(value) {
853
+ return `'${value.replace(/'/g, `'\\''`)}'`;
854
+ }
855
+ function exportLines(credential, os = (0, import_node_os2.platform)()) {
856
+ if (os === "win32") {
857
+ return `set ${USERNAME_VAR}=${credential.username}
858
+ set ${PASSWORD_VAR}=${credential.password}`;
859
+ }
860
+ return [
861
+ `export ${USERNAME_VAR}=${shellQuote(credential.username)}`,
862
+ `export ${PASSWORD_VAR}=${shellQuote(credential.password)}`
863
+ ].join("\n");
864
+ }
865
+ function profileBlock(credential) {
866
+ return [
867
+ BEGIN,
868
+ "# \u7531 `pep nexus setup` \u5199\u5165\u3002\u8FD9\u4E00\u6BB5\u4F1A\u88AB\u4E0B\u4E00\u6B21\u8FD0\u884C\u6574\u4F53\u66FF\u6362\uFF0C\u624B\u6539\u65E0\u6548\u3002",
869
+ `export ${USERNAME_VAR}=${shellQuote(credential.username)}`,
870
+ `export ${PASSWORD_VAR}=${shellQuote(credential.password)}`,
871
+ END
872
+ ].join("\n");
873
+ }
874
+ function planProfile(existing, credential) {
875
+ const block = profileBlock(credential);
876
+ if (existing === null || existing.trim() === "") {
877
+ return { action: "append", content: `${block}
878
+ ` };
879
+ }
880
+ const begin = existing.indexOf(BEGIN);
881
+ const end = existing.indexOf(END);
882
+ if (begin === -1 && end === -1) {
883
+ const separator = existing.endsWith("\n") ? "" : "\n";
884
+ return { action: "append", content: `${existing}${separator}
885
+ ${block}
886
+ ` };
887
+ }
888
+ if (begin === -1 || end === -1 || end < begin) {
889
+ return {
890
+ action: "manual",
891
+ reason: "profile \u91CC pep-cli \u7684\u56F4\u680F\u53EA\u5269\u534A\u6761\uFF08\u88AB\u624B\u6539\u8FC7\uFF0C\u6216\u4E0A\u6B21\u5199\u4E86\u4E00\u534A\uFF09\uFF0C\u4E0D\u731C\u8FB9\u754C"
892
+ };
893
+ }
894
+ if (existing.indexOf(BEGIN, begin + 1) !== -1) {
895
+ return { action: "manual", reason: "profile \u91CC\u6709\u4E0D\u6B62\u4E00\u6BB5 pep-cli \u56F4\u680F\uFF0C\u4E0D\u731C\u8BE5\u6362\u54EA\u4E00\u6BB5" };
896
+ }
897
+ return {
898
+ action: "replace",
899
+ content: existing.slice(0, begin) + block + existing.slice(end + END.length)
900
+ };
901
+ }
902
+ function stamp(at) {
903
+ return at.toISOString().replace(/\.\d+Z$/, "").replace(/:/g, "");
904
+ }
905
+ async function persistMavenEnv(credential, target = resolveTarget(), now = () => /* @__PURE__ */ new Date()) {
906
+ if (target.kind === "windows") {
907
+ await run("setx", [USERNAME_VAR, credential.username]);
908
+ await run("setx", [PASSWORD_VAR, credential.password]);
909
+ return { status: "windows" };
910
+ }
911
+ const existing = await (0, import_promises3.readFile)(target.path, "utf8").catch((error) => {
912
+ if (error.code === "ENOENT") return null;
913
+ throw error;
914
+ });
915
+ const plan = planProfile(existing, credential);
916
+ if (plan.action === "manual") {
917
+ return { status: "manual", reason: plan.reason, path: target.path };
918
+ }
919
+ let backupPath;
920
+ if (existing !== null) {
921
+ backupPath = `${target.path}.bak-${stamp(now())}`;
922
+ try {
923
+ await (0, import_promises3.writeFile)(backupPath, existing, { encoding: "utf8", flag: "wx" });
924
+ } catch (error) {
925
+ return {
926
+ status: "manual",
927
+ reason: `\u5907\u4EFD\u5199\u4E0D\u6210\uFF08${backupPath}\uFF1A${error.message}\uFF09\uFF0C\u56E0\u6B64\u6CA1\u6709\u6539\u52A8 profile`,
928
+ path: target.path
929
+ };
930
+ }
931
+ }
932
+ await (0, import_promises3.writeFile)(target.path, plan.content, "utf8");
933
+ return plan.action === "replace" ? { status: "profile-replaced", path: target.path, backupPath } : { status: "profile-appended", path: target.path, ...backupPath ? { backupPath } : {} };
934
+ }
935
+
936
+ // src/nexus-service.ts
937
+ var CREDENTIAL_PATH = "/api/nexus/credential";
938
+ function describeFailure2(status, code) {
939
+ if (status === 401) {
940
+ return "PEP rejected the access token. Run `pep auth login` again.";
941
+ }
942
+ if (status === 403 && code === "32003") {
943
+ return "Your organisation's contract does not include Maven repository access. Contact your Newland representative \u2014 re-logging in will not change this.";
944
+ }
945
+ if (status === 403) {
946
+ return "This access token carries no `nexus-credentials:write` scope. Ask an operator to add it to this client's allowed_scopes in PEP, then run `pep auth login` again \u2014 existing tokens do not gain new scopes.";
947
+ }
948
+ if (status === 409) {
949
+ return "This organisation already has a Maven credential. PEP does not store the password, so it cannot be shown again \u2014 ask an operator to reset it in Nexus, or reuse the one you saved earlier.";
950
+ }
951
+ if (status === 503) {
952
+ return "PEP could not reach Nexus (not configured, or upstream is down). This is a platform-side problem \u2014 retrying will not help. Report it to a PEP operator.";
953
+ }
954
+ return `PEP answered ${status} when asking for a Maven credential.`;
955
+ }
956
+ async function errorCodeOf(response) {
957
+ try {
958
+ const body = await response.json();
959
+ return typeof body.code === "string" ? body.code : void 0;
960
+ } catch {
961
+ return void 0;
962
+ }
963
+ }
964
+ async function fetchNexusCredential(dependencies) {
965
+ const doFetch = dependencies.fetch ?? globalThis.fetch;
966
+ const response = await doFetch(`${dependencies.issuer.replace(/\/+$/, "")}${CREDENTIAL_PATH}`, {
967
+ method: "POST",
968
+ headers: {
969
+ Authorization: `Bearer ${dependencies.accessToken}`,
970
+ Accept: "application/json"
971
+ }
972
+ });
973
+ if (!response.ok) {
974
+ throw new Error(describeFailure2(response.status, await errorCodeOf(response)));
975
+ }
976
+ const body = await response.json();
977
+ const username = body.data?.username;
978
+ const password = body.data?.password;
979
+ if (typeof username !== "string" || typeof password !== "string") {
980
+ throw new Error("PEP returned a credential without a username or password.");
981
+ }
982
+ return { username, password };
983
+ }
984
+ async function setupNexusCredential(dependencies) {
985
+ const credential = await fetchNexusCredential(dependencies);
986
+ const target = dependencies.target ?? resolveTarget(dependencies.os);
987
+ const lines = exportLines(credential, dependencies.os);
988
+ const persisted = await persistMavenEnv(credential, target, dependencies.now).catch(
989
+ (error) => ({
990
+ status: "manual",
991
+ reason: error.message,
992
+ ...target.kind === "profile" ? { path: target.path } : {}
993
+ })
994
+ );
995
+ return { username: credential.username, lines, persisted };
996
+ }
997
+
998
+ // src/skills-service.ts
999
+ var import_promises4 = require("node:fs/promises");
1000
+ var import_node_os3 = require("node:os");
1001
+ var import_node_path4 = require("node:path");
1002
+ var import_node_crypto2 = require("node:crypto");
772
1003
  var import_node_zlib = require("node:zlib");
773
1004
 
774
1005
  // src/tar.ts
@@ -846,122 +1077,180 @@ function readTar(bytes) {
846
1077
 
847
1078
  // src/skills-service.ts
848
1079
  var ARCHIVE_PATH = "/api/skills/archive";
849
- var SKILLS_SEGMENT = "skills";
1080
+ var SKILL_MANIFEST = "SKILL.md";
850
1081
  function planSkillFiles(entries) {
851
- const files = [];
1082
+ const roots = [];
852
1083
  for (const entry of entries) {
853
- const segments = entry.path.split("/").filter((one) => one !== "" && one !== ".");
854
- if (segments.some((one) => one === "..")) {
1084
+ const segments = segmentsOf(entry.path);
1085
+ if (segments.at(-1) !== SKILL_MANIFEST) continue;
1086
+ const root = segments.slice(0, -1);
1087
+ if (root.length < 2) continue;
1088
+ roots.push(root);
1089
+ }
1090
+ const seen = /* @__PURE__ */ new Map();
1091
+ for (const root of roots) {
1092
+ const name = root[root.length - 1];
1093
+ const where = root.join("/");
1094
+ const first = seen.get(name);
1095
+ if (first !== void 0) {
855
1096
  throw new Error(
856
- `Refusing an archive entry that escapes its directory: ${entry.path}`
1097
+ `Two skills in this package would both be called "${name}": ${first} and ${where}. Skill names come from the directory name, so they must be unique within a package.`
857
1098
  );
858
1099
  }
859
- const withoutRoot = segments.slice(1);
860
- const relative2 = withoutRoot[0] === SKILLS_SEGMENT ? withoutRoot.slice(1) : withoutRoot;
861
- if (relative2.length < 2) continue;
1100
+ seen.set(name, where);
1101
+ }
1102
+ const files = [];
1103
+ for (const entry of entries) {
1104
+ const segments = segmentsOf(entry.path);
1105
+ const root = roots.filter((r) => r.length < segments.length && r.every((seg, i) => segments[i] === seg)).sort((x, y) => y.length - x.length)[0];
1106
+ if (!root) continue;
862
1107
  files.push({
863
- skill: relative2[0],
864
- path: relative2.slice(1).join("/"),
1108
+ skill: root[root.length - 1],
1109
+ path: segments.slice(root.length).join("/"),
865
1110
  data: entry.data
866
1111
  });
867
1112
  }
868
1113
  return files;
869
1114
  }
870
- function describeFailure2(status) {
1115
+ function segmentsOf(path) {
1116
+ const segments = path.split("/").filter((one) => one !== "" && one !== ".");
1117
+ if (segments.some((one) => one === "..")) {
1118
+ throw new Error(`Refusing an archive entry that escapes its directory: ${path}`);
1119
+ }
1120
+ return segments;
1121
+ }
1122
+ function describeFailure3(status) {
871
1123
  if (status === 401)
872
1124
  return "PEP rejected the access token. Run `pep auth login` again.";
873
1125
  if (status === 403) {
874
1126
  return "This access token carries no `skills:read` scope. Ask an operator to add it to this client's allowed_scopes in PEP, then run `pep auth login` again \u2014 existing tokens do not gain new scopes.";
875
1127
  }
1128
+ if (status === 400)
1129
+ return "PEP did not accept that repository address. It must be an https URL on the platform's GitLab host, or a <group>/<project> path on it.";
876
1130
  if (status === 404) {
877
- return "This PEP deployment does not serve skills \u2014 it has no /api/skills/archive endpoint. Ask an operator whether skills are enabled here.";
1131
+ return "No such repository or ref on the platform's GitLab (or this PEP deployment has no /api/skills/archive endpoint at all). Check the address, then ask an operator whether skills are enabled here.";
878
1132
  }
879
1133
  if (status === 503) {
880
1134
  return "PEP could not reach the skills repository. Retry shortly, or ask an operator whether this deployment serves skills.";
881
1135
  }
882
1136
  return `PEP answered ${status}.`;
883
1137
  }
1138
+ async function physicalDir(path) {
1139
+ const absolute = (0, import_node_path4.resolve)(path);
1140
+ try {
1141
+ return (0, import_node_path4.join)(await (0, import_promises4.realpath)((0, import_node_path4.dirname)(absolute)), (0, import_node_path4.basename)(absolute));
1142
+ } catch {
1143
+ return absolute;
1144
+ }
1145
+ }
884
1146
  async function linkSkill(canonicalSkillDir, linkPath) {
885
- await (0, import_promises3.rm)(linkPath, { recursive: true, force: true });
886
- await (0, import_promises3.mkdir)((0, import_node_path3.dirname)(linkPath), { recursive: true });
1147
+ await (0, import_promises4.rm)(linkPath, { recursive: true, force: true });
1148
+ const linkDir = (0, import_node_path4.dirname)(linkPath);
1149
+ await (0, import_promises4.mkdir)(linkDir, { recursive: true });
887
1150
  try {
888
- const junction = (0, import_node_os2.platform)() === "win32";
889
- await (0, import_promises3.symlink)(
890
- junction ? canonicalSkillDir : (0, import_node_path3.relative)((0, import_node_path3.dirname)(linkPath), canonicalSkillDir),
1151
+ const junction = (0, import_node_os3.platform)() === "win32";
1152
+ await (0, import_promises4.symlink)(
1153
+ junction ? canonicalSkillDir : (0, import_node_path4.relative)(await physicalDir(linkDir), canonicalSkillDir),
891
1154
  linkPath,
892
1155
  junction ? "junction" : void 0
893
1156
  );
894
1157
  return "linked";
895
1158
  } catch {
896
- await (0, import_promises3.cp)(canonicalSkillDir, linkPath, { recursive: true });
1159
+ await (0, import_promises4.cp)(canonicalSkillDir, linkPath, { recursive: true });
897
1160
  return "copied";
898
1161
  }
899
1162
  }
900
- async function syncSkills(dependencies) {
1163
+ function hashSkill(files) {
1164
+ const hash = (0, import_node_crypto2.createHash)("sha256");
1165
+ for (const file of [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
1166
+ hash.update(file.path);
1167
+ hash.update("\0");
1168
+ hash.update(file.data);
1169
+ hash.update("\0");
1170
+ }
1171
+ return hash.digest("hex").slice(0, 32);
1172
+ }
1173
+ async function updateSkills(dependencies) {
901
1174
  const doFetch = dependencies.fetch ?? globalThis.fetch;
902
- const response = await doFetch(`${dependencies.issuer}${ARCHIVE_PATH}`, {
1175
+ const name = dependencies.source;
1176
+ const url = new URL(`${dependencies.issuer.replace(/\/+$/, "")}${ARCHIVE_PATH}`);
1177
+ url.searchParams.set("source", name);
1178
+ const response = await doFetch(url, {
903
1179
  headers: { Authorization: `Bearer ${dependencies.accessToken}` }
904
1180
  });
905
1181
  if (!response.ok) {
906
1182
  await response.body?.cancel();
907
- throw new Error(describeFailure2(response.status));
1183
+ throw new Error(describeFailure3(response.status));
908
1184
  }
909
1185
  const commit = response.headers.get("x-skills-commit") ?? void 0;
910
- const previous = await dependencies.stateStore.read();
911
- if (commit !== void 0 && commit === previous?.commit && previous.directory === dependencies.directory) {
1186
+ const state = await dependencies.stateStore.read();
1187
+ const previous = state?.packages[name];
1188
+ if (commit !== void 0 && commit === previous?.commit && state?.directory === dependencies.directory) {
912
1189
  await response.body?.cancel();
913
- return { status: "unchanged", commit };
1190
+ return { status: "unchanged", name, commit };
914
1191
  }
915
1192
  const gzipped = new Uint8Array(await response.arrayBuffer());
916
1193
  const files = planSkillFiles(readTar((0, import_node_zlib.gunzipSync)(gzipped)));
917
- const skills = [...new Set(files.map((file) => file.skill))].sort();
1194
+ const bySkill = /* @__PURE__ */ new Map();
1195
+ for (const file of files) {
1196
+ const group = bySkill.get(file.skill);
1197
+ if (group) group.push(file);
1198
+ else bySkill.set(file.skill, [file]);
1199
+ }
1200
+ const skills = [...bySkill.keys()].sort();
1201
+ const hashes = {};
1202
+ const updated = [];
1203
+ const unchanged = [];
918
1204
  for (const skill of skills) {
919
- await (0, import_promises3.rm)((0, import_node_path3.join)(dependencies.directory, skill), {
920
- recursive: true,
921
- force: true
922
- });
1205
+ const hash = hashSkill(bySkill.get(skill));
1206
+ hashes[skill] = hash;
1207
+ const before = previous?.skills[skill];
1208
+ if (before !== void 0 && before !== "" && before === hash) unchanged.push(skill);
1209
+ else updated.push(skill);
1210
+ }
1211
+ for (const skill of skills) {
1212
+ await (0, import_promises4.rm)((0, import_node_path4.join)(dependencies.directory, skill), { recursive: true, force: true });
923
1213
  }
924
1214
  for (const file of files) {
925
- const target = (0, import_node_path3.join)(
926
- dependencies.directory,
927
- file.skill,
928
- ...file.path.split("/")
929
- );
930
- await (0, import_promises3.mkdir)((0, import_node_path3.dirname)(target), { recursive: true });
931
- await (0, import_promises3.writeFile)(target, file.data);
1215
+ const target = (0, import_node_path4.join)(dependencies.directory, file.skill, ...file.path.split("/"));
1216
+ await (0, import_promises4.mkdir)((0, import_node_path4.dirname)(target), { recursive: true });
1217
+ await (0, import_promises4.writeFile)(target, file.data);
932
1218
  }
933
1219
  let copiedCount = 0;
934
1220
  if (dependencies.linkInto !== void 0) {
935
1221
  for (const skill of skills) {
936
1222
  const how = await linkSkill(
937
- (0, import_node_path3.join)(dependencies.directory, skill),
938
- (0, import_node_path3.join)(dependencies.linkInto, skill)
1223
+ (0, import_node_path4.join)(dependencies.directory, skill),
1224
+ (0, import_node_path4.join)(dependencies.linkInto, skill)
939
1225
  );
940
1226
  if (how === "copied") copiedCount += 1;
941
1227
  }
942
1228
  }
943
- const removed = (previous?.skills ?? []).filter(
944
- (skill) => !skills.includes(skill)
945
- );
1229
+ const removed = Object.keys(previous?.skills ?? {}).filter((skill) => !skills.includes(skill));
946
1230
  for (const skill of removed) {
947
- await (0, import_promises3.rm)((0, import_node_path3.join)(previous?.directory ?? dependencies.directory, skill), {
1231
+ await (0, import_promises4.rm)((0, import_node_path4.join)(state?.directory ?? dependencies.directory, skill), {
948
1232
  recursive: true,
949
1233
  force: true
950
1234
  });
951
- if (previous?.linkedInto !== void 0) {
952
- await (0, import_promises3.rm)((0, import_node_path3.join)(previous.linkedInto, skill), { recursive: true, force: true });
1235
+ if (state?.linkedInto !== void 0) {
1236
+ await (0, import_promises4.rm)((0, import_node_path4.join)(state.linkedInto, skill), { recursive: true, force: true });
953
1237
  }
954
1238
  }
955
- const state = {
956
- version: 1,
957
- skills,
1239
+ await dependencies.stateStore.write({
1240
+ version: 3,
958
1241
  directory: dependencies.directory,
959
1242
  ...dependencies.linkInto !== void 0 ? { linkedInto: dependencies.linkInto } : {},
960
- ...commit ? { commit } : {}
961
- };
962
- await dependencies.stateStore.write(state);
1243
+ packages: {
1244
+ // 别的包的账原样留着 —— 装 B 不该把 A 的记录抹掉,那会让 A 铺的目录从此没人清理。
1245
+ ...state?.packages ?? {},
1246
+ [name]: { ...commit ? { commit } : {}, skills: hashes }
1247
+ }
1248
+ });
963
1249
  return {
964
1250
  status: "written",
1251
+ name,
1252
+ updated,
1253
+ unchanged,
965
1254
  skills,
966
1255
  fileCount: files.length,
967
1256
  removed,
@@ -971,9 +1260,13 @@ async function syncSkills(dependencies) {
971
1260
  ...commit ? { commit } : {}
972
1261
  };
973
1262
  }
1263
+ async function installedPackages(store) {
1264
+ const state = await store.read();
1265
+ return Object.keys(state?.packages ?? {}).sort();
1266
+ }
974
1267
 
975
1268
  // src/cli.ts
976
- var VERSION = "0.1.2";
1269
+ var VERSION = "0.3.0";
977
1270
  function usage() {
978
1271
  return `PEP CLI ${VERSION}
979
1272
 
@@ -982,9 +1275,12 @@ Usage:
982
1275
  pep auth status
983
1276
  pep auth token
984
1277
  pep auth logout
985
- pep skills sync [--dir <path>]
1278
+ pep skills list
1279
+ pep skills add <repo-url | group/project[@ref]> [--dir <path>]
1280
+ pep skills update [<repo>...] [--dir <path>]
986
1281
  pep docs list [--docs-url <url>]
987
1282
  pep docs get <path> [--docs-url <url>]
1283
+ pep nexus setup
988
1284
 
989
1285
  The issuer is built into this executable. --issuer overrides it for that one command and is
990
1286
  NOT remembered \u2014 pass it every time you log in against a non-default deployment.
@@ -993,7 +1289,21 @@ one. It defaults to the docs platform \u2014 a token minted without it is reject
993
1289
  resource server, and their answer looks exactly like "this token does not exist".
994
1290
  Use \`pep auth token\` when another agent needs a fresh bearer token.
995
1291
 
996
- \`pep skills sync\` fetches the latest skills from PEP and writes them to the shared agent
1292
+ \`pep skills add\` takes a repository on the platform's own GitLab, either as a full https URL or
1293
+ as the path with the host left off:
1294
+
1295
+ pep skills add https://git.example.com/group/sub/project
1296
+ pep skills add https://git.example.com/group/sub/project/-/tree/some-branch
1297
+ pep skills add group/sub/project
1298
+ pep skills add group/sub/project@some-branch
1299
+
1300
+ Anything on another host is refused. PEP fetches it with its own read-only service account, so no
1301
+ GitLab credential ever reaches this machine. \`pep skills list\` shows what you have added and
1302
+ \`pep skills update\` refreshes it \u2014 all of it, or just the repositories you name. One repository
1303
+ may hold more than one skill: every directory containing a SKILL.md becomes one, wherever it sits,
1304
+ and update reports which of them actually changed rather than just that the repository moved.
1305
+
1306
+ \`pep skills update\` fetches the latest skills from PEP and writes them to the shared agent
997
1307
  directory (${defaultSkillsDirectory()}), which Codex, Cursor, Amp and ~20 other agents read
998
1308
  directly. Claude Code keeps its own directory, so each skill is also linked into
999
1309
  ${claudeSkillsDirectory()} \u2014 one copy on disk, updated in one place. Where links are not
@@ -1005,7 +1315,38 @@ hand is left alone.
1005
1315
 
1006
1316
  \`pep docs list\` prints the documents this account can read (path + description); feed a path
1007
1317
  straight to \`pep docs get\` to print that document as markdown on stdout. The documentation
1008
- site is built in (${DEFAULT_DOCS_URL}); --docs-url overrides it and is then remembered.`;
1318
+ site is built in (${DEFAULT_DOCS_URL}); --docs-url overrides it and is then remembered.
1319
+
1320
+ \`pep nexus setup\` asks PEP for this organisation's Maven repository credential and saves it as
1321
+ the two environment variables the Newland Android SDK reads \u2014 \`${USERNAME_VAR}\` and
1322
+ \`${PASSWORD_VAR}\` \u2014 so you do not copy anything by hand. On Windows it writes them to your user
1323
+ environment (\`setx\`); on macOS it keeps a marked block in your shell profile, backing the file up
1324
+ first and touching nothing else. Either way persistence only affects NEW shells, so the two lines
1325
+ are also printed on stdout: \`eval "$(pep nexus setup)"\` uses them in the current one.
1326
+ Only Android needs this \u2014 the Windows and iOS SDKs are cloned from Git, not pulled from Maven.
1327
+ The password is shown by PEP once and never stored, so a second run reports 409 rather than
1328
+ handing it out again.`;
1329
+ }
1330
+ function reportUpdate(result) {
1331
+ if (result.status === "unchanged") {
1332
+ console.log(`${result.name}: already at ${result.commit}. Nothing to do.`);
1333
+ return;
1334
+ }
1335
+ console.log(
1336
+ result.updated.length > 0 ? `${result.name}: updated ${result.updated.join(" ")}` : `${result.name}: no skill changed (the repository moved, but not these)`
1337
+ );
1338
+ if (result.unchanged.length > 0) {
1339
+ console.log(` unchanged: ${result.unchanged.join(" ")}`);
1340
+ }
1341
+ console.log(` ${result.fileCount} file(s) in ${result.directory}`);
1342
+ if (result.linkedInto !== void 0) {
1343
+ console.log(
1344
+ result.copiedCount === void 0 ? ` linked into ${result.linkedInto} for Claude Code` : ` linked into ${result.linkedInto} for Claude Code (${result.copiedCount} copied instead \u2014 links unavailable here)`
1345
+ );
1346
+ }
1347
+ if (result.commit) console.log(` commit: ${result.commit}`);
1348
+ if (result.removed.length > 0)
1349
+ console.log(` removed (gone upstream): ${result.removed.join(" ")}`);
1009
1350
  }
1010
1351
  function repeatedOption(args, name) {
1011
1352
  const values = [];
@@ -1053,7 +1394,7 @@ async function main() {
1053
1394
  return;
1054
1395
  }
1055
1396
  const group = args.shift();
1056
- if (group !== "auth" && group !== "skills" && group !== "docs")
1397
+ if (group !== "auth" && group !== "skills" && group !== "docs" && group !== "nexus")
1057
1398
  throw new Error(`Unknown command.
1058
1399
 
1059
1400
  ${usage()}`);
@@ -1095,36 +1436,86 @@ ${usage()}`);
1095
1436
  process.stdout.write(await fetchDocContent(dependencies, path));
1096
1437
  return;
1097
1438
  }
1098
- if (group === "skills") {
1099
- if (command !== "sync") throw new Error(`Unknown skills command.
1439
+ if (group === "nexus") {
1440
+ if (command !== "setup") throw new Error(`Unknown nexus command.
1100
1441
 
1101
1442
  ${usage()}`);
1102
- const explicitDirectory = option(args, "--dir");
1103
- const directory = explicitDirectory ?? defaultSkillsDirectory();
1104
1443
  if (args.length > 0) throw new Error(`Unknown option: ${args[0]}`);
1105
1444
  const authorization = await auth.currentAuthorization();
1106
- const result = await syncSkills({
1445
+ const result = await setupNexusCredential({
1107
1446
  issuer: authorization.issuer,
1108
- accessToken: authorization.accessToken,
1109
- directory,
1110
- ...explicitDirectory === void 0 ? { linkInto: claudeSkillsDirectory() } : {},
1111
- stateStore: fileSkillsStateStore()
1447
+ accessToken: authorization.accessToken
1112
1448
  });
1113
- if (result.status === "unchanged") {
1114
- console.log(`Already at ${result.commit}. Nothing to do.`);
1449
+ console.log(result.lines);
1450
+ const persisted = result.persisted;
1451
+ if (persisted.status === "windows") {
1452
+ console.error(`Saved ${USERNAME_VAR} and ${PASSWORD_VAR} to your Windows user environment.`);
1453
+ console.error("Existing terminals do not see them \u2014 open a new one (or use the lines above).");
1454
+ } else if (persisted.status === "manual") {
1455
+ console.error(
1456
+ `Nothing was written${persisted.path ? ` to ${persisted.path}` : ""}: ${persisted.reason}`
1457
+ );
1458
+ console.error("The two lines above are the only copy \u2014 PEP does not store the password.");
1459
+ } else {
1460
+ console.error(
1461
+ persisted.status === "profile-replaced" ? `Replaced the pep-cli block in ${persisted.path}` : `Appended a pep-cli block to ${persisted.path}`
1462
+ );
1463
+ if (persisted.backupPath) console.error(`Backup: ${persisted.backupPath}`);
1464
+ console.error("New shells will have it; this one will not until you re-source the profile.");
1465
+ }
1466
+ console.error(`Nexus user: ${result.username}`);
1467
+ return;
1468
+ }
1469
+ if (group === "skills") {
1470
+ if (command !== "update" && command !== "add" && command !== "list") {
1471
+ throw new Error(`Unknown skills command.
1472
+
1473
+ ${usage()}`);
1474
+ }
1475
+ const requested = command === "add" ? args.shift() : void 0;
1476
+ const named = command === "update" ? args.filter((one) => !one.startsWith("--")) : [];
1477
+ for (const one of named) args.splice(args.indexOf(one), 1);
1478
+ if (command === "add" && !requested) {
1479
+ throw new Error("pep skills add needs a repository: a full https URL, or <group>/<project>.");
1480
+ }
1481
+ const explicitDirectory = command === "list" ? void 0 : option(args, "--dir");
1482
+ const directory = explicitDirectory ?? defaultSkillsDirectory();
1483
+ if (args.length > 0) throw new Error(`Unknown option: ${args[0]}`);
1484
+ const authorization = await auth.currentAuthorization();
1485
+ const remote = { issuer: authorization.issuer, accessToken: authorization.accessToken };
1486
+ if (command === "list") {
1487
+ const installed2 = await installedPackages(fileSkillsStateStore());
1488
+ if (installed2.length === 0) {
1489
+ console.error("Nothing added yet. Run `pep skills add <repo>`.");
1490
+ return;
1491
+ }
1492
+ for (const one of installed2) console.log(one);
1493
+ console.error("\n`pep skills update` refreshes all of them.");
1115
1494
  return;
1116
1495
  }
1117
- console.log(
1118
- `Wrote ${result.fileCount} file(s) across ${result.skills.length} skill(s) to ${result.directory}`
1119
- );
1120
- if (result.linkedInto !== void 0) {
1121
- console.log(
1122
- result.copiedCount === void 0 ? `Linked into ${result.linkedInto} for Claude Code` : `Linked into ${result.linkedInto} for Claude Code (${result.copiedCount} copied instead \u2014 links unavailable here)`
1496
+ const stateStore = fileSkillsStateStore();
1497
+ const installed = await installedPackages(stateStore);
1498
+ const targets = command === "add" ? [requested] : named.length > 0 ? named : installed;
1499
+ if (targets.length === 0) {
1500
+ console.error("Nothing added yet. Run `pep skills add <repo>`.");
1501
+ return;
1502
+ }
1503
+ const unknown = named.filter((one) => !installed.includes(one));
1504
+ if (unknown.length > 0) {
1505
+ throw new Error(
1506
+ `Not added yet: ${unknown.join(" ")}. Run \`pep skills add <repo>\` first, or \`pep skills list\` to see what is.`
1123
1507
  );
1124
1508
  }
1125
- if (result.commit) console.log(`Commit: ${result.commit}`);
1126
- if (result.removed.length > 0)
1127
- console.log(`Removed (gone upstream): ${result.removed.join(" ")}`);
1509
+ for (const source of targets) {
1510
+ const result = await updateSkills({
1511
+ ...remote,
1512
+ source,
1513
+ directory,
1514
+ ...explicitDirectory === void 0 ? { linkInto: claudeSkillsDirectory() } : {},
1515
+ stateStore
1516
+ });
1517
+ reportUpdate(result);
1518
+ }
1128
1519
  return;
1129
1520
  }
1130
1521
  if (command === "login") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newlandnpt/pep-cli",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "PEP CLI —— 凭 PEP 身份读取 Newland 开发者文档与 agent skills",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",