@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 +67 -0
- package/bin/cli-router.js +91 -0
- package/bin/cli.js +27 -4
- package/dist/cli/src/deploy/index.js +231 -24
- 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 +277 -0
- package/dist/cli/src/utils/api.js +12 -1
- 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 +38 -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 +108 -0
- package/dist/types/src/deploy-backend/wrangler-config.d.ts.map +1 -0
- package/dist/types/src/utils/api.d.ts +8 -0
- package/dist/types/src/utils/api.d.ts.map +1 -1
- package/dist/types/src/utils/bin.d.ts +24 -0
- package/dist/types/src/utils/bin.d.ts.map +1 -0
- package/package.json +11 -3
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|