@mondaydotcomorg/z2h-cli 0.33.0 → 0.33.2
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/dist/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
- package/dist/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
- package/dist/backend/client.d.ts +32 -2
- package/dist/backend/client.d.ts.map +1 -1
- package/dist/backend/client.js +8 -5
- package/dist/backend/format-sandbox-run.d.ts +4 -0
- package/dist/backend/format-sandbox-run.d.ts.map +1 -0
- package/dist/backend/format-sandbox-run.js +49 -0
- package/dist/commands/__tests__/backend-invoke.test.d.ts +2 -0
- package/dist/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
- package/dist/commands/backend.d.ts +12 -6
- package/dist/commands/backend.d.ts.map +1 -1
- package/dist/commands/backend.js +41 -15
- package/dist/commands/deploy.js +29 -16
- package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
- package/dist/esm/backend/client.d.ts +32 -2
- package/dist/esm/backend/client.d.ts.map +1 -1
- package/dist/esm/backend/client.mjs +8 -5
- package/dist/esm/backend/format-sandbox-run.d.ts +4 -0
- package/dist/esm/backend/format-sandbox-run.d.ts.map +1 -0
- package/dist/esm/backend/format-sandbox-run.mjs +47 -0
- package/dist/esm/commands/__tests__/backend-invoke.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
- package/dist/esm/commands/backend.d.ts +12 -6
- package/dist/esm/commands/backend.d.ts.map +1 -1
- package/dist/esm/commands/backend.mjs +42 -16
- package/dist/esm/commands/deploy.mjs +29 -16
- package/dist/esm/index.mjs +9 -4
- package/dist/esm/util/broker/app.d.ts +2 -0
- package/dist/esm/util/broker/app.d.ts.map +1 -1
- package/dist/esm/util/broker/app.mjs +20 -7
- package/dist/index.js +9 -4
- package/dist/util/broker/app.d.ts +2 -0
- package/dist/util/broker/app.d.ts.map +1 -1
- package/dist/util/broker/app.js +20 -7
- package/handler-api/backend-runner/backend-runner.errors.ts +12 -0
- package/handler-api/backend-runner/backend-runner.types.ts +3 -0
- package/handler-api/backend-runner/channels/channel-map.ts +12 -3
- package/handler-api/backend-runner/channels/channel-telemetry.ts +13 -7
- package/handler-api/backend-runner/channels/channel-trace.ts +52 -0
- package/package.json +2 -2
- package/src/backend/__tests__/client.test.ts +17 -13
- package/src/backend/__tests__/format-sandbox-run.test.ts +42 -0
- package/src/backend/client.ts +27 -10
- package/src/backend/format-sandbox-run.ts +51 -0
- package/src/commands/__tests__/backend-deploy.test.ts +42 -21
- package/src/commands/__tests__/backend-invoke.test.ts +125 -0
- package/src/commands/__tests__/deploy.test.ts +53 -0
- package/src/commands/backend.ts +57 -18
- package/src/commands/deploy.ts +31 -17
- package/src/index.ts +13 -5
- package/src/util/broker/app.ts +59 -50
package/src/commands/backend.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
4
|
import { readJson, writeJson } from 'fs-extra';
|
|
4
5
|
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
|
5
6
|
import { info, output } from '../util/logger';
|
|
@@ -9,7 +10,9 @@ import { readHandlers } from '../backend/handlers';
|
|
|
9
10
|
import { detectIntegrations } from '../backend/detect-integrations';
|
|
10
11
|
import { getS3Client } from '../util/s3/client';
|
|
11
12
|
import { BUCKET_WRITE_NAME } from '../constants';
|
|
12
|
-
import {
|
|
13
|
+
import { runSandbox } from '../backend/client';
|
|
14
|
+
import { formatSandboxRun } from '../backend/format-sandbox-run';
|
|
15
|
+
import { validateHandlerSource } from '../backend/validate';
|
|
13
16
|
import { tagDeployError } from '../util/errors';
|
|
14
17
|
|
|
15
18
|
function currentAppName(): string {
|
|
@@ -42,10 +45,12 @@ async function writeUsageJson(appDir: string, patch: Record<string, unknown>): P
|
|
|
42
45
|
await writeJson(path.join(appDir, 'usage.json'), { ...existing, ...patch }, { spaces: 2 });
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
export interface ReadDetectAndUploadResult {
|
|
49
|
+
integrations: string[];
|
|
50
|
+
handlerNames: string[];
|
|
51
|
+
apiUsages: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
async function readDetectAndUpload(
|
|
50
55
|
appDir: string,
|
|
51
56
|
prefix: string,
|
|
@@ -53,17 +58,19 @@ async function readDetectAndUpload(
|
|
|
53
58
|
version: number,
|
|
54
59
|
deployedBy: string,
|
|
55
60
|
logLabel: string
|
|
56
|
-
): Promise<
|
|
61
|
+
): Promise<ReadDetectAndUploadResult> {
|
|
57
62
|
if (!hasHandlers(appDir)) {
|
|
58
|
-
|
|
63
|
+
await writeUsageJson(appDir, { appName, deployedBy, apiUsages: {} });
|
|
64
|
+
return { integrations: [], handlerNames: [], apiUsages: {} };
|
|
59
65
|
}
|
|
60
66
|
info(`reading backend handlers ${logLabel}`);
|
|
61
67
|
const { handlers, apiDomains, apiUsages } = await readHandlers(appDir);
|
|
62
68
|
const integrations = detectIntegrations(apiDomains);
|
|
69
|
+
const handlerNames = Object.keys(handlers);
|
|
70
|
+
await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
|
|
63
71
|
if (Object.keys(handlers).length === 0) {
|
|
64
|
-
return integrations;
|
|
72
|
+
return { integrations, handlerNames, apiUsages };
|
|
65
73
|
}
|
|
66
|
-
await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
|
|
67
74
|
const s3 = getS3Client();
|
|
68
75
|
const meta = JSON.stringify({ version, deployedBy, handlers: Object.keys(handlers) });
|
|
69
76
|
const put = (key: string, body: string, contentType: string) =>
|
|
@@ -72,14 +79,10 @@ async function readDetectAndUpload(
|
|
|
72
79
|
...Object.entries(handlers).map(([name, code]) => put(`${prefix}/${name}.js`, code, 'application/javascript')),
|
|
73
80
|
put(`${prefix}/meta.json`, meta, 'application/json'),
|
|
74
81
|
]);
|
|
75
|
-
return integrations;
|
|
82
|
+
return { integrations, handlerNames, apiUsages };
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
/**
|
|
79
|
-
* Deploy the app's backend handlers to S3 and return the integrations detected
|
|
80
|
-
* from their sources. Each `.js` handler is uploaded as its own flat file — no
|
|
81
|
-
* compilation, no bundling, no npm deps.
|
|
82
|
-
*
|
|
83
86
|
* Integration sync note: the returned set is re-derived from the CURRENT backend
|
|
84
87
|
* sources on every deploy, and the platform set is reconciled to exactly match
|
|
85
88
|
* it (there is no stored declaration to fall back on). A deploy therefore always
|
|
@@ -93,7 +96,7 @@ export async function deployBackend(opts: {
|
|
|
93
96
|
version: number;
|
|
94
97
|
deployedBy: string;
|
|
95
98
|
isPreview: boolean;
|
|
96
|
-
}): Promise<
|
|
99
|
+
}): Promise<ReadDetectAndUploadResult> {
|
|
97
100
|
const { appDir, appName, mfKeyPrefix, version, deployedBy, isPreview } = opts;
|
|
98
101
|
const prefix = isPreview ? `${mfKeyPrefix.replace(/\/MF$/, '')}/MS` : `${appName}/backend-${version}`;
|
|
99
102
|
const logLabel = isPreview ? 'for preview' : `for "${appName}"`;
|
|
@@ -125,7 +128,35 @@ export async function backendValidateCommand(appDir: string = process.cwd()): Pr
|
|
|
125
128
|
});
|
|
126
129
|
}
|
|
127
130
|
|
|
128
|
-
|
|
131
|
+
async function readHandlerSource(appDir: string, handlerName: string): Promise<string> {
|
|
132
|
+
const dir = path.join(appDir, 'backend', 'handlers');
|
|
133
|
+
try {
|
|
134
|
+
return await readFile(path.join(dir, `${handlerName}.js`), 'utf8');
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if ((err as { code?: string }).code !== 'ENOENT') {
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
const files = hasHandlers(appDir) ? await readdir(dir) : [];
|
|
140
|
+
const available = files
|
|
141
|
+
.filter(f => f.endsWith('.js'))
|
|
142
|
+
.map(f => path.parse(f).name)
|
|
143
|
+
.sort();
|
|
144
|
+
throw new Error(
|
|
145
|
+
`no handler "${handlerName}" in backend/handlers/` +
|
|
146
|
+
(available.length ? ` (available: ${available.join(', ')})` : ' (no handlers found)')
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Runs a handler from the working tree (or `--code`) on the runner's sandbox and
|
|
153
|
+
* prints the trace. Validated locally first, like deploy; exits 1 when the handler fails.
|
|
154
|
+
*/
|
|
155
|
+
export async function backendInvokeCommand(
|
|
156
|
+
handlerName: string,
|
|
157
|
+
opts: { input?: string; code?: string } = {},
|
|
158
|
+
appDir: string = process.cwd()
|
|
159
|
+
): Promise<void> {
|
|
129
160
|
const appName = currentAppName();
|
|
130
161
|
let input: unknown = null;
|
|
131
162
|
if (opts.input) {
|
|
@@ -136,6 +167,14 @@ export async function backendInvokeCommand(handlerName: string, opts: { input?:
|
|
|
136
167
|
}
|
|
137
168
|
}
|
|
138
169
|
|
|
139
|
-
const
|
|
140
|
-
|
|
170
|
+
const file = `${handlerName}.js`;
|
|
171
|
+
const code = opts.code ?? (await readHandlerSource(appDir, handlerName));
|
|
172
|
+
validateHandlerSource(file, code);
|
|
173
|
+
info(`validated ${opts.code ? '--code' : file}`);
|
|
174
|
+
|
|
175
|
+
const res = await runSandbox(appName, { code, input, handlerName });
|
|
176
|
+
output(formatSandboxRun(res), res);
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
}
|
|
141
180
|
}
|
package/src/commands/deploy.ts
CHANGED
|
@@ -131,13 +131,9 @@ async function uploadAndRegister(opts: {
|
|
|
131
131
|
const { paths, keyPrefix, appName, token, nextVersion, entry, gitRemote, isPreview, description, masterAppName } =
|
|
132
132
|
opts;
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// `integrations` is inferred from the backend sources in the working tree and
|
|
138
|
-
// reconciled to exactly match on register — see `deployBackend` for why this
|
|
139
|
-
// depends on deploying from the app's latest merged state.
|
|
140
|
-
const integrations = await deployBackend({
|
|
134
|
+
// Runs before the frontend upload so a `deployBackend` failure can't leave a
|
|
135
|
+
// preview's unversioned `MF` dir overwritten with no rollback.
|
|
136
|
+
const { integrations, handlerNames, apiUsages } = await deployBackend({
|
|
141
137
|
appDir: process.cwd(),
|
|
142
138
|
appName,
|
|
143
139
|
mfKeyPrefix: keyPrefix,
|
|
@@ -146,16 +142,34 @@ async function uploadAndRegister(opts: {
|
|
|
146
142
|
isPreview,
|
|
147
143
|
});
|
|
148
144
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
145
|
+
try {
|
|
146
|
+
info(`uploading ${isPreview ? 'preview ' : ''}build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
|
|
147
|
+
await uploadDir(paths.shadowBuild, keyPrefix);
|
|
148
|
+
|
|
149
|
+
await registerOrUpdateApp({
|
|
150
|
+
appName,
|
|
151
|
+
token,
|
|
152
|
+
nextVersion,
|
|
153
|
+
entry,
|
|
154
|
+
gitRemote,
|
|
155
|
+
integrations,
|
|
156
|
+
handlerNames,
|
|
157
|
+
apiUsages,
|
|
158
|
+
description,
|
|
159
|
+
masterAppName,
|
|
160
|
+
});
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// A live deploy is safe either way — RDS's version gates what's actually served, so
|
|
163
|
+
// an orphaned S3 object is harmless — but a preview is served straight from its
|
|
164
|
+
// unversioned prefix with no such gate, so only it needs the warning below.
|
|
165
|
+
if (isPreview) {
|
|
166
|
+
warn(
|
|
167
|
+
`preview backend was already deployed before this failed — re-run "z2h-cli deploy --preview" to retry ` +
|
|
168
|
+
`(every step overwrites, so a retry fully repairs it)`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
159
173
|
}
|
|
160
174
|
|
|
161
175
|
async function deployPreview({ paths, consumerName, appName, token, opts }: DeployContext): Promise<void> {
|
package/src/index.ts
CHANGED
|
@@ -279,7 +279,7 @@ async function main(): Promise<void> {
|
|
|
279
279
|
|
|
280
280
|
const backend = program
|
|
281
281
|
.command('backend')
|
|
282
|
-
.description("Inspect the app's backend: validate handler sources or
|
|
282
|
+
.description("Inspect the app's backend: validate handler sources or run a handler from the working tree");
|
|
283
283
|
|
|
284
284
|
backend
|
|
285
285
|
.command('validate')
|
|
@@ -291,15 +291,21 @@ async function main(): Promise<void> {
|
|
|
291
291
|
backend
|
|
292
292
|
.command('invoke <handlerName>')
|
|
293
293
|
.description(
|
|
294
|
-
|
|
294
|
+
"Run backend/handlers/<handlerName>.js from the working tree — not the deployed handler — against the app's real " +
|
|
295
|
+
'channels (real db data, real monday calls), and print a trace of every channel call and the ' +
|
|
296
|
+
'result. Nothing is deployed. Exits 1 when the handler fails.'
|
|
297
|
+
)
|
|
298
|
+
.option('--input <json>', 'JSON passed to the handler as ctx.input (what the frontend would send)')
|
|
299
|
+
.option(
|
|
300
|
+
'--code <source>',
|
|
301
|
+
'run this source instead of backend/handlers/<handlerName>.js — <handlerName> is then just a label'
|
|
295
302
|
)
|
|
296
|
-
.option('--input <json>', 'JSON payload passed as ctx.input')
|
|
297
303
|
.action(
|
|
298
304
|
runCommand(
|
|
299
305
|
{
|
|
300
306
|
data: ([handlerName]: [string, unknown]) => ({ app_name: getCwdAppName(), handler: handlerName }),
|
|
301
307
|
},
|
|
302
|
-
(handlerName: string, opts: { input?: string }) => backendInvokeCommand(handlerName, opts)
|
|
308
|
+
(handlerName: string, opts: { input?: string; code?: string }) => backendInvokeCommand(handlerName, opts)
|
|
303
309
|
)
|
|
304
310
|
);
|
|
305
311
|
|
|
@@ -330,7 +336,9 @@ void main()
|
|
|
330
336
|
if (getOutputMode() === 'human') {
|
|
331
337
|
selfUpdate();
|
|
332
338
|
}
|
|
333
|
-
|
|
339
|
+
// A command can finish normally and still report a non-zero code (e.g.
|
|
340
|
+
// `backend invoke` when the handler failed) via process.exitCode.
|
|
341
|
+
process.exit(process.exitCode ?? 0);
|
|
334
342
|
})
|
|
335
343
|
.catch(async (err: unknown) => {
|
|
336
344
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
package/src/util/broker/app.ts
CHANGED
|
@@ -64,21 +64,52 @@ export async function claimApp(appName: string, gitRemote: string): Promise<void
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
appName: string
|
|
69
|
-
token: string
|
|
70
|
-
|
|
71
|
-
entry: ManifestEntry
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
67
|
+
export interface RegisterOrUpdateOptions {
|
|
68
|
+
appName: string;
|
|
69
|
+
token: string;
|
|
70
|
+
nextVersion: number;
|
|
71
|
+
entry: ManifestEntry;
|
|
72
|
+
gitRemote: string;
|
|
73
|
+
integrations?: string[];
|
|
74
|
+
handlerNames?: string[];
|
|
75
|
+
apiUsages?: Record<string, unknown>;
|
|
76
|
+
description?: string;
|
|
77
|
+
/**
|
|
78
|
+
* The base app a preview is for, sent only on first registration — the link
|
|
79
|
+
* is set once and is immutable thereafter, so a later update doesn't need it.
|
|
80
|
+
*/
|
|
81
|
+
masterAppName?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function registerApp(params: RegisterOrUpdateOptions & { visibility: 'public' | 'private' }): Promise<void> {
|
|
85
|
+
const {
|
|
86
|
+
appName,
|
|
87
|
+
token,
|
|
88
|
+
nextVersion: version,
|
|
89
|
+
entry,
|
|
90
|
+
visibility,
|
|
91
|
+
gitRemote,
|
|
92
|
+
integrations,
|
|
93
|
+
handlerNames,
|
|
94
|
+
apiUsages,
|
|
95
|
+
description,
|
|
96
|
+
masterAppName,
|
|
97
|
+
} = params;
|
|
78
98
|
const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/register`, {
|
|
79
99
|
method: 'POST',
|
|
80
100
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
|
|
81
|
-
body: JSON.stringify({
|
|
101
|
+
body: JSON.stringify({
|
|
102
|
+
appName,
|
|
103
|
+
version,
|
|
104
|
+
entry,
|
|
105
|
+
visibility,
|
|
106
|
+
gitRemote,
|
|
107
|
+
integrations,
|
|
108
|
+
handlerNames,
|
|
109
|
+
apiUsages,
|
|
110
|
+
description,
|
|
111
|
+
masterAppName,
|
|
112
|
+
}),
|
|
82
113
|
});
|
|
83
114
|
if (!res.ok) {
|
|
84
115
|
const text = await res.text();
|
|
@@ -87,19 +118,22 @@ async function registerApp(
|
|
|
87
118
|
}
|
|
88
119
|
|
|
89
120
|
/** Releases a new version onto an app that already exists. */
|
|
90
|
-
async function updateApp(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
121
|
+
async function updateApp(params: RegisterOrUpdateOptions): Promise<void> {
|
|
122
|
+
const {
|
|
123
|
+
appName,
|
|
124
|
+
token,
|
|
125
|
+
nextVersion: version,
|
|
126
|
+
entry,
|
|
127
|
+
gitRemote,
|
|
128
|
+
integrations,
|
|
129
|
+
handlerNames,
|
|
130
|
+
apiUsages,
|
|
131
|
+
description,
|
|
132
|
+
} = params;
|
|
99
133
|
const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/update`, {
|
|
100
134
|
method: 'POST',
|
|
101
135
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
|
|
102
|
-
body: JSON.stringify({ appName, version, entry, gitRemote, integrations, description }),
|
|
136
|
+
body: JSON.stringify({ appName, version, entry, gitRemote, integrations, handlerNames, apiUsages, description }),
|
|
103
137
|
});
|
|
104
138
|
if (!res.ok) {
|
|
105
139
|
const text = await res.text();
|
|
@@ -107,21 +141,6 @@ async function updateApp(
|
|
|
107
141
|
}
|
|
108
142
|
}
|
|
109
143
|
|
|
110
|
-
export interface RegisterOrUpdateOptions {
|
|
111
|
-
appName: string;
|
|
112
|
-
token: string;
|
|
113
|
-
nextVersion: number;
|
|
114
|
-
entry: ManifestEntry;
|
|
115
|
-
gitRemote: string;
|
|
116
|
-
integrations?: string[];
|
|
117
|
-
description?: string;
|
|
118
|
-
/**
|
|
119
|
-
* The base app a preview is for, sent only on first registration — the link
|
|
120
|
-
* is set once and is immutable thereafter, so a later update doesn't need it.
|
|
121
|
-
*/
|
|
122
|
-
masterAppName?: string;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
144
|
/**
|
|
126
145
|
* v1 creates the app row (+ owner grant, prompting for visibility); later
|
|
127
146
|
* versions release onto it via update instead — the two are separate
|
|
@@ -129,26 +148,16 @@ export interface RegisterOrUpdateOptions {
|
|
|
129
148
|
* deploy and preview so this branching logic lives in exactly one place.
|
|
130
149
|
*/
|
|
131
150
|
export async function registerOrUpdateApp(opts: RegisterOrUpdateOptions): Promise<void> {
|
|
132
|
-
const { appName,
|
|
151
|
+
const { appName, nextVersion } = opts;
|
|
133
152
|
|
|
134
153
|
try {
|
|
135
154
|
if (nextVersion === 1) {
|
|
136
155
|
const visibility = await resolveAppVisibility(appName);
|
|
137
156
|
info(`registering "${appName}" → v${nextVersion} (${visibility})`);
|
|
138
|
-
await registerApp(
|
|
139
|
-
appName,
|
|
140
|
-
token,
|
|
141
|
-
nextVersion,
|
|
142
|
-
entry,
|
|
143
|
-
visibility,
|
|
144
|
-
gitRemote,
|
|
145
|
-
integrations,
|
|
146
|
-
description,
|
|
147
|
-
masterAppName
|
|
148
|
-
);
|
|
157
|
+
await registerApp({ ...opts, visibility });
|
|
149
158
|
} else {
|
|
150
159
|
info(`updating "${appName}" → v${nextVersion}`);
|
|
151
|
-
await updateApp(
|
|
160
|
+
await updateApp(opts);
|
|
152
161
|
}
|
|
153
162
|
} catch (err) {
|
|
154
163
|
// registerApp/updateApp already tag the non-ok-response case; this catches anything
|