@juuno-sdk/cli 4.0.2 → 4.1.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,271 @@
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
+ return metadata;
232
+ }
233
+ /**
234
+ * The tenant script name wrangler will have built for this environment.
235
+ *
236
+ * Read off the raw config rather than the resolved one because the rule is not
237
+ * inheritance: an env that declares its own `name` uses it verbatim, and one
238
+ * that does not gets `<top-level-name>-<env>`. The merged config cannot tell the
239
+ * two apart, so reading it would address the upload to `google-reviews` for a
240
+ * bundle wrangler built as `google-reviews-prod` — refused as unregistered, or
241
+ * worse, landing on whichever tenant already holds that name.
242
+ */
243
+ export function scriptName(config, env) {
244
+ const envName = ownEnv(config, env)?.name;
245
+ if (envName) {
246
+ return envName;
247
+ }
248
+ if (!config.name) {
249
+ throw new Error('wrangler.jsonc has no "name".');
250
+ }
251
+ return `${config.name}-${env}`;
252
+ }
253
+ /** Files in the dry-run output that belong in the upload. */
254
+ export function isModuleFile(filename) {
255
+ // Anchored, which is what excludes sourcemaps: `index.js.map` ends in `.map`,
256
+ // so it never matches in the first place.
257
+ return /\.(js|mjs|wasm)$/.test(filename);
258
+ }
259
+ /**
260
+ * The Content-Type of a module part, which is how Cloudflare decides what kind
261
+ * of module it is — not a formality. `application/javascript+module` makes it an
262
+ * ES module and `application/wasm` a CompiledWasm one, so labelling a `.wasm`
263
+ * as JavaScript would have Cloudflare try to parse the binary as source and
264
+ * reject the upload. isModuleFile() already accepts `.wasm`, so the two have to
265
+ * agree about what that means.
266
+ */
267
+ export function moduleContentType(filename) {
268
+ return filename.endsWith('.wasm')
269
+ ? 'application/wasm'
270
+ : 'application/javascript+module';
271
+ }
@@ -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;