@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 +40 -0
- package/bin/cli-router.js +90 -0
- package/bin/cli.js +25 -4
- package/dist/cli/src/deploy/index.js +81 -1
- package/dist/cli/src/deploy-backend/context.js +62 -0
- package/dist/cli/src/deploy-backend/index.js +196 -0
- package/dist/cli/src/deploy-backend/outcome.js +85 -0
- package/dist/cli/src/deploy-backend/wrangler-config.js +271 -0
- package/dist/cli/src/utils/bin.js +23 -0
- package/dist/cli/tsconfig.build.tsbuildinfo +1 -1
- package/dist/types/src/deploy/index.d.ts +32 -0
- package/dist/types/src/deploy/index.d.ts.map +1 -1
- package/dist/types/src/deploy-backend/context.d.ts +29 -0
- package/dist/types/src/deploy-backend/context.d.ts.map +1 -0
- package/dist/types/src/deploy-backend/index.d.ts +54 -0
- package/dist/types/src/deploy-backend/index.d.ts.map +1 -0
- package/dist/types/src/deploy-backend/outcome.d.ts +63 -0
- package/dist/types/src/deploy-backend/outcome.d.ts.map +1 -0
- package/dist/types/src/deploy-backend/wrangler-config.d.ts +106 -0
- package/dist/types/src/deploy-backend/wrangler-config.d.ts.map +1 -0
- package/dist/types/src/utils/bin.d.ts +24 -0
- package/dist/types/src/utils/bin.d.ts.map +1 -0
- package/package.json +29 -22
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|