@juuno-sdk/cli 4.0.2 → 4.2.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.
@@ -0,0 +1,196 @@
1
+ import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { getApiUrl, getAuthToken } from '../auth/index.js';
4
+ import { spawnSync } from '../utils/bin.js';
5
+ import { fetchContext, withTimeout } from './context.js';
6
+ import { getErrorMessage } from '../utils/error.js';
7
+ import { describeOutcome, report, } from './outcome.js';
8
+ import { buildMetadata, isModuleFile, moduleContentType, parseWranglerConfig, resolveApiUrl, resolveEnvConfig, scriptName, } from './wrangler-config.js';
9
+ /**
10
+ * Far longer than the context read: this uploads the whole bundle and the server
11
+ * then provisions stores and runs the tenant's migrations before answering.
12
+ */
13
+ const DEPLOY_TIMEOUT_MS = 180_000;
14
+ /**
15
+ * Main script entry point.
16
+ */
17
+ export async function deployBackend(options) {
18
+ try {
19
+ await run(options);
20
+ }
21
+ catch (error) {
22
+ // Matches the other commands: one line the developer can act on, not a
23
+ // stack trace. The messages here already name the fix.
24
+ console.error(`❌ ${getErrorMessage(error)}`);
25
+ process.exit(1);
26
+ }
27
+ }
28
+ /**
29
+ * Resolve the plan, print the metadata, and deploy.
30
+ */
31
+ async function run(options) {
32
+ const plan = await resolvePlan(options);
33
+ if (options.printMetadata) {
34
+ const { script, env, namespace, metadata } = plan;
35
+ console.log(JSON.stringify({ script, env, namespace, metadata }, null, 2));
36
+ return;
37
+ }
38
+ const target = plan.namespace ? `${plan.env} (${plan.namespace})` : plan.env;
39
+ console.log(`Deploying '${plan.script}' to ${target} via ${plan.api}`);
40
+ const outcome = await postDeploy(plan, bundle(plan.workerDir, plan.env));
41
+ report(outcome);
42
+ }
43
+ /**
44
+ * Bundle the worker with its own pinned wrangler.
45
+ *
46
+ * Shelled out rather than imported so the version in the worker's
47
+ * devDependencies is the one that runs — a bundler mismatch between the CLI and
48
+ * the worker would be a genuinely confusing failure — and so the CLI takes no
49
+ * wrangler dependency of its own.
50
+ */
51
+ function bundle(workerDir, env) {
52
+ const outDir = join(workerDir, 'dist');
53
+ // Emptied first: wrangler writes into --outdir without clearing it, and
54
+ // everything left there is uploaded as a module. A renamed entry point or a
55
+ // dropped code-split chunk would otherwise ship alongside the current build
56
+ // forever — dead weight against the Worker size limit, and a bundle whose
57
+ // contents depend on what happened to be built in this directory before.
58
+ rmSync(outDir, { recursive: true, force: true });
59
+ const bundled = spawnSync('npx', ['wrangler', 'deploy', '--env', env, '--dry-run', '--outdir', outDir], { cwd: workerDir, stdio: 'inherit' });
60
+ // spawnSync reports rather than throws, so both failures are checked here:
61
+ // the spawn itself (wrangler missing, PATH wrong) and a non-zero exit.
62
+ if (bundled.error) {
63
+ throw new Error(`Could not run wrangler: ${bundled.error.message}`);
64
+ }
65
+ if (bundled.status !== 0) {
66
+ throw new Error(`wrangler exited with code ${bundled.status ?? 'unknown'}.`);
67
+ }
68
+ if (!existsSync(outDir)) {
69
+ throw new Error(`wrangler produced no output at ${outDir}.`);
70
+ }
71
+ const modules = collectModules(outDir);
72
+ if (!modules.length) {
73
+ throw new Error(`No module files in ${outDir}.`);
74
+ }
75
+ return modules;
76
+ }
77
+ /**
78
+ * Every module in the dry-run output, as parts to upload.
79
+ *
80
+ * The walk is recursive because wrangler writes a code-split chunk or an
81
+ * imported `.wasm` into a subdirectory, and a flat read would leave it out of
82
+ * the upload entirely — either rejected for a missing module, or accepted and
83
+ * then throwing `No such module` on the first request.
84
+ */
85
+ export function collectModules(outDir) {
86
+ // Separators normalised on the way in: Cloudflare addresses a module by a
87
+ // posix path, and a nested entry on Windows arrives here as `lib\chunk.js`.
88
+ const names = readdirSync(outDir, {
89
+ encoding: 'utf8',
90
+ recursive: true,
91
+ }).map((entry) => entry.replaceAll('\\', '/'));
92
+ return names.filter(isModuleFile).map((name) => ({
93
+ name,
94
+ contents: new Uint8Array(readFileSync(join(outDir, name))),
95
+ }));
96
+ }
97
+ /**
98
+ * Work out what would be deployed, without deploying it.
99
+ *
100
+ * Everything here is a read, so `--print-metadata` can stop straight afterwards
101
+ * and show exactly what a real run would send.
102
+ */
103
+ async function resolvePlan(options) {
104
+ const workerDir = resolve(options.dir ?? process.cwd());
105
+ const configPath = join(workerDir, 'wrangler.jsonc');
106
+ if (!existsSync(configPath)) {
107
+ throw new Error(`No wrangler.jsonc in ${workerDir}. Run this from a worker directory, or pass --dir.`);
108
+ }
109
+ // Resolved before the token, not after. On the --email/--password path
110
+ // getAuthToken() posts the credentials to this url, so a guard that ran
111
+ // afterwards would refuse a cleartext connection the password had already gone
112
+ // over. Passed back in so the checked value is the one that gets used.
113
+ const api = resolveApiUrl(getApiUrl(options));
114
+ const token = await getAuthToken({ ...options, apiUrl: api });
115
+ // The profile picks the API; the API states its environment. That single
116
+ // answer then selects the wrangler env to bundle, so the bindings that get
117
+ // built and the namespace that receives them can never disagree.
118
+ const { env, namespace } = await fetchContext(api, token);
119
+ const config = parseWranglerConfig(readFileSync(configPath, 'utf8'));
120
+ // Throws naming the envs the file does declare, which is the useful error
121
+ // when a worker has not been set up for the environment you are pointed at.
122
+ const resolved = resolveEnvConfig(config, env);
123
+ return {
124
+ workerDir,
125
+ api,
126
+ token,
127
+ env,
128
+ namespace,
129
+ // The raw config, not the resolved one: naming the script is the one place
130
+ // wrangler suffixes rather than inherits. See scriptName().
131
+ script: scriptName(config, env),
132
+ metadata: buildMetadata(resolved),
133
+ };
134
+ }
135
+ /**
136
+ * The multipart upload Cloudflare's Workers API expects, as the platform
137
+ * forwards it: the script name, the metadata, and one part per module.
138
+ */
139
+ function buildUploadForm(plan, modules) {
140
+ const form = new FormData();
141
+ form.append('script', plan.script);
142
+ form.append('metadata', JSON.stringify(plan.metadata));
143
+ for (const module of modules) {
144
+ form.append(module.name, new Blob([module.contents], { type: moduleContentType(module.name) }), basename(module.name));
145
+ }
146
+ return form;
147
+ }
148
+ /**
149
+ * Read the body, tolerating one that will not parse.
150
+ *
151
+ * The status is still meaningful, and every status has an answer without a body.
152
+ * An abort is not a parse failure, though — that is the deploy running past its
153
+ * timeout, which has its own answer — so it propagates rather than being flatted
154
+ * into an empty body and reported as "HTTP 200: no error message".
155
+ */
156
+ export async function readDeployResponse(response) {
157
+ try {
158
+ return (await response.json());
159
+ }
160
+ catch (error) {
161
+ if (error instanceof Error && error.name === 'TimeoutError') {
162
+ throw error;
163
+ }
164
+ return {};
165
+ }
166
+ }
167
+ /**
168
+ * Upload the modules and say what happened.
169
+ *
170
+ * Returns the outcome rather than a status and a body, because nothing upstream
171
+ * wants the transport details — the only question is what happened, and
172
+ * describeOutcome() is what answers it.
173
+ */
174
+ async function postDeploy(plan, modules) {
175
+ // One timeout around the request and the body read together. The deploy is
176
+ // not finished until the server has answered in full, so an abort partway
177
+ // through the body is the same "may still have landed" situation as an abort
178
+ // before the headers, and gets the same sentence.
179
+ const { status, body } = await withTimeout(async () => {
180
+ const response = await fetch(`${plan.api}/api/v1/developer/app-backends/deploy`, {
181
+ method: 'POST',
182
+ headers: {
183
+ Authorization: `Bearer ${plan.token}`,
184
+ Accept: 'application/json',
185
+ },
186
+ body: buildUploadForm(plan, modules),
187
+ signal: AbortSignal.timeout(DEPLOY_TIMEOUT_MS),
188
+ });
189
+ return {
190
+ status: response.status,
191
+ body: await readDeployResponse(response),
192
+ };
193
+ }, `The deploy did not finish within ${DEPLOY_TIMEOUT_MS / 1000}s. It may still ` +
194
+ "have landed — check the app's Backend tab in Superadmin before retrying.");
195
+ return describeOutcome(status, body, modules.map((module) => module.name), plan.api);
196
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Reading a deploy response, and saying what happened.
3
+ *
4
+ * Two functions, split so the decision is separable from the printing:
5
+ * `describeOutcome` is pure and decides, `report` performs. Kept apart from the
6
+ * command because the distinction they draw is a safety property rather than
7
+ * presentation — a 404 means the platform refused before anything reached
8
+ * Cloudflare, so nothing is live and the CLI can fail loudly; any other failure
9
+ * may have uploaded the script and then failed a later step, so the server's own
10
+ * message is the only accurate account of what happened and is passed through
11
+ * untouched.
12
+ *
13
+ * Getting that backwards would either hide a live half-deploy behind a tidy
14
+ * error, or tell a developer to go and register something that already exists.
15
+ */
16
+ /**
17
+ * What the response means, given the modules that were sent.
18
+ *
19
+ * A body of `{ success: false }` on a 200 counts as a failure — the endpoint
20
+ * reports step failures in the body, so the HTTP status alone is not the answer.
21
+ */
22
+ export function describeOutcome(status, body, moduleNames, api) {
23
+ const failed = status < 200 || status >= 300 || body.success !== true;
24
+ if (!failed) {
25
+ const lines = [
26
+ `\n${body.message ?? 'Deployed.'}`,
27
+ ` modules: ${moduleNames.join(', ')}`,
28
+ ];
29
+ // Only apps with a database report one, so its absence is not a gap.
30
+ if (body.schema_version != null) {
31
+ lines.push(` schema version: ${body.schema_version}`);
32
+ }
33
+ return { kind: 'success', lines };
34
+ }
35
+ // A 404 alone does not mean "not registered". The deploy endpoint answers one
36
+ // with its own error envelope; a wrong --api, a proxy, or an instance too old
37
+ // to have the route answers one with HTML or nothing, which postDeploy turns
38
+ // into an empty body. Telling that developer to go and register a backend they
39
+ // already registered sends them looking in the wrong place entirely, so the
40
+ // envelope is what distinguishes them.
41
+ if (status === 404) {
42
+ if (typeof body.error === 'string') {
43
+ return {
44
+ kind: 'unregistered',
45
+ message: `${body.error}\n` +
46
+ 'Register it in Superadmin, on the Backend tab of the app it serves.',
47
+ };
48
+ }
49
+ return {
50
+ kind: 'failed',
51
+ message: `\nDeploy failed (HTTP 404): ${api}/api/v1/developer/app-backends/deploy ` +
52
+ 'did not answer as the deploy endpoint.\nCheck the profile’s API url — ' +
53
+ 'a 404 without an error body usually means the request never reached it.',
54
+ };
55
+ }
56
+ return {
57
+ kind: 'failed',
58
+ message: `\nDeploy failed (HTTP ${status}): ${body.error ?? 'no error message'}`,
59
+ details: body.errors ? JSON.stringify(body.errors, null, 2) : undefined,
60
+ };
61
+ }
62
+ /**
63
+ * Turn an outcome into output and an exit code.
64
+ *
65
+ * Only `unregistered` throws, and only because nothing was uploaded: the caller
66
+ * wraps a throw as a one-line `❌ …`, which is the right shape for "this was
67
+ * refused, here is the fix". A failure that may have half-landed must not be
68
+ * wrapped, so it is written straight to stderr and the exit code is set by hand.
69
+ */
70
+ export function report(outcome) {
71
+ if (outcome.kind === 'unregistered') {
72
+ throw new Error(outcome.message);
73
+ }
74
+ if (outcome.kind === 'failed') {
75
+ console.error(outcome.message);
76
+ if (outcome.details) {
77
+ console.error(outcome.details);
78
+ }
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+ for (const line of outcome.lines) {
83
+ console.log(line);
84
+ }
85
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Pure helpers for a Workers-for-Platforms deploy: building the payload from a
3
+ * worker's wrangler.jsonc, and resolving where to send it.
4
+ *
5
+ * Kept free of I/O so the part that fails silently — the metadata — is directly
6
+ * testable. See wrangler-config.test.ts.
7
+ */
8
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
9
+ /**
10
+ * Normalise the API base, and refuse to put the developer token on a cleartext
11
+ * connection.
12
+ *
13
+ * Loopback stays exempt so a local Laravel over http still works. Anything else
14
+ * must be https, because the next thing the caller does with this value is send
15
+ * a bearer token to it.
16
+ */
17
+ export function resolveApiUrl(value) {
18
+ let url;
19
+ try {
20
+ url = new URL(value);
21
+ }
22
+ catch {
23
+ throw new Error(`API url must be absolute, got: ${value}`);
24
+ }
25
+ if (url.protocol !== 'https:' && !LOOPBACK_HOSTS.has(url.hostname)) {
26
+ throw new Error(`API url must use https (or a loopback host for local development), got: ${value}`);
27
+ }
28
+ return value.replace(/\/$/, '');
29
+ }
30
+ /**
31
+ * Binding types wrangler does NOT inherit from the top level into `env.<name>`.
32
+ * Everything else (main, compatibility_date, compatibility_flags, …) is
33
+ * inherited. Getting this wrong in the lenient direction is the dangerous one:
34
+ * it would ship a top-level store binding into an environment that deliberately
35
+ * declared none.
36
+ */
37
+ const NOT_INHERITED = [
38
+ 'vars',
39
+ 'kv_namespaces',
40
+ 'd1_databases',
41
+ 'r2_buckets',
42
+ 'queues',
43
+ 'routes',
44
+ 'route',
45
+ 'durable_objects',
46
+ ];
47
+ /**
48
+ * Binding kinds buildMetadata cannot express.
49
+ *
50
+ * Cloudflare treats the metadata part as the authoritative binding list, so one
51
+ * dropped here deploys a worker whose binding is simply absent: the deploy
52
+ * reports success and the first request that touches it 500s, with nothing in
53
+ * the output connecting the two. Refused instead, until the platform endpoint
54
+ * provisions them the way it already does KV and D1.
55
+ */
56
+ const UNSUPPORTED_BINDINGS = [
57
+ 'r2_buckets',
58
+ 'queues',
59
+ 'durable_objects',
60
+ 'services',
61
+ ];
62
+ /**
63
+ * An env block by name, own properties only.
64
+ *
65
+ * `constructor` and `toString` are inherited from Object.prototype, so a plain
66
+ * lookup finds a function there and sails through a truthiness check — resolving
67
+ * `--env constructor` to a nonsense config instead of erroring.
68
+ */
69
+ function ownEnv(config, env) {
70
+ return Object.prototype.hasOwnProperty.call(config.env ?? {}, env)
71
+ ? config.env?.[env]
72
+ : undefined;
73
+ }
74
+ /**
75
+ * Strip comments and trailing commas from JSONC so JSON.parse accepts it.
76
+ *
77
+ * String-aware: a `//` or block-comment opener inside a quoted value is left
78
+ * alone, as is an escaped quote. Written by hand rather than pulling in a parser
79
+ * because this is the only JSONC we read and the input is our own.
80
+ *
81
+ * The complexity is suppressed rather than refactored. This is a character
82
+ * scanner: four mutually-exclusive states over one cursor, sharing `out`, `i`
83
+ * and three flags. Extracting the branches means threading all of that through
84
+ * helpers or hoisting it into an object, which trades a shape every reader
85
+ * recognises for one nobody does — and a state machine you have to reassemble
86
+ * across functions is exactly where an off-by-one hides. The 31 cases in
87
+ * `__tests__/wrangler-config.test.ts` are what keeps it honest instead.
88
+ */
89
+ // fallow-ignore-next-line complexity
90
+ export function stripJsonc(source) {
91
+ let out = '';
92
+ let inString = false;
93
+ let inLine = false;
94
+ let inBlock = false;
95
+ for (let i = 0; i < source.length; i++) {
96
+ const c = source[i];
97
+ const next = source[i + 1];
98
+ if (inLine) {
99
+ if (c === '\n') {
100
+ inLine = false;
101
+ out += c;
102
+ }
103
+ continue;
104
+ }
105
+ if (inBlock) {
106
+ if (c === '*' && next === '/') {
107
+ inBlock = false;
108
+ i++;
109
+ }
110
+ continue;
111
+ }
112
+ if (inString) {
113
+ out += c;
114
+ if (c === '\\') {
115
+ // Copy the escaped character verbatim so \" doesn't end the string.
116
+ out += next ?? '';
117
+ i++;
118
+ }
119
+ else if (c === '"') {
120
+ inString = false;
121
+ }
122
+ continue;
123
+ }
124
+ if (c === '"') {
125
+ inString = true;
126
+ out += c;
127
+ continue;
128
+ }
129
+ if (c === '/' && next === '/') {
130
+ inLine = true;
131
+ i++;
132
+ continue;
133
+ }
134
+ if (c === '/' && next === '*') {
135
+ inBlock = true;
136
+ i++;
137
+ continue;
138
+ }
139
+ // Trailing comma before a closing brace/bracket. Done here rather than with
140
+ // a regex over the finished string so a quoted value containing ",}" or
141
+ // ",]" is left alone — at this point we know we are outside a string.
142
+ if (c === '}' || c === ']') {
143
+ out = out.replace(/,\s*$/, '');
144
+ }
145
+ out += c;
146
+ }
147
+ return out;
148
+ }
149
+ export function parseWranglerConfig(source) {
150
+ return JSON.parse(stripJsonc(source));
151
+ }
152
+ /**
153
+ * Flatten a wrangler config for one environment.
154
+ *
155
+ * Inherited keys come from the top level unless the environment overrides them;
156
+ * the binding keys in NOT_INHERITED come from the environment alone, matching
157
+ * wrangler's own rule.
158
+ */
159
+ export function resolveEnvConfig(config, env) {
160
+ const envConfig = ownEnv(config, env);
161
+ if (!envConfig) {
162
+ throw new Error(`wrangler.jsonc declares no env "${env}" (found: ${Object.keys(config.env ?? {}).join(', ') || 'none'}).`);
163
+ }
164
+ const resolved = { ...config, ...envConfig };
165
+ delete resolved.env;
166
+ for (const key of NOT_INHERITED) {
167
+ if (envConfig[key] === undefined) {
168
+ delete resolved[key];
169
+ }
170
+ }
171
+ return resolved;
172
+ }
173
+ /**
174
+ * The entry module's name as it appears in the dry-run output. wrangler compiles
175
+ * `./src/index.ts` to `dist/index.js`, so the extension is normalised.
176
+ */
177
+ export function entryModuleName(main) {
178
+ // Both separators: a wrangler.jsonc authored on Windows can say
179
+ // "src\\index.ts", and the parts bundle() uploads are named by readdirSync —
180
+ // bare basenames. Leaving a backslash in would make main_module name a part
181
+ // that was never sent, and Cloudflare rejects the upload for a missing entry.
182
+ return main.replace(/^.*[/\\]/, '').replace(/\.(ts|tsx|mts|cts)$/, '.js');
183
+ }
184
+ /**
185
+ * Build the `metadata` part for the platform deploy endpoint.
186
+ *
187
+ * Store bindings are emitted **by name only**. The platform provisions the
188
+ * store and fills the id — sending one would make it adopt an existing store
189
+ * instead, and nothing would report that it had. Note the two are not
190
+ * symmetric: KV's id field is `namespace_id`, D1's is `id`, and neither is set
191
+ * here.
192
+ */
193
+ export function buildMetadata(resolved) {
194
+ if (!resolved.main) {
195
+ throw new Error('wrangler.jsonc has no "main" entry point.');
196
+ }
197
+ if (!resolved.compatibility_date) {
198
+ throw new Error('wrangler.jsonc has no "compatibility_date".');
199
+ }
200
+ const unsupported = UNSUPPORTED_BINDINGS.filter((key) => resolved[key] !== undefined);
201
+ if (unsupported.length) {
202
+ throw new Error(`wrangler.jsonc declares bindings this deploy path cannot send: ${unsupported.join(', ')}.`);
203
+ }
204
+ const bindings = [];
205
+ for (const kv of resolved.kv_namespaces ?? []) {
206
+ bindings.push({ type: 'kv_namespace', name: kv.binding });
207
+ }
208
+ for (const d1 of resolved.d1_databases ?? []) {
209
+ bindings.push({ type: 'd1', name: d1.binding });
210
+ }
211
+ for (const [name, value] of Object.entries(resolved.vars ?? {})) {
212
+ // Every var ships as plain text, so an object or array would arrive as
213
+ // "[object Object]" or a comma-joined list — a binding that looks present in
214
+ // the metadata and is not the value the worker reads. `typeof null` is
215
+ // 'object' too, and "null" is no better. Refused rather than corrupted.
216
+ if (typeof value === 'object') {
217
+ throw new Error(`var "${name}" must be a string, number or boolean — this deploy path sends vars as plain text.`);
218
+ }
219
+ bindings.push({ type: 'plain_text', name, text: String(value) });
220
+ }
221
+ const metadata = {
222
+ main_module: entryModuleName(resolved.main),
223
+ compatibility_date: resolved.compatibility_date,
224
+ };
225
+ if (resolved.compatibility_flags?.length) {
226
+ metadata.compatibility_flags = resolved.compatibility_flags;
227
+ }
228
+ if (bindings.length) {
229
+ metadata.bindings = bindings;
230
+ }
231
+ // Forwarded as written; the platform validates it and turns observability on
232
+ // when the key is absent, so this only matters for sampling settings or an
233
+ // explicit opt-out.
234
+ if (resolved.observability !== undefined) {
235
+ metadata.observability = resolved.observability;
236
+ }
237
+ return metadata;
238
+ }
239
+ /**
240
+ * The tenant script name wrangler will have built for this environment.
241
+ *
242
+ * Read off the raw config rather than the resolved one because the rule is not
243
+ * inheritance: an env that declares its own `name` uses it verbatim, and one
244
+ * that does not gets `<top-level-name>-<env>`. The merged config cannot tell the
245
+ * two apart, so reading it would address the upload to `google-reviews` for a
246
+ * bundle wrangler built as `google-reviews-prod` — refused as unregistered, or
247
+ * worse, landing on whichever tenant already holds that name.
248
+ */
249
+ export function scriptName(config, env) {
250
+ const envName = ownEnv(config, env)?.name;
251
+ if (envName) {
252
+ return envName;
253
+ }
254
+ if (!config.name) {
255
+ throw new Error('wrangler.jsonc has no "name".');
256
+ }
257
+ return `${config.name}-${env}`;
258
+ }
259
+ /** Files in the dry-run output that belong in the upload. */
260
+ export function isModuleFile(filename) {
261
+ // Anchored, which is what excludes sourcemaps: `index.js.map` ends in `.map`,
262
+ // so it never matches in the first place.
263
+ return /\.(js|mjs|wasm)$/.test(filename);
264
+ }
265
+ /**
266
+ * The Content-Type of a module part, which is how Cloudflare decides what kind
267
+ * of module it is — not a formality. `application/javascript+module` makes it an
268
+ * ES module and `application/wasm` a CompiledWasm one, so labelling a `.wasm`
269
+ * as JavaScript would have Cloudflare try to parse the binary as source and
270
+ * reject the upload. isModuleFile() already accepts `.wasm`, so the two have to
271
+ * agree about what that means.
272
+ */
273
+ export function moduleContentType(filename) {
274
+ return filename.endsWith('.wasm')
275
+ ? 'application/wasm'
276
+ : 'application/javascript+module';
277
+ }
@@ -1,4 +1,15 @@
1
1
  import { getErrorMessage } from './error.js';
2
+ /**
3
+ * A non-2xx answer from the developer API. It keeps the status, so a caller
4
+ * can treat one status, such as a 404, as an answer and not as a failure.
5
+ */
6
+ export class HttpError extends Error {
7
+ status;
8
+ constructor(status, message) {
9
+ super(message);
10
+ this.status = status;
11
+ }
12
+ }
2
13
  /**
3
14
  * Fetch JSON from a developer-API endpoint with bearer-token auth, throwing
4
15
  * a useful error on non-2xx responses (status, statusText, body). Used by
@@ -14,7 +25,7 @@ export async function fetchAuthorisedJson(url, token) {
14
25
  });
15
26
  if (!response.ok) {
16
27
  const body = await response.text();
17
- throw new Error(`${response.status} ${response.statusText}\n${body}`);
28
+ throw new HttpError(response.status, `${response.status} ${response.statusText}\n${body}`);
18
29
  }
19
30
  return (await response.json());
20
31
  }
@@ -0,0 +1,23 @@
1
+ import crossSpawn from 'cross-spawn';
2
+ /**
3
+ * Spawning a package-manager binary, portably.
4
+ *
5
+ * `npx`, `pnpm` and `yarn` are shell scripts on POSIX and `.cmd` shims on
6
+ * Windows, and neither Node nor libuv will run a `.cmd` from `spawn` without a
7
+ * shell — `CreateProcess` cannot execute one, and since the fix for
8
+ * CVE-2024-27980 Node refuses rather than silently routing through cmd.exe.
9
+ * Naming the shim (`npx.cmd`) fixes PATH resolution and not the spawn.
10
+ *
11
+ * Doing it correctly means invoking `cmd.exe /d /s /c` with the command and
12
+ * every argument escaped for cmd's parser, plus a second round of escaping for
13
+ * shims under `node_modules/.bin`, and `windowsVerbatimArguments` so Node does
14
+ * not re-quote what was just escaped. `shell: true` is the wrong shortcut: it
15
+ * hands the whole line to cmd.exe to re-parse, so a worker directory containing
16
+ * `&` or `^` becomes a command.
17
+ *
18
+ * cross-spawn already does all of it, is the ecosystem's answer to this exact
19
+ * problem, and was already in the tree transitively. Preferred over a local
20
+ * reimplementation, which is where the escaping bugs live.
21
+ */
22
+ export const spawn = crossSpawn;
23
+ export const spawnSync = crossSpawn.sync;