@juuno-sdk/cli 4.0.1 → 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.
package/README.md CHANGED
@@ -120,6 +120,46 @@ npx juuno-cli deploy
120
120
  npx juuno-cli deploy --build-dir ./build
121
121
  ```
122
122
 
123
+ Build with source maps on. An error thrown in your app is reported as a position
124
+ in the minified bundle, and a map beside that bundle is what turns it back into
125
+ a line of your own source, for you and for Juuno support. The deploy uploads
126
+ whatever the build directory holds, maps included, and warns when it finds none.
127
+
128
+ ### Deploying an App Backend
129
+
130
+ An **app backend** is a Cloudflare Workers-for-Platforms tenant script that owns
131
+ one app's data and logic. Deploying goes through Juuno's platform rather than
132
+ straight to Cloudflare — that is what provisions the app's KV and D1 stores,
133
+ fills their ids, and triggers the tenant's own migrations. A raw
134
+ `wrangler deploy --dispatch-namespace` silently skips all of it.
135
+
136
+ Run it from the worker directory:
137
+
138
+ ```bash
139
+ cd cloudflare-workers/app-google-reviews
140
+
141
+ # Deploy to stage
142
+ npx juuno-cli deploy-backend --profile stage
143
+
144
+ # See what would be sent, without deploying
145
+ npx juuno-cli deploy-backend --profile stage --print-metadata
146
+ ```
147
+
148
+ The environment is not chosen here. Each API instance is pinned to exactly one
149
+ dispatch namespace and reports which environment that is, so the profile you
150
+ deploy with determines the target; the CLI uses that answer to select the
151
+ matching env in `wrangler.jsonc`. The bindings that get built and the namespace
152
+ that receives them therefore always agree, with nothing to keep in sync. A
153
+ worker with no matching env fails with the envs it does declare.
154
+
155
+ Bundling shells out to the worker's own pinned `wrangler`, so the version in its
156
+ `devDependencies` is the one that runs.
157
+
158
+ **The backend must already be registered** in Superadmin, on the Backend tab of
159
+ the app it serves. Registration is what attaches it to an app and mints its
160
+ platform secret; deploying an unregistered script is refused before anything
161
+ uploads.
162
+
123
163
  ### List Apps
124
164
 
125
165
  View all your deployed apps.
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
  */
@@ -56,6 +104,27 @@ const COMMANDS = {
56
104
  ...parseAuthOptions(args),
57
105
  });
58
106
  },
107
+ 'deploy-backend': async (args, importPrefix) => {
108
+ const problem = checkDeployBackendArgs(args);
109
+ if (problem) {
110
+ console.error(`❌ ${problem}`);
111
+ console.error(
112
+ ' Usage: juuno-cli deploy-backend [--dir <path>] [--print-metadata]',
113
+ );
114
+ process.exit(1);
115
+ }
116
+
117
+ const { deployBackend } = await import(
118
+ `${importPrefix}deploy-backend/index.js`
119
+ );
120
+ const dirIndex = args.indexOf('--dir');
121
+ await deployBackend({
122
+ dir: dirIndex !== -1 ? args[dirIndex + 1] : undefined,
123
+ printMetadata: args.includes('--print-metadata'),
124
+ ...parseAuthOptions(args),
125
+ });
126
+ },
127
+
59
128
  list: async (args, importPrefix) => {
60
129
  const { listApps } = await import(`${importPrefix}list/index.js`);
61
130
  await listApps(parseAuthOptions(args));
@@ -72,6 +141,27 @@ const COMMANDS = {
72
141
  },
73
142
  };
74
143
 
144
+ /**
145
+ * The code the process should exit with.
146
+ *
147
+ * `process.exitCode` is how a command reports a failure it must not throw for —
148
+ * deploy-backend writes the server's own account of a deploy that may already
149
+ * have landed, rather than throwing and having it wrapped as a refusal. An
150
+ * explicit code passed to `process.exit()` overrides that, so handing it the
151
+ * handler's 0 turned a failed deploy into a green CI run.
152
+ *
153
+ * @param {number} commandExitCode - What runCommand() returned
154
+ * @param {number|string|undefined} reportedExitCode - process.exitCode, as the command left it
155
+ * @returns {number} The code to exit with
156
+ */
157
+ export function resolveExitCode(commandExitCode, reportedExitCode) {
158
+ if (commandExitCode !== 0) {
159
+ return commandExitCode;
160
+ }
161
+
162
+ return Number(reportedExitCode ?? 0);
163
+ }
164
+
75
165
  /**
76
166
  * Execute a CLI command with the given import prefix.
77
167
  *
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
@@ -61,7 +62,20 @@ Deploy Options:
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.
63
64
 
64
- Authentication Options (for deploy, list, info):
65
+ Deploy Backend Options:
66
+ --dir <path> Worker directory (default: current directory)
67
+ --print-metadata Print the resolved script + metadata, then stop
68
+
69
+ The backend must already be registered in Superadmin, on the Backend tab of
70
+ the app it serves — that is what attaches it to an app and mints its platform
71
+ secret. Deploying an unregistered script is refused before anything uploads.
72
+
73
+ The API associated with the active profile reports which environment it
74
+ deploys to; eg prod|stage - this selects the matching environment in
75
+ wrangler.jsonc, so the bindings that get built and the namespace that
76
+ receives them always agree.
77
+
78
+ Authentication Options (for deploy, deploy-backend, list, info):
65
79
  By default, these commands use the token saved by 'juuno-cli login'.
66
80
  You can override with:
67
81
  --profile <name> Use credentials from a named profile
@@ -100,6 +114,10 @@ Examples:
100
114
  juuno-cli deploy
101
115
  juuno-cli deploy --build-dir ./dist
102
116
 
117
+ # Deploy an app backend from its worker directory
118
+ cd cloudflare-workers/app-google-reviews
119
+ juuno-cli deploy-backend --profile stage
120
+
103
121
  # List apps (uses saved credentials)
104
122
  juuno-cli list
105
123
 
@@ -115,11 +133,14 @@ if (
115
133
  command === 'logout' ||
116
134
  command === 'whoami' ||
117
135
  command === 'deploy' ||
136
+ command === 'deploy-backend' ||
118
137
  command === 'list' ||
119
138
  command === 'info'
120
139
  ) {
121
140
  const exitCode = await runCommand(command, args, '../dist/cli/src/');
122
- process.exit(exitCode);
141
+
142
+ // Not process.exit(exitCode) — see resolveExitCode().
143
+ process.exit(resolveExitCode(exitCode, process.exitCode));
123
144
  }
124
145
 
125
146
  // `dev` is not unknown, it moved: the simulator is its own package so CI
@@ -1,4 +1,4 @@
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
3
  import JSZip from 'jszip';
4
4
  import semver from 'semver';
@@ -79,6 +79,80 @@ export function checkTranslations(declared, bundled) {
79
79
  }
80
80
  return null;
81
81
  }
82
+ /**
83
+ * Every JavaScript bundle the upload will carry, and the ones with a map
84
+ * beside them.
85
+ *
86
+ * A map is looked for at `<bundle>.map`, which is where every bundler writes
87
+ * one and the only place a reader of a stack trace looks for it. A map named
88
+ * something else, or inlined into the bundle as a data URL, is not found by
89
+ * this and is not found by the tooling either.
90
+ *
91
+ * Each file is classified from the same directory entry and the same
92
+ * containment rule `createZipBundle` applies below, so the two agree on what
93
+ * ships. A path resolving outside the build directory is dropped on upload,
94
+ * and a directory is walked into rather than uploaded, so neither can stand in
95
+ * for a map the CDN will never serve.
96
+ */
97
+ export function listBundles(buildDir) {
98
+ const bundles = [];
99
+ const maps = new Set();
100
+ const resolvedBuildDir = realpathSync(buildDir);
101
+ function walk(dir) {
102
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
103
+ const fullPath = join(dir, entry.name);
104
+ const resolved = safeResolveWithinBase(fullPath, resolvedBuildDir);
105
+ if (resolved === 'unresolvable' || resolved === 'escapes') {
106
+ continue;
107
+ }
108
+ if (entry.isDirectory()) {
109
+ walk(fullPath);
110
+ continue;
111
+ }
112
+ const file = relative(resolvedBuildDir, fullPath);
113
+ if (entry.name.endsWith('.map')) {
114
+ maps.add(file);
115
+ continue;
116
+ }
117
+ // `.mjs` as well as `.js`: a third-party build picks its own extension
118
+ // for an ES module, and an all-`.mjs` build would otherwise count no
119
+ // bundles and say nothing about itself.
120
+ if (/\.m?js$/.test(entry.name)) {
121
+ bundles.push(file);
122
+ }
123
+ }
124
+ }
125
+ walk(resolvedBuildDir);
126
+ return {
127
+ bundles,
128
+ mapped: bundles.filter((bundle) => maps.has(`${bundle}.map`)),
129
+ };
130
+ }
131
+ /**
132
+ * Say that this build ships nothing that can turn a crash back into a line of
133
+ * source, or null when at least one bundle has a map.
134
+ *
135
+ * Only a build with no maps at all is worth saying anything about. A build that
136
+ * has them on still emits the occasional generated chunk with no original
137
+ * source and so no map, and a warning that fires on a correctly configured app
138
+ * is one nobody reads.
139
+ *
140
+ * A warning rather than an error, because a build with no maps is a valid
141
+ * deploy. It is just one nobody can debug.
142
+ */
143
+ export function describeMissingSourceMaps(bundles, mapped) {
144
+ if (bundles.length === 0 || mapped.length > 0) {
145
+ return null;
146
+ }
147
+ const noun = bundles.length === 1 ? 'bundle' : 'bundles';
148
+ return (`No source maps in this build (${bundles.length} JavaScript ${noun}, ` +
149
+ 'none with a .map beside it).\n' +
150
+ ' An error thrown in your app is reported as a position in the minified\n' +
151
+ ' bundle. Without a map beside it that position names no file and no\n' +
152
+ ' line, for you or for Juuno support.\n' +
153
+ ' Turn on source maps in your bundler (`build.sourcemap: true` in Vite)\n' +
154
+ ' and deploy again. They upload with the rest of the build directory.');
155
+ }
82
156
  /**
83
157
  * Resolve a real path under a base directory. Returns 'unresolvable' when
84
158
  * realpathSync throws (broken symlink, missing target), 'escapes' when the
@@ -304,6 +378,12 @@ export async function deployApp(options) {
304
378
  console.log(` Version: ${manifest.version}`);
305
379
  console.log(` ID: ${manifest.id}`);
306
380
  console.log('');
381
+ const { bundles, mapped } = listBundles(options.buildDir);
382
+ const sourceMapWarning = describeMissingSourceMaps(bundles, mapped);
383
+ if (sourceMapWarning) {
384
+ console.warn(`⚠️ ${sourceMapWarning}`);
385
+ console.log('');
386
+ }
307
387
  const apiUrl = getApiUrl(options);
308
388
  console.log('🔑 Authenticating...');
309
389
  const token = await getAuthToken(options);
@@ -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
+ }
@@ -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
+ }