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 +22 -2
- package/bin/create-honua-app.mjs +1 -1
- package/lib/cli.mjs +59 -11
- package/lib/scaffold.mjs +23 -6
- package/lib/sdk-version.mjs +291 -0
- package/lib/templates.mjs +24 -1
- package/package.json +1 -1
- package/templates/react-ts/package.json +1 -1
- package/templates/vanilla-ts/package.json +1 -1
- package/templates.manifest.json +2 -1
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
|
|
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
|
package/bin/create-honua-app.mjs
CHANGED
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
|
|
5
|
-
// a full scaffold in-process without spawning
|
|
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 = {
|
|
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(
|
|
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
|
-
|
|
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.
|
|
106
|
-
* so the bin wrapper stays the only place that touches process
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
/**
|
|
75
|
-
|
|
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
|
-
|
|
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: { ...
|
|
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
|
|
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.
|
|
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",
|