@mondaydotcomorg/z2h-cli 0.32.2 → 0.33.1

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 (67) 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/__tests__/custom-integrations.test.d.ts +2 -0
  12. package/dist/commands/__tests__/custom-integrations.test.d.ts.map +1 -0
  13. package/dist/commands/backend.d.ts +6 -1
  14. package/dist/commands/backend.d.ts.map +1 -1
  15. package/dist/commands/backend.js +35 -3
  16. package/dist/commands/custom-integrations.d.ts +3 -0
  17. package/dist/commands/custom-integrations.d.ts.map +1 -0
  18. package/dist/commands/custom-integrations.js +51 -0
  19. package/dist/commands/deploy.js +3 -0
  20. package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts +2 -0
  21. package/dist/esm/backend/__tests__/format-sandbox-run.test.d.ts.map +1 -0
  22. package/dist/esm/backend/client.d.ts +32 -2
  23. package/dist/esm/backend/client.d.ts.map +1 -1
  24. package/dist/esm/backend/client.mjs +8 -5
  25. package/dist/esm/backend/format-sandbox-run.d.ts +4 -0
  26. package/dist/esm/backend/format-sandbox-run.d.ts.map +1 -0
  27. package/dist/esm/backend/format-sandbox-run.mjs +47 -0
  28. package/dist/esm/commands/__tests__/backend-invoke.test.d.ts +2 -0
  29. package/dist/esm/commands/__tests__/backend-invoke.test.d.ts.map +1 -0
  30. package/dist/esm/commands/__tests__/custom-integrations.test.d.ts +2 -0
  31. package/dist/esm/commands/__tests__/custom-integrations.test.d.ts.map +1 -0
  32. package/dist/esm/commands/backend.d.ts +6 -1
  33. package/dist/esm/commands/backend.d.ts.map +1 -1
  34. package/dist/esm/commands/backend.mjs +36 -4
  35. package/dist/esm/commands/custom-integrations.d.ts +3 -0
  36. package/dist/esm/commands/custom-integrations.d.ts.map +1 -0
  37. package/dist/esm/commands/custom-integrations.mjs +48 -0
  38. package/dist/esm/commands/deploy.mjs +3 -0
  39. package/dist/esm/index.mjs +18 -4
  40. package/dist/esm/util/app-name.mjs +1 -1
  41. package/dist/esm/util/broker/app.d.ts +4 -1
  42. package/dist/esm/util/broker/app.d.ts.map +1 -1
  43. package/dist/index.js +18 -4
  44. package/dist/util/app-name.js +1 -1
  45. package/dist/util/broker/app.d.ts +4 -1
  46. package/dist/util/broker/app.d.ts.map +1 -1
  47. package/handler-api/backend-runner/backend-runner.errors.ts +13 -0
  48. package/handler-api/backend-runner/backend-runner.types.ts +3 -0
  49. package/handler-api/backend-runner/channels/channel-map.ts +26 -6
  50. package/handler-api/backend-runner/channels/channel-telemetry.ts +13 -7
  51. package/handler-api/backend-runner/channels/channel-trace.ts +52 -0
  52. package/handler-api/backend-runner/channels/custom-integration-channel/custom-integration.channel.ts +243 -0
  53. package/handler-api/signatures.json +4 -0
  54. package/package.json +2 -2
  55. package/src/backend/__tests__/client.test.ts +17 -13
  56. package/src/backend/__tests__/format-sandbox-run.test.ts +42 -0
  57. package/src/backend/client.ts +27 -10
  58. package/src/backend/format-sandbox-run.ts +51 -0
  59. package/src/commands/__tests__/backend-invoke.test.ts +125 -0
  60. package/src/commands/__tests__/custom-integrations.test.ts +62 -0
  61. package/src/commands/backend.ts +43 -4
  62. package/src/commands/custom-integrations.ts +68 -0
  63. package/src/commands/deploy.ts +3 -0
  64. package/src/index.ts +24 -5
  65. package/src/util/app-name.ts +1 -1
  66. package/src/util/broker/__tests__/app.test.ts +32 -0
  67. package/src/util/broker/app.ts +4 -1
@@ -1,4 +1,8 @@
1
1
  {
2
+ "customIntegration.request": [
3
+ "name",
4
+ "options"
5
+ ],
2
6
  "db.delete": [
3
7
  "key"
4
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.32.2",
3
+ "version": "0.33.1",
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.6.2",
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",
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
2
2
 
3
- import { backendBaseUrl, declareIntegration, invokeBackend, listAppIntegrations, removeIntegration } from '../client';
3
+ import { backendBaseUrl, declareIntegration, listAppIntegrations, removeIntegration, runSandbox } from '../client';
4
4
 
5
5
  vi.mock('../../util/auth/auth-store', () => ({
6
6
  readZthAuth: vi.fn().mockResolvedValue({ token: 'jwt-token' }),
@@ -21,6 +21,8 @@ const respond = (init: { ok?: boolean; status?: number; statusText?: string; bod
21
21
 
22
22
  const lastCall = () => (fetch as Mock).mock.calls[0];
23
23
 
24
+ const SANDBOX_BODY = { code: 'function handler() {}', input: null };
25
+
24
26
  beforeEach(() => {
25
27
  vi.stubGlobal('fetch', respond({ body: '{"result":1,"durationMs":5}' }));
26
28
  });
@@ -42,13 +44,15 @@ describe('backendBaseUrl', () => {
42
44
  });
43
45
 
44
46
  describe('where each call goes', () => {
45
- it('sends an invoke to the runner, authenticated', async () => {
46
- await invokeBackend('mf-reports', 'get_items', { n: 1 });
47
+ it('sends a sandbox run to the runner, authenticated, with the code in the body', async () => {
48
+ const body = { code: 'function handler() {}', input: { n: 1 }, handlerName: 'get_items' };
49
+ await runSandbox('mf-reports', body);
47
50
 
48
51
  const [url, opts] = lastCall();
49
- expect(url).toBe('https://zth-backend-runner.bigbrain.me/z2h-apps/mf-reports/invoke/get_items');
52
+ expect(url).toBe('https://zth-backend-runner.bigbrain.me/z2h-apps/mf-reports/sandbox/run');
53
+ expect(opts.method).toBe('POST');
50
54
  expect(opts.headers).toMatchObject({ Authorization: 'Bearer jwt-token', 'x-route': 'prod' });
51
- expect(JSON.parse(opts.body)).toEqual({ input: { n: 1 } });
55
+ expect(JSON.parse(opts.body)).toEqual(body);
52
56
  });
53
57
 
54
58
  // Integrations live in the platform's registry, which the broker owns — not the runner.
@@ -72,9 +76,9 @@ describe('where each call goes', () => {
72
76
  });
73
77
 
74
78
  it('escapes an app name rather than letting it alter the path', async () => {
75
- await invokeBackend('mf-a/../b', 'get_items', null);
79
+ await runSandbox('mf-a/../b', SANDBOX_BODY);
76
80
 
77
- expect(lastCall()[0]).toContain('/z2h-apps/mf-a%2F..%2Fb/invoke/get_items');
81
+ expect(lastCall()[0]).toContain('/z2h-apps/mf-a%2F..%2Fb/sandbox/run');
78
82
  });
79
83
  });
80
84
 
@@ -85,27 +89,27 @@ describe('where each call goes', () => {
85
89
  */
86
90
  describe('what the author is told when it fails', () => {
87
91
  it('uses the server’s own message when there is one', async () => {
88
- vi.stubGlobal('fetch', respond({ ok: false, status: 404, body: '{"message":"No backend deployed for \\"x\\""}' }));
92
+ vi.stubGlobal('fetch', respond({ ok: false, status: 404, body: '{"message":"app \\"x\\" is not registered"}' }));
89
93
 
90
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow('No backend deployed for "x"');
94
+ await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow('app "x" is not registered');
91
95
  });
92
96
 
93
97
  it('falls back to the status plus a snippet when the body is HTML', async () => {
94
98
  vi.stubGlobal('fetch', respond({ ok: false, status: 502, statusText: 'Bad Gateway', body: '<html>nginx</html>' }));
95
99
 
96
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(/502 Bad Gateway.*nginx/s);
100
+ await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(/502 Bad Gateway.*nginx/s);
97
101
  });
98
102
 
99
103
  it('collapses whitespace in that snippet so the message stays one line', async () => {
100
104
  vi.stubGlobal('fetch', respond({ ok: false, status: 500, body: '<html>\n a\n\n b\n</html>' }));
101
105
 
102
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(/<html> a b <\/html>/);
106
+ await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(/<html> a b <\/html>/);
103
107
  });
104
108
 
105
109
  it('names the URL it could not reach when the request never lands', async () => {
106
110
  vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
107
111
 
108
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(
112
+ await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(
109
113
  /cannot reach https:\/\/zth-backend-runner.*ECONNREFUSED/
110
114
  );
111
115
  });
@@ -113,7 +117,7 @@ describe('what the author is told when it fails', () => {
113
117
  it('reports a 200 whose body is not JSON rather than returning nothing', async () => {
114
118
  vi.stubGlobal('fetch', respond({ body: '<html>login</html>' }));
115
119
 
116
- await expect(invokeBackend('mf-x', 'get_items', null)).rejects.toThrow(/non-JSON response/);
120
+ await expect(runSandbox('mf-x', SANDBOX_BODY)).rejects.toThrow(/non-JSON response/);
117
121
  });
118
122
 
119
123
  // DELETE returns 204 with no body, which is success, not a parse failure.
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { formatSandboxRun } from '../format-sandbox-run';
4
+
5
+ describe('formatSandboxRun', () => {
6
+ it('shows each call with its SQL on one line, params, and row count, then the result', () => {
7
+ const text = formatSandboxRun({
8
+ ok: true,
9
+ calls: [
10
+ {
11
+ method: 'snowflake.query',
12
+ args: ['SELECT region\n FROM accounts\n WHERE d >= :since', { since: '2026-01-01' }],
13
+ durationMs: 812.4,
14
+ ok: true,
15
+ result: [{ r: 1 }, { r: 2 }, { r: 3 }, { r: 4 }],
16
+ },
17
+ ],
18
+ result: { regions: [] },
19
+ durationMs: 1200,
20
+ });
21
+
22
+ expect(text).toContain('sandbox run ✓ 1.2s');
23
+ expect(text).toContain('✓ snowflake.query 812ms 4 rows');
24
+ expect(text).toContain('SELECT region FROM accounts WHERE d >= :since');
25
+ expect(text).toContain('params: {"since":"2026-01-01"}');
26
+ expect(text).toContain('result:\n{\n "regions": []\n}');
27
+ });
28
+
29
+ it('ends with the error instead of a result on failure', () => {
30
+ const text = formatSandboxRun({
31
+ ok: false,
32
+ error: { status: 504, code: 'isolate_timeout', message: 'Script execution timed out.' },
33
+ calls: [],
34
+ result: null,
35
+ durationMs: 5000,
36
+ });
37
+
38
+ expect(text).toContain('sandbox run ✗ 5.0s');
39
+ expect(text).toContain('error: 504 isolate_timeout — Script execution timed out.');
40
+ expect(text).not.toContain('result:');
41
+ });
42
+ });
@@ -59,20 +59,37 @@ async function request<T>(method: string, baseUrl: string, path: string, body?:
59
59
  return (parsed ?? ({} as T)) as T;
60
60
  }
61
61
 
62
- export interface InvokeResponse {
62
+ export interface SandboxRunRequest {
63
+ code: string;
64
+ input: unknown;
65
+ handlerName?: string;
66
+ }
67
+
68
+ /** One channel call the handler made, as the runner traced it. */
69
+ export interface SandboxCall {
70
+ method: string;
71
+ args: unknown[];
72
+ durationMs: number;
73
+ ok: boolean;
74
+ error?: { status: number; code?: string; message: string };
75
+ result?: unknown;
76
+ }
77
+
78
+ export interface SandboxRunResponse {
79
+ ok: boolean;
80
+ error?: { status: number; code?: string; message: string };
81
+ calls: SandboxCall[];
63
82
  result: unknown;
64
83
  durationMs: number;
65
84
  }
66
85
 
67
- export function invokeBackend(appName: string, handlerName: string, input: unknown): Promise<InvokeResponse> {
68
- // The runner resolves the caller from the request or 401s, so an anonymous
69
- // invoke can never reach a handler.
70
- return request(
71
- 'POST',
72
- backendBaseUrl(),
73
- `/z2h-apps/${encodeURIComponent(appName)}/invoke/${encodeURIComponent(handlerName)}`,
74
- { input }
75
- );
86
+ /**
87
+ * Runs handler source from the working tree on the runner's sandbox route —
88
+ * against the app's real channels, without deploying it. A handler failure
89
+ * comes back as `ok: false` with the trace; only a refused request throws.
90
+ */
91
+ export function runSandbox(appName: string, body: SandboxRunRequest): Promise<SandboxRunResponse> {
92
+ return request('POST', backendBaseUrl(), `/z2h-apps/${encodeURIComponent(appName)}/sandbox/run`, body);
76
93
  }
77
94
 
78
95
  export interface AppIntegrationsResponse {
@@ -0,0 +1,51 @@
1
+ import type { SandboxCall, SandboxRunResponse } from './client';
2
+
3
+ const MAX_SQL_CHARS = 400;
4
+
5
+ const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
6
+ const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
7
+ const clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max)}…` : text);
8
+
9
+ /** The first arg is the SQL / GraphQL text for query channels — shown on its own line so it can be read. */
10
+ function describeArgs(args: unknown[]): string[] {
11
+ const [first, ...rest] = args;
12
+ const lines: string[] = [];
13
+ if (typeof first === 'string') {
14
+ lines.push(clip(oneLine(first), MAX_SQL_CHARS));
15
+ } else if (first !== undefined) {
16
+ lines.push(clip(JSON.stringify(first), MAX_SQL_CHARS));
17
+ }
18
+ const params = rest.filter(arg => arg !== undefined && arg !== null);
19
+ if (params.length > 0) {
20
+ lines.push(`params: ${clip(params.map(p => JSON.stringify(p)).join(', '), MAX_SQL_CHARS)}`);
21
+ }
22
+ return lines;
23
+ }
24
+
25
+ function describeCall(call: SandboxCall): string[] {
26
+ const rows = Array.isArray(call.result) ? ` ${call.result.length} rows` : '';
27
+ const head = ` ${call.ok ? '✓' : '✗'} ${call.method} ${Math.round(call.durationMs)}ms${rows}`;
28
+ const detail = describeArgs(call.args);
29
+ if (call.error) {
30
+ const code = call.error.code ? ` ${call.error.code}` : '';
31
+ detail.push(`${call.error.status}${code}: ${call.error.message}`);
32
+ }
33
+ return [head, ...detail.map(line => ` ${line}`)];
34
+ }
35
+
36
+ /** Human-readable trace of a sandbox run; `--json` prints the raw response instead. */
37
+ export function formatSandboxRun(res: SandboxRunResponse): string {
38
+ const lines = [`[z2h-cli] sandbox run ${res.ok ? '✓' : '✗'} ${seconds(res.durationMs)}`, ''];
39
+
40
+ lines.push(res.calls.length > 0 ? 'calls:' : 'calls: none');
41
+ res.calls.forEach(call => lines.push(...describeCall(call)));
42
+
43
+ lines.push('');
44
+ if (res.ok) {
45
+ lines.push('result:', JSON.stringify(res.result, null, 2));
46
+ } else if (res.error) {
47
+ const code = res.error.code ? ` ${res.error.code}` : '';
48
+ lines.push(`error: ${res.error.status}${code} — ${res.error.message}`);
49
+ }
50
+ return lines.join('\n');
51
+ }
@@ -0,0 +1,125 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { mkdtemp, rm, ensureDir, writeFile } from 'fs-extra';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { backendInvokeCommand } from '../backend';
7
+ import { runSandbox } from '../../backend/client';
8
+ import { output } from '../../util/logger';
9
+
10
+ vi.mock('../../util/logger', () => ({
11
+ info: vi.fn(),
12
+ warn: vi.fn(),
13
+ output: vi.fn(),
14
+ }));
15
+ vi.mock('../../util/tracker', () => ({
16
+ trackEvent: vi.fn().mockResolvedValue(undefined),
17
+ trackDuration: vi.fn(),
18
+ }));
19
+ vi.mock('../../util/s3/client', () => ({ getS3Client: vi.fn() }));
20
+ vi.mock('../../backend/client', () => ({ runSandbox: vi.fn() }));
21
+ vi.mock('../../util/app-name', async importOriginal => ({
22
+ ...(await importOriginal<typeof import('../../util/app-name')>()),
23
+ getCwdAppName: () => 'sales-kpis',
24
+ }));
25
+
26
+ const runSandboxMock = vi.mocked(runSandbox);
27
+ const outputMock = vi.mocked(output);
28
+
29
+ const OK = { ok: true, calls: [], result: { n: 1 }, durationMs: 12 };
30
+
31
+ describe('backendInvokeCommand', () => {
32
+ let tmp: string;
33
+
34
+ const writeHandler = async (name: string, source: string) => {
35
+ const dir = path.join(tmp, 'backend', 'handlers');
36
+ await ensureDir(dir);
37
+ await writeFile(path.join(dir, `${name}.js`), source);
38
+ };
39
+
40
+ beforeEach(async () => {
41
+ tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-backend-invoke-'));
42
+ runSandboxMock.mockReset();
43
+ runSandboxMock.mockResolvedValue(OK);
44
+ outputMock.mockClear();
45
+ process.exitCode = undefined;
46
+ });
47
+
48
+ afterEach(async () => {
49
+ process.exitCode = undefined;
50
+ await rm(tmp, { recursive: true, force: true });
51
+ });
52
+
53
+ it('sends the handler file from the working tree', async () => {
54
+ const source = 'async function handler(ctx) { return ctx.api.v1.monday.query("query { me { id } }"); }';
55
+ await writeHandler('get_kpis', source);
56
+
57
+ await backendInvokeCommand('get_kpis', { input: '{"since":"2026-01-01"}' }, tmp);
58
+
59
+ expect(runSandboxMock).toHaveBeenCalledWith('mf-sales-kpis', {
60
+ code: source,
61
+ input: { since: '2026-01-01' },
62
+ handlerName: 'get_kpis',
63
+ });
64
+ expect(outputMock).toHaveBeenCalledWith(expect.stringContaining('sandbox run ✓'), OK);
65
+ expect(process.exitCode).toBeUndefined();
66
+ });
67
+
68
+ it('runs --code instead of the file', async () => {
69
+ await backendInvokeCommand('scratch', { code: 'function handler() { return 1; }' }, tmp);
70
+
71
+ expect(runSandboxMock.mock.calls[0][1]).toMatchObject({ code: 'function handler() { return 1; }', input: null });
72
+ });
73
+
74
+ it('fails locally, without a request, when the source breaks an isolate rule', async () => {
75
+ await writeHandler('get_kpis', "import x from 'y';\nfunction handler() {}");
76
+
77
+ await expect(backendInvokeCommand('get_kpis', {}, tmp)).rejects.toThrow();
78
+ expect(runSandboxMock).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('names the available handlers when the one asked for does not exist', async () => {
82
+ await writeHandler('get_kpis', 'function handler() {}');
83
+ await writeHandler('list_items', 'function handler() {}');
84
+
85
+ await expect(backendInvokeCommand('get_kpi', {}, tmp)).rejects.toThrow(
86
+ 'no handler "get_kpi" in backend/handlers/ (available: get_kpis, list_items)'
87
+ );
88
+ });
89
+
90
+ it('rejects --input that is not JSON', async () => {
91
+ await expect(backendInvokeCommand('get_kpis', { input: '{bad' }, tmp)).rejects.toThrow(
92
+ /--input must be valid JSON/
93
+ );
94
+ });
95
+
96
+ it('prints the trace and exits 1 when the handler fails', async () => {
97
+ await writeHandler('get_kpis', 'async function handler(ctx) { return ctx.api.v1.snowflake.query("SELECT ar"); }');
98
+ const failed = {
99
+ ok: false,
100
+ error: { status: 400, code: 'snowflake_query_error', message: "invalid identifier 'AR'" },
101
+ calls: [
102
+ {
103
+ method: 'snowflake.query',
104
+ args: ['SELECT ar'],
105
+ durationMs: 640,
106
+ ok: false,
107
+ error: { status: 400, code: 'snowflake_query_error', message: "invalid identifier 'AR'" },
108
+ },
109
+ ],
110
+ result: null,
111
+ durationMs: 900,
112
+ };
113
+ runSandboxMock.mockResolvedValue(failed);
114
+
115
+ await backendInvokeCommand('get_kpis', {}, tmp);
116
+
117
+ const [human, data] = outputMock.mock.calls[0];
118
+ expect(data).toBe(failed);
119
+ expect(human).toContain('✗ snowflake.query 640ms');
120
+ expect(human).toContain('SELECT ar');
121
+ expect(human).toContain("400 snowflake_query_error: invalid identifier 'AR'");
122
+ expect(human).toContain("error: 400 snowflake_query_error — invalid identifier 'AR'");
123
+ expect(process.exitCode).toBe(1);
124
+ });
125
+ });
@@ -0,0 +1,62 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { mkdtemp, rm, remove, ensureDir, writeJson } from 'fs-extra';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { listCustomIntegrationsCommand, removeCustomIntegrationCommand } from '../custom-integrations';
7
+ import * as authStore from '../../util/auth/auth-store';
8
+
9
+ vi.spyOn(authStore, 'readZthAuth').mockResolvedValue({ token: 'jwt-stub' });
10
+
11
+ function mockFetchOnce(status = 200, body: unknown = {}) {
12
+ const fetchMock = vi
13
+ .fn()
14
+ .mockResolvedValue({ ok: status < 400, status, text: async () => '', json: async () => body });
15
+ vi.stubGlobal('fetch', fetchMock);
16
+ return fetchMock;
17
+ }
18
+
19
+ describe('custom-integration commands — app-name resolution', () => {
20
+ let tmp: string;
21
+ let cwd: string;
22
+
23
+ beforeEach(async () => {
24
+ tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-custom-integration-'));
25
+ cwd = process.cwd();
26
+ process.chdir(tmp);
27
+ tmp = process.cwd();
28
+ await writeJson(path.join(tmp, 'package.json'), { name: 'my-app' });
29
+ });
30
+
31
+ afterEach(async () => {
32
+ process.chdir(cwd);
33
+ vi.unstubAllGlobals();
34
+ await rm(tmp, { recursive: true, force: true });
35
+ });
36
+
37
+ it('throws a clear error instead of crashing when run outside a consumer app directory', async () => {
38
+ await remove(path.join(tmp, 'package.json'));
39
+
40
+ await expect(listCustomIntegrationsCommand()).rejects.toThrow(/consumer app directory/);
41
+ await expect(removeCustomIntegrationCommand('stripe')).rejects.toThrow(/consumer app directory/);
42
+ });
43
+
44
+ it('targets the base app', async () => {
45
+ const fetchMock = mockFetchOnce(200, { connections: [] });
46
+
47
+ await listCustomIntegrationsCommand();
48
+
49
+ const [url] = fetchMock.mock.calls[0] as [string];
50
+ expect(url).toContain('/z2h-apps/mf-my-app/custom-integrations');
51
+ });
52
+
53
+ it('remove targets the base app', async () => {
54
+ await ensureDir(path.join(tmp, '.zth'));
55
+ const fetchMock = mockFetchOnce(204);
56
+
57
+ await removeCustomIntegrationCommand('stripe');
58
+
59
+ const [url] = fetchMock.mock.calls[0] as [string];
60
+ expect(url).toContain('/z2h-apps/mf-my-app/custom-integrations/stripe');
61
+ });
62
+ });
@@ -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 {
@@ -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
  }
@@ -0,0 +1,68 @@
1
+ import { output } from '../util/logger';
2
+ import { getCwdAppName, mfAppName } from '../util/app-name';
3
+ import { brokerBaseUrl, routingHeaders } from '../constants';
4
+ import { readZthAuth } from '../util/auth/auth-store';
5
+
6
+ function getTargetAppName(): string {
7
+ const consumerName = getCwdAppName();
8
+ if (!consumerName) {
9
+ throw new Error('Run this from inside a consumer app directory (the one with your package.json).');
10
+ }
11
+ return mfAppName(consumerName);
12
+ }
13
+
14
+ interface CustomIntegrationSummary {
15
+ name: string;
16
+ allowedHost: string;
17
+ authHeaderName: string;
18
+ authScheme: string;
19
+ createdBy: string;
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ /** True when this is a preview showing an entry inherited from its base app, not its own. */
23
+ inherited?: boolean;
24
+ }
25
+
26
+ export async function listCustomIntegrationsCommand(): Promise<void> {
27
+ const appName = getTargetAppName();
28
+ const { token } = await readZthAuth();
29
+
30
+ const res = await fetch(`${brokerBaseUrl()}/z2h-apps/${appName}/custom-integrations`, {
31
+ headers: { Authorization: `Bearer ${token}`, ...routingHeaders() },
32
+ });
33
+ if (!res.ok) {
34
+ const text = await res.text();
35
+ throw new Error(`Listing custom integrations failed (${res.status}): ${text.slice(0, 1024)}`);
36
+ }
37
+
38
+ const { connections } = (await res.json()) as { connections: CustomIntegrationSummary[] };
39
+ if (connections.length === 0) {
40
+ output(`[z2h-cli] "${appName}" has no custom integrations registered`, { ok: true, appName, connections: [] });
41
+ return;
42
+ }
43
+
44
+ const lines = connections.map(
45
+ c => ` ${c.name} → ${c.allowedHost} (${c.authHeaderName})${c.inherited ? ' [inherited from base app]' : ''}`
46
+ );
47
+ output(`[z2h-cli] "${appName}" custom integrations:\n${lines.join('\n')}`, { ok: true, appName, connections });
48
+ }
49
+
50
+ export async function removeCustomIntegrationCommand(name: string | undefined): Promise<void> {
51
+ if (!name) {
52
+ throw new Error('Specify the connection name to remove — e.g. z2h-cli custom-integration:remove stripe');
53
+ }
54
+
55
+ const appName = getTargetAppName();
56
+ const { token } = await readZthAuth();
57
+
58
+ const res = await fetch(`${brokerBaseUrl()}/z2h-apps/${appName}/custom-integrations/${encodeURIComponent(name)}`, {
59
+ method: 'DELETE',
60
+ headers: { Authorization: `Bearer ${token}`, ...routingHeaders() },
61
+ });
62
+ if (!res.ok) {
63
+ const text = await res.text();
64
+ throw new Error(`Removing custom integration failed (${res.status}): ${text.slice(0, 1024)}`);
65
+ }
66
+
67
+ output(`[z2h-cli] custom integration "${name}" removed from "${appName}"`, { ok: true, appName, name });
68
+ }
@@ -206,6 +206,9 @@ async function deployPreview({ paths, consumerName, appName, token, opts }: Depl
206
206
  entry,
207
207
  gitRemote: '',
208
208
  isPreview: true,
209
+ // The base app this preview is for — bigbrain-zth links the preview's row
210
+ // to it (once the caller's EDITOR access on it is confirmed) so custom
211
+ // integrations set on the base app are inherited by the preview.
209
212
  masterAppName: appName,
210
213
  });
211
214