create-honua-app 0.1.5 → 0.1.6

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
@@ -1,8 +1,9 @@
1
1
  # create-honua-app
2
2
 
3
3
  Scaffold a [Honua JavaScript SDK](https://github.com/honua-io/honua-sdk-js) map application — Vite + TypeScript,
4
- pinned to a published SDK version, running a map on the first `npm run dev`.
4
+ pinned to the certified SDK release, running a map on the first `npm run dev`.
5
5
 
6
+ <!-- doc-run: blocked https://github.com/honua-io/honua-release/issues/423 -->
6
7
  ```bash
7
8
  npm create honua-app@latest my-map
8
9
  cd my-map
@@ -16,6 +17,8 @@ npm run dev
16
17
  create-honua-app [directory] [options]
17
18
 
18
19
  -t, --template <id> Starter to scaffold (default: vanilla-ts)
20
+ --sdk-version <v> Pin this exact @honua/sdk-js version instead of the promoted
21
+ release channel (the only way to scaffold a prerelease)
19
22
  --list-templates Print the available templates and their playground links
20
23
  --force Scaffold into a directory that already has files
21
24
  -h, --help Print usage
@@ -24,8 +27,25 @@ create-honua-app [directory] [options]
24
27
 
25
28
  With `npm create`, pass CLI options after `--`:
26
29
 
30
+ <!-- doc-run: blocked https://github.com/honua-io/honua-release/issues/423 -->
27
31
  ```bash
28
- npm create honua-app@latest my-map -- --template react-ts
32
+ npm create honua-app@latest my-react-map -- --template react-ts
33
+ ```
34
+
35
+ ## Which SDK version a scaffold pins
36
+
37
+ The scaffold resolves `@honua/sdk-js` when it runs, from the `release-2026.1` npm dist-tag. Only a Honua release
38
+ promotion moves that tag, so a newly promoted SDK reaches fresh scaffolds without a new `create-honua-app`. The
39
+ generated `package.json` still records one exact version, so the app stays reproducible.
40
+
41
+ The channel is used only when it names a stable release on the starter's own SDK line. Until the channel is promoted,
42
+ or if it names a prerelease or another line, or if the registry cannot be read, the scaffold pins the certified
43
+ version this release of `create-honua-app` ships with. The default path never pins a prerelease. To choose a version
44
+ yourself, pass `--sdk-version`:
45
+
46
+ <!-- doc-run: blocked https://github.com/honua-io/honua-release/issues/423 -->
47
+ ```bash
48
+ npm create honua-app@latest my-pinned-map -- --sdk-version 0.1.12
29
49
  ```
30
50
 
31
51
  ## Templates
@@ -4,4 +4,4 @@
4
4
 
5
5
  import { run } from "../lib/cli.mjs";
6
6
 
7
- process.exitCode = run(process.argv.slice(2));
7
+ process.exitCode = await run(process.argv.slice(2));
package/lib/cli.mjs CHANGED
@@ -1,19 +1,23 @@
1
1
  // Argument parsing and console output for `npm create honua-app`.
2
2
  //
3
3
  // `parseArgs` is pure so the repository test suite can assert the grammar, and
4
- // `run` takes its streams and working directory as options so a test can drive
5
- // a full scaffold in-process without spawning npm or a build.
4
+ // `run` takes its streams, working directory, environment, and registry fetch
5
+ // as options so a test can drive a full scaffold in-process without spawning
6
+ // npm, a build, or a real registry request.
6
7
 
7
8
  import fs from "node:fs";
8
9
  import path from "node:path";
9
10
 
10
- import { scaffoldProject } from "./scaffold.mjs";
11
+ import { assertWritableTarget, projectNameFromDirectory, scaffoldProject } from "./scaffold.mjs";
12
+ import { resolveSdkVersion } from "./sdk-version.mjs";
11
13
  import { PACKAGE_ROOT, defaultTemplate, loadTemplateManifest, playgroundLinks, templateIds } from "./templates.mjs";
12
14
 
13
15
  const USAGE = `Usage: create-honua-app [directory] [options]
14
16
 
15
17
  Options:
16
18
  -t, --template <id> Starter to scaffold (default: the manifest's default template)
19
+ --sdk-version <v> Pin this exact SDK version instead of the promoted
20
+ release channel (the only way to scaffold a prerelease)
17
21
  --list-templates Print the available templates and exit
18
22
  --force Scaffold into a directory that already has files
19
23
  -h, --help Print this message and exit
@@ -21,7 +25,13 @@ Options:
21
25
 
22
26
  /** Parse argv (already stripped of node and the script path). */
23
27
  export function parseArgs(argv) {
24
- const options = { mode: "scaffold", directory: undefined, templateId: undefined, force: false };
28
+ const options = {
29
+ mode: "scaffold",
30
+ directory: undefined,
31
+ templateId: undefined,
32
+ sdkVersion: undefined,
33
+ force: false,
34
+ };
25
35
  for (let index = 0; index < argv.length; index += 1) {
26
36
  const argument = argv[index];
27
37
  if (argument === "--help" || argument === "-h") {
@@ -53,6 +63,19 @@ export function parseArgs(argv) {
53
63
  options.templateId = value;
54
64
  continue;
55
65
  }
66
+ if (argument === "--sdk-version") {
67
+ const value = argv[index + 1];
68
+ if (!value || value.startsWith("-")) throw new Error(`${argument} requires a version.`);
69
+ options.sdkVersion = value;
70
+ index += 1;
71
+ continue;
72
+ }
73
+ if (argument.startsWith("--sdk-version=")) {
74
+ const value = argument.slice("--sdk-version=".length);
75
+ if (value.length === 0) throw new Error("--sdk-version requires a version.");
76
+ options.sdkVersion = value;
77
+ continue;
78
+ }
56
79
  if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}`);
57
80
  if (options.directory !== undefined) throw new Error(`Unexpected extra argument: ${argument}`);
58
81
  options.directory = argument;
@@ -74,18 +97,29 @@ export function templateListing(manifest) {
74
97
  for (const template of manifest.templates) {
75
98
  lines.push(`${template.id}${template.default ? " (default)" : ""} — ${template.title}`);
76
99
  lines.push(` ${template.summary}`);
77
- lines.push(` SDK: ${manifest.sdk.package}@${manifest.sdk.version}`);
100
+ lines.push(
101
+ ` SDK: ${manifest.sdk.package} from the ${manifest.sdk.channel} channel (certified fallback ${manifest.sdk.version})`,
102
+ );
78
103
  for (const link of playgroundLinks(manifest, template)) lines.push(` ${link.title}: ${link.url}`);
79
104
  }
80
105
  return `${lines.join("\n")}\n`;
81
106
  }
82
107
 
108
+ /** One line saying which SDK version the scaffold pinned and why. */
109
+ export function sdkPinLine(sdk) {
110
+ const pinned = `${sdk.package}@${sdk.version}`;
111
+ if (sdk.source === "channel") return `Pinned ${pinned} from the promoted ${sdk.channel} channel.`;
112
+ if (sdk.source === "override")
113
+ return `Pinned ${pinned} as requested by --sdk-version${sdk.note ? ` (${sdk.note})` : ""}.`;
114
+ return `Pinned ${pinned}, the certified version this release of create-honua-app ships${sdk.note ? ` (${sdk.note})` : ""}.`;
115
+ }
116
+
83
117
  /** Next-step instructions printed after a successful scaffold. */
84
118
  export function nextSteps(receipt, cwd) {
85
119
  const relative = path.relative(cwd, receipt.targetRoot) || ".";
86
120
  return [
87
121
  `Created ${receipt.projectName} from the ${receipt.templateId} template in ${relative}.`,
88
- `Pinned ${receipt.sdk.package}@${receipt.sdk.version}.`,
122
+ sdkPinLine(receipt.sdk),
89
123
  "",
90
124
  "Next steps:",
91
125
  ...(relative === "." ? [] : [` cd ${relative}`]),
@@ -102,12 +136,20 @@ function packageVersion(packageRoot) {
102
136
  }
103
137
 
104
138
  /**
105
- * Run the CLI. Returns the process exit code instead of calling `process.exit`
106
- * so the bin wrapper stays the only place that touches process state.
139
+ * Run the CLI. Resolves to the process exit code instead of calling
140
+ * `process.exit` so the bin wrapper stays the only place that touches process
141
+ * state. `env` and `fetch` reach the registry lookup in lib/sdk-version.mjs.
107
142
  */
108
- export function run(
143
+ export async function run(
109
144
  argv,
110
- { cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr, packageRoot = PACKAGE_ROOT } = {},
145
+ {
146
+ cwd = process.cwd(),
147
+ stdout = process.stdout,
148
+ stderr = process.stderr,
149
+ packageRoot = PACKAGE_ROOT,
150
+ env = process.env,
151
+ fetch = globalThis.fetch,
152
+ } = {},
111
153
  ) {
112
154
  let options;
113
155
  try {
@@ -139,7 +181,13 @@ export function run(
139
181
  }
140
182
 
141
183
  try {
142
- const receipt = scaffoldProject({ templateId, directory, force: options.force, cwd, packageRoot });
184
+ // Refuse an unusable target before the registry round-trip, which can take
185
+ // seconds; scaffoldProject repeats both checks when it writes.
186
+ const targetRoot = path.resolve(cwd, directory);
187
+ projectNameFromDirectory(targetRoot);
188
+ assertWritableTarget(targetRoot, options.force);
189
+ const sdk = await resolveSdkVersion({ manifest, override: options.sdkVersion, env, fetch });
190
+ const receipt = scaffoldProject({ templateId, directory, force: options.force, cwd, packageRoot, sdk });
143
191
  stdout.write(nextSteps(receipt, cwd));
144
192
  return 0;
145
193
  } catch (error) {
package/lib/scaffold.mjs CHANGED
@@ -71,14 +71,24 @@ export function isPlaygroundOnlyFile(relativePath) {
71
71
  return PLAYGROUND_ONLY_FILES.has(relativePath.split("/").pop());
72
72
  }
73
73
 
74
- /** Rewrite a template `package.json` so the scaffolded app carries its own name. */
75
- export function renderProjectManifest(source, projectName) {
74
+ /**
75
+ * Rewrite a template `package.json` so the scaffolded app carries its own name
76
+ * and pins the SDK version the scaffold resolved (`sdk`, when given).
77
+ */
78
+ export function renderProjectManifest(source, projectName, sdk) {
76
79
  const manifest = JSON.parse(source);
77
80
  manifest.name = projectName;
81
+ if (sdk) {
82
+ if (!Object.hasOwn(manifest.dependencies ?? {}, sdk.package)) {
83
+ throw new Error(`Template package.json does not depend on ${sdk.package}.`);
84
+ }
85
+ manifest.dependencies[sdk.package] = sdk.version;
86
+ }
78
87
  return `${JSON.stringify(manifest, null, 2)}\n`;
79
88
  }
80
89
 
81
- function assertWritableTarget(targetRoot, force) {
90
+ /** Throw unless `targetRoot` is absent, empty, or `force` allows writing into it. */
91
+ export function assertWritableTarget(targetRoot, force) {
82
92
  if (!fs.existsSync(targetRoot)) return;
83
93
  const stats = fs.statSync(targetRoot);
84
94
  if (!stats.isDirectory()) throw new Error(`${targetRoot} exists and is not a directory.`);
@@ -92,7 +102,9 @@ function assertWritableTarget(targetRoot, force) {
92
102
  /**
93
103
  * Copy `templateId` into `directory`, returning the scaffold receipt the CLI
94
104
  * prints. `directory` is resolved against `cwd` so tests never depend on the
95
- * process working directory.
105
+ * process working directory. `sdk` is the resolution from
106
+ * `resolveSdkVersion` (lib/sdk-version.mjs); without one the scaffold pins the
107
+ * manifest's certified version.
96
108
  */
97
109
  export function scaffoldProject({
98
110
  templateId,
@@ -100,8 +112,13 @@ export function scaffoldProject({
100
112
  force = false,
101
113
  cwd = process.cwd(),
102
114
  packageRoot = PACKAGE_ROOT,
115
+ sdk,
103
116
  }) {
104
117
  const manifest = loadTemplateManifest(packageRoot);
118
+ const pinnedSdk = sdk ?? { ...manifest.sdk, source: "pinned" };
119
+ if (pinnedSdk.package !== manifest.sdk.package) {
120
+ throw new Error(`Resolved SDK ${pinnedSdk.package} is not the manifest's ${manifest.sdk.package}.`);
121
+ }
105
122
  const template = templateById(manifest, templateId);
106
123
  const sourceRoot = templateRoot(manifest, template.id, packageRoot);
107
124
  const targetRoot = path.resolve(cwd, directory);
@@ -116,7 +133,7 @@ export function scaffoldProject({
116
133
  const destination = path.join(targetRoot, destinationRelative);
117
134
  fs.mkdirSync(path.dirname(destination), { recursive: true });
118
135
  if (destinationRelative === "package.json") {
119
- fs.writeFileSync(destination, renderProjectManifest(fs.readFileSync(source, "utf8"), projectName));
136
+ fs.writeFileSync(destination, renderProjectManifest(fs.readFileSync(source, "utf8"), projectName, pinnedSdk));
120
137
  } else {
121
138
  fs.copyFileSync(source, destination);
122
139
  }
@@ -128,7 +145,7 @@ export function scaffoldProject({
128
145
  templateTitle: template.title,
129
146
  projectName,
130
147
  targetRoot,
131
- sdk: { ...manifest.sdk },
148
+ sdk: { ...pinnedSdk },
132
149
  files: written,
133
150
  };
134
151
  }
@@ -0,0 +1,291 @@
1
+ // Scaffold-time SDK version resolution (#1824, honua-release#376 ruling R18).
2
+ //
3
+ // A starter follows the promoted release channel: the npm dist-tag that only
4
+ // release promotion moves. A new SDK promotion therefore reaches fresh
5
+ // scaffolds without a `create-honua-app` republish, and the scaffolded
6
+ // `package.json` still records one exact version so the app stays
7
+ // reproducible.
8
+ //
9
+ // The channel is trusted only for a stable version on the starter's own SDK
10
+ // line (same major, and same minor while the SDK is 0.x), because the template
11
+ // source is written against that API. Anything else — the channel is not
12
+ // promoted yet, it names a prerelease or another line, or the registry cannot
13
+ // be read — falls back to the certified version the manifest pins, so a first
14
+ // map never lands on a beta the user did not ask for. An explicit
15
+ // `--sdk-version` is the only way to scaffold a prerelease.
16
+ //
17
+ // When npm launched the scaffold (`npm create`, `npx`), the registry is read
18
+ // through npm's own CLI (`npm view`), so the lookup sees exactly what the
19
+ // later `npm install` will: scope registries, `.npmrc` auth, proxies, and CA
20
+ // settings. Otherwise it is a direct read of the exported registry setting.
21
+ // A registry that answers 404 for the SDK, or that lists neither the channel
22
+ // version nor the certified fallback, is not "offline": the app it would
23
+ // scaffold could never install, so that fails the scaffold instead.
24
+
25
+ import { execFile } from "node:child_process";
26
+ import path from "node:path";
27
+
28
+ export const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
29
+ export const REGISTRY_TIMEOUT_MS = 5000;
30
+ export const NPM_VIEW_TIMEOUT_MS = 30000;
31
+
32
+ // The SemVer 2.0.0 grammar (semver.org): numeric identifiers carry no leading
33
+ // zeros and every prerelease or build identifier is non-empty, so a malformed
34
+ // --sdk-version is refused even when the registry cannot be asked about it.
35
+ const VERSION_PATTERN =
36
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
37
+
38
+ /** Parse an exact semver version, or return undefined for anything else (ranges, tags, garbage). */
39
+ export function parseVersion(value) {
40
+ const match = VERSION_PATTERN.exec(String(value));
41
+ if (!match) return undefined;
42
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] };
43
+ }
44
+
45
+ /** True for an exact release version with no prerelease suffix. */
46
+ export function isStableVersion(value) {
47
+ const parsed = parseVersion(value);
48
+ return parsed !== undefined && parsed.prerelease === undefined;
49
+ }
50
+
51
+ /**
52
+ * True when `candidate` is a stable release on the same SDK line as the
53
+ * certified `pinned` version: the line a caret range on the pin would accept,
54
+ * ignoring the lower bound so a promoted channel may also move back within it.
55
+ */
56
+ export function onPinnedLine(candidate, pinned) {
57
+ const next = parseVersion(candidate);
58
+ const pin = parseVersion(pinned);
59
+ if (!next || !pin || next.prerelease !== undefined) return false;
60
+ if (next.major !== pin.major) return false;
61
+ return pin.major !== 0 || next.minor === pin.minor;
62
+ }
63
+
64
+ /** A registry URL safe to print: any userinfo (`user:token@`) is removed. */
65
+ export function redactUrl(value) {
66
+ const url = new URL(value);
67
+ url.username = "";
68
+ url.password = "";
69
+ return url.toString();
70
+ }
71
+
72
+ /**
73
+ * The registry npm installs `packageName` from, as npm exported it to this
74
+ * process: the scope's own registry (`@scope:registry`) first, then the
75
+ * default `registry`, else npmjs. The setting is never echoed back, because a
76
+ * registry URL may carry credentials.
77
+ */
78
+ export function registryUrl(env = process.env, packageName = "") {
79
+ const scope = packageName.startsWith("@") ? packageName.split("/")[0] : undefined;
80
+ const configured =
81
+ (scope && (env[`npm_config_${scope}:registry`] || env[`NPM_CONFIG_${scope}:registry`])) ||
82
+ env.npm_config_registry ||
83
+ env.NPM_CONFIG_REGISTRY ||
84
+ DEFAULT_REGISTRY;
85
+ let url;
86
+ try {
87
+ url = new URL(configured);
88
+ } catch {
89
+ throw new Error(`The npm registry configured for ${packageName || "packages"} is not a valid URL.`);
90
+ }
91
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
92
+ throw new Error(`The npm registry configured for ${packageName || "packages"} must be an http(s) URL.`);
93
+ }
94
+ if (!url.pathname.endsWith("/")) url.pathname = `${url.pathname}/`;
95
+ return url.toString();
96
+ }
97
+
98
+ /**
99
+ * Registry document URL for a package name. The whole name is one path
100
+ * segment: `@scope/name` keeps its leading `@` and escapes everything else,
101
+ * including the scope slash, exactly as the npm CLI requests it.
102
+ */
103
+ export function packumentUrl(packageName, registry) {
104
+ return new URL(encodeURIComponent(packageName).replace(/^%40/, "@"), registry).toString();
105
+ }
106
+
107
+ /** The registry answered that it has no such package: definitive, not a transport failure. */
108
+ export class PackageNotFoundError extends Error {}
109
+
110
+ /**
111
+ * Read a package's dist-tags and published versions from the registry.
112
+ * Throws `PackageNotFoundError` on HTTP 404 and a plain `Error` with a
113
+ * reader-facing, credential-free reason on any other transport, status, or
114
+ * shape failure.
115
+ */
116
+ export async function fetchPackument(
117
+ packageName,
118
+ { registry = DEFAULT_REGISTRY, fetch: fetchImpl = globalThis.fetch, timeoutMs = REGISTRY_TIMEOUT_MS } = {},
119
+ ) {
120
+ if (typeof fetchImpl !== "function") throw new Error("this Node.js runtime has no fetch");
121
+ const target = new URL(packumentUrl(packageName, registry));
122
+ const headers = { accept: "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8" };
123
+ // fetch refuses a URL with userinfo; send it the way npm does for a
124
+ // credentialed registry URL, as basic auth to that same registry.
125
+ if (target.username || target.password) {
126
+ const credentials = `${decodeURIComponent(target.username)}:${decodeURIComponent(target.password)}`;
127
+ headers.authorization = `Basic ${Buffer.from(credentials).toString("base64")}`;
128
+ }
129
+ const url = redactUrl(target);
130
+ let response;
131
+ try {
132
+ response = await fetchImpl(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
133
+ } catch (error) {
134
+ throw new Error(`${url} could not be reached (${error instanceof Error ? error.message : String(error)})`);
135
+ }
136
+ if (response.status === 404) throw new PackageNotFoundError(`${url} returned HTTP 404`);
137
+ if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`);
138
+ let document;
139
+ try {
140
+ document = await response.json();
141
+ } catch {
142
+ throw new Error(`${url} did not return JSON`);
143
+ }
144
+ const distTags = document?.["dist-tags"];
145
+ const versions = document?.versions;
146
+ if (!distTags || typeof distTags !== "object" || !versions || typeof versions !== "object") {
147
+ throw new Error(`${url} did not return package metadata`);
148
+ }
149
+ return { distTags, versions: new Set(Object.keys(versions)) };
150
+ }
151
+
152
+ /** npm's own CLI script when npm launched this process; undefined for pnpm, yarn, or a direct run. */
153
+ export function npmCliPath(env = process.env) {
154
+ const execpath = env.npm_execpath;
155
+ return typeof execpath === "string" && path.basename(execpath) === "npm-cli.js" ? execpath : undefined;
156
+ }
157
+
158
+ /**
159
+ * Read a package's dist-tags and published versions with `npm view`, under
160
+ * npm's effective configuration. Throws `PackageNotFoundError` for npm's E404
161
+ * and a plain `Error` naming only npm's error code otherwise, so nothing npm
162
+ * printed (URLs included) reaches the scaffold's output.
163
+ */
164
+ export function viewPackument(packageName, { npmCli, env = process.env, timeoutMs = NPM_VIEW_TIMEOUT_MS } = {}) {
165
+ return new Promise((resolve, reject) => {
166
+ execFile(
167
+ process.execPath,
168
+ [npmCli, "view", packageName, "dist-tags", "versions", "--json"],
169
+ { env, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: timeoutMs, windowsHide: true },
170
+ (error, stdout) => {
171
+ let document;
172
+ try {
173
+ document = JSON.parse(stdout);
174
+ } catch {
175
+ document = undefined;
176
+ }
177
+ if (error) {
178
+ const code = typeof document?.error?.code === "string" ? document.error.code : undefined;
179
+ if (code === "E404") {
180
+ reject(new PackageNotFoundError(`npm view ${packageName} returned E404`));
181
+ return;
182
+ }
183
+ reject(new Error(`npm view ${packageName} failed (${code ?? (error.killed ? "timed out" : "no result")})`));
184
+ return;
185
+ }
186
+ const distTags = document?.["dist-tags"];
187
+ const versions = typeof document?.versions === "string" ? [document.versions] : document?.versions;
188
+ if (!distTags || typeof distTags !== "object" || !Array.isArray(versions)) {
189
+ reject(new Error(`npm view ${packageName} did not return package metadata`));
190
+ return;
191
+ }
192
+ resolve({ distTags, versions: new Set(versions) });
193
+ },
194
+ );
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Decide which `manifest.sdk.package` version a scaffold pins.
200
+ *
201
+ * Returns `{ package, version, source, channel, note }` where `source` is
202
+ * `"override"` (the user passed `--sdk-version`), `"channel"` (the promoted
203
+ * dist-tag), or `"pinned"` (the certified fallback); `note` explains a
204
+ * fallback or an unconfirmed override. Throws when the override is malformed
205
+ * or unpublished, when the registry has no SDK package at all, or when it
206
+ * lists neither a usable channel version nor the certified fallback.
207
+ */
208
+ export async function resolveSdkVersion({
209
+ manifest,
210
+ override,
211
+ env = process.env,
212
+ fetch: fetchImpl,
213
+ timeoutMs,
214
+ npmCli = npmCliPath(env),
215
+ } = {}) {
216
+ const { package: packageName, version: pinned, channel } = manifest.sdk;
217
+ const base = { package: packageName, channel };
218
+ if (override !== undefined && !parseVersion(override)) {
219
+ throw new Error(`--sdk-version must be an exact version such as ${pinned}, found ${JSON.stringify(override)}.`);
220
+ }
221
+ // Resolved inside read() so a malformed registry setting is just another
222
+ // unreadable registry: the fallback (or an unconfirmed override) applies.
223
+ let shownRegistry = "the npm registry npm is configured to use";
224
+ const read = async () => {
225
+ try {
226
+ if (npmCli) return await viewPackument(packageName, { npmCli, env, timeoutMs });
227
+ const registry = registryUrl(env, packageName);
228
+ shownRegistry = redactUrl(registry);
229
+ return await fetchPackument(packageName, { registry, fetch: fetchImpl, timeoutMs });
230
+ } catch (error) {
231
+ if (!(error instanceof PackageNotFoundError)) throw error;
232
+ throw new PackageNotFoundError(
233
+ `${packageName} is not on ${npmCli ? shownRegistry : `the configured npm registry ${shownRegistry}`}, so the app could not install.`,
234
+ );
235
+ }
236
+ };
237
+
238
+ if (override !== undefined) {
239
+ let packument;
240
+ try {
241
+ packument = await read();
242
+ } catch (error) {
243
+ if (error instanceof PackageNotFoundError) throw error;
244
+ return {
245
+ ...base,
246
+ version: override,
247
+ source: "override",
248
+ note: `could not confirm ${packageName}@${override} is published: ${error.message}`,
249
+ };
250
+ }
251
+ if (!packument.versions.has(override)) {
252
+ throw new Error(`${packageName}@${override} is not published on ${shownRegistry}.`);
253
+ }
254
+ return { ...base, version: override, source: "override" };
255
+ }
256
+
257
+ let packument;
258
+ try {
259
+ packument = await read();
260
+ } catch (error) {
261
+ if (error instanceof PackageNotFoundError) throw error;
262
+ return {
263
+ ...base,
264
+ version: pinned,
265
+ source: "pinned",
266
+ note: `could not read the ${channel} channel: ${error.message}`,
267
+ };
268
+ }
269
+ // The registry answered, so the fallback must be installable from it too.
270
+ const pinnedResult = (note) => {
271
+ if (!packument.versions.has(pinned)) {
272
+ throw new Error(
273
+ `${note}, and ${shownRegistry} does not list the certified ${packageName}@${pinned}, so the app could not install. Pass --sdk-version to choose a published version.`,
274
+ );
275
+ }
276
+ return { ...base, version: pinned, source: "pinned", note };
277
+ };
278
+ const promoted = packument.distTags[channel];
279
+ if (typeof promoted !== "string" || promoted.length === 0) {
280
+ return pinnedResult(`the ${channel} channel has not been promoted yet`);
281
+ }
282
+ if (!onPinnedLine(promoted, pinned)) {
283
+ return pinnedResult(
284
+ `the ${channel} channel names ${promoted}, which is not a stable release on this starter's ${pinned} line`,
285
+ );
286
+ }
287
+ if (!packument.versions.has(promoted)) {
288
+ return pinnedResult(`the ${channel} channel names ${promoted}, which the registry does not list`);
289
+ }
290
+ return { ...base, version: promoted, source: "channel" };
291
+ }
package/lib/templates.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // Template manifest reader for `create-honua-app`.
2
2
  //
3
3
  // `templates.manifest.json` is the single source of truth for which starters
4
- // exist, which published SDK version they pin, and where their zero-install
4
+ // exist, which release channel and certified SDK version they follow, and
5
+ // where their zero-install
5
6
  // playgrounds live. The CLI, the repository verifier
6
7
  // (scripts/verify-create-honua-app.mjs) and the generated playground page
7
8
  // (scripts/playgrounds.mjs) all read it through this module so a template can
@@ -11,10 +12,25 @@ import fs from "node:fs";
11
12
  import path from "node:path";
12
13
  import { fileURLToPath } from "node:url";
13
14
 
15
+ import { isStableVersion } from "./sdk-version.mjs";
16
+
14
17
  export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15
18
  export const MANIFEST_FILE = "templates.manifest.json";
16
19
  export const MANIFEST_FORMAT = "honua.sdk.create-honua-app-templates.v1";
17
20
  export const TEMPLATE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
+ /**
22
+ * npm dist-tag names the manifest may follow: lowercase, dot-separated words.
23
+ * npm refuses a tag that is also a valid semver range, which is why the
24
+ * 2026.1 channel is `release-2026.1` and not `2026.1`, so a name whose first
25
+ * segment is a range token (`x`, `v1`) is refused as well.
26
+ */
27
+ export const CHANNEL_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z0-9-]+)*$/;
28
+ const RANGE_LIKE_CHANNEL = /^(?:x|v\d+)(?:\.|$)/;
29
+
30
+ /** True for a channel name npm can publish as a dist-tag. */
31
+ export function isChannelName(name) {
32
+ return typeof name === "string" && CHANNEL_PATTERN.test(name) && !RANGE_LIKE_CHANNEL.test(name);
33
+ }
18
34
 
19
35
  function fail(message) {
20
36
  throw new Error(`${MANIFEST_FILE} is invalid: ${message}`);
@@ -33,6 +49,13 @@ export function loadTemplateManifest(packageRoot = PACKAGE_ROOT) {
33
49
  if (manifest.schemaVersion !== 1) fail("schemaVersion must be 1");
34
50
  nonEmptyString(manifest.sdk?.package, "sdk.package");
35
51
  nonEmptyString(manifest.sdk?.version, "sdk.version");
52
+ if (!isStableVersion(manifest.sdk.version)) {
53
+ fail(`sdk.version must be a stable release version, found ${JSON.stringify(manifest.sdk.version)}`);
54
+ }
55
+ nonEmptyString(manifest.sdk?.channel, "sdk.channel");
56
+ if (!isChannelName(manifest.sdk.channel)) {
57
+ fail(`sdk.channel must be an npm dist-tag name, found ${JSON.stringify(manifest.sdk.channel)}`);
58
+ }
36
59
  for (const field of ["owner", "name", "branch"]) nonEmptyString(manifest.repository?.[field], `repository.${field}`);
37
60
  if (!Array.isArray(manifest.playgroundProviders) || manifest.playgroundProviders.length === 0) {
38
61
  fail("playgroundProviders must be a non-empty array");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-honua-app",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Scaffold a Honua JavaScript SDK map application: Vite + TypeScript + MapLibre starters that connect and mount a source without hand-assembling peers",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Honua",
@@ -16,7 +16,7 @@
16
16
  "@bufbuild/protobuf": "2.13.0",
17
17
  "@connectrpc/connect": "2.1.2",
18
18
  "@connectrpc/connect-web": "2.1.2",
19
- "@honua/sdk-js": "0.1.11-beta.0",
19
+ "@honua/sdk-js": "0.1.12",
20
20
  "maplibre-gl": "6.4.1",
21
21
  "react": "19.2.8",
22
22
  "react-dom": "19.2.8"
@@ -16,7 +16,7 @@
16
16
  "@bufbuild/protobuf": "2.13.0",
17
17
  "@connectrpc/connect": "2.1.2",
18
18
  "@connectrpc/connect-web": "2.1.2",
19
- "@honua/sdk-js": "0.1.11-beta.0",
19
+ "@honua/sdk-js": "0.1.12",
20
20
  "maplibre-gl": "6.4.1"
21
21
  },
22
22
  "devDependencies": {
@@ -3,7 +3,8 @@
3
3
  "schemaVersion": 1,
4
4
  "sdk": {
5
5
  "package": "@honua/sdk-js",
6
- "version": "0.1.11-beta.0"
6
+ "channel": "release-2026.1",
7
+ "version": "0.1.12"
7
8
  },
8
9
  "repository": {
9
10
  "owner": "honua-io",