@hanv89/azure-arch-skill 0.6.0 → 0.7.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.
@@ -26,7 +26,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
26
26
  return (mod && mod.__esModule) ? mod : { "default": mod };
27
27
  };
28
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
- exports.withFatalReturn = exports.stripFrontmatter = exports.parseFrontmatter = exports.fetchManifest = exports.headOk = exports.fetchText = exports.fetchWithTimeout = exports.safeResolveTarget = exports.baseUrl = exports.USER_AGENT = exports.FETCH_TIMEOUT_MS = exports.CANARY_ICON_PATH = exports.MANIFEST_PATH = exports.SKILL_NAME = exports.DEFAULT_BASE_RAW_URL = void 0;
29
+ exports.withFatalReturn = exports.verifyIconsAvailability = exports.satisfiesRequiresIcons = exports.stripFrontmatter = exports.parseFrontmatter = exports.fetchManifest = exports.headOk = exports.fetchText = exports.fetchWithTimeout = exports.safeResolveTarget = exports.baseUrl = exports.USER_AGENT = exports.FETCH_TIMEOUT_MS = exports.CANARY_ICON_PATH = exports.MANIFEST_PATH = exports.SKILL_NAME = exports.DEFAULT_BASE_RAW_URL = void 0;
30
30
  const fs = __importStar(require("node:fs/promises"));
31
31
  const path = __importStar(require("node:path"));
32
32
  const package_json_1 = __importDefault(require("../../package.json"));
@@ -35,6 +35,10 @@ const package_json_1 = __importDefault(require("../../package.json"));
35
35
  // adapter file that imports from here. Codex / Cursor / future adapters
36
36
  // re-use these helpers via the same import path.
37
37
  exports.DEFAULT_BASE_RAW_URL = "https://raw.githubusercontent.com/hanv89/azure-icons-for-architecture-diagrams/main";
38
+ // Same repo root without the ref segment; baseUrl() appends `main` (default)
39
+ // or `skill-vX.Y.Z` when --version is supplied.
40
+ const RAW_BASE_NO_REF = "https://raw.githubusercontent.com/hanv89/azure-icons-for-architecture-diagrams";
41
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
38
42
  // SKILL_NAME must stay in lockstep with dist/skill/SKILL.md frontmatter `name`.
39
43
  // Renaming the skill is a breaking change requiring a coordinated CLI release;
40
44
  // existing installs become un-uninstallable until users upgrade the CLI
@@ -48,28 +52,46 @@ exports.FETCH_TIMEOUT_MS = 30_000;
48
52
  exports.USER_AGENT = `azure-arch-skill/${package_json_1.default.version}`;
49
53
  const ALLOWED_BASE_URL_HOSTS = new Set(["raw.githubusercontent.com"]);
50
54
  const ALLOWED_BASE_URL_PATH_PREFIX = "/hanv89/azure-icons-for-architecture-diagrams/";
51
- function baseUrl() {
55
+ /**
56
+ * Resolve the base URL for fetching the skill bundle.
57
+ *
58
+ * - `version` undefined → `<RAW_BASE>/main` (default, tracks the upstream main branch).
59
+ * - `version="X.Y.Z"` → `<RAW_BASE>/skill-vX.Y.Z` (tag-pinned fetch).
60
+ * - `AZURE_ARCH_SKILL_BASE_URL` env set → env override wins; `version` is ignored
61
+ * (the env exists only for validation harnesses).
62
+ *
63
+ * `version` is validated against the strict X.Y.Z regex here as defense-in-depth;
64
+ * `src/index.ts` also rejects malformed values pre-dispatch.
65
+ */
66
+ function baseUrl(version) {
52
67
  const override = process.env.AZURE_ARCH_SKILL_BASE_URL;
53
- if (!override)
54
- return exports.DEFAULT_BASE_RAW_URL;
55
- let u;
56
- try {
57
- u = new URL(override);
58
- }
59
- catch {
60
- throw new Error(`AZURE_ARCH_SKILL_BASE_URL is not a valid URL: ${override}`);
61
- }
62
- if (u.protocol !== "https:") {
63
- throw new Error(`AZURE_ARCH_SKILL_BASE_URL must use https; got ${u.protocol}`);
64
- }
65
- if (!ALLOWED_BASE_URL_HOSTS.has(u.hostname)) {
66
- throw new Error(`AZURE_ARCH_SKILL_BASE_URL host '${u.hostname}' not in allow-list (${[...ALLOWED_BASE_URL_HOSTS].join(", ")})`);
68
+ if (override) {
69
+ let u;
70
+ try {
71
+ u = new URL(override);
72
+ }
73
+ catch {
74
+ throw new Error(`AZURE_ARCH_SKILL_BASE_URL is not a valid URL: ${override}`);
75
+ }
76
+ if (u.protocol !== "https:") {
77
+ throw new Error(`AZURE_ARCH_SKILL_BASE_URL must use https; got ${u.protocol}`);
78
+ }
79
+ if (!ALLOWED_BASE_URL_HOSTS.has(u.hostname)) {
80
+ throw new Error(`AZURE_ARCH_SKILL_BASE_URL host '${u.hostname}' not in allow-list (${[...ALLOWED_BASE_URL_HOSTS].join(", ")})`);
81
+ }
82
+ if (!u.pathname.startsWith(ALLOWED_BASE_URL_PATH_PREFIX)) {
83
+ throw new Error(`AZURE_ARCH_SKILL_BASE_URL path must start with ${ALLOWED_BASE_URL_PATH_PREFIX}`);
84
+ }
85
+ process.stderr.write(`warn: AZURE_ARCH_SKILL_BASE_URL override active: ${override}\n`);
86
+ return override.replace(/\/$/, "");
67
87
  }
68
- if (!u.pathname.startsWith(ALLOWED_BASE_URL_PATH_PREFIX)) {
69
- throw new Error(`AZURE_ARCH_SKILL_BASE_URL path must start with ${ALLOWED_BASE_URL_PATH_PREFIX}`);
88
+ if (version !== undefined) {
89
+ if (!VERSION_RE.test(version)) {
90
+ throw new Error(`--version must match X.Y.Z (got: ${version})`);
91
+ }
92
+ return `${RAW_BASE_NO_REF}/skill-v${version}`;
70
93
  }
71
- process.stderr.write(`warn: AZURE_ARCH_SKILL_BASE_URL override active: ${override}\n`);
72
- return override.replace(/\/$/, "");
94
+ return exports.DEFAULT_BASE_RAW_URL;
73
95
  }
74
96
  exports.baseUrl = baseUrl;
75
97
  let envTargetRootWarned = false;
@@ -272,6 +294,85 @@ function stripFrontmatter(md) {
272
294
  return match ? text.slice(match[0].length) : text;
273
295
  }
274
296
  exports.stripFrontmatter = stripFrontmatter;
297
+ /**
298
+ * Test whether an icons-tag semver satisfies the SKILL.md's `requires_icons`
299
+ * constraint. Hand-rolled to keep the runtime dep tree minimal (commander +
300
+ * nothing else; pulling in `semver` would add transitive deps for a feature
301
+ * that today only needs `>=X.Y.Z` matching).
302
+ *
303
+ * Supported constraint forms:
304
+ * - "X.Y.Z" (exact match)
305
+ * - ">=X.Y.Z" (tag >= constraint)
306
+ * - "^X.Y.Z" (same major, tag >= constraint — npm caret semantics)
307
+ * - "~X.Y.Z" (same major.minor, tag.patch >= constraint.patch)
308
+ *
309
+ * Throws on any other input. The project's SKILL.md frontmatter only ships
310
+ * `>=X.Y.Z` today; the other 3 forms exist for future-proofing.
311
+ *
312
+ * Numeric encoding `maj * 1e6 + min * 1e3 + pat` rules out individual segments
313
+ * >= 1000, which is fine for icons semver in the foreseeable future.
314
+ */
315
+ function satisfiesRequiresIcons(constraint, iconsSemver) {
316
+ const tagParts = iconsSemver.split(".").map(Number);
317
+ if (tagParts.length !== 3 || tagParts.some(n => isNaN(n))) {
318
+ throw new Error(`icons semver malformed: ${iconsSemver}`);
319
+ }
320
+ const [tagMaj, tagMin, tagPat] = tagParts;
321
+ const trimmed = constraint.trim().replace(/^["']|["']$/g, "");
322
+ const m = trimmed.match(/^(>=|\^|~|)(\d+)\.(\d+)\.(\d+)$/);
323
+ if (!m) {
324
+ throw new Error(`requires_icons constraint form not supported: ${constraint}`);
325
+ }
326
+ const [, op, majS, minS, patS] = m;
327
+ const maj = Number(majS);
328
+ const min = Number(minS);
329
+ const pat = Number(patS);
330
+ const tag = tagMaj * 1e6 + tagMin * 1e3 + tagPat;
331
+ const ref = maj * 1e6 + min * 1e3 + pat;
332
+ if (op === "")
333
+ return tag === ref;
334
+ if (op === ">=")
335
+ return tag >= ref;
336
+ if (op === "^")
337
+ return tagMaj === maj && tag >= ref;
338
+ if (op === "~")
339
+ return tagMaj === maj && tagMin === min && tagPat >= pat;
340
+ throw new Error(`unreachable constraint op: ${op}`);
341
+ }
342
+ exports.satisfiesRequiresIcons = satisfiesRequiresIcons;
343
+ /**
344
+ * Verify the icon set the skill bundle references is reachable AND its
345
+ * semver satisfies SKILL.md's requires_icons.
346
+ *
347
+ * - Always: HEAD the canary icon URL (`CANARY_ICON_PATH` at the current base).
348
+ * Unreachable → throw with the URL in the message.
349
+ * - If `requestedVersion` is provided: additionally infer the icons-tag from
350
+ * `manifest.requires_icons`'s lower bound and assert it satisfies the
351
+ * constraint via `satisfiesRequiresIcons`. The inference is intentionally
352
+ * simple: `requires_icons` lower bound IS the icons-tag the skill was
353
+ * built against. Future work: record an exact `icons_version` field in
354
+ * `manifest.json` and read it here directly.
355
+ */
356
+ async function verifyIconsAvailability(base, manifest, requestedVersion) {
357
+ const requires = manifest.requires_icons;
358
+ const canaryUrl = `${base}/${exports.CANARY_ICON_PATH}`;
359
+ const reachable = await headOk(canaryUrl);
360
+ if (!reachable) {
361
+ throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${requires}; this release verifies reachability only, strict semver match planned)`);
362
+ }
363
+ if (!requestedVersion) {
364
+ return;
365
+ }
366
+ const lowerMatch = requires.match(/(\d+\.\d+\.\d+)/);
367
+ if (!lowerMatch) {
368
+ throw new Error(`SKILL.md requires_icons has no parseable lower bound: ${requires}`);
369
+ }
370
+ const iconsTagSemver = lowerMatch[1];
371
+ if (!satisfiesRequiresIcons(requires, iconsTagSemver)) {
372
+ throw new Error(`requires_icons constraint ${requires} not satisfied by inferred icons tag ${iconsTagSemver}`);
373
+ }
374
+ }
375
+ exports.verifyIconsAvailability = verifyIconsAvailability;
275
376
  async function withFatalReturn(fn) {
276
377
  try {
277
378
  return await fn();
@@ -61,7 +61,7 @@ async function isOurSkillDir(dir) {
61
61
  async function install(opts) {
62
62
  return (0, _shared_1.withFatalReturn)(async () => {
63
63
  const target = await resolveTarget(opts.target ?? defaultTarget());
64
- const base = (0, _shared_1.baseUrl)();
64
+ const base = (0, _shared_1.baseUrl)(opts.version);
65
65
  if (!process.env.AZURE_ARCH_SKILL_TARGET_ROOT && path.basename(target) !== _shared_1.SKILL_NAME) {
66
66
  throw new Error(`refusing to install at ${target} - target basename must be '${_shared_1.SKILL_NAME}' (default ~/.claude/skills/${_shared_1.SKILL_NAME}/). Set AZURE_ARCH_SKILL_TARGET_ROOT to install into a custom test root.`);
67
67
  }
@@ -86,11 +86,7 @@ async function install(opts) {
86
86
  if (!fm.requires_icons) {
87
87
  throw new Error("SKILL.md missing requires_icons frontmatter");
88
88
  }
89
- const canaryUrl = `${base}/${_shared_1.CANARY_ICON_PATH}`;
90
- const reachable = await (0, _shared_1.headOk)(canaryUrl);
91
- if (!reachable) {
92
- throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${fm.requires_icons}; this release verifies reachability only, strict semver match planned)`);
93
- }
89
+ await (0, _shared_1.verifyIconsAvailability)(base, manifest, opts.version);
94
90
  for (const { dest } of manifest.files) {
95
91
  await fs.mkdir(path.dirname(path.join(target, dest)), { recursive: true });
96
92
  }
@@ -61,7 +61,7 @@ async function isOurSkillDir(dir) {
61
61
  async function install(opts) {
62
62
  return (0, _shared_1.withFatalReturn)(async () => {
63
63
  const target = await resolveTarget(opts.target ?? defaultTarget());
64
- const base = (0, _shared_1.baseUrl)();
64
+ const base = (0, _shared_1.baseUrl)(opts.version);
65
65
  if (!process.env.AZURE_ARCH_SKILL_TARGET_ROOT && path.basename(target) !== _shared_1.SKILL_NAME) {
66
66
  throw new Error(`refusing to install at ${target} - target basename must be '${_shared_1.SKILL_NAME}' (default ${codexRootDisplay()}/skills/${_shared_1.SKILL_NAME}/). Set AZURE_ARCH_SKILL_TARGET_ROOT to install into a custom test root.`);
67
67
  }
@@ -86,11 +86,7 @@ async function install(opts) {
86
86
  if (!fm.requires_icons) {
87
87
  throw new Error("SKILL.md missing requires_icons frontmatter");
88
88
  }
89
- const canaryUrl = `${base}/${_shared_1.CANARY_ICON_PATH}`;
90
- const reachable = await (0, _shared_1.headOk)(canaryUrl);
91
- if (!reachable) {
92
- throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${fm.requires_icons}; this release verifies reachability only, strict semver match planned)`);
93
- }
89
+ await (0, _shared_1.verifyIconsAvailability)(base, manifest, opts.version);
94
90
  for (const { dest } of manifest.files) {
95
91
  await fs.mkdir(path.dirname(path.join(target, dest)), { recursive: true });
96
92
  }
@@ -76,7 +76,7 @@ async function isOurRuleFile(file) {
76
76
  async function install(opts) {
77
77
  return (0, _shared_1.withFatalReturn)(async () => {
78
78
  const targetDir = await resolveTarget(opts.target ?? defaultTarget());
79
- const base = (0, _shared_1.baseUrl)();
79
+ const base = (0, _shared_1.baseUrl)(opts.version);
80
80
  const ruleFile = path.join(targetDir, RULE_BASENAME);
81
81
  // Fetch manifest first so we know which file is the canonical SKILL.md
82
82
  // and what version / requires_icons to embed in the provenance marker.
@@ -90,11 +90,7 @@ async function install(opts) {
90
90
  if (!fm.requires_icons) {
91
91
  throw new Error("SKILL.md missing requires_icons frontmatter");
92
92
  }
93
- const canaryUrl = `${base}/${_shared_1.CANARY_ICON_PATH}`;
94
- const reachable = await (0, _shared_1.headOk)(canaryUrl);
95
- if (!reachable) {
96
- throw new Error(`icon-set unreachable - HEAD ${canaryUrl} failed (skill declares requires_icons=${fm.requires_icons}; this release verifies reachability only, strict semver match planned)`);
97
- }
93
+ await (0, _shared_1.verifyIconsAvailability)(base, manifest, opts.version);
98
94
  const exists = await fs.stat(ruleFile).then(() => true).catch(() => false);
99
95
  if (exists && !opts.overwrite) {
100
96
  const probe = await isOurRuleFile(ruleFile);
package/dist/index.js CHANGED
@@ -18,18 +18,23 @@ const program = new commander_1.Command()
18
18
  .name("azure-arch-skill")
19
19
  .description("Install the Azure architecture diagram skill into your AI coding agent.")
20
20
  .version(package_json_1.default.version, "-V, --version");
21
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
21
22
  function defineSubcommand(name, description) {
22
23
  program
23
24
  .command(name)
24
25
  .description(description)
25
26
  .requiredOption("--agent <name>", `target AI agent (${registry_1.SUPPORTED_TARGETS.join("|")})`)
26
27
  .option("--target <dir>", "override target directory (validation use)")
28
+ .option("--version <semver>", "pin to a specific skill version (X.Y.Z); default = latest from main")
27
29
  .action(async (opts) => {
28
30
  // Set process.exitCode so any pending async cleanup (file handles, the
29
31
  // override-warning stderr write) drains before the event loop empties.
30
32
  // Both this top-level path and adapter-internal failures emit a single
31
33
  // '^fatal: ' prefix line on stderr — log-parsers can rely on the prefix.
32
- const optsForAdapter = { target: opts.target };
34
+ if (opts.version !== undefined && !VERSION_RE.test(opts.version)) {
35
+ throw new Error(`--version must match X.Y.Z (got: ${opts.version})`);
36
+ }
37
+ const optsForAdapter = { target: opts.target, version: opts.version };
33
38
  if (opts.agent === registry_1.ALL_TARGET) {
34
39
  process.exitCode = await (0, all_1.runOverAll)(name, optsForAdapter);
35
40
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanv89/azure-arch-skill",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Install the Azure architecture diagram skill into your AI coding agent (Claude Code, Codex CLI, Cursor).",
5
5
  "bin": {
6
6
  "azure-arch-skill": "dist/index.js"