@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.
package/README.md CHANGED
@@ -118,8 +118,74 @@ npx juuno-cli deploy
118
118
 
119
119
  # Specify build directory
120
120
  npx juuno-cli deploy --build-dir ./build
121
+
122
+ # Let the backend check the build, and deploy nothing
123
+ npx juuno-cli deploy --dry-run
124
+ ```
125
+
126
+ `--dry-run` sends the same upload with `dry_run=1`. The backend runs every
127
+ check of a real deploy, including the rules for a config schema, and writes
128
+ nothing, so you find a problem before the release is immutable. The CLI prints
129
+ "Dry run passed", or the backend's error message unchanged and exits non-zero.
130
+
131
+ A deploy and a dry run print each `required` config key that the release adds,
132
+ with the default that goes into your existing scenes, for example:
133
+
134
+ ```text
135
+ Adds `meta.styles.spotsTextColor` (required, default `#011841`).
136
+ ```
137
+
138
+ A release with a config schema is not served until the backend has filled the
139
+ existing scenes, so the deploy waits until the release is live. If the fill
140
+ fails, the previous release stays live and the CLI exits non-zero. Releases are
141
+ immutable, so the next deploy needs a higher version.
142
+
143
+ Build with source maps on. An error thrown in your app is reported as a position
144
+ in the minified bundle, and a map beside that bundle is what turns it back into
145
+ a line of your own source, for you and for Juuno support. The deploy uploads
146
+ whatever the build directory holds, maps included, and warns when it finds none.
147
+
148
+ ### Deploying an App Backend
149
+
150
+ An **app backend** is a Cloudflare Workers-for-Platforms tenant script that owns
151
+ one app's data and logic. Deploying goes through Juuno's platform rather than
152
+ straight to Cloudflare β€” that is what provisions the app's KV and D1 stores,
153
+ fills their ids, and triggers the tenant's own migrations. A raw
154
+ `wrangler deploy --dispatch-namespace` silently skips all of it.
155
+
156
+ Run it from the worker directory:
157
+
158
+ ```bash
159
+ cd cloudflare-workers/app-google-reviews
160
+
161
+ # Deploy to stage
162
+ npx juuno-cli deploy-backend --profile stage
163
+
164
+ # See what would be sent, without deploying
165
+ npx juuno-cli deploy-backend --profile stage --print-metadata
121
166
  ```
122
167
 
168
+ The environment is not chosen here. Each API instance is pinned to exactly one
169
+ dispatch namespace and reports which environment that is, so the profile you
170
+ deploy with determines the target; the CLI uses that answer to select the
171
+ matching env in `wrangler.jsonc`. The bindings that get built and the namespace
172
+ that receives them therefore always agree, with nothing to keep in sync. A
173
+ worker with no matching env fails with the envs it does declare.
174
+
175
+ Bundling shells out to the worker's own pinned `wrangler`, so the version in its
176
+ `devDependencies` is the one that runs.
177
+
178
+ Observability is on for every deploy: the platform fills `enabled: true` when
179
+ the upload does not say otherwise, so the tenant's logs reach the Superadmin
180
+ Logs tab without any `wrangler.jsonc` setting. The worker's `observability`
181
+ block is still forwarded as written, for a sampling rate or an explicit
182
+ `"enabled": false`.
183
+
184
+ **The backend must already be registered** in Superadmin, on the Backend tab of
185
+ the app it serves. Registration is what attaches it to an app and mints its
186
+ platform secret; deploying an unregistered script is refused before anything
187
+ uploads.
188
+
123
189
  ### List Apps
124
190
 
125
191
  View all your deployed apps.
@@ -305,6 +371,7 @@ juuno-cli deploy [options]
305
371
 
306
372
  Options:
307
373
  --build-dir <dir> Build directory to deploy (default: ./dist)
374
+ --dry-run Let the backend check the build, and deploy nothing
308
375
  --profile <name> Use credentials from the given profile
309
376
  --token <token> Override with specific token (for CI/CD)
310
377
  --email <email> Login with email instead of using saved credentials
package/bin/cli-router.js CHANGED
@@ -22,6 +22,54 @@ export function parseAuthOptions(args) {
22
22
  };
23
23
  }
24
24
 
25
+ /** Every flag `deploy-backend` accepts, its own and the shared auth ones. */
26
+ const DEPLOY_BACKEND_FLAGS = [
27
+ '--dir',
28
+ '--print-metadata',
29
+ '--email',
30
+ '--password',
31
+ '--token',
32
+ '--profile',
33
+ '--api-url',
34
+ ];
35
+
36
+ /**
37
+ * What is wrong with a `deploy-backend` line, or null if nothing is.
38
+ *
39
+ * Strict where the other commands are lenient, and deliberately so: this one
40
+ * uploads to a real dispatch namespace, and it takes no `--env` because the
41
+ * environment comes from the profile's API. Ignoring an unknown argument would
42
+ * turn `--env stage` into a silent deploy of stage code wherever the active
43
+ * profile points, and a `--dir` with no path would deploy whichever worker the
44
+ * shell happens to be standing in.
45
+ *
46
+ * @param {string[]} args - Command arguments, including the command itself
47
+ * @returns {string|null} The problem, or null if the line is usable
48
+ */
49
+ export function checkDeployBackendArgs(args) {
50
+ const unknown = args.filter(
51
+ (arg) => arg.startsWith('--') && !DEPLOY_BACKEND_FLAGS.includes(arg),
52
+ );
53
+
54
+ if (unknown.length > 0) {
55
+ return `Unknown argument: ${unknown.join(', ')}`;
56
+ }
57
+
58
+ const dirIndex = args.indexOf('--dir');
59
+
60
+ if (dirIndex !== -1) {
61
+ const dir = args[dirIndex + 1];
62
+
63
+ // Nothing after it, or the next flag β€” which it would otherwise swallow as
64
+ // its value.
65
+ if (dir === undefined || dir.startsWith('--')) {
66
+ return '--dir requires a path.';
67
+ }
68
+ }
69
+
70
+ return null;
71
+ }
72
+
25
73
  /**
26
74
  * Per-command dispatch table. adding a command is one entry.
27
75
  */
@@ -53,9 +101,31 @@ const COMMANDS = {
53
101
  await deployApp({
54
102
  buildDir,
55
103
  skipIfDeployed: args.includes('--skip-if-deployed'),
104
+ dryRun: args.includes('--dry-run'),
56
105
  ...parseAuthOptions(args),
57
106
  });
58
107
  },
108
+ 'deploy-backend': async (args, importPrefix) => {
109
+ const problem = checkDeployBackendArgs(args);
110
+ if (problem) {
111
+ console.error(`❌ ${problem}`);
112
+ console.error(
113
+ ' Usage: juuno-cli deploy-backend [--dir <path>] [--print-metadata]',
114
+ );
115
+ process.exit(1);
116
+ }
117
+
118
+ const { deployBackend } = await import(
119
+ `${importPrefix}deploy-backend/index.js`
120
+ );
121
+ const dirIndex = args.indexOf('--dir');
122
+ await deployBackend({
123
+ dir: dirIndex !== -1 ? args[dirIndex + 1] : undefined,
124
+ printMetadata: args.includes('--print-metadata'),
125
+ ...parseAuthOptions(args),
126
+ });
127
+ },
128
+
59
129
  list: async (args, importPrefix) => {
60
130
  const { listApps } = await import(`${importPrefix}list/index.js`);
61
131
  await listApps(parseAuthOptions(args));
@@ -72,6 +142,27 @@ const COMMANDS = {
72
142
  },
73
143
  };
74
144
 
145
+ /**
146
+ * The code the process should exit with.
147
+ *
148
+ * `process.exitCode` is how a command reports a failure it must not throw for β€”
149
+ * deploy-backend writes the server's own account of a deploy that may already
150
+ * have landed, rather than throwing and having it wrapped as a refusal. An
151
+ * explicit code passed to `process.exit()` overrides that, so handing it the
152
+ * handler's 0 turned a failed deploy into a green CI run.
153
+ *
154
+ * @param {number} commandExitCode - What runCommand() returned
155
+ * @param {number|string|undefined} reportedExitCode - process.exitCode, as the command left it
156
+ * @returns {number} The code to exit with
157
+ */
158
+ export function resolveExitCode(commandExitCode, reportedExitCode) {
159
+ if (commandExitCode !== 0) {
160
+ return commandExitCode;
161
+ }
162
+
163
+ return Number(reportedExitCode ?? 0);
164
+ }
165
+
75
166
  /**
76
167
  * Execute a CLI command with the given import prefix.
77
168
  *
package/bin/cli.js CHANGED
@@ -9,8 +9,8 @@
9
9
  */
10
10
 
11
11
  import { fileURLToPath } from 'url';
12
- import { dirname, join } from 'path';
13
- import { runCommand } from './cli-router.js';
12
+ import { dirname } from 'path';
13
+ import { resolveExitCode, runCommand } from './cli-router.js';
14
14
 
15
15
  const __filename = fileURLToPath(import.meta.url);
16
16
  const __dirname = dirname(__filename);
@@ -31,6 +31,7 @@ Commands:
31
31
  logout Remove stored credentials
32
32
  whoami Show authentication status
33
33
  deploy Deploy your app to Juuno
34
+ deploy-backend Deploy an app backend (Workers-for-Platforms tenant script)
34
35
  list List your deployed apps
35
36
  info <app-id> Show detailed information about an app
36
37
  help Show this help message
@@ -60,8 +61,23 @@ Deploy Options:
60
61
  --build-dir <dir> Build directory to deploy (default: ./dist)
61
62
  --skip-if-deployed Exit 0 with a notice when the manifest version is not
62
63
  above the deployed one, instead of erroring. For CI.
64
+ --dry-run Send the build for the backend to check, and deploy
65
+ nothing. Exits non-zero when the backend refuses it.
63
66
 
64
- Authentication Options (for deploy, list, info):
67
+ Deploy Backend Options:
68
+ --dir <path> Worker directory (default: current directory)
69
+ --print-metadata Print the resolved script + metadata, then stop
70
+
71
+ The backend must already be registered in Superadmin, on the Backend tab of
72
+ the app it serves β€” that is what attaches it to an app and mints its platform
73
+ secret. Deploying an unregistered script is refused before anything uploads.
74
+
75
+ The API associated with the active profile reports which environment it
76
+ deploys to; eg prod|stage - this selects the matching environment in
77
+ wrangler.jsonc, so the bindings that get built and the namespace that
78
+ receives them always agree.
79
+
80
+ Authentication Options (for deploy, deploy-backend, list, info):
65
81
  By default, these commands use the token saved by 'juuno-cli login'.
66
82
  You can override with:
67
83
  --profile <name> Use credentials from a named profile
@@ -100,6 +116,10 @@ Examples:
100
116
  juuno-cli deploy
101
117
  juuno-cli deploy --build-dir ./dist
102
118
 
119
+ # Deploy an app backend from its worker directory
120
+ cd cloudflare-workers/app-google-reviews
121
+ juuno-cli deploy-backend --profile stage
122
+
103
123
  # List apps (uses saved credentials)
104
124
  juuno-cli list
105
125
 
@@ -115,11 +135,14 @@ if (
115
135
  command === 'logout' ||
116
136
  command === 'whoami' ||
117
137
  command === 'deploy' ||
138
+ command === 'deploy-backend' ||
118
139
  command === 'list' ||
119
140
  command === 'info'
120
141
  ) {
121
142
  const exitCode = await runCommand(command, args, '../dist/cli/src/');
122
- process.exit(exitCode);
143
+
144
+ // Not process.exit(exitCode) β€” see resolveExitCode().
145
+ process.exit(resolveExitCode(exitCode, process.exitCode));
123
146
  }
124
147
 
125
148
  // `dev` is not unknown, it moved: the simulator is its own package so CI
@@ -1,10 +1,11 @@
1
- import { readFileSync, existsSync, readdirSync, statSync, realpathSync, } from 'fs';
1
+ import { readFileSync, existsSync, readdirSync, realpathSync } from 'fs';
2
2
  import { isAbsolute, join, relative } from 'path';
3
+ import { setTimeout as sleep } from 'timers/promises';
3
4
  import JSZip from 'jszip';
4
5
  import semver from 'semver';
5
6
  import { getAuthToken, getApiUrl } from '../auth/index.js';
6
7
  import { getErrorMessage } from '../utils/error.js';
7
- import { wrapError } from '../utils/api.js';
8
+ import { assertSuccessEnvelope, fetchAuthorisedJson, HttpError, wrapError, } from '../utils/api.js';
8
9
  import { isRecord } from '../utils/is-record.js';
9
10
  /**
10
11
  * Fetches the currently deployed version of an app.
@@ -79,6 +80,82 @@ export function checkTranslations(declared, bundled) {
79
80
  }
80
81
  return null;
81
82
  }
83
+ /**
84
+ * Every JavaScript bundle the upload will carry, and the ones with a map
85
+ * beside them.
86
+ *
87
+ * A map is looked for at `<bundle>.map`, which is where every bundler writes
88
+ * one and the only place a reader of a stack trace looks for it. A map named
89
+ * something else, or inlined into the bundle as a data URL, is not found by
90
+ * this and is not found by the tooling either.
91
+ *
92
+ * Each file is classified from the same directory entry and the same
93
+ * containment rule `createZipBundle` applies below, so the two agree on what
94
+ * ships. A path resolving outside the build directory is dropped on upload,
95
+ * and a directory is walked into rather than uploaded, so neither can stand in
96
+ * for a map the CDN will never serve.
97
+ */
98
+ export function listBundles(buildDir) {
99
+ const bundles = [];
100
+ const maps = new Set();
101
+ const resolvedBuildDir = realpathSync(buildDir);
102
+ function walk(dir) {
103
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
104
+ const fullPath = join(dir, entry.name);
105
+ const resolved = safeResolveWithinBase(fullPath, resolvedBuildDir);
106
+ if (resolved === 'unresolvable' || resolved === 'escapes') {
107
+ continue;
108
+ }
109
+ if (entry.isDirectory()) {
110
+ walk(fullPath);
111
+ continue;
112
+ }
113
+ const file = relative(resolvedBuildDir, fullPath);
114
+ if (entry.name.endsWith('.map')) {
115
+ maps.add(file);
116
+ continue;
117
+ }
118
+ // `.mjs` as well as `.js`: a third-party build picks its own extension
119
+ // for an ES module, and an all-`.mjs` build would otherwise count no
120
+ // bundles and say nothing about itself.
121
+ if (/\.m?js$/.test(entry.name)) {
122
+ bundles.push(file);
123
+ }
124
+ }
125
+ }
126
+ walk(resolvedBuildDir);
127
+ return {
128
+ bundles,
129
+ mapped: bundles.filter((bundle) => maps.has(`${bundle}.map`)),
130
+ };
131
+ }
132
+ /**
133
+ * Say that this build ships nothing that can turn a crash back into a line of
134
+ * source, or null when at least one bundle has a map.
135
+ *
136
+ * Only a build with no maps at all is worth saying anything about. A build that
137
+ * has them on still emits the occasional generated chunk with no original
138
+ * source and so no map, and a warning that fires on a correctly configured app
139
+ * is one nobody reads.
140
+ *
141
+ * A warning rather than an error, because a build with no maps is a valid
142
+ * deploy. It is just one nobody can debug.
143
+ */
144
+ export function describeMissingSourceMaps(bundles, mapped) {
145
+ if (bundles.length === 0 || mapped.length > 0) {
146
+ return null;
147
+ }
148
+ const noun = bundles.length === 1 ? 'bundle' : 'bundles';
149
+ return (`No source maps in this build (${bundles.length} JavaScript ${noun}, ` +
150
+ 'none with a .map beside it).\n' +
151
+ ' An error thrown in your app is reported as a position in the minified\n' +
152
+ ' bundle. Without a map beside it that position names no file and no\n' +
153
+ ' line, for you or for Juuno support.\n' +
154
+ ' Turn on source maps in your bundler (`build.sourcemap: true` in Vite)\n' +
155
+ ' and deploy again. They upload with the rest of the build directory.');
156
+ }
157
+ /** How long the CLI waits between two reads of a filling release's status. */
158
+ const RELEASE_POLL_MS = 2000;
82
159
  /**
83
160
  * Resolve a real path under a base directory. Returns 'unresolvable' when
84
161
  * realpathSync throws (broken symlink, missing target), 'escapes' when the
@@ -142,7 +219,7 @@ async function createZipBundle(buildDir) {
142
219
  /**
143
220
  * Uploads the app bundle to the developer API.
144
221
  */
145
- async function uploadBundle(token, zipBuffer, apiUrl) {
222
+ async function uploadBundle(token, zipBuffer, apiUrl, dryRun) {
146
223
  const uploadUrl = `${apiUrl}/api/v1/developer/apps/upload`;
147
224
  const formData = new FormData();
148
225
  // @types/node 26 makes Buffer generic over ArrayBufferLike, which BlobPart
@@ -152,6 +229,9 @@ async function uploadBundle(token, zipBuffer, apiUrl) {
152
229
  type: 'application/zip',
153
230
  });
154
231
  formData.append('app_bundle', blob, 'app.zip');
232
+ if (dryRun) {
233
+ formData.append('dry_run', '1');
234
+ }
155
235
  try {
156
236
  const response = await fetch(uploadUrl, {
157
237
  method: 'POST',
@@ -162,19 +242,101 @@ async function uploadBundle(token, zipBuffer, apiUrl) {
162
242
  body: formData,
163
243
  });
164
244
  if (!response.ok) {
165
- const errorText = await response.text();
166
- throw new Error(`Upload failed: ${response.status} ${response.statusText}\n${errorText}`);
245
+ throw new Error(await describeRefusal(response));
167
246
  }
168
247
  const data = (await response.json());
169
248
  if (!data.success) {
170
- throw new Error(`Upload failed: ${data.message || 'Unknown error'}`);
249
+ throw new Error(data.message || 'Unknown error');
171
250
  }
172
251
  return data;
173
252
  }
174
253
  catch (error) {
175
- throw wrapError('Upload failed', error);
254
+ throw wrapError(dryRun ? 'Dry run failed' : 'Upload failed', error);
176
255
  }
177
256
  }
257
+ /**
258
+ * The message of a refused upload. The backend answers a failed check as
259
+ * `{ error }`, and Laravel answers a request it refuses as `{ message }`. That
260
+ * message tells the author what to fix, so it is shown unchanged rather than
261
+ * inside the HTTP status and the raw body.
262
+ */
263
+ async function describeRefusal(response) {
264
+ const body = await response.text();
265
+ let parsed = null;
266
+ try {
267
+ parsed = JSON.parse(body);
268
+ }
269
+ catch {
270
+ // A body that is not JSON, such as a proxy's error page, is shown below
271
+ // with its status.
272
+ }
273
+ if (isRecord(parsed)) {
274
+ if (typeof parsed.error === 'string') {
275
+ return parsed.error;
276
+ }
277
+ if (typeof parsed.message === 'string') {
278
+ return parsed.message;
279
+ }
280
+ }
281
+ return `${response.status} ${response.statusText}\n${body}`;
282
+ }
283
+ /**
284
+ * Read the status of one release, or null when the backend has no such
285
+ * release.
286
+ */
287
+ async function fetchReleaseStatus(token, apiUrl, manifest) {
288
+ const url = `${apiUrl}/api/v1/developer/apps/${encodeURIComponent(manifest.id)}` +
289
+ `/releases/${encodeURIComponent(manifest.version)}`;
290
+ try {
291
+ const envelope = await fetchAuthorisedJson(url, token);
292
+ return assertSuccessEnvelope(envelope).status;
293
+ }
294
+ catch (error) {
295
+ if (error instanceof HttpError && error.status === 404) {
296
+ return null;
297
+ }
298
+ throw error;
299
+ }
300
+ }
301
+ /**
302
+ * Wait until a filling release is live. The upload answers `filling` for each
303
+ * release with a config schema, and the release is served only when the fill
304
+ * of the existing scenes is done, so the deploy is not done before that.
305
+ *
306
+ * Throws when the fill failed, when the release is gone, when the status
307
+ * cannot be read, and on a status that is not part of a deploy's end. Each one
308
+ * exits non-zero, so CI does not report a release that no screen gets.
309
+ */
310
+ async function waitUntilLive(token, apiUrl, manifest, uploadStatus) {
311
+ let status = uploadStatus;
312
+ while (status === 'filling') {
313
+ await sleep(RELEASE_POLL_MS);
314
+ try {
315
+ status = await fetchReleaseStatus(token, apiUrl, manifest);
316
+ }
317
+ catch (error) {
318
+ throw new Error(`Could not read the status of version ${manifest.version}: ` +
319
+ `${getErrorMessage(error)}\n The fill can still finish. Run ` +
320
+ `\`juuno-cli info ${manifest.id}\` with the --profile or --api-url ` +
321
+ 'of this deploy to see the version that is served.');
322
+ }
323
+ }
324
+ if (status === 'live') {
325
+ return;
326
+ }
327
+ if (status === 'failed') {
328
+ throw new Error(`The fill of existing scenes failed, so version ${manifest.version} ` +
329
+ 'is not served and the previous release stays live.\n Releases ' +
330
+ `are immutable: the next deploy needs a version above ${manifest.version}.`);
331
+ }
332
+ if (status === null) {
333
+ throw new Error(`Version ${manifest.version} is not on the backend. The upload ` +
334
+ 'failed before the fill, and the backend deleted the release.\n ' +
335
+ 'Deploy again with the same version.');
336
+ }
337
+ throw new Error(`Version ${manifest.version} is \`${status}\`, which is not a state ` +
338
+ 'that a deploy waits in.');
339
+ }
178
340
  /**
179
341
  * Read and validate the manifest.json from a build directory. Throws with
180
342
  * actionable messages when the build is missing, the manifest is missing,
@@ -277,26 +439,42 @@ function printVersionDelta(deployedVersion, manifest) {
277
439
  }
278
440
  console.log('');
279
441
  }
280
- function printDeploySuccess(result) {
281
- console.log('βœ… Deployment successful!');
282
- console.log('');
283
- if (result.data) {
284
- console.log('πŸ“‹ App Details:');
285
- console.log(` ID: ${result.data.id}`);
286
- console.log(` Name: ${result.data.name}`);
287
- console.log(` Version: ${result.data.version}`);
288
- console.log('');
442
+ /**
443
+ * Print each `required` key that the release adds, with its default, as the
444
+ * backend reports it.
445
+ */
446
+ function printFillReport(fill) {
447
+ if (fill.keys.length === 0) {
448
+ return;
289
449
  }
290
- if (result.message) {
291
- console.log(`πŸ’‘ ${result.message}`);
292
- console.log('');
450
+ for (const key of fill.keys) {
451
+ // A string default shows without JSON quotes, as the author wrote it.
452
+ const value = typeof key.default === 'string'
453
+ ? key.default
454
+ : JSON.stringify(key.default);
455
+ console.log(` Adds \`${key.path}\` (required, default \`${value}\`).`);
293
456
  }
457
+ console.log('');
458
+ }
459
+ /**
460
+ * Print the release from the manifest. The upload answers the app as it is
461
+ * served, which is still the previous release while the new one fills.
462
+ */
463
+ function printDeploySuccess(manifest) {
464
+ console.log('βœ… Deployment successful!');
465
+ console.log('');
466
+ console.log('πŸ“‹ App Details:');
467
+ console.log(` ID: ${manifest.id}`);
468
+ console.log(` Name: ${manifest.name}`);
469
+ console.log(` Version: ${manifest.version}`);
470
+ console.log('');
294
471
  }
295
472
  /**
296
473
  * Deploys an external app to the Juuno platform.
297
474
  */
298
475
  export async function deployApp(options) {
299
- console.log('πŸ“¦ Juuno CLI - Deploy');
476
+ const { dryRun } = options;
477
+ console.log(`πŸ“¦ Juuno CLI - Deploy${dryRun ? ' (dry run)' : ''}`);
300
478
  console.log('');
301
479
  try {
302
480
  const manifest = loadAndValidateManifest(options.buildDir);
@@ -304,6 +482,12 @@ export async function deployApp(options) {
304
482
  console.log(` Version: ${manifest.version}`);
305
483
  console.log(` ID: ${manifest.id}`);
306
484
  console.log('');
485
+ const { bundles, mapped } = listBundles(options.buildDir);
486
+ const sourceMapWarning = describeMissingSourceMaps(bundles, mapped);
487
+ if (sourceMapWarning) {
488
+ console.warn(`⚠️ ${sourceMapWarning}`);
489
+ console.log('');
490
+ }
307
491
  const apiUrl = getApiUrl(options);
308
492
  console.log('πŸ”‘ Authenticating...');
309
493
  const token = await getAuthToken(options);
@@ -319,11 +503,34 @@ export async function deployApp(options) {
319
503
  const zipBuffer = await createZipBundle(options.buildDir);
320
504
  console.log(` βœ“ Bundle created (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
321
505
  console.log('');
322
- console.log('☁️ Uploading to Juuno...');
323
- const result = await uploadBundle(token, zipBuffer, apiUrl);
324
- console.log(' βœ“ Upload complete');
506
+ console.log(dryRun ? 'πŸ§ͺ Sending for a dry run...' : '☁️ Uploading to Juuno...');
507
+ const result = await uploadBundle(token, zipBuffer, apiUrl, dryRun);
508
+ console.log(dryRun ? ' βœ“ Checked' : ' βœ“ Upload complete');
325
509
  console.log('');
326
- printDeploySuccess(result);
510
+ if (dryRun) {
511
+ // A backend from before the dry run ignores `dry_run` and deploys, and
512
+ // answers without a fill report.
513
+ if (result.fill === undefined) {
514
+ throw new Error(`The API at ${apiUrl} does not support a dry run, so it deployed ` +
515
+ `version ${manifest.version}.`);
516
+ }
517
+ printFillReport(result.fill);
518
+ console.log('βœ… Dry run passed. Nothing was deployed.');
519
+ console.log('');
520
+ return;
521
+ }
522
+ if (result.fill !== undefined) {
523
+ printFillReport(result.fill);
524
+ }
525
+ // A backend from before the fill sends no status. Its release is live
526
+ // when it answers.
527
+ if (result.status !== undefined && result.status !== 'live') {
528
+ console.log('⏳ Filling existing scenes...');
529
+ await waitUntilLive(token, apiUrl, manifest, result.status);
530
+ console.log(' βœ“ Live');
531
+ console.log('');
532
+ }
533
+ printDeploySuccess(manifest);
327
534
  }
328
535
  catch (error) {
329
536
  console.error(`❌ ${getErrorMessage(error)}`);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Which environment the API deploys to, and into which dispatch namespace.
3
+ *
4
+ * The environment is the API's to state, not the caller's. Each instance is
5
+ * pinned to exactly one dispatch namespace in its own config, so asking removes
6
+ * the only way a deploy could bundle one environment's bindings and upload them
7
+ * to another's namespace. There is nothing to keep in sync and nothing to
8
+ * mistype β€” which is why this has no `--env` flag and never will.
9
+ */
10
+ /** One small GET; an instance that has not answered by now is not going to. */
11
+ const CONTEXT_TIMEOUT_MS = 15_000;
12
+ export async function fetchContext(api, token) {
13
+ const response = await withTimeout(() => fetch(`${api}/api/v1/developer/app-backends/context`, {
14
+ headers: {
15
+ Authorization: `Bearer ${token}`,
16
+ Accept: 'application/json',
17
+ },
18
+ signal: AbortSignal.timeout(CONTEXT_TIMEOUT_MS),
19
+ }), `${api} did not answer within ${CONTEXT_TIMEOUT_MS / 1000}s.`);
20
+ if (!response.ok) {
21
+ // Names both plausible causes, because the two are indistinguishable from
22
+ // here: pointed at the wrong instance, or not logged in to the right one.
23
+ throw new Error(`Could not read the deploy context from ${api} (HTTP ${response.status}). ` +
24
+ 'Check the profile and that you are logged in.');
25
+ }
26
+ // A 200 that will not parse is the same misconfiguration a 404 here would be:
27
+ // an SPA catch-all, a proxy interstitial, or an instance too old to have the
28
+ // route. Left unguarded it surfaces as `Unexpected token '<'`, which points a
29
+ // developer at everything except their API url.
30
+ let body;
31
+ try {
32
+ body = (await response.json());
33
+ }
34
+ catch {
35
+ throw new Error(`${api} did not answer as the deploy context endpoint. ` +
36
+ 'Check the profile’s API url.');
37
+ }
38
+ // Refused rather than defaulted. Guessing an environment here is how a stage
39
+ // bundle reaches a production namespace.
40
+ if (!body.data?.env) {
41
+ throw new Error(`${api} did not report which environment it deploys to.`);
42
+ }
43
+ return { env: body.data.env, namespace: body.data.namespace };
44
+ }
45
+ /**
46
+ * Run a fetch and turn its abort into a sentence.
47
+ *
48
+ * `AbortSignal.timeout` rejects with `TimeoutError: The operation was aborted`,
49
+ * which tells a developer nothing about which request gave up or how long it
50
+ * waited. Everything else propagates untouched.
51
+ */
52
+ export async function withTimeout(request, message) {
53
+ try {
54
+ return await request();
55
+ }
56
+ catch (error) {
57
+ if (error instanceof Error && error.name === 'TimeoutError') {
58
+ throw new Error(message);
59
+ }
60
+ throw error;
61
+ }
62
+ }