@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.
- 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/__tests__/custom-integrations.test.d.ts +2 -0
- package/dist/commands/__tests__/custom-integrations.test.d.ts.map +1 -0
- package/dist/commands/backend.d.ts +6 -1
- package/dist/commands/backend.d.ts.map +1 -1
- package/dist/commands/backend.js +35 -3
- package/dist/commands/custom-integrations.d.ts +3 -0
- package/dist/commands/custom-integrations.d.ts.map +1 -0
- package/dist/commands/custom-integrations.js +51 -0
- package/dist/commands/deploy.js +3 -0
- 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/__tests__/custom-integrations.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/custom-integrations.test.d.ts.map +1 -0
- package/dist/esm/commands/backend.d.ts +6 -1
- package/dist/esm/commands/backend.d.ts.map +1 -1
- package/dist/esm/commands/backend.mjs +36 -4
- package/dist/esm/commands/custom-integrations.d.ts +3 -0
- package/dist/esm/commands/custom-integrations.d.ts.map +1 -0
- package/dist/esm/commands/custom-integrations.mjs +48 -0
- package/dist/esm/commands/deploy.mjs +3 -0
- package/dist/esm/index.mjs +18 -4
- package/dist/esm/util/app-name.mjs +1 -1
- package/dist/esm/util/broker/app.d.ts +4 -1
- package/dist/esm/util/broker/app.d.ts.map +1 -1
- package/dist/index.js +18 -4
- package/dist/util/app-name.js +1 -1
- package/dist/util/broker/app.d.ts +4 -1
- package/dist/util/broker/app.d.ts.map +1 -1
- package/handler-api/backend-runner/backend-runner.errors.ts +13 -0
- package/handler-api/backend-runner/backend-runner.types.ts +3 -0
- package/handler-api/backend-runner/channels/channel-map.ts +26 -6
- package/handler-api/backend-runner/channels/channel-telemetry.ts +13 -7
- package/handler-api/backend-runner/channels/channel-trace.ts +52 -0
- package/handler-api/backend-runner/channels/custom-integration-channel/custom-integration.channel.ts +243 -0
- package/handler-api/signatures.json +4 -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-invoke.test.ts +125 -0
- package/src/commands/__tests__/custom-integrations.test.ts +62 -0
- package/src/commands/backend.ts +43 -4
- package/src/commands/custom-integrations.ts +68 -0
- package/src/commands/deploy.ts +3 -0
- package/src/index.ts +24 -5
- package/src/util/app-name.ts +1 -1
- package/src/util/broker/__tests__/app.test.ts +32 -0
- package/src/util/broker/app.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mondaydotcomorg/z2h-cli",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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,
|
|
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
|
|
46
|
-
|
|
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/
|
|
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(
|
|
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
|
|
79
|
+
await runSandbox('mf-a/../b', SANDBOX_BODY);
|
|
76
80
|
|
|
77
|
-
expect(lastCall()[0]).toContain('/z2h-apps/mf-a%2F..%2Fb/
|
|
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":"
|
|
92
|
+
vi.stubGlobal('fetch', respond({ ok: false, status: 404, body: '{"message":"app \\"x\\" is not registered"}' }));
|
|
89
93
|
|
|
90
|
-
await expect(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
});
|
package/src/backend/client.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
});
|
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 {
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
package/src/commands/deploy.ts
CHANGED
|
@@ -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
|
|