@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.
Files changed (53) hide show
  1. package/dist/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
  2. package/dist/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
  3. package/dist/backend/client.d.ts +32 -2
  4. package/dist/backend/client.d.ts.map +1 -1
  5. package/dist/backend/client.js +8 -5
  6. package/dist/backend/format-sandbox-run.d.ts +4 -0
  7. package/dist/backend/format-sandbox-run.d.ts.map +1 -0
  8. package/dist/backend/format-sandbox-run.js +49 -0
  9. package/dist/commands/__tests__/backend-invoke.test.d.ts +2 -0
  10. package/dist/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
  11. package/dist/commands/backend.d.ts +12 -6
  12. package/dist/commands/backend.d.ts.map +1 -1
  13. package/dist/commands/backend.js +41 -15
  14. package/dist/commands/deploy.js +29 -16
  15. package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
  16. package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
  17. package/dist/esm/backend/client.d.ts +32 -2
  18. package/dist/esm/backend/client.d.ts.map +1 -1
  19. package/dist/esm/backend/client.mjs +8 -5
  20. package/dist/esm/backend/format-sandbox-run.d.ts +4 -0
  21. package/dist/esm/backend/format-sandbox-run.d.ts.map +1 -0
  22. package/dist/esm/backend/format-sandbox-run.mjs +47 -0
  23. package/dist/esm/commands/__tests__/backend-invoke.test.d.ts +2 -0
  24. package/dist/esm/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
  25. package/dist/esm/commands/backend.d.ts +12 -6
  26. package/dist/esm/commands/backend.d.ts.map +1 -1
  27. package/dist/esm/commands/backend.mjs +42 -16
  28. package/dist/esm/commands/deploy.mjs +29 -16
  29. package/dist/esm/index.mjs +9 -4
  30. package/dist/esm/util/broker/app.d.ts +2 -0
  31. package/dist/esm/util/broker/app.d.ts.map +1 -1
  32. package/dist/esm/util/broker/app.mjs +20 -7
  33. package/dist/index.js +9 -4
  34. package/dist/util/broker/app.d.ts +2 -0
  35. package/dist/util/broker/app.d.ts.map +1 -1
  36. package/dist/util/broker/app.js +20 -7
  37. package/handler-api/backend-runner/backend-runner.errors.ts +12 -0
  38. package/handler-api/backend-runner/backend-runner.types.ts +3 -0
  39. package/handler-api/backend-runner/channels/channel-map.ts +12 -3
  40. package/handler-api/backend-runner/channels/channel-telemetry.ts +13 -7
  41. package/handler-api/backend-runner/channels/channel-trace.ts +52 -0
  42. package/package.json +2 -2
  43. package/src/backend/__tests__/client.test.ts +17 -13
  44. package/src/backend/__tests__/format-sandbox-run.test.ts +42 -0
  45. package/src/backend/client.ts +27 -10
  46. package/src/backend/format-sandbox-run.ts +51 -0
  47. package/src/commands/__tests__/backend-deploy.test.ts +42 -21
  48. package/src/commands/__tests__/backend-invoke.test.ts +125 -0
  49. package/src/commands/__tests__/deploy.test.ts +53 -0
  50. package/src/commands/backend.ts +57 -18
  51. package/src/commands/deploy.ts +31 -17
  52. package/src/index.ts +13 -5
  53. package/src/util/broker/app.ts +59 -50
@@ -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 { invokeBackend } from '../backend/client';
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
- * Returns `[]` when there is no `backend/handlers/` dir (or it is empty of sources)
47
- * so callers can always pass the result to `registerOrUpdateApp` without a separate gate.
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<string[]> {
61
+ ): Promise<ReadDetectAndUploadResult> {
57
62
  if (!hasHandlers(appDir)) {
58
- return [];
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<string[]> {
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
- export async function backendInvokeCommand(handlerName: string, opts: { input?: string } = {}): Promise<void> {
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 res = await invokeBackend(appName, handlerName, input);
140
- output(`[z2h-cli] result (${Math.round(res.durationMs)}ms):\n${JSON.stringify(res.result, null, 2)}`, res);
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
  }
@@ -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
- info(`uploading ${isPreview ? 'preview ' : ''}build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
135
- await uploadDir(paths.shadowBuild, keyPrefix);
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
- await registerOrUpdateApp({
150
- appName,
151
- token,
152
- nextVersion,
153
- entry,
154
- gitRemote,
155
- integrations,
156
- description,
157
- masterAppName,
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 invoke a deployed handler by name");
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
- 'Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend'
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
- process.exit(0);
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);
@@ -64,21 +64,52 @@ export async function claimApp(appName: string, gitRemote: string): Promise<void
64
64
  }
65
65
  }
66
66
 
67
- async function registerApp(
68
- appName: string,
69
- token: string,
70
- version: number,
71
- entry: ManifestEntry,
72
- visibility: 'public' | 'private',
73
- gitRemote: string,
74
- integrations?: string[],
75
- description?: string,
76
- masterAppName?: string
77
- ): Promise<void> {
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({ appName, version, entry, visibility, gitRemote, integrations, description, masterAppName }),
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
- appName: string,
92
- token: string,
93
- version: number,
94
- entry: ManifestEntry,
95
- gitRemote: string,
96
- integrations?: string[],
97
- description?: string
98
- ): Promise<void> {
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, token, nextVersion, entry, gitRemote, integrations, description, masterAppName } = opts;
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(appName, token, nextVersion, entry, gitRemote, integrations, description);
160
+ await updateApp(opts);
152
161
  }
153
162
  } catch (err) {
154
163
  // registerApp/updateApp already tag the non-ok-response case; this catches anything