@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 { readFile, readdir } from 'node:fs/promises';
3
4
  import { readJson, writeJson } from 'fs-extra';
4
5
  import { PutObjectCommand } from '@aws-sdk/client-s3';
5
6
  import { output, info } from '../util/logger.mjs';
@@ -9,7 +10,9 @@ import { readHandlers } from '../backend/handlers.mjs';
9
10
  import { detectIntegrations } from '../backend/detect-integrations.mjs';
10
11
  import { getS3Client } from '../util/s3/client.mjs';
11
12
  import { BUCKET_WRITE_NAME } from '../constants.mjs';
12
- import { invokeBackend } from '../backend/client.mjs';
13
+ import { runSandbox } from '../backend/client.mjs';
14
+ import { formatSandboxRun } from '../backend/format-sandbox-run.mjs';
15
+ import { validateHandlerSource } from '../backend/validate.mjs';
13
16
  import { tagDeployError } from '../util/errors.mjs';
14
17
 
15
18
  function currentAppName() {
@@ -39,21 +42,19 @@ async function writeUsageJson(appDir, patch) {
39
42
  const existing = await readExistingUsage(appDir);
40
43
  await writeJson(path.join(appDir, 'usage.json'), { ...existing, ...patch }, { spaces: 2 });
41
44
  }
42
- /**
43
- * Returns `[]` when there is no `backend/handlers/` dir (or it is empty of sources)
44
- * so callers can always pass the result to `registerOrUpdateApp` without a separate gate.
45
- */
46
45
  async function readDetectAndUpload(appDir, prefix, appName, version, deployedBy, logLabel) {
47
46
  if (!hasHandlers(appDir)) {
48
- return [];
47
+ await writeUsageJson(appDir, { appName, deployedBy, apiUsages: {} });
48
+ return { integrations: [], handlerNames: [], apiUsages: {} };
49
49
  }
50
50
  info(`reading backend handlers ${logLabel}`);
51
51
  const { handlers, apiDomains, apiUsages } = await readHandlers(appDir);
52
52
  const integrations = detectIntegrations(apiDomains);
53
+ const handlerNames = Object.keys(handlers);
54
+ await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
53
55
  if (Object.keys(handlers).length === 0) {
54
- return integrations;
56
+ return { integrations, handlerNames, apiUsages };
55
57
  }
56
- await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
57
58
  const s3 = getS3Client();
58
59
  const meta = JSON.stringify({ version, deployedBy, handlers: Object.keys(handlers) });
59
60
  const put = (key, body, contentType) => s3.send(new PutObjectCommand({ Bucket: BUCKET_WRITE_NAME, Key: key, Body: body, ContentType: contentType }));
@@ -61,13 +62,9 @@ async function readDetectAndUpload(appDir, prefix, appName, version, deployedBy,
61
62
  ...Object.entries(handlers).map(([name, code]) => put(`${prefix}/${name}.js`, code, 'application/javascript')),
62
63
  put(`${prefix}/meta.json`, meta, 'application/json'),
63
64
  ]);
64
- return integrations;
65
+ return { integrations, handlerNames, apiUsages };
65
66
  }
66
67
  /**
67
- * Deploy the app's backend handlers to S3 and return the integrations detected
68
- * from their sources. Each `.js` handler is uploaded as its own flat file — no
69
- * compilation, no bundling, no npm deps.
70
- *
71
68
  * Integration sync note: the returned set is re-derived from the CURRENT backend
72
69
  * sources on every deploy, and the platform set is reconciled to exactly match
73
70
  * it (there is no stored declaration to fall back on). A deploy therefore always
@@ -105,7 +102,29 @@ async function backendValidateCommand(appDir = process.cwd()) {
105
102
  integrations,
106
103
  });
107
104
  }
108
- async function backendInvokeCommand(handlerName, opts = {}) {
105
+ async function readHandlerSource(appDir, handlerName) {
106
+ const dir = path.join(appDir, 'backend', 'handlers');
107
+ try {
108
+ return await readFile(path.join(dir, `${handlerName}.js`), 'utf8');
109
+ }
110
+ catch (err) {
111
+ if (err.code !== 'ENOENT') {
112
+ throw err;
113
+ }
114
+ const files = hasHandlers(appDir) ? await readdir(dir) : [];
115
+ const available = files
116
+ .filter(f => f.endsWith('.js'))
117
+ .map(f => path.parse(f).name)
118
+ .sort();
119
+ throw new Error(`no handler "${handlerName}" in backend/handlers/` +
120
+ (available.length ? ` (available: ${available.join(', ')})` : ' (no handlers found)'));
121
+ }
122
+ }
123
+ /**
124
+ * Runs a handler from the working tree (or `--code`) on the runner's sandbox and
125
+ * prints the trace. Validated locally first, like deploy; exits 1 when the handler fails.
126
+ */
127
+ async function backendInvokeCommand(handlerName, opts = {}, appDir = process.cwd()) {
109
128
  const appName = currentAppName();
110
129
  let input = null;
111
130
  if (opts.input) {
@@ -116,8 +135,15 @@ async function backendInvokeCommand(handlerName, opts = {}) {
116
135
  throw new Error(`--input must be valid JSON (got: ${opts.input})`);
117
136
  }
118
137
  }
119
- const res = await invokeBackend(appName, handlerName, input);
120
- output(`[z2h-cli] result (${Math.round(res.durationMs)}ms):\n${JSON.stringify(res.result, null, 2)}`, res);
138
+ const file = `${handlerName}.js`;
139
+ const code = opts.code ?? (await readHandlerSource(appDir, handlerName));
140
+ validateHandlerSource(file, code);
141
+ info(`validated ${opts.code ? '--code' : file}`);
142
+ const res = await runSandbox(appName, { code, input, handlerName });
143
+ output(formatSandboxRun(res), res);
144
+ if (!res.ok) {
145
+ process.exitCode = 1;
146
+ }
121
147
  }
122
148
 
123
149
  export { backendInvokeCommand, backendValidateCommand, deployBackend, hasHandlers };
@@ -79,12 +79,9 @@ async function buildAndStamp(opts) {
79
79
  */
80
80
  async function uploadAndRegister(opts) {
81
81
  const { paths, keyPrefix, appName, token, nextVersion, entry, gitRemote, isPreview, description, masterAppName } = opts;
82
- info(`uploading ${isPreview ? 'preview ' : ''}build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
83
- await uploadDir(paths.shadowBuild, keyPrefix);
84
- // `integrations` is inferred from the backend sources in the working tree and
85
- // reconciled to exactly match on register — see `deployBackend` for why this
86
- // depends on deploying from the app's latest merged state.
87
- const integrations = await deployBackend({
82
+ // Runs before the frontend upload so a `deployBackend` failure can't leave a
83
+ // preview's unversioned `MF` dir overwritten with no rollback.
84
+ const { integrations, handlerNames, apiUsages } = await deployBackend({
88
85
  appDir: process.cwd(),
89
86
  appName,
90
87
  mfKeyPrefix: keyPrefix,
@@ -92,16 +89,32 @@ async function uploadAndRegister(opts) {
92
89
  deployedBy: whoAmI(),
93
90
  isPreview,
94
91
  });
95
- await registerOrUpdateApp({
96
- appName,
97
- token,
98
- nextVersion,
99
- entry,
100
- gitRemote,
101
- integrations,
102
- description,
103
- masterAppName,
104
- });
92
+ try {
93
+ info(`uploading ${isPreview ? 'preview ' : ''}build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
94
+ await uploadDir(paths.shadowBuild, keyPrefix);
95
+ await registerOrUpdateApp({
96
+ appName,
97
+ token,
98
+ nextVersion,
99
+ entry,
100
+ gitRemote,
101
+ integrations,
102
+ handlerNames,
103
+ apiUsages,
104
+ description,
105
+ masterAppName,
106
+ });
107
+ }
108
+ catch (err) {
109
+ // A live deploy is safe either way — RDS's version gates what's actually served, so
110
+ // an orphaned S3 object is harmless — but a preview is served straight from its
111
+ // unversioned prefix with no such gate, so only it needs the warning below.
112
+ if (isPreview) {
113
+ warn(`preview backend was already deployed before this failed — re-run "z2h-cli deploy --preview" to retry ` +
114
+ `(every step overwrites, so a retry fully repairs it)`);
115
+ }
116
+ throw err;
117
+ }
105
118
  }
106
119
  async function deployPreview({ paths, consumerName, appName, token, opts }) {
107
120
  // A preview inherits from the live app it previews, resolved by name server-side —
@@ -180,15 +180,18 @@ async function main() {
180
180
  .action(runCommand({ data: cwdApp }, (opts) => deleteCommand(opts)));
181
181
  const backend = program
182
182
  .command('backend')
183
- .description("Inspect the app's backend: validate handler sources or invoke a deployed handler by name");
183
+ .description("Inspect the app's backend: validate handler sources or run a handler from the working tree");
184
184
  backend
185
185
  .command('validate')
186
186
  .description('Validate backend/handlers/*.js exactly as deploy would (syntax, isolate rules, integrations) without uploading')
187
187
  .action(runCommand({ data: cwdApp }, () => backendValidateCommand()));
188
188
  backend
189
189
  .command('invoke <handlerName>')
190
- .description('Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend')
191
- .option('--input <json>', 'JSON payload passed as ctx.input')
190
+ .description("Run backend/handlers/<handlerName>.js from the working tree — not the deployed handler — against the app's real " +
191
+ 'channels (real db data, real monday calls), and print a trace of every channel call and the ' +
192
+ 'result. Nothing is deployed. Exits 1 when the handler fails.')
193
+ .option('--input <json>', 'JSON passed to the handler as ctx.input (what the frontend would send)')
194
+ .option('--code <source>', 'run this source instead of backend/handlers/<handlerName>.js — <handlerName> is then just a label')
192
195
  .action(runCommand({
193
196
  data: ([handlerName]) => ({ app_name: getCwdAppName(), handler: handlerName }),
194
197
  }, (handlerName, opts) => backendInvokeCommand(handlerName, opts)));
@@ -215,7 +218,9 @@ void main()
215
218
  if (getOutputMode() === 'human') {
216
219
  selfUpdate();
217
220
  }
218
- process.exit(0);
221
+ // A command can finish normally and still report a non-zero code (e.g.
222
+ // `backend invoke` when the handler failed) via process.exitCode.
223
+ process.exit(process.exitCode ?? 0);
219
224
  })
220
225
  .catch(async (err) => {
221
226
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -29,6 +29,8 @@ export interface RegisterOrUpdateOptions {
29
29
  entry: ManifestEntry;
30
30
  gitRemote: string;
31
31
  integrations?: string[];
32
+ handlerNames?: string[];
33
+ apiUsages?: Record<string, unknown>;
32
34
  description?: string;
33
35
  /**
34
36
  * The base app a preview is for, sent only on first registration — the link
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AA6CD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BtF"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AAED,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AA8DD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBtF"}
@@ -47,11 +47,23 @@ async function claimApp(appName, gitRemote) {
47
47
  throw new Error(`Claiming app name failed (${res.status}): ${text.slice(0, 1024)}`);
48
48
  }
49
49
  }
50
- async function registerApp(appName, token, version, entry, visibility, gitRemote, integrations, description, masterAppName) {
50
+ async function registerApp(params) {
51
+ const { appName, token, nextVersion: version, entry, visibility, gitRemote, integrations, handlerNames, apiUsages, description, masterAppName, } = params;
51
52
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/register`, {
52
53
  method: 'POST',
53
54
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
54
- body: JSON.stringify({ appName, version, entry, visibility, gitRemote, integrations, description, masterAppName }),
55
+ body: JSON.stringify({
56
+ appName,
57
+ version,
58
+ entry,
59
+ visibility,
60
+ gitRemote,
61
+ integrations,
62
+ handlerNames,
63
+ apiUsages,
64
+ description,
65
+ masterAppName,
66
+ }),
55
67
  });
56
68
  if (!res.ok) {
57
69
  const text = await res.text();
@@ -59,11 +71,12 @@ async function registerApp(appName, token, version, entry, visibility, gitRemote
59
71
  }
60
72
  }
61
73
  /** Releases a new version onto an app that already exists. */
62
- async function updateApp(appName, token, version, entry, gitRemote, integrations, description) {
74
+ async function updateApp(params) {
75
+ const { appName, token, nextVersion: version, entry, gitRemote, integrations, handlerNames, apiUsages, description, } = params;
63
76
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/update`, {
64
77
  method: 'POST',
65
78
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
66
- body: JSON.stringify({ appName, version, entry, gitRemote, integrations, description }),
79
+ body: JSON.stringify({ appName, version, entry, gitRemote, integrations, handlerNames, apiUsages, description }),
67
80
  });
68
81
  if (!res.ok) {
69
82
  const text = await res.text();
@@ -77,16 +90,16 @@ async function updateApp(appName, token, version, entry, gitRemote, integrations
77
90
  * deploy and preview so this branching logic lives in exactly one place.
78
91
  */
79
92
  async function registerOrUpdateApp(opts) {
80
- const { appName, token, nextVersion, entry, gitRemote, integrations, description, masterAppName } = opts;
93
+ const { appName, nextVersion } = opts;
81
94
  try {
82
95
  if (nextVersion === 1) {
83
96
  const visibility = await resolveAppVisibility(appName);
84
97
  info(`registering "${appName}" → v${nextVersion} (${visibility})`);
85
- await registerApp(appName, token, nextVersion, entry, visibility, gitRemote, integrations, description, masterAppName);
98
+ await registerApp({ ...opts, visibility });
86
99
  }
87
100
  else {
88
101
  info(`updating "${appName}" → v${nextVersion}`);
89
- await updateApp(appName, token, nextVersion, entry, gitRemote, integrations, description);
102
+ await updateApp(opts);
90
103
  }
91
104
  }
92
105
  catch (err) {
package/dist/index.js CHANGED
@@ -186,15 +186,18 @@ async function main() {
186
186
  .action(util_runCommand.runCommand({ data: cwdApp }, (opts) => commands_delete.deleteCommand(opts)));
187
187
  const backend = program
188
188
  .command('backend')
189
- .description("Inspect the app's backend: validate handler sources or invoke a deployed handler by name");
189
+ .description("Inspect the app's backend: validate handler sources or run a handler from the working tree");
190
190
  backend
191
191
  .command('validate')
192
192
  .description('Validate backend/handlers/*.js exactly as deploy would (syntax, isolate rules, integrations) without uploading')
193
193
  .action(util_runCommand.runCommand({ data: cwdApp }, () => commands_backend.backendValidateCommand()));
194
194
  backend
195
195
  .command('invoke <handlerName>')
196
- .description('Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend')
197
- .option('--input <json>', 'JSON payload passed as ctx.input')
196
+ .description("Run backend/handlers/<handlerName>.js from the working tree — not the deployed handler — against the app's real " +
197
+ 'channels (real db data, real monday calls), and print a trace of every channel call and the ' +
198
+ 'result. Nothing is deployed. Exits 1 when the handler fails.')
199
+ .option('--input <json>', 'JSON passed to the handler as ctx.input (what the frontend would send)')
200
+ .option('--code <source>', 'run this source instead of backend/handlers/<handlerName>.js — <handlerName> is then just a label')
198
201
  .action(util_runCommand.runCommand({
199
202
  data: ([handlerName]) => ({ app_name: util_appName.getCwdAppName(), handler: handlerName }),
200
203
  }, (handlerName, opts) => commands_backend.backendInvokeCommand(handlerName, opts)));
@@ -221,7 +224,9 @@ void main()
221
224
  if (util_logger.getOutputMode() === 'human') {
222
225
  selfUpdate();
223
226
  }
224
- process.exit(0);
227
+ // A command can finish normally and still report a non-zero code (e.g.
228
+ // `backend invoke` when the handler failed) via process.exitCode.
229
+ process.exit(process.exitCode ?? 0);
225
230
  })
226
231
  .catch(async (err) => {
227
232
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -29,6 +29,8 @@ export interface RegisterOrUpdateOptions {
29
29
  entry: ManifestEntry;
30
30
  gitRemote: string;
31
31
  integrations?: string[];
32
+ handlerNames?: string[];
33
+ apiUsages?: Record<string, unknown>;
32
34
  description?: string;
33
35
  /**
34
36
  * The base app a preview is for, sent only on first registration — the link
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AA6CD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BtF"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AAED,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AA8DD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBtF"}
@@ -49,11 +49,23 @@ async function claimApp(appName, gitRemote) {
49
49
  throw new Error(`Claiming app name failed (${res.status}): ${text.slice(0, 1024)}`);
50
50
  }
51
51
  }
52
- async function registerApp(appName, token, version, entry, visibility, gitRemote, integrations, description, masterAppName) {
52
+ async function registerApp(params) {
53
+ const { appName, token, nextVersion: version, entry, visibility, gitRemote, integrations, handlerNames, apiUsages, description, masterAppName, } = params;
53
54
  const res = await fetch(`${constants.brokerBaseUrl()}/z2h-cli/apps/register`, {
54
55
  method: 'POST',
55
56
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...constants.routingHeaders() },
56
- body: JSON.stringify({ appName, version, entry, visibility, gitRemote, integrations, description, masterAppName }),
57
+ body: JSON.stringify({
58
+ appName,
59
+ version,
60
+ entry,
61
+ visibility,
62
+ gitRemote,
63
+ integrations,
64
+ handlerNames,
65
+ apiUsages,
66
+ description,
67
+ masterAppName,
68
+ }),
57
69
  });
58
70
  if (!res.ok) {
59
71
  const text = await res.text();
@@ -61,11 +73,12 @@ async function registerApp(appName, token, version, entry, visibility, gitRemote
61
73
  }
62
74
  }
63
75
  /** Releases a new version onto an app that already exists. */
64
- async function updateApp(appName, token, version, entry, gitRemote, integrations, description) {
76
+ async function updateApp(params) {
77
+ const { appName, token, nextVersion: version, entry, gitRemote, integrations, handlerNames, apiUsages, description, } = params;
65
78
  const res = await fetch(`${constants.brokerBaseUrl()}/z2h-cli/apps/update`, {
66
79
  method: 'POST',
67
80
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...constants.routingHeaders() },
68
- body: JSON.stringify({ appName, version, entry, gitRemote, integrations, description }),
81
+ body: JSON.stringify({ appName, version, entry, gitRemote, integrations, handlerNames, apiUsages, description }),
69
82
  });
70
83
  if (!res.ok) {
71
84
  const text = await res.text();
@@ -79,16 +92,16 @@ async function updateApp(appName, token, version, entry, gitRemote, integrations
79
92
  * deploy and preview so this branching logic lives in exactly one place.
80
93
  */
81
94
  async function registerOrUpdateApp(opts) {
82
- const { appName, token, nextVersion, entry, gitRemote, integrations, description, masterAppName } = opts;
95
+ const { appName, nextVersion } = opts;
83
96
  try {
84
97
  if (nextVersion === 1) {
85
98
  const visibility = await util_promptVisibility.resolveAppVisibility(appName);
86
99
  util_logger.info(`registering "${appName}" → v${nextVersion} (${visibility})`);
87
- await registerApp(appName, token, nextVersion, entry, visibility, gitRemote, integrations, description, masterAppName);
100
+ await registerApp({ ...opts, visibility });
88
101
  }
89
102
  else {
90
103
  util_logger.info(`updating "${appName}" → v${nextVersion}`);
91
- await updateApp(appName, token, nextVersion, entry, gitRemote, integrations, description);
104
+ await updateApp(opts);
92
105
  }
93
106
  }
94
107
  catch (err) {
@@ -54,3 +54,15 @@ export class ChannelError extends BackendRunnerError {
54
54
  this.name = 'ChannelError';
55
55
  }
56
56
  }
57
+
58
+ /**
59
+ * A sandbox run request the runner refuses before any code runs — the code is
60
+ * unusable or the app doesn't exist. Carries a `code` so
61
+ * the CLI can say exactly which, the same way a channel error does.
62
+ */
63
+ export class SandboxRequestError extends BackendRunnerError {
64
+ constructor(statusCode: number, message: string, public readonly code: string) {
65
+ super(statusCode, message);
66
+ this.name = 'SandboxRequestError';
67
+ }
68
+ }
@@ -34,6 +34,9 @@ export type ChannelHandler = (args: unknown[]) => Promise<unknown> | unknown;
34
34
  */
35
35
  export type ChannelMap = Record<string, ChannelHandler>;
36
36
 
37
+ /** Which path built the channels — a deployed handler (`invoke`) or source sent to the sandbox. Tags channel telemetry. */
38
+ export type RunMode = 'invoke' | 'sandbox';
39
+
37
40
  /**
38
41
  * A breakdown of where the isolate wall-clock went, in ms. Lets us see whether a
39
42
  * slow invoke is the V8 setup (create + compile) or the guest's own work + its
@@ -6,13 +6,19 @@ import { makeDbChannel } from './db-channel/db.channel';
6
6
  import { makeLlmChannel } from './llm-channel/llm.channel';
7
7
  import { makeMondayChannel } from './monday-channel/monday.channel';
8
8
  import { makeSnowflakeChannel } from './snowflake-channel/snowflake.channel';
9
- import type { Caller, ChannelMap } from '../backend-runner.types';
9
+ import type { Caller, ChannelMap, RunMode } from '../backend-runner.types';
10
10
  import type { RunnerCustomIntegration } from '../runner-data.store';
11
11
 
12
12
  const OAUTH_CHANNELS: Record<string, (caller: Caller) => ChannelMap> = {
13
13
  monday: (caller) => makeMondayChannel(caller),
14
14
  };
15
15
 
16
+ /**
17
+ * Every integration with a registered channel. Sandbox runs build all of them —
18
+ * safe because each fetches the caller's token only when a method is called.
19
+ */
20
+ export const OAUTH_INTEGRATIONS = Object.keys(OAUTH_CHANNELS);
21
+
16
22
  /**
17
23
  * Builds the complete channel surface for one invocation — the only way out of
18
24
  * the isolate.
@@ -63,7 +69,7 @@ const OAUTH_CHANNELS: Record<string, (caller: Caller) => ChannelMap> = {
63
69
  * setup) is wrong and retrying won't help; `502` means the upstream failed.
64
70
  *
65
71
  * Each key emits a unified call counter, duration distribution, and BI event tagged
66
- * by app, channel, method, and outcome. Uncaught failures also reach BI at the
72
+ * by app, mode (`invoke` or `sandbox`), channel, method, and outcome. Uncaught failures also reach BI at the
67
73
  * invocation level — see `runner.service.ts`.
68
74
  */
69
75
  export function buildChannels(
@@ -71,6 +77,7 @@ export function buildChannels(
71
77
  caller: Caller,
72
78
  declaredIntegrations: string[],
73
79
  customIntegrations: RunnerCustomIntegration[],
80
+ mode: RunMode,
74
81
  ): ChannelMap {
75
82
  const channels: ChannelMap = {
76
83
  ...makeDbChannel(appName),
@@ -91,5 +98,7 @@ export function buildChannels(
91
98
  }
92
99
  }
93
100
 
94
- return Object.fromEntries(Object.entries(channels).map(([key, fn]) => [key, withChannelTelemetry(appName, key, fn)]));
101
+ return Object.fromEntries(
102
+ Object.entries(channels).map(([key, fn]) => [key, withChannelTelemetry(appName, mode, key, fn)]),
103
+ );
95
104
  }
@@ -4,7 +4,7 @@ import { trackEvent } from '@mondaydotcomorg/z2h-shared-utils/observability/trid
4
4
 
5
5
  import { ChannelError } from '../backend-runner.errors';
6
6
  import { BI_SOURCE } from '../constants';
7
- import type { ChannelHandler } from '../backend-runner.types';
7
+ import type { ChannelHandler, RunMode } from '../backend-runner.types';
8
8
 
9
9
  type ChannelOutcome = 'success' | 'failure';
10
10
 
@@ -26,6 +26,7 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
26
26
 
27
27
  function recordChannelCall(
28
28
  appName: string,
29
+ mode: RunMode,
29
30
  channel: string,
30
31
  method: string,
31
32
  outcome: ChannelOutcome,
@@ -33,7 +34,7 @@ function recordChannelCall(
33
34
  error?: unknown,
34
35
  ): void {
35
36
  const durationMs = performance.now() - startedAt;
36
- const dimensions = { appName, channel, method };
37
+ const dimensions = { appName, mode, channel, method };
37
38
 
38
39
  metric.increment(BackendRunnerEvents.channel.callMetric, { ...dimensions, outcome });
39
40
  metric.distribution(BackendRunnerEvents.channel.durationMetric, durationMs, dimensions);
@@ -49,7 +50,12 @@ function recordChannelCall(
49
50
  );
50
51
  }
51
52
 
52
- export function withChannelTelemetry(appName: string, key: string, handler: ChannelHandler): ChannelHandler {
53
+ export function withChannelTelemetry(
54
+ appName: string,
55
+ mode: RunMode,
56
+ key: string,
57
+ handler: ChannelHandler,
58
+ ): ChannelHandler {
53
59
  const { channel, method } = parseChannelKey(key);
54
60
 
55
61
  return (args) => {
@@ -59,20 +65,20 @@ export function withChannelTelemetry(appName: string, key: string, handler: Chan
59
65
  if (isPromiseLike(result)) {
60
66
  return Promise.resolve(result).then(
61
67
  (value) => {
62
- recordChannelCall(appName, channel, method, 'success', startedAt);
68
+ recordChannelCall(appName, mode, channel, method, 'success', startedAt);
63
69
  return value;
64
70
  },
65
71
  (error: unknown) => {
66
- recordChannelCall(appName, channel, method, 'failure', startedAt, error);
72
+ recordChannelCall(appName, mode, channel, method, 'failure', startedAt, error);
67
73
  throw error;
68
74
  },
69
75
  );
70
76
  }
71
77
 
72
- recordChannelCall(appName, channel, method, 'success', startedAt);
78
+ recordChannelCall(appName, mode, channel, method, 'success', startedAt);
73
79
  return result;
74
80
  } catch (error) {
75
- recordChannelCall(appName, channel, method, 'failure', startedAt, error);
81
+ recordChannelCall(appName, mode, channel, method, 'failure', startedAt, error);
76
82
  throw error;
77
83
  }
78
84
  };
@@ -0,0 +1,52 @@
1
+ import { ChannelError } from '../backend-runner.errors';
2
+ import type { ChannelHandler, ChannelMap } from '../backend-runner.types';
3
+
4
+ /**
5
+ * One channel call as the sandbox reports it back to the author. Raw args are
6
+ * recorded — for `snowflake.query` that is the SQL text and its params, not the
7
+ * bound SQL the channel builds from them.
8
+ */
9
+ export interface ChannelCallTrace {
10
+ method: string;
11
+ args: unknown[];
12
+ durationMs: number;
13
+ ok: boolean;
14
+ /** Why the call failed — same shape as the run's `error`. A non-`ChannelError` is a 500 with no code. */
15
+ error?: { status: number; code?: string; message: string };
16
+ /** What the channel returned to the handler, as-is — e.g. Snowflake rows or monday's GraphQL `data`. */
17
+ result?: unknown;
18
+ }
19
+
20
+ type CallOutcome = Pick<ChannelCallTrace, 'ok' | 'error' | 'result'>;
21
+
22
+ function withChannelTrace(calls: ChannelCallTrace[], method: string, handler: ChannelHandler): ChannelHandler {
23
+ return async (args) => {
24
+ const startedAt = performance.now();
25
+ const record = (outcome: CallOutcome): void => {
26
+ calls.push({ method, args, durationMs: performance.now() - startedAt, ...outcome });
27
+ };
28
+
29
+ try {
30
+ const result = await handler(args);
31
+ record({ ok: true, result });
32
+ return result;
33
+ } catch (err) {
34
+ record({
35
+ ok: false,
36
+ error:
37
+ err instanceof ChannelError
38
+ ? { status: err.statusCode, code: err.code, message: err.message }
39
+ : { status: 500, message: err instanceof Error ? err.message : String(err) },
40
+ });
41
+ throw err;
42
+ }
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Wraps every channel so each call is appended to `calls` in the order it
48
+ * finished. Sandbox-only: the deployed invoke path never records a trace.
49
+ */
50
+ export function traceChannels(channels: ChannelMap, calls: ChannelCallTrace[]): ChannelMap {
51
+ return Object.fromEntries(Object.entries(channels).map(([key, fn]) => [key, withChannelTrace(calls, key, fn)]));
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.33.0",
3
+ "version": "0.33.2",
4
4
  "bin": "./bin/z2h-cli.js",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -52,7 +52,7 @@
52
52
  "dependencies": {
53
53
  "@aws-sdk/client-s3": "^3.706.0",
54
54
  "@mondaydotcomorg/z2h-dashboard-templates": "^0.4.0",
55
- "@mondaydotcomorg/z2h-shared-utils": "^0.7.0",
55
+ "@mondaydotcomorg/z2h-shared-utils": "^0.7.1",
56
56
  "acorn": "^8.14.0",
57
57
  "acorn-walk": "^8.3.4",
58
58
  "chokidar": "^3.6.0",