@hugojosefson/cli 0.12.0 → 0.13.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.
package/README.md CHANGED
@@ -2,6 +2,6 @@
2
2
 
3
3
  Repository tools for Linux x64 glibc. Requires Node.js 24+ or Bun 1.4.2+.
4
4
 
5
- Run `npx @hugojosefson/cli --help` or `bunx --bun @hugojosefson/cli --help`. Ordinary commands run natively. Commands that run Deno project tasks need a compatible Deno executable.
5
+ Run `npx @hugojosefson/cli --help` or `bunx --bun --package @hugojosefson/cli hj --help`. Ordinary commands run natively. Commands that run Deno project tasks need a compatible Deno executable.
6
6
 
7
7
  See https://github.com/hugojosefson/cli for documentation.
package/esm/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  "name": "@hugojosefson/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "hj": {
6
6
  "commandName": "hj"
package/esm/hj.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/bin/sh
2
+ ':' //; if node -e 'process.exit((() => { if (process.versions.deno) return false; const name = process.versions.bun ? "bun" : "node"; const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(process.versions[name] || ""); if (!match) return false; const minimum = {"node":[24,0,0],"bun":[1,4,2]}[name]; for (let index = 0; index < 3; index++) { const actual = Number(match[index + 1]); if (actual !== minimum[index]) return actual > minimum[index]; } return true; })() ? 0 : 1)' >/dev/null 2>&1; then exec node "$0" "$@"; fi; if bun -e 'process.exit((() => { if (process.versions.deno) return false; const name = process.versions.bun ? "bun" : "node"; const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(process.versions[name] || ""); if (!match) return false; const minimum = {"node":[24,0,0],"bun":[1,4,2]}[name]; for (let index = 0; index < 3; index++) { const actual = Number(match[index + 1]); if (actual !== minimum[index]) return actual > minimum[index]; } return true; })() ? 0 : 1)' >/dev/null 2>&1; then exec bun "$0" "$@"; fi; printf '%s\n' 'hj requires Node.js >=24.0.0 or Bun >=1.4.2. Install a supported runtime and add it to PATH.' >&2; exit 127
3
+ import process from "node:process";
4
+ if (!(() => {
5
+ if (process.versions.deno) return false;
6
+ const name = process.versions.bun ? "bun" : "node";
7
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(process.versions[name] || "");
8
+ if (!match) return false;
9
+ const minimum = {"node":[24,0,0],"bun":[1,4,2]}[name];
10
+ for (let index = 0; index < 3; index++) {
11
+ const actual = Number(match[index + 1]);
12
+ if (actual !== minimum[index]) return actual > minimum[index];
13
+ }
14
+ return true;
15
+ })()) {
16
+ console.error("hj requires Node.js >=24.0.0 or Bun >=1.4.2. Install a supported runtime and add it to PATH.");
17
+ process.exit(1);
18
+ }
19
+ await import("./src/cli/cli.js");
@@ -1,4 +1,5 @@
1
1
  /** @module Lifecycle declarations for generated release publication workflows. */
2
+ import { withNpmReadmeBadge } from "./npm-readme-badge.js";
2
3
  import { inspectLegacyRelease } from "./github-release-legacy.js";
3
4
  import { checkLegacyReleaseMigration, legacyReleaseCompanionPreconditions, legacyReleaseMigrationChanges, legacyReleaseMigrationSummary, } from "./github-release-legacy-migration.js";
4
5
  import { jsrPublishCheckArgs } from "./jsr-package-config.js";
@@ -79,10 +80,10 @@ export const githubReleasePublishGithubFeature = releaseFeature(githubReleasePub
79
80
  featureId: githubReleasePublishTagFeatureId,
80
81
  reason: "GitHub Releases start after a release tag.",
81
82
  }]);
82
- export const githubReleasePublishNpmFeature = releaseFeature(githubReleasePublishNpmFeatureId, "GitHub npm release publication", publishNpmArtifact, [{
83
+ export const githubReleasePublishNpmFeature = withNpmReadmeBadge(releaseFeature(githubReleasePublishNpmFeatureId, "GitHub npm release publication", publishNpmArtifact, [{
83
84
  featureId: githubReleasePublishTagFeatureId,
84
85
  reason: "npm publication starts after a release tag.",
85
- }]);
86
+ }]));
86
87
  export const githubReleasePublisherFeatures = [
87
88
  githubReleasePublishJsrFeature,
88
89
  githubReleasePublishGithubFeature,
@@ -0,0 +1,163 @@
1
+ import { validNpmPublishName } from "../package/npm-name.js";
2
+ import { inspectContribution, npmBadgeImages, } from "../readme/contribution-blocks.js";
3
+ import { inspectDenoConfig } from "./deno-config.js";
4
+ import { isObject, isReadmeTask } from "./deno-tasks.js";
5
+ export const npmBadgeOwner = "github-release-publish-npm";
6
+ export function npmBadge(name) {
7
+ return {
8
+ id: `${npmBadgeOwner}:badge`,
9
+ position: "badges",
10
+ content: `[![npm Version](https://img.shields.io/npm/v/${name})](https://www.npmjs.com/package/${name})`,
11
+ };
12
+ }
13
+ /** Use the same explicit Deno-config name that publication verifies in its archive. */
14
+ export async function npmBadgeIdentity(context) {
15
+ const config = await inspectDenoConfig(context);
16
+ return config.kind === "config" && validNpmPublishName(config.value.name)
17
+ ? config.value.name
18
+ : undefined;
19
+ }
20
+ async function inspect(context) {
21
+ const config = await inspectDenoConfig(context);
22
+ const tasks = config.kind === "config" && isObject(config.value.tasks)
23
+ ? config.value.tasks
24
+ : {};
25
+ const build = isReadmeTask(tasks.readme);
26
+ const path = build ? "readme/README.md" : "README.md";
27
+ const entry = await context.files.observe(path);
28
+ const name = await npmBadgeIdentity(context);
29
+ const text = entry.kind === "file" ? entry.content : "";
30
+ const desired = npmBadge(name ?? "@unknown/package");
31
+ const owned = await inspectContribution(text, desired);
32
+ const npmImages = npmBadgeImages(text);
33
+ const custom = owned === "absent" && npmImages.length > 0;
34
+ const compatibleCustom = custom && npmImages.length === 1 &&
35
+ text.includes(desired.content);
36
+ const kind = entry.kind !== "file" && entry.kind !== "absent"
37
+ ? "custom"
38
+ : owned === "duplicate" || npmImages.length > 1
39
+ ? "duplicate"
40
+ : custom && !compatibleCustom
41
+ ? "custom"
42
+ : compatibleCustom
43
+ ? "exact"
44
+ : owned;
45
+ return {
46
+ path,
47
+ name,
48
+ text,
49
+ kind,
50
+ owned,
51
+ compatibleCustom,
52
+ missingReadme: entry.kind === "absent",
53
+ };
54
+ }
55
+ function finding(state, path, observation, resolution) {
56
+ return {
57
+ state,
58
+ evidence: [],
59
+ issues: [{
60
+ code: "npm-readme-badge",
61
+ kind: "readme",
62
+ subject: { kind: "repository-path", identifier: path },
63
+ observation,
64
+ resolution,
65
+ }],
66
+ };
67
+ }
68
+ /** Keep publication's existing workflow checks and add badge lifecycle ownership. */
69
+ export function withNpmReadmeBadge(workflow) {
70
+ return {
71
+ ...workflow,
72
+ capabilities: {
73
+ provides: [],
74
+ requires: [{
75
+ capabilityId: "readme",
76
+ reason: "npm publication includes a README version badge.",
77
+ }],
78
+ },
79
+ detect: async (context) => {
80
+ const current = await workflow.detect(context);
81
+ if (current.state === "ambiguous")
82
+ return current;
83
+ const badge = await inspect(context);
84
+ if (current.state === "disabled") {
85
+ if (badge.owned === "exact" || badge.owned === "stale") {
86
+ return finding("drifted", badge.path, `The npm publication workflow is absent but ${badge.path} retains its owned npm badge.`, `Disable --github-release-publish-npm to remove its owned badge, or enable it with --repair to restore publication.`);
87
+ }
88
+ return current;
89
+ }
90
+ if (!badge.name) {
91
+ return finding("ambiguous", "deno.json|deno.jsonc", "Cannot resolve the npm badge target from one explicit scoped Deno package name.", "Set name in exactly one valid deno.json or deno.jsonc to the npm package published by npm-build. The built archive must use the same name.");
92
+ }
93
+ if (badge.kind === "custom" || badge.kind === "duplicate") {
94
+ return finding("ambiguous", badge.path, `${badge.path} contains ${badge.kind === "duplicate" ? "duplicate" : "custom or conflicting"} npm badge content.`, `Preserve custom text, then replace the conflicting badge with ${npmBadge(badge.name).content}, or remove it before enabling npm publication.`);
95
+ }
96
+ if (current.state === "drifted")
97
+ return current;
98
+ if (badge.kind !== "exact") {
99
+ return finding("drifted", badge.path, `The npm version badge for ${badge.name} is ${badge.kind === "absent" ? "missing" : "outdated"} in ${badge.path}.`, `Use --repair to ${badge.kind === "absent" ? "add" : "replace"} the owned npm badge in ${badge.path} with ${npmBadge(badge.name).content}${badge.path.startsWith("readme/") ? " and rebuild README.md." : "."}`);
100
+ }
101
+ return {
102
+ state: "enabled",
103
+ evidence: [...current.evidence, {
104
+ code: "npm-readme-badge",
105
+ kind: "readme",
106
+ subject: { kind: "repository-path", identifier: badge.path },
107
+ observation: `${badge.path} links its ${badge.compatibleCustom ? "custom" : "owned"} npm version badge to https://www.npmjs.com/package/${badge.name}.`,
108
+ }],
109
+ };
110
+ },
111
+ checkEnable: async (context) => {
112
+ const check = await workflow.checkEnable(context);
113
+ if (check.result === "blocked")
114
+ return check;
115
+ const badge = await inspect(context);
116
+ if (!badge.name) {
117
+ return blocked("Set an explicit scoped npm package name in exactly one deno.json or deno.jsonc before adding its badge.");
118
+ }
119
+ if (badge.kind === "custom" || badge.kind === "duplicate") {
120
+ return blocked(`Resolve custom or duplicate npm badges in ${badge.path}. Keep custom text and use exactly ${npmBadge(badge.name).content}, or remove the conflicting badge. Custom content will not be overwritten.`);
121
+ }
122
+ if (badge.missingReadme &&
123
+ !context.resolvedChanges.some((change) => change.enabled &&
124
+ ["readme-static", "readme-build"].includes(change.featureId))) {
125
+ return blocked(`Select a README provider to create ${badge.path} for the npm badge.`);
126
+ }
127
+ if (badge.kind !== "exact" &&
128
+ (await workflow.detect(context)).state === "enabled" &&
129
+ !repairing(context)) {
130
+ return blocked(`Use --repair to ${badge.kind === "absent" ? "add" : "replace"} the npm badge for ${badge.name} in ${badge.path}.`);
131
+ }
132
+ return check.result === "no-op" && badge.kind !== "exact"
133
+ ? { result: "allowed", warnings: [], preconditions: [] }
134
+ : check;
135
+ },
136
+ checkDisable: async (context) => {
137
+ const check = await workflow.checkDisable(context);
138
+ if (check.result !== "no-op")
139
+ return check;
140
+ const badge = await inspect(context);
141
+ return badge.owned === "exact" || badge.owned === "stale"
142
+ ? { result: "allowed", warnings: [], preconditions: [] }
143
+ : check;
144
+ },
145
+ };
146
+ }
147
+ function repairing(context) {
148
+ return context.repair?.kind === "all-drifted" ||
149
+ context.repair?.kind === "features" &&
150
+ context.repair.featureIds.includes(npmBadgeOwner);
151
+ }
152
+ function blocked(message) {
153
+ return {
154
+ result: "blocked",
155
+ warnings: [],
156
+ blockers: [{
157
+ code: "npm-readme-badge",
158
+ message,
159
+ subjects: [],
160
+ resolution: "Resolve the stated README badge or package identity before continuing.",
161
+ }],
162
+ };
163
+ }
@@ -1,4 +1,5 @@
1
1
  /** @module Compose feature-owned README guides against the final feature plans. */
2
+ import { npmBadge, npmBadgeIdentity, npmBadgeOwner, } from "./npm-readme-badge.js";
2
3
  import { fileAccess } from "../repository/file-access.js";
3
4
  import { fromFileUrl } from "@jsr/std__path";
4
5
  import { packageImports, rewritePackageImport, } from "../readme/package-import.js";
@@ -132,9 +133,23 @@ export async function reconcileReadmePlans(context, plans) {
132
133
  github,
133
134
  cliPermissions,
134
135
  });
136
+ const npmName = await npmBadgeIdentity(projected);
137
+ if (active(npmBadgeOwner) && npmName)
138
+ contributions.push(npmBadge(npmName));
139
+ const preserveNpmBadge = context.resolvedChanges.find((change) => change.featureId === npmBadgeOwner)
140
+ ?.enabled !== false &&
141
+ (!npmName || context.detections.get(npmBadgeOwner)?.state === "ambiguous" ||
142
+ context.detections.get(npmBadgeOwner) === undefined);
135
143
  const provider = build ? "readme-build" : "readme-static";
136
144
  // Remove unchanged legacy JSR API blocks before the library adds its block.
137
- const owners = [provider, "jsr-package", "deno-cli", "deno-lib", "github-ci"];
145
+ const owners = [
146
+ provider,
147
+ "jsr-package",
148
+ "deno-cli",
149
+ "deno-lib",
150
+ npmBadgeOwner,
151
+ "github-ci",
152
+ ];
138
153
  const appended = [];
139
154
  // File/export ownership belongs to JSR publication. README blocks stay with
140
155
  // the feature named in their marker so per-feature commits remain possible.
@@ -144,6 +159,8 @@ export async function reconcileReadmePlans(context, plans) {
144
159
  for (const owner of owners) {
145
160
  if (owner === "github-ci" && preserveCiBadge)
146
161
  continue;
162
+ if (owner === npmBadgeOwner && preserveNpmBadge)
163
+ continue;
147
164
  const before = files.changes.length;
148
165
  const current = await files.read(sourcePath);
149
166
  if (current === undefined)
@@ -0,0 +1,5 @@
1
+ /** @module Package identity accepted by the native npm publisher. */
2
+ export function validNpmPublishName(value) {
3
+ return typeof value === "string" &&
4
+ /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(value);
5
+ }
@@ -70,18 +70,24 @@ export async function reconcileBlocks(text, desired, owner) {
70
70
  if (item.position === "badges" &&
71
71
  (item.id.startsWith("jsr-package")
72
72
  ? text.includes("[![JSR")
73
- : text.includes("[![CI]")))
73
+ : item.id === "github-ci:badge"
74
+ ? text.includes("[![CI]")
75
+ : npmBadgeImages(text).length > 0))
74
76
  continue;
75
77
  const block = await wrap(item);
76
78
  if (item.position === "badges") {
77
79
  const section = text.search(/^## /m);
78
80
  const marker = text.indexOf("<!-- hj:readme ");
79
81
  let at = Math.min(section < 0 ? text.length : section, marker < 0 ? text.length : marker);
80
- if (item.id === "github-ci:badge") {
81
- const jsr = [...text.matchAll(pattern)].find((match) => match[1] === "jsr-package:badges");
82
- if (jsr)
83
- at = jsr.index + jsr[0].length;
84
- }
82
+ const order = [
83
+ "jsr-package:badges",
84
+ "github-release-publish-npm:badge",
85
+ "github-ci:badge",
86
+ ];
87
+ const earlier = [...text.matchAll(pattern)].filter((match) => order.indexOf(match[1]) >= 0 &&
88
+ order.indexOf(match[1]) < order.indexOf(item.id)).at(-1);
89
+ if (earlier)
90
+ at = earlier.index + earlier[0].length;
85
91
  text = insert(text, at, block);
86
92
  }
87
93
  else {
@@ -108,3 +114,24 @@ function insert(text, at, block) {
108
114
  const after = text.slice(at);
109
115
  return before + "\n\n" + block + (after ? "\n" + after : "");
110
116
  }
117
+ /** Inspect one owned contribution without treating edited bodies as generated. */
118
+ export async function inspectContribution(text, desired) {
119
+ const matches = [...text.matchAll(pattern)].filter((match) => match[1] === desired.id);
120
+ const markers = text.split(`<!-- hj:readme ${desired.id} `).length - 1;
121
+ if (markers > 1)
122
+ return "duplicate";
123
+ const match = matches[0];
124
+ if (!match) {
125
+ return text.includes(`<!-- hj:readme ${desired.id} `) ? "custom" : "absent";
126
+ }
127
+ if (await contributionHash(match[3]) !== match[2])
128
+ return "custom";
129
+ return await contributionHash(desired.content) === match[2]
130
+ ? "exact"
131
+ : "stale";
132
+ }
133
+ /** Recognize npm badge images without claiming ownership of custom Markdown. */
134
+ export function npmBadgeImages(text) {
135
+ return [...text.matchAll(/!\[([^\]]*)\]\(([^)\n]+)\)/g)].filter((match) => /^npm(?: version)?$/i.test(match[1]) ||
136
+ match[2].startsWith("https://img.shields.io/npm/v/")).map((match) => match[0]);
137
+ }
@@ -1,4 +1,5 @@
1
1
  /** Independent npm publication of a checked, reproducible build artifact. */
2
+ import { validNpmPublishName } from "../package/npm-name.js";
2
3
  import * as fs from "node:fs/promises";
3
4
  import { isDeepStrictEqual } from "node:util";
4
5
  import { confirmPublication, } from "./confirm-publication.js";
@@ -75,8 +76,7 @@ export async function publishNpm(input) {
75
76
  throw new TypeError("npm publication requires a clean release checkout.");
76
77
  }
77
78
  const config = await versionConfig(input.files, release.version);
78
- if (typeof config.name !== "string" ||
79
- !/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(config.name)) {
79
+ if (!validNpmPublishName(config.name)) {
80
80
  throw new TypeError("npm publication requires a scoped package name in Deno config.");
81
81
  }
82
82
  const tasks = object(config.tasks);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hugojosefson/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Repository tools for Deno, Node.js and Bun.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,12 +8,12 @@
8
8
  },
9
9
  "license": "MIT",
10
10
  "bin": {
11
- "hj": "./esm/src/cli/cli.js"
11
+ "hj": "./esm/hj.js"
12
12
  },
13
- "gitHead": "ec9fb8eafc9ad315cb90c298a84d93f1384d1254",
13
+ "gitHead": "47f0f3cdb7ded4b6389f41adf02a78e2926d1733",
14
14
  "type": "module",
15
15
  "engines": {
16
- "node": ">=24",
16
+ "node": ">=24.0.0",
17
17
  "bun": ">=1.4.2"
18
18
  },
19
19
  "os": [
@@ -29,7 +29,7 @@
29
29
  "access": "public",
30
30
  "registry": "https://registry.npmjs.org/"
31
31
  },
32
- "hjNpmArchive": "hugojosefson-cli-0.12.0.tgz",
32
+ "hjNpmArchive": "hugojosefson-cli-0.13.0.tgz",
33
33
  "_generatedBy": "dnt@0.43.2",
34
34
  "files": [
35
35
  "esm",