@mondaydotcomorg/z2h-cli 0.27.1 → 0.29.0
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__/ast.test.d.ts +2 -0
- package/dist/backend/__tests__/ast.test.d.ts.map +1 -0
- package/dist/backend/__tests__/parse.test.d.ts +2 -0
- package/dist/backend/__tests__/parse.test.d.ts.map +1 -0
- package/dist/backend/__tests__/validate.test.d.ts +2 -0
- package/dist/backend/__tests__/validate.test.d.ts.map +1 -0
- package/dist/backend/ast.d.ts +41 -0
- package/dist/backend/ast.d.ts.map +1 -0
- package/dist/backend/ast.js +152 -0
- package/dist/backend/detect-integrations.d.ts +8 -4
- package/dist/backend/detect-integrations.d.ts.map +1 -1
- package/dist/backend/detect-integrations.js +12 -15
- package/dist/backend/handler-declaration.d.ts +17 -0
- package/dist/backend/handler-declaration.d.ts.map +1 -0
- package/dist/backend/handler-declaration.js +83 -0
- package/dist/backend/handlers.d.ts +2 -0
- package/dist/backend/handlers.d.ts.map +1 -1
- package/dist/backend/handlers.js +7 -36
- package/dist/backend/messages.d.ts +10 -0
- package/dist/backend/messages.d.ts.map +1 -0
- package/dist/backend/messages.js +14 -0
- package/dist/backend/parse.d.ts +11 -0
- package/dist/backend/parse.d.ts.map +1 -0
- package/dist/backend/parse.js +66 -0
- package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
- package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
- package/dist/backend/rules/index.d.ts +5 -0
- package/dist/backend/rules/index.d.ts.map +1 -0
- package/dist/backend/rules/index.js +8 -0
- package/dist/backend/rules/snowflake-static-sql.d.ts +15 -0
- package/dist/backend/rules/snowflake-static-sql.d.ts.map +1 -0
- package/dist/backend/rules/snowflake-static-sql.js +75 -0
- package/dist/backend/rules/types.d.ts +29 -0
- package/dist/backend/rules/types.d.ts.map +1 -0
- package/dist/backend/rules/types.js +1 -0
- package/dist/backend/validate.d.ts +23 -0
- package/dist/backend/validate.d.ts.map +1 -0
- package/dist/backend/validate.js +64 -0
- package/dist/commands/__tests__/backend-validate.test.d.ts +2 -0
- package/dist/commands/__tests__/backend-validate.test.d.ts.map +1 -0
- package/dist/commands/backend.d.ts +6 -0
- package/dist/commands/backend.d.ts.map +1 -1
- package/dist/commands/backend.js +22 -2
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +10 -3
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +2 -1
- package/dist/constants.d.ts +1 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +10 -6
- package/dist/esm/backend/__tests__/ast.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/ast.test.d.ts.map +1 -0
- package/dist/esm/backend/__tests__/parse.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/parse.test.d.ts.map +1 -0
- package/dist/esm/backend/__tests__/validate.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/validate.test.d.ts.map +1 -0
- package/dist/esm/backend/ast.d.ts +41 -0
- package/dist/esm/backend/ast.d.ts.map +1 -0
- package/dist/esm/backend/ast.mjs +146 -0
- package/dist/esm/backend/detect-integrations.d.ts +8 -4
- package/dist/esm/backend/detect-integrations.d.ts.map +1 -1
- package/dist/esm/backend/detect-integrations.mjs +12 -15
- package/dist/esm/backend/handler-declaration.d.ts +17 -0
- package/dist/esm/backend/handler-declaration.d.ts.map +1 -0
- package/dist/esm/backend/handler-declaration.mjs +78 -0
- package/dist/esm/backend/handlers.d.ts +2 -0
- package/dist/esm/backend/handlers.d.ts.map +1 -1
- package/dist/esm/backend/handlers.mjs +7 -36
- package/dist/esm/backend/messages.d.ts +10 -0
- package/dist/esm/backend/messages.d.ts.map +1 -0
- package/dist/esm/backend/messages.mjs +12 -0
- package/dist/esm/backend/parse.d.ts +11 -0
- package/dist/esm/backend/parse.d.ts.map +1 -0
- package/dist/esm/backend/parse.mjs +64 -0
- package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
- package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
- package/dist/esm/backend/rules/index.d.ts +5 -0
- package/dist/esm/backend/rules/index.d.ts.map +1 -0
- package/dist/esm/backend/rules/index.mjs +6 -0
- package/dist/esm/backend/rules/snowflake-static-sql.d.ts +15 -0
- package/dist/esm/backend/rules/snowflake-static-sql.d.ts.map +1 -0
- package/dist/esm/backend/rules/snowflake-static-sql.mjs +73 -0
- package/dist/esm/backend/rules/types.d.ts +29 -0
- package/dist/esm/backend/rules/types.d.ts.map +1 -0
- package/dist/esm/backend/rules/types.mjs +1 -0
- package/dist/esm/backend/validate.d.ts +23 -0
- package/dist/esm/backend/validate.d.ts.map +1 -0
- package/dist/esm/backend/validate.mjs +62 -0
- package/dist/esm/commands/__tests__/backend-validate.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/backend-validate.test.d.ts.map +1 -0
- package/dist/esm/commands/backend.d.ts +6 -0
- package/dist/esm/commands/backend.d.ts.map +1 -1
- package/dist/esm/commands/backend.mjs +23 -4
- package/dist/esm/commands/create.d.ts.map +1 -1
- package/dist/esm/commands/create.mjs +11 -4
- package/dist/esm/commands/generate.d.ts.map +1 -1
- package/dist/esm/commands/generate.mjs +2 -1
- package/dist/esm/constants.d.ts +1 -2
- package/dist/esm/constants.d.ts.map +1 -1
- package/dist/esm/constants.mjs +3 -6
- package/dist/esm/index.mjs +6 -2
- package/dist/esm/util/broker/app.d.ts +12 -2
- package/dist/esm/util/broker/app.d.ts.map +1 -1
- package/dist/esm/util/broker/app.mjs +20 -2
- package/dist/index.js +5 -1
- package/dist/util/broker/app.d.ts +12 -2
- package/dist/util/broker/app.d.ts.map +1 -1
- package/dist/util/broker/app.js +20 -1
- package/package.json +4 -2
- package/src/backend/__tests__/ast.test.ts +53 -0
- package/src/backend/__tests__/detect-integrations.test.ts +12 -30
- package/src/backend/__tests__/handlers.test.ts +21 -34
- package/src/backend/__tests__/parse.test.ts +25 -0
- package/src/backend/__tests__/validate.test.ts +246 -0
- package/src/backend/ast.ts +204 -0
- package/src/backend/detect-integrations.ts +12 -14
- package/src/backend/handler-declaration.ts +104 -0
- package/src/backend/handlers.ts +9 -44
- package/src/backend/messages.ts +16 -0
- package/src/backend/parse.ts +82 -0
- package/src/backend/rules/__tests__/snowflake-static-sql.test.ts +175 -0
- package/src/backend/rules/index.ts +7 -0
- package/src/backend/rules/snowflake-static-sql.ts +78 -0
- package/src/backend/rules/types.ts +30 -0
- package/src/backend/validate.ts +73 -0
- package/src/commands/__tests__/backend-deploy.test.ts +5 -3
- package/src/commands/__tests__/backend-validate.test.ts +71 -0
- package/src/commands/__tests__/create.test.ts +46 -0
- package/src/commands/__tests__/edit.test.ts +6 -0
- package/src/commands/backend.ts +23 -3
- package/src/commands/create.ts +13 -4
- package/src/commands/generate.ts +2 -1
- package/src/constants.ts +5 -6
- package/src/index.ts +9 -2
- package/src/util/broker/__tests__/app.test.ts +29 -1
- package/src/util/broker/app.ts +24 -2
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const EXAMPLE = 'async function handler(ctx) { ... }';
|
|
2
|
+
|
|
3
|
+
export const messages = {
|
|
4
|
+
mustDefine: (file: string) => `${file} must define a handler — e.g. ${EXAMPLE}`,
|
|
5
|
+
notAFunction: (file: string) => `${file} declares handler but it is not a function — e.g. ${EXAMPLE}`,
|
|
6
|
+
mustNotImport: (file: string) =>
|
|
7
|
+
`${file} must not import other files or packages — handlers are self-contained; reach the outside world through ctx capabilities`,
|
|
8
|
+
mustNotExportHandler: (file: string) =>
|
|
9
|
+
`${file} must not export handler — the isolate has no module system; declare it as a plain global, e.g. ${EXAMPLE}`,
|
|
10
|
+
mustNotExport: (file: string) =>
|
|
11
|
+
`${file} must not use export — the isolate has no module system; declare handler as a plain global, e.g. ${EXAMPLE}`,
|
|
12
|
+
moduleOnlySyntax: (file: string, detail: string) =>
|
|
13
|
+
`${file} uses module-only syntax (${detail}) — handlers run as plain scripts, so top-level await and import.meta are not available`,
|
|
14
|
+
syntaxError: (file: string, line: number, column: number, detail: string) =>
|
|
15
|
+
`${file}:${line}:${column}: not valid JavaScript — ${detail}`,
|
|
16
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { parse, type ExportNamedDeclaration, type ModuleDeclaration, type Program, type Statement } from 'acorn';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { trackEvent } from '../util/tracker';
|
|
4
|
+
import { exportsHandler } from './handler-declaration';
|
|
5
|
+
import { messages } from './messages';
|
|
6
|
+
|
|
7
|
+
const acornLocSchema = z.object({ line: z.number(), column: z.number() });
|
|
8
|
+
|
|
9
|
+
interface AcornSyntaxError extends SyntaxError {
|
|
10
|
+
loc: { line: number; column: number };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isAcornSyntaxError(err: unknown): err is AcornSyntaxError {
|
|
14
|
+
return err instanceof SyntaxError && acornLocSchema.safeParse((err as { loc?: unknown }).loc).success;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ParseResult = { ok: true; program: Program } | { ok: false; error: AcornSyntaxError };
|
|
18
|
+
|
|
19
|
+
function tryParse(code: string, sourceType: 'script' | 'module'): ParseResult {
|
|
20
|
+
try {
|
|
21
|
+
return { ok: true, program: parse(code, { ecmaVersion: 'latest', sourceType, locations: true }) };
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (isAcornSyntaxError(err)) {
|
|
24
|
+
return { ok: false, error: err };
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Acorn appends ` (line:col)` to its messages; we report the position ourselves. */
|
|
31
|
+
const stripPosition = (message: string): string => message.replace(/ \(\d+:\d+\)$/, '');
|
|
32
|
+
|
|
33
|
+
type ImportLike = (ModuleDeclaration & { type: 'ImportDeclaration' | 'ExportAllDeclaration' }) | ExportNamedDeclaration;
|
|
34
|
+
|
|
35
|
+
const isImportLike = (node: Statement | ModuleDeclaration): node is ImportLike =>
|
|
36
|
+
node.type === 'ImportDeclaration' ||
|
|
37
|
+
node.type === 'ExportAllDeclaration' ||
|
|
38
|
+
(node.type === 'ExportNamedDeclaration' && node.source != null);
|
|
39
|
+
|
|
40
|
+
type ExportLike = ModuleDeclaration & { type: 'ExportNamedDeclaration' | 'ExportDefaultDeclaration' };
|
|
41
|
+
|
|
42
|
+
const isExport = (node: Statement | ModuleDeclaration): node is ExportLike =>
|
|
43
|
+
node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration';
|
|
44
|
+
|
|
45
|
+
/** Reports the failure, then returns the error for the caller to throw. */
|
|
46
|
+
function parseError(message: string): Error {
|
|
47
|
+
void trackEvent('z2h_cli_handler_parse_error', { error: message });
|
|
48
|
+
return new Error(message);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The runner compiles handlers with `isolate.compileScript`, i.e. the classic
|
|
53
|
+
* *script* grammar — so that is the grammar we validate against. `import`/`export`
|
|
54
|
+
* declarations and top-level `await`/`import.meta` are only legal under the
|
|
55
|
+
* *module* grammar, so a handler using any of them fails the script parse; we
|
|
56
|
+
* re-parse it as a module purely to name which of those four things it was,
|
|
57
|
+
* instead of surfacing V8's bare "Unexpected token 'export'".
|
|
58
|
+
*/
|
|
59
|
+
export function parseOrThrow(file: string, code: string): Program {
|
|
60
|
+
const asScript = tryParse(code, 'script');
|
|
61
|
+
if (asScript.ok) {
|
|
62
|
+
return asScript.program;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const asModule = tryParse(code, 'module');
|
|
66
|
+
if (asModule.ok) {
|
|
67
|
+
const { body } = asModule.program;
|
|
68
|
+
if (body.some(exportsHandler)) {
|
|
69
|
+
throw parseError(messages.mustNotExportHandler(file));
|
|
70
|
+
}
|
|
71
|
+
if (body.some(isImportLike)) {
|
|
72
|
+
throw parseError(messages.mustNotImport(file));
|
|
73
|
+
}
|
|
74
|
+
if (body.some(isExport)) {
|
|
75
|
+
throw parseError(messages.mustNotExport(file));
|
|
76
|
+
}
|
|
77
|
+
throw parseError(messages.moduleOnlySyntax(file, stripPosition(asScript.error.message)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { line, column } = asScript.error.loc;
|
|
81
|
+
throw parseError(messages.syntaxError(file, line, column, stripPosition(asScript.error.message)));
|
|
82
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { parse } from 'acorn';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { collectBindings, createPathResolver } from '../../ast';
|
|
4
|
+
import { findHandlerDeclaration, handlerContextParamName } from '../../handler-declaration';
|
|
5
|
+
import { SnowflakeStaticSqlRule } from '../snowflake-static-sql';
|
|
6
|
+
|
|
7
|
+
const rule = new SnowflakeStaticSqlRule();
|
|
8
|
+
|
|
9
|
+
const check = (source: string) => {
|
|
10
|
+
const program = parse(source, { ecmaVersion: 'latest', sourceType: 'script', locations: true });
|
|
11
|
+
const bindings = collectBindings(program);
|
|
12
|
+
const declaration = findHandlerDeclaration(program.body);
|
|
13
|
+
return rule.check({
|
|
14
|
+
file: 'h.js',
|
|
15
|
+
program,
|
|
16
|
+
bindings,
|
|
17
|
+
paths: createPathResolver(bindings, declaration && handlerContextParamName(declaration)),
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const inHandler = (body: string) => `async function handler(ctx) {\n${body}\n}`;
|
|
22
|
+
|
|
23
|
+
describe('snowflake/static-sql — accepted', () => {
|
|
24
|
+
it.each([
|
|
25
|
+
['a string literal', 'return ctx.api.v1.snowflake.query("select 1");'],
|
|
26
|
+
['a template literal without holes', 'return ctx.api.v1.snowflake.query(`select 1`);'],
|
|
27
|
+
[
|
|
28
|
+
'a template literal whose holes are static',
|
|
29
|
+
'const t = "items"; return ctx.api.v1.snowflake.query(`select * from ${t}`);',
|
|
30
|
+
],
|
|
31
|
+
['a local const', 'const sql = "select 1"; return ctx.api.v1.snowflake.query(sql);'],
|
|
32
|
+
[
|
|
33
|
+
'a var declared before use',
|
|
34
|
+
'var sql = "select 1"; return ctx.api.v1.snowflake.query(sql, undefined, { identifier: "x" });',
|
|
35
|
+
],
|
|
36
|
+
['concatenated literals', 'return ctx.api.v1.snowflake.query("select 1" + " from dual");'],
|
|
37
|
+
[
|
|
38
|
+
'a variable chain',
|
|
39
|
+
'const base = "select 1"; const sql = base + " limit 1"; return ctx.api.v1.snowflake.query(sql);',
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
'a conditional between two static queries',
|
|
43
|
+
'const sql = ctx.input.all ? "select 1" : "select 2"; return ctx.api.v1.snowflake.query(sql);',
|
|
44
|
+
],
|
|
45
|
+
[
|
|
46
|
+
'a nullish fallback between static queries',
|
|
47
|
+
'return ctx.api.v1.snowflake.query(A ?? "select 1"); const A = "select 2";',
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
'client values in params and options',
|
|
51
|
+
'return ctx.api.v1.snowflake.query("select :id", { id: ctx.input.id }, { timeoutMs: ctx.input.t });',
|
|
52
|
+
],
|
|
53
|
+
['optional chaining on the channel', 'return ctx?.api?.v1?.snowflake?.query("select 1");'],
|
|
54
|
+
])('accepts %s', (_label, body) => {
|
|
55
|
+
expect(check(inHandler(body))).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('accepts a top-level const shared by several calls', () => {
|
|
59
|
+
expect(
|
|
60
|
+
check(
|
|
61
|
+
`const SQL = "select 1";\n${inHandler(
|
|
62
|
+
'await ctx.api.v1.snowflake.query(SQL); return ctx.api.v1.snowflake.query(SQL);'
|
|
63
|
+
)}`
|
|
64
|
+
)
|
|
65
|
+
).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('ignores other snowflake members and other channels', () => {
|
|
69
|
+
expect(
|
|
70
|
+
check(inHandler('ctx.api.v1.snowflake.other(ctx.input.sql); return ctx.api.v1.monday.query(ctx.input.q);'))
|
|
71
|
+
).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('accepts static SQL through a const alias of the channel', () => {
|
|
75
|
+
expect(
|
|
76
|
+
check(inHandler('const sf = ctx.api.v1.snowflake; return sf.query("select 1", { id: ctx.input.id });'))
|
|
77
|
+
).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('snowflake/static-sql — channel aliases', () => {
|
|
82
|
+
it.each([
|
|
83
|
+
['a const alias of the channel', 'const sf = ctx.api.v1.snowflake; return sf.query(ctx.input.sql);'],
|
|
84
|
+
['a const alias of the api root', 'const api = ctx.api.v1; return api.snowflake.query(ctx.input.sql);'],
|
|
85
|
+
['a chain of const aliases', 'const api = ctx.api; const sf = api.v1.snowflake; return sf.query(ctx.input.sql);'],
|
|
86
|
+
['a destructured channel', 'const { snowflake } = ctx.api.v1; return snowflake.query(ctx.input.sql);'],
|
|
87
|
+
['a renamed destructured channel', 'const { snowflake: sf } = ctx.api.v1; return sf.query(ctx.input.sql);'],
|
|
88
|
+
['a nested destructuring', 'const { api: { v1: { snowflake } } } = ctx; return snowflake.query(ctx.input.sql);'],
|
|
89
|
+
['a destructured method', 'const { query } = ctx.api.v1.snowflake; return query(ctx.input.sql);'],
|
|
90
|
+
['an optional-chained alias', 'const sf = ctx?.api?.v1?.snowflake; return sf.query(ctx.input.sql);'],
|
|
91
|
+
['a computed string-literal channel', 'return ctx.api.v1["snowflake"].query(ctx.input.sql);'],
|
|
92
|
+
['a computed alias', 'const sf = ctx.api.v1["snowflake"]; return sf.query(ctx.input.sql);'],
|
|
93
|
+
])('sees through %s', (_label, body) => {
|
|
94
|
+
expect(check(inHandler(body))).toHaveLength(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('follows a top-level const alias into the handler', () => {
|
|
98
|
+
expect(check(`const sf = ctx.api.v1.snowflake;\n${inHandler('return sf.query(ctx.input.sql);')}`)).toHaveLength(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('sees through a renamed handler parameter', () => {
|
|
102
|
+
expect(
|
|
103
|
+
check('async function handler(context) { return context.api.v1.snowflake.query(context.input.sql); }')
|
|
104
|
+
).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it.each([
|
|
108
|
+
['a let alias', 'let sf = ctx.api.v1.snowflake; return sf.query(ctx.input.sql);'],
|
|
109
|
+
['a reassigned alias', 'var sf = ctx.api.v1.snowflake; sf = other; return sf.query(ctx.input.sql);'],
|
|
110
|
+
[
|
|
111
|
+
'an alias shadowed by a parameter',
|
|
112
|
+
'const sf = ctx.api.v1.snowflake; const run = (sf) => sf.query(ctx.input.sql); return run(fake);',
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
'an alias declared twice',
|
|
116
|
+
'const sf = ctx.api.v1.snowflake; { const sf = fake; return sf.query(ctx.input.sql); }',
|
|
117
|
+
],
|
|
118
|
+
['an alias with a default value', 'const { snowflake: sf = fake } = ctx.api.v1; return sf.query(ctx.input.sql);'],
|
|
119
|
+
])('does not follow %s — the binding cannot be pinned', (_label, body) => {
|
|
120
|
+
expect(check(inHandler(body))).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('snowflake/static-sql — rejected', () => {
|
|
125
|
+
it.each([
|
|
126
|
+
['ctx.input directly', 'return ctx.api.v1.snowflake.query(ctx.input.sql);'],
|
|
127
|
+
['a variable bound to ctx.input', 'const sql = ctx.input.sql; return ctx.api.v1.snowflake.query(sql);'],
|
|
128
|
+
[
|
|
129
|
+
'a template literal with a ctx.input hole',
|
|
130
|
+
'return ctx.api.v1.snowflake.query(`select * from ${ctx.input.table}`);',
|
|
131
|
+
],
|
|
132
|
+
['concatenation with ctx.input', 'return ctx.api.v1.snowflake.query("select * from " + ctx.input.table);'],
|
|
133
|
+
[
|
|
134
|
+
'a conditional with one dynamic branch',
|
|
135
|
+
'return ctx.api.v1.snowflake.query(ctx.input.x ? "select 1" : ctx.input.sql);',
|
|
136
|
+
],
|
|
137
|
+
['a call result', 'return ctx.api.v1.snowflake.query(buildSql(ctx.input));'],
|
|
138
|
+
['a destructured binding', 'const { sql } = ctx.input; return ctx.api.v1.snowflake.query(sql);'],
|
|
139
|
+
['a reassigned variable', 'let sql = "select 1"; sql = ctx.input.sql; return ctx.api.v1.snowflake.query(sql);'],
|
|
140
|
+
[
|
|
141
|
+
'a compound-assigned variable',
|
|
142
|
+
'let sql = "select 1"; sql += ctx.input.where; return ctx.api.v1.snowflake.query(sql);',
|
|
143
|
+
],
|
|
144
|
+
['a function parameter', 'const run = (sql) => ctx.api.v1.snowflake.query(sql); return run(ctx.input.sql);'],
|
|
145
|
+
['an undeclared identifier', 'return ctx.api.v1.snowflake.query(sql);'],
|
|
146
|
+
['a spread argument', 'return ctx.api.v1.snowflake.query(...ctx.input.args);'],
|
|
147
|
+
['a non-string literal', 'return ctx.api.v1.snowflake.query(1);'],
|
|
148
|
+
['no arguments', 'return ctx.api.v1.snowflake.query();'],
|
|
149
|
+
])('rejects %s', (_label, body) => {
|
|
150
|
+
const violations = check(inHandler(body));
|
|
151
|
+
expect(violations).toHaveLength(1);
|
|
152
|
+
expect(violations[0]).toMatch(/^h\.js:2: ctx\.api\.v1\.snowflake\.query must receive SQL written in the handler/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects a variable shadowed by a parameter anywhere in the file', () => {
|
|
156
|
+
expect(
|
|
157
|
+
check(
|
|
158
|
+
`const sql = "select 1";\nfunction run(sql) { return ctx.api.v1.snowflake.query(sql); }\n${inHandler(
|
|
159
|
+
'return run(ctx.input.sql);'
|
|
160
|
+
)}`
|
|
161
|
+
)
|
|
162
|
+
).toHaveLength(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('does not loop on self-referential bindings', () => {
|
|
166
|
+
expect(check(inHandler('const a = b; const b = a; return ctx.api.v1.snowflake.query(a);'))).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('reports every offending call with its own line', () => {
|
|
170
|
+
const violations = check(
|
|
171
|
+
inHandler('await ctx.api.v1.snowflake.query(ctx.input.a);\nreturn ctx.api.v1.snowflake.query(ctx.input.b);')
|
|
172
|
+
);
|
|
173
|
+
expect(violations.map(v => v.slice(0, 6))).toEqual(['h.js:2', 'h.js:3']);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { HandlerRule } from './types';
|
|
2
|
+
import { SnowflakeStaticSqlRule } from './snowflake-static-sql';
|
|
3
|
+
|
|
4
|
+
export type { HandlerRule, RuleContext } from './types';
|
|
5
|
+
|
|
6
|
+
/** Every rule the validator runs, in order. Add new capability rules here. */
|
|
7
|
+
export const HANDLER_RULES: readonly HandlerRule[] = [new SnowflakeStaticSqlRule()];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { AnyNode } from 'acorn';
|
|
2
|
+
import { simple as walkSimple } from 'acorn-walk';
|
|
3
|
+
import { type Bindings, lineOf } from '../ast';
|
|
4
|
+
import type { HandlerRule, RuleContext } from './types';
|
|
5
|
+
|
|
6
|
+
const SNOWFLAKE_QUERY = 'ctx.api.v1.snowflake.query';
|
|
7
|
+
|
|
8
|
+
const message = (file: string, line: number | undefined) =>
|
|
9
|
+
`${file}${line ? `:${line}` : ''}: ${SNOWFLAKE_QUERY} must receive SQL written in the handler — a string literal, ` +
|
|
10
|
+
`or a variable bound to one. Client-supplied values go through params (\`:name\` bindings) and options, never into the SQL text.`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* True when every possible value of `node` is text authored in this file:
|
|
14
|
+
* string literals, templates whose holes are themselves static, `+`
|
|
15
|
+
* concatenation, either branch of a conditional, and identifiers that resolve
|
|
16
|
+
* (within the file) to such values. Anything reaching outside the file —
|
|
17
|
+
* `ctx.input`, calls, member reads, destructuring — is not static.
|
|
18
|
+
*/
|
|
19
|
+
function isStaticSql(node: AnyNode | null | undefined, bindings: Bindings, resolving: ReadonlySet<string>): boolean {
|
|
20
|
+
if (!node) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const staticChild = (child: AnyNode | null | undefined) => isStaticSql(child, bindings, resolving);
|
|
24
|
+
switch (node.type) {
|
|
25
|
+
case 'Literal':
|
|
26
|
+
return typeof node.value === 'string';
|
|
27
|
+
case 'TemplateLiteral':
|
|
28
|
+
return node.expressions.every(staticChild);
|
|
29
|
+
case 'BinaryExpression':
|
|
30
|
+
return node.operator === '+' && staticChild(node.left) && staticChild(node.right);
|
|
31
|
+
case 'ConditionalExpression':
|
|
32
|
+
return staticChild(node.consequent) && staticChild(node.alternate);
|
|
33
|
+
case 'LogicalExpression':
|
|
34
|
+
return staticChild(node.left) && staticChild(node.right);
|
|
35
|
+
case 'Identifier': {
|
|
36
|
+
const { name } = node;
|
|
37
|
+
if (bindings.unpinnable.has(name) || resolving.has(name)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const decls = bindings.declarations.get(name);
|
|
41
|
+
if (!decls || decls.length === 0) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const next = new Set(resolving).add(name);
|
|
45
|
+
return decls.every(d => d.path?.length === 0 && isStaticSql(d.init, bindings, next));
|
|
46
|
+
}
|
|
47
|
+
default:
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `ctx.api.v1.snowflake.query(sql, params?, options?)` — the SQL text must live
|
|
54
|
+
* in the handler. The frontend chooses *which* handler to call and supplies
|
|
55
|
+
* `params` / `options`; it never ships SQL. Binding placeholders (`:name`) are
|
|
56
|
+
* how client values reach the query.
|
|
57
|
+
*
|
|
58
|
+
* The call is matched through `const` aliases of the channel
|
|
59
|
+
* (`const sf = ctx.api.v1.snowflake; sf.query(…)`), see `PathResolver`.
|
|
60
|
+
*/
|
|
61
|
+
export class SnowflakeStaticSqlRule implements HandlerRule {
|
|
62
|
+
readonly name = 'snowflake/static-sql';
|
|
63
|
+
|
|
64
|
+
check({ file, program, bindings, paths }: RuleContext): string[] {
|
|
65
|
+
const violations: string[] = [];
|
|
66
|
+
walkSimple(program, {
|
|
67
|
+
CallExpression(node) {
|
|
68
|
+
if (paths.pathOf(node.callee) !== SNOWFLAKE_QUERY) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!isStaticSql(node.arguments[0], bindings, new Set())) {
|
|
72
|
+
violations.push(message(file, lineOf(node)));
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
return violations;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Program } from 'acorn';
|
|
2
|
+
import type { Bindings, PathResolver } from '../ast';
|
|
3
|
+
|
|
4
|
+
export interface RuleContext {
|
|
5
|
+
/** Handler file name, used to prefix messages. */
|
|
6
|
+
file: string;
|
|
7
|
+
/** Parsed with `locations: true`, so rules can point at a line. */
|
|
8
|
+
program: Program;
|
|
9
|
+
/** Every variable binding in the file — see `collectBindings`. */
|
|
10
|
+
bindings: Bindings;
|
|
11
|
+
/** Resolves member chains through `const` aliases — see `createPathResolver`. */
|
|
12
|
+
paths: PathResolver;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A capability-specific check that runs once the handler is structurally valid
|
|
17
|
+
* (parses as a script, defines `handler`, imports nothing).
|
|
18
|
+
*
|
|
19
|
+
* Rules return human-readable violations rather than throwing so the validator
|
|
20
|
+
* can report every problem in one pass — agent-generated handlers are fixed in
|
|
21
|
+
* a loop, and one round-trip per violation is slow.
|
|
22
|
+
*
|
|
23
|
+
* Implement as a class (see `SnowflakeStaticSqlRule`) and add an instance to
|
|
24
|
+
* `HANDLER_RULES` in `./index.ts` — keeps every rule the same shape to read.
|
|
25
|
+
*/
|
|
26
|
+
export interface HandlerRule {
|
|
27
|
+
/** Stable identifier, e.g. `snowflake/static-sql`. */
|
|
28
|
+
name: string;
|
|
29
|
+
check(ctx: RuleContext): string[];
|
|
30
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { simple as walkSimple } from 'acorn-walk';
|
|
2
|
+
import { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS } from '@mondaydotcomorg/z2h-shared-utils';
|
|
3
|
+
import { warn } from '../util/logger';
|
|
4
|
+
import { collectApiDomains, collectBindings, createPathResolver } from './ast';
|
|
5
|
+
import { findHandlerDeclaration, handlerContextParamName, NEVER_A_FUNCTION } from './handler-declaration';
|
|
6
|
+
import { messages } from './messages';
|
|
7
|
+
import { parseOrThrow } from './parse';
|
|
8
|
+
import { HANDLER_RULES } from './rules';
|
|
9
|
+
|
|
10
|
+
const KNOWN_API_DOMAINS: readonly string[] = [...ALWAYS_ON_CHANNELS, ...DECLARED_INTEGRATIONS];
|
|
11
|
+
|
|
12
|
+
export interface HandlerValidation {
|
|
13
|
+
/** Every `ctx.api.v1.<domain>` the handler touches (known or not), sorted and unique. */
|
|
14
|
+
apiDomains: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validates a handler's source before upload: real parsing (via acorn) catches
|
|
19
|
+
* syntax errors at build time instead of an opaque failure inside the isolate.
|
|
20
|
+
* Handlers run with no module system and no bundler — `handler` must be a plain
|
|
21
|
+
* top-level global, and imports/requires (which would resolve to nothing at
|
|
22
|
+
* runtime) are rejected.
|
|
23
|
+
*
|
|
24
|
+
* Once the handler is structurally sound, capability-specific rules from
|
|
25
|
+
* `./rules` run and every violation is reported at once.
|
|
26
|
+
*
|
|
27
|
+
* Unknown `ctx.api.v1.<domain>` usage is a warning, not a failure: this pass is
|
|
28
|
+
* build-time DX feedback for agent-generated code, never a security boundary —
|
|
29
|
+
* the isolate has no ambient capabilities regardless of what a handler contains.
|
|
30
|
+
*
|
|
31
|
+
* Returns the `ctx.api.v1.*` domains the handler touches so callers (integration
|
|
32
|
+
* detection) can reuse the parse instead of re-scanning the source.
|
|
33
|
+
*/
|
|
34
|
+
export function validateHandlerSource(file: string, source: string): HandlerValidation {
|
|
35
|
+
const program = parseOrThrow(file, source);
|
|
36
|
+
|
|
37
|
+
const bindings = collectBindings(program);
|
|
38
|
+
|
|
39
|
+
walkSimple(program, {
|
|
40
|
+
CallExpression(node) {
|
|
41
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'require') {
|
|
42
|
+
throw new Error(messages.mustNotImport(file));
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
ImportExpression() {
|
|
46
|
+
throw new Error(messages.mustNotImport(file));
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const declaration = findHandlerDeclaration(program.body);
|
|
51
|
+
if (!declaration) {
|
|
52
|
+
throw new Error(messages.mustDefine(file));
|
|
53
|
+
}
|
|
54
|
+
if (declaration.kind === 'variable' && (!declaration.init || NEVER_A_FUNCTION.has(declaration.init.type))) {
|
|
55
|
+
throw new Error(messages.notAFunction(file));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const paths = createPathResolver(bindings, handlerContextParamName(declaration));
|
|
59
|
+
const apiDomains = collectApiDomains(program, paths);
|
|
60
|
+
|
|
61
|
+
const violations = HANDLER_RULES.flatMap(rule => rule.check({ file, program, bindings, paths }));
|
|
62
|
+
if (violations.length > 0) {
|
|
63
|
+
throw new Error(violations.join('\n'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const domain of apiDomains) {
|
|
67
|
+
if (!KNOWN_API_DOMAINS.includes(domain)) {
|
|
68
|
+
warn(`${file}: ctx.api.v1.${domain} is not a known capability (available: ${KNOWN_API_DOMAINS.join(', ')})`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { apiDomains: [...apiDomains].sort() };
|
|
73
|
+
}
|
|
@@ -23,7 +23,7 @@ const readHandlersMock = vi.mocked(readHandlers);
|
|
|
23
23
|
// handlers directory is known to exist.
|
|
24
24
|
beforeEach(() => {
|
|
25
25
|
readHandlersMock.mockReset();
|
|
26
|
-
readHandlersMock.mockResolvedValue({ handlers: { call_snowflake: '// code' } });
|
|
26
|
+
readHandlersMock.mockResolvedValue({ handlers: { call_snowflake: '// code' }, apiDomains: [] });
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
describe('hasHandlers', () => {
|
|
@@ -60,7 +60,7 @@ describe('deployBackend (live)', () => {
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it('skips upload when the handlers directory holds nothing', async () => {
|
|
63
|
-
readHandlersMock.mockResolvedValue({ handlers: {} });
|
|
63
|
+
readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [] });
|
|
64
64
|
|
|
65
65
|
await expect(
|
|
66
66
|
deployBackend({
|
|
@@ -80,6 +80,7 @@ describe('deployBackend (live)', () => {
|
|
|
80
80
|
await writeFile(path.join(tmp, 'backend', 'handlers', 'call_snowflake.ts'), '// placeholder');
|
|
81
81
|
readHandlersMock.mockResolvedValue({
|
|
82
82
|
handlers: { call_snowflake: 'async function handler(ctx) { return ctx.api.v1.snowflake.runQuery("x"); }' },
|
|
83
|
+
apiDomains: ['snowflake'],
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
// snowflake is always-on — not a declared integration
|
|
@@ -129,7 +130,7 @@ describe('deployBackend (preview)', () => {
|
|
|
129
130
|
});
|
|
130
131
|
|
|
131
132
|
it('skips upload when the handlers directory holds nothing', async () => {
|
|
132
|
-
readHandlersMock.mockResolvedValue({ handlers: {} });
|
|
133
|
+
readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [] });
|
|
133
134
|
|
|
134
135
|
await expect(
|
|
135
136
|
deployBackend({
|
|
@@ -149,6 +150,7 @@ describe('deployBackend (preview)', () => {
|
|
|
149
150
|
await writeFile(path.join(tmp, 'backend', 'handlers', 'call_snowflake.ts'), '// placeholder');
|
|
150
151
|
readHandlersMock.mockResolvedValue({
|
|
151
152
|
handlers: { call_snowflake: 'async function handler(ctx) { return ctx.api.v1.monday.query("x"); }' },
|
|
153
|
+
apiDomains: ['monday'],
|
|
152
154
|
});
|
|
153
155
|
|
|
154
156
|
await expect(
|
|
@@ -0,0 +1,71 @@
|
|
|
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 { backendValidateCommand } from '../backend';
|
|
7
|
+
import { output } from '../../util/logger';
|
|
8
|
+
|
|
9
|
+
vi.mock('../../util/logger', () => ({
|
|
10
|
+
info: vi.fn(),
|
|
11
|
+
output: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('../../util/tracker', () => ({
|
|
14
|
+
trackEvent: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
trackDuration: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const mockSend = vi.fn();
|
|
19
|
+
vi.mock('../../util/s3/client', () => ({
|
|
20
|
+
getS3Client: vi.fn(() => ({ send: mockSend })),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const outputMock = vi.mocked(output);
|
|
24
|
+
|
|
25
|
+
describe('backendValidateCommand', () => {
|
|
26
|
+
let tmp: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-backend-validate-'));
|
|
30
|
+
outputMock.mockClear();
|
|
31
|
+
mockSend.mockClear();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await rm(tmp, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reports nothing to validate when there is no handlers directory', async () => {
|
|
39
|
+
await backendValidateCommand(tmp);
|
|
40
|
+
expect(outputMock).toHaveBeenCalledWith(expect.stringContaining('nothing to validate'), {
|
|
41
|
+
handlers: [],
|
|
42
|
+
integrations: [],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('reports valid handlers and detected integrations without uploading', async () => {
|
|
47
|
+
const dir = path.join(tmp, 'backend', 'handlers');
|
|
48
|
+
await ensureDir(dir);
|
|
49
|
+
await writeFile(
|
|
50
|
+
path.join(dir, 'list_items.js'),
|
|
51
|
+
'async function handler(ctx) { return ctx.api.v1.monday.query("query { me { id } }"); }'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await backendValidateCommand(tmp);
|
|
55
|
+
|
|
56
|
+
expect(outputMock).toHaveBeenCalledWith(expect.stringContaining('1 handler(s) valid: list_items'), {
|
|
57
|
+
handlers: ['list_items'],
|
|
58
|
+
integrations: ['monday'],
|
|
59
|
+
});
|
|
60
|
+
expect(mockSend).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('throws the same validation error deploy would', async () => {
|
|
64
|
+
const dir = path.join(tmp, 'backend', 'handlers');
|
|
65
|
+
await ensureDir(dir);
|
|
66
|
+
await writeFile(path.join(dir, 'bad.js'), 'export async function handler(ctx) { return 1; }');
|
|
67
|
+
|
|
68
|
+
await expect(backendValidateCommand(tmp)).rejects.toThrow(/must not (use )?export/);
|
|
69
|
+
expect(outputMock).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
6
6
|
import { runYarnInstall } from '../../util/npm/install';
|
|
7
7
|
import { initRepo, defaultS3Remote } from '../../util/git/repo';
|
|
8
8
|
import { prepareShadow } from '../../shadow/prepare';
|
|
9
|
+
import { fetchApp, claimApp } from '../../util/broker/app';
|
|
9
10
|
import { createCommand } from '../create';
|
|
10
11
|
|
|
11
12
|
// Mock the install helper so `yarn install` (with its broker preflight) doesn't
|
|
@@ -28,6 +29,7 @@ vi.mock('../../shadow/prepare', () => ({
|
|
|
28
29
|
// Mock broker app lookup — no token needed in tests; default to null (app does not exist).
|
|
29
30
|
vi.mock('../../util/broker/app', () => ({
|
|
30
31
|
fetchApp: vi.fn().mockResolvedValue(null),
|
|
32
|
+
claimApp: vi.fn().mockResolvedValue(undefined),
|
|
31
33
|
}));
|
|
32
34
|
|
|
33
35
|
async function makeWorkspace(tmp: string): Promise<void> {
|
|
@@ -50,6 +52,8 @@ describe('create', () => {
|
|
|
50
52
|
vi.mocked(initRepo).mockClear();
|
|
51
53
|
vi.mocked(defaultS3Remote).mockClear();
|
|
52
54
|
vi.mocked(prepareShadow).mockClear();
|
|
55
|
+
vi.mocked(fetchApp).mockReset().mockResolvedValue(null);
|
|
56
|
+
vi.mocked(claimApp).mockReset().mockResolvedValue(undefined);
|
|
53
57
|
});
|
|
54
58
|
|
|
55
59
|
afterEach(async () => {
|
|
@@ -162,4 +166,46 @@ describe('create', () => {
|
|
|
162
166
|
await createCommand('gh-app', { remote: 'github', repoUrl: 'https://github.com/acme/gh-app' });
|
|
163
167
|
expect(vi.mocked(initRepo)).toHaveBeenCalledWith(path.join(tmp, 'gh-app'), 'https://github.com/acme/gh-app');
|
|
164
168
|
});
|
|
169
|
+
|
|
170
|
+
it('claims the app name with the resolved gitRemote when the name is genuinely free', async () => {
|
|
171
|
+
await makeWorkspace(tmp);
|
|
172
|
+
await createCommand('my-app', { remote: 's3' });
|
|
173
|
+
expect(vi.mocked(claimApp)).toHaveBeenCalledWith(
|
|
174
|
+
'mf-my-app',
|
|
175
|
+
's3://prod-use1-bigbrain-zth-mf-assets/mf-my-app/git'
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('throws when the app already has a real release', async () => {
|
|
180
|
+
await makeWorkspace(tmp);
|
|
181
|
+
vi.mocked(fetchApp).mockResolvedValue({
|
|
182
|
+
appName: 'mf-demo-app',
|
|
183
|
+
version: 1,
|
|
184
|
+
entry: { version: 1, url: 'https://cdn/demo-app/1/' },
|
|
185
|
+
deployed: true,
|
|
186
|
+
deployedBy: 'x@x.com',
|
|
187
|
+
gitRemote: 's3://bucket/demo-app/git',
|
|
188
|
+
createdBy: 'x@x.com',
|
|
189
|
+
});
|
|
190
|
+
await expect(createCommand('demo-app', { remote: 's3' })).rejects.toThrow(/already exists/);
|
|
191
|
+
expect(claimApp).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('proceeds without re-claiming when the app was already claimed but never published', async () => {
|
|
195
|
+
await makeWorkspace(tmp);
|
|
196
|
+
vi.mocked(fetchApp).mockResolvedValue({
|
|
197
|
+
appName: 'mf-demo-app',
|
|
198
|
+
version: 0,
|
|
199
|
+
entry: null,
|
|
200
|
+
deployed: false,
|
|
201
|
+
deployedBy: 'x@x.com',
|
|
202
|
+
gitRemote: 's3://bucket/demo-app/git-claim',
|
|
203
|
+
createdBy: 'x@x.com',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await createCommand('demo-app', { remote: 's3' });
|
|
207
|
+
|
|
208
|
+
expect(claimApp).not.toHaveBeenCalled();
|
|
209
|
+
expect(await pathExists(path.join(tmp, 'demo-app', 'package.json'))).toBe(true);
|
|
210
|
+
});
|
|
165
211
|
});
|