@mondaydotcomorg/z2h-cli 0.30.0 → 0.31.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.
Files changed (146) hide show
  1. package/dist/backend/handlers.d.ts +3 -0
  2. package/dist/backend/handlers.d.ts.map +1 -1
  3. package/dist/backend/handlers.js +7 -4
  4. package/dist/backend/parser/__tests__/ast.test.d.ts.map +1 -0
  5. package/dist/backend/parser/__tests__/parse.test.d.ts.map +1 -0
  6. package/dist/backend/parser/api-signatures.d.ts +4 -0
  7. package/dist/backend/parser/api-signatures.d.ts.map +1 -0
  8. package/dist/backend/parser/api-signatures.js +31 -0
  9. package/dist/backend/parser/api-usage.d.ts +5 -0
  10. package/dist/backend/parser/api-usage.d.ts.map +1 -0
  11. package/dist/backend/parser/api-usage.js +61 -0
  12. package/dist/backend/parser/bindings.d.ts +5 -0
  13. package/dist/backend/parser/bindings.d.ts.map +1 -0
  14. package/dist/backend/parser/bindings.js +84 -0
  15. package/dist/backend/parser/handler-declaration.d.ts.map +1 -0
  16. package/dist/backend/parser/member-path.d.ts +3 -0
  17. package/dist/backend/parser/member-path.d.ts.map +1 -0
  18. package/dist/backend/parser/member-path.js +31 -0
  19. package/dist/backend/parser/messages.d.ts.map +1 -0
  20. package/dist/backend/parser/parse.d.ts.map +1 -0
  21. package/dist/backend/{parse.js → parser/parse.js} +9 -9
  22. package/dist/backend/parser/static-value.d.ts +6 -0
  23. package/dist/backend/parser/static-value.d.ts.map +1 -0
  24. package/dist/backend/parser/static-value.js +117 -0
  25. package/dist/backend/parser/types.d.ts +53 -0
  26. package/dist/backend/parser/types.d.ts.map +1 -0
  27. package/dist/backend/parser/types.js +1 -0
  28. package/dist/backend/parser/utils.d.ts +19 -0
  29. package/dist/backend/parser/utils.d.ts.map +1 -0
  30. package/dist/backend/parser/utils.js +97 -0
  31. package/dist/backend/validate.d.ts +3 -3
  32. package/dist/backend/validate.d.ts.map +1 -1
  33. package/dist/backend/validate.js +18 -18
  34. package/dist/commands/backend.d.ts +2 -13
  35. package/dist/commands/backend.d.ts.map +1 -1
  36. package/dist/commands/backend.js +30 -21
  37. package/dist/commands/create.d.ts.map +1 -1
  38. package/dist/commands/create.js +1 -0
  39. package/dist/commands/deploy.d.ts.map +1 -1
  40. package/dist/commands/deploy.js +2 -8
  41. package/dist/esm/backend/handlers.d.ts +3 -0
  42. package/dist/esm/backend/handlers.d.ts.map +1 -1
  43. package/dist/esm/backend/handlers.mjs +7 -4
  44. package/dist/esm/backend/parser/__tests__/ast.test.d.ts.map +1 -0
  45. package/dist/esm/backend/parser/__tests__/parse.test.d.ts.map +1 -0
  46. package/dist/esm/backend/parser/api-signatures.d.ts +4 -0
  47. package/dist/esm/backend/parser/api-signatures.d.ts.map +1 -0
  48. package/dist/esm/backend/parser/api-signatures.mjs +24 -0
  49. package/dist/esm/backend/parser/api-usage.d.ts +5 -0
  50. package/dist/esm/backend/parser/api-usage.d.ts.map +1 -0
  51. package/dist/esm/backend/parser/api-usage.mjs +58 -0
  52. package/dist/esm/backend/parser/bindings.d.ts +5 -0
  53. package/dist/esm/backend/parser/bindings.d.ts.map +1 -0
  54. package/dist/esm/backend/parser/bindings.mjs +81 -0
  55. package/dist/esm/backend/parser/handler-declaration.d.ts.map +1 -0
  56. package/dist/esm/backend/parser/member-path.d.ts +3 -0
  57. package/dist/esm/backend/parser/member-path.d.ts.map +1 -0
  58. package/dist/esm/backend/parser/member-path.mjs +29 -0
  59. package/dist/esm/backend/parser/messages.d.ts.map +1 -0
  60. package/dist/esm/backend/parser/parse.d.ts.map +1 -0
  61. package/dist/esm/backend/{parse.mjs → parser/parse.mjs} +1 -1
  62. package/dist/esm/backend/parser/static-value.d.ts +6 -0
  63. package/dist/esm/backend/parser/static-value.d.ts.map +1 -0
  64. package/dist/esm/backend/parser/static-value.mjs +114 -0
  65. package/dist/esm/backend/parser/types.d.ts +53 -0
  66. package/dist/esm/backend/parser/types.d.ts.map +1 -0
  67. package/dist/esm/backend/parser/types.mjs +1 -0
  68. package/dist/esm/backend/parser/utils.d.ts +19 -0
  69. package/dist/esm/backend/parser/utils.d.ts.map +1 -0
  70. package/dist/esm/backend/parser/utils.mjs +88 -0
  71. package/dist/esm/backend/validate.d.ts +3 -3
  72. package/dist/esm/backend/validate.d.ts.map +1 -1
  73. package/dist/esm/backend/validate.mjs +8 -8
  74. package/dist/esm/commands/backend.d.ts +2 -13
  75. package/dist/esm/commands/backend.d.ts.map +1 -1
  76. package/dist/esm/commands/backend.mjs +31 -22
  77. package/dist/esm/commands/create.d.ts.map +1 -1
  78. package/dist/esm/commands/create.mjs +1 -0
  79. package/dist/esm/commands/deploy.d.ts.map +1 -1
  80. package/dist/esm/commands/deploy.mjs +3 -8
  81. package/dist/esm/templates/app/usage.mjs +5 -0
  82. package/dist/esm/util/machine-identity.d.ts +2 -3
  83. package/dist/esm/util/machine-identity.d.ts.map +1 -1
  84. package/dist/esm/util/machine-identity.mjs +7 -4
  85. package/dist/templates/app/usage.js +7 -0
  86. package/dist/util/machine-identity.d.ts +2 -3
  87. package/dist/util/machine-identity.d.ts.map +1 -1
  88. package/dist/util/machine-identity.js +11 -3
  89. package/handler-api/signatures.json +26 -0
  90. package/package.json +1 -1
  91. package/src/backend/__tests__/handlers.test.ts +13 -2
  92. package/src/backend/__tests__/validate.test.ts +54 -0
  93. package/src/backend/handlers.ts +10 -5
  94. package/src/backend/parser/__tests__/ast.test.ts +252 -0
  95. package/src/backend/{__tests__ → parser/__tests__}/parse.test.ts +2 -2
  96. package/src/backend/parser/api-signatures.ts +23 -0
  97. package/src/backend/parser/api-usage.ts +73 -0
  98. package/src/backend/parser/bindings.ts +87 -0
  99. package/src/backend/parser/member-path.ts +31 -0
  100. package/src/backend/{parse.ts → parser/parse.ts} +1 -1
  101. package/src/backend/parser/static-value.ts +145 -0
  102. package/src/backend/parser/types.ts +66 -0
  103. package/src/backend/parser/utils.ts +106 -0
  104. package/src/backend/validate.ts +12 -8
  105. package/src/commands/__tests__/backend-deploy.test.ts +70 -5
  106. package/src/commands/__tests__/backend-validate.test.ts +11 -1
  107. package/src/commands/__tests__/create.test.ts +1 -0
  108. package/src/commands/backend.ts +31 -20
  109. package/src/commands/create.ts +1 -0
  110. package/src/commands/deploy.ts +3 -9
  111. package/src/templates/app/usage.json +1 -0
  112. package/src/util/machine-identity.ts +7 -3
  113. package/dist/backend/__tests__/ast.test.d.ts.map +0 -1
  114. package/dist/backend/__tests__/parse.test.d.ts.map +0 -1
  115. package/dist/backend/ast.d.ts +0 -39
  116. package/dist/backend/ast.d.ts.map +0 -1
  117. package/dist/backend/ast.js +0 -149
  118. package/dist/backend/handler-declaration.d.ts.map +0 -1
  119. package/dist/backend/messages.d.ts.map +0 -1
  120. package/dist/backend/parse.d.ts.map +0 -1
  121. package/dist/esm/backend/__tests__/ast.test.d.ts.map +0 -1
  122. package/dist/esm/backend/__tests__/parse.test.d.ts.map +0 -1
  123. package/dist/esm/backend/ast.d.ts +0 -39
  124. package/dist/esm/backend/ast.d.ts.map +0 -1
  125. package/dist/esm/backend/ast.mjs +0 -144
  126. package/dist/esm/backend/handler-declaration.d.ts.map +0 -1
  127. package/dist/esm/backend/messages.d.ts.map +0 -1
  128. package/dist/esm/backend/parse.d.ts.map +0 -1
  129. package/src/backend/__tests__/ast.test.ts +0 -53
  130. package/src/backend/ast.ts +0 -201
  131. /package/dist/backend/{__tests__ → parser/__tests__}/ast.test.d.ts +0 -0
  132. /package/dist/backend/{__tests__ → parser/__tests__}/parse.test.d.ts +0 -0
  133. /package/dist/backend/{handler-declaration.d.ts → parser/handler-declaration.d.ts} +0 -0
  134. /package/dist/backend/{handler-declaration.js → parser/handler-declaration.js} +0 -0
  135. /package/dist/backend/{messages.d.ts → parser/messages.d.ts} +0 -0
  136. /package/dist/backend/{messages.js → parser/messages.js} +0 -0
  137. /package/dist/backend/{parse.d.ts → parser/parse.d.ts} +0 -0
  138. /package/dist/esm/backend/{__tests__ → parser/__tests__}/ast.test.d.ts +0 -0
  139. /package/dist/esm/backend/{__tests__ → parser/__tests__}/parse.test.d.ts +0 -0
  140. /package/dist/esm/backend/{handler-declaration.d.ts → parser/handler-declaration.d.ts} +0 -0
  141. /package/dist/esm/backend/{handler-declaration.mjs → parser/handler-declaration.mjs} +0 -0
  142. /package/dist/esm/backend/{messages.d.ts → parser/messages.d.ts} +0 -0
  143. /package/dist/esm/backend/{messages.mjs → parser/messages.mjs} +0 -0
  144. /package/dist/esm/backend/{parse.d.ts → parser/parse.d.ts} +0 -0
  145. /package/src/backend/{handler-declaration.ts → parser/handler-declaration.ts} +0 -0
  146. /package/src/backend/{messages.ts → parser/messages.ts} +0 -0
@@ -0,0 +1,66 @@
1
+ import type { AnyNode, Expression, Node, VariableDeclaration } from 'acorn';
2
+
3
+ /** `"<domain>.<method>"` → parameter names by position, as documented for handler authors. */
4
+ export type ApiSignatures = Record<string, readonly string[]>;
5
+
6
+ // ─────────── Bindings ───────────
7
+
8
+ export interface Declaration {
9
+ kind: VariableDeclaration['kind'] | 'param';
10
+ init: Expression | null | undefined;
11
+ /**
12
+ * Property path from `init` to this binding: `[]` for `const x = init`,
13
+ * `['a', 'b']` for `const { a: { b: x } } = init`, `undefined` when the value
14
+ * is not a plain property read (array/rest/default patterns).
15
+ */
16
+ path: string[] | undefined;
17
+ scope: Node;
18
+ }
19
+
20
+ export interface Bindings {
21
+ declarations: Map<string, Declaration[]>;
22
+ /** Names reassigned or updated after declaration — never followed as a stable alias. */
23
+ unpinnable: Set<string>;
24
+ }
25
+
26
+ export interface PatternBinding {
27
+ name: string;
28
+ path: string[] | undefined;
29
+ }
30
+
31
+ // ─────────── Static values ───────────
32
+
33
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
34
+
35
+ /** Wraps a resolved value so "resolved to null" is distinguishable from "unresolved" (`undefined`). */
36
+ export type Resolved<T> = { readonly value: T };
37
+
38
+ // ─────────── Member paths ───────────
39
+
40
+ export interface MemberChain {
41
+ root: string;
42
+ parts: string[];
43
+ }
44
+
45
+ export interface PathResolver {
46
+ /**
47
+ * Dotted path of a member chain, following `const` aliases back to their
48
+ * source: with `const sf = ctx.api.v1.snowflake`, `sf.query` resolves to
49
+ * `ctx.api.v1.snowflake.query`. Destructuring counts too
50
+ * (`const { snowflake: sf } = ctx.api.v1`). `undefined` if any link is dynamic.
51
+ *
52
+ * Only single, never-reassigned, never-shadowed `const` bindings are followed —
53
+ * anything else is left as written. The handler's first parameter is treated as
54
+ * `ctx` even when renamed (`handler(context)` → `context.api.v1.db` resolves as
55
+ * `ctx.api.v1.db`).
56
+ */
57
+ pathOf(node: AnyNode): string | undefined;
58
+ }
59
+
60
+ // ─────────── API usage ───────────
61
+
62
+ /** Undocumented arguments or methods fall back to `arg<index>` keys. */
63
+ export type ApiCall = Record<string, JsonValue>;
64
+
65
+ /** `domain -> method -> calls`, later written into `usage.json` by deploy. */
66
+ export type ApiUsage = Record<string, Record<string, ApiCall[]>>;
@@ -0,0 +1,106 @@
1
+ import type { AnyNode, Node, Pattern } from 'acorn';
2
+ import type { JsonValue, MemberChain, PatternBinding, Resolved } from './types';
3
+
4
+ // ─────────── Source text ───────────
5
+
6
+ export const sourceTextOf = (node: AnyNode, source: string): string => source.slice(node.start, node.end);
7
+
8
+ export function displayText(value: JsonValue): string {
9
+ return typeof value === 'string' ? value : JSON.stringify(value);
10
+ }
11
+
12
+ export function resolveOrSourceText(
13
+ node: AnyNode,
14
+ source: string,
15
+ resolve: (node: AnyNode) => Resolved<JsonValue> | undefined,
16
+ fallbackText: (node: AnyNode) => string = n => sourceTextOf(n, source)
17
+ ): JsonValue {
18
+ const resolved = resolve(node);
19
+ return resolved !== undefined ? resolved.value : fallbackText(node);
20
+ }
21
+
22
+ // ─────────── Keys and member chains ───────────
23
+
24
+ /** Static property key of `obj.key` / `obj['key']` / `{ key: … }`; `undefined` when computed from a non-literal. */
25
+ export function propertyKey(key: AnyNode, computed: boolean): string | undefined {
26
+ if (key.type === 'Literal' && typeof key.value === 'string') {
27
+ return key.value;
28
+ }
29
+ if (!computed && key.type === 'Identifier') {
30
+ return key.name;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ /** `a.b.c` / `a['b'].c` → root `a`, parts `[b, c]`; `undefined` if any link is dynamic or the root is not an identifier. */
36
+ export function memberChain(node: AnyNode): MemberChain | undefined {
37
+ if (node.type === 'ChainExpression') {
38
+ return memberChain(node.expression);
39
+ }
40
+ const parts: string[] = [];
41
+ let cur: AnyNode = node;
42
+ while (cur.type === 'MemberExpression') {
43
+ const key = propertyKey(cur.property, cur.computed);
44
+ if (key === undefined) {
45
+ return undefined;
46
+ }
47
+ parts.unshift(key);
48
+ cur = cur.object;
49
+ }
50
+ return cur.type === 'Identifier' ? { root: cur.name, parts } : undefined;
51
+ }
52
+
53
+ // ─────────── Binding patterns ───────────
54
+
55
+ /**
56
+ * Every identifier a binding pattern introduces, with its property path from the
57
+ * pattern's value. `path` is deliberately not defaulted: callers pass `[]` for
58
+ * the root and `undefined` once the pattern stops being a plain property read.
59
+ */
60
+ export function patternBindings(pattern: Pattern, path: string[] | undefined): PatternBinding[] {
61
+ switch (pattern.type) {
62
+ case 'Identifier':
63
+ return [{ name: pattern.name, path }];
64
+ case 'ObjectPattern':
65
+ return pattern.properties.flatMap(p => {
66
+ if (p.type === 'RestElement') {
67
+ return patternBindings(p.argument, undefined);
68
+ }
69
+ const key = propertyKey(p.key, p.computed);
70
+ return patternBindings(p.value, path && key !== undefined ? [...path, key] : undefined);
71
+ });
72
+ case 'ArrayPattern':
73
+ return pattern.elements.flatMap(e => (e ? patternBindings(e, undefined) : []));
74
+ case 'AssignmentPattern':
75
+ return patternBindings(pattern.left, undefined);
76
+ case 'RestElement':
77
+ return patternBindings(pattern.argument, undefined);
78
+ default:
79
+ // MemberExpression targets (`obj.x = …`) bind no names.
80
+ return [];
81
+ }
82
+ }
83
+
84
+ export const patternNames = (pattern: Pattern): string[] => patternBindings(pattern, []).map(b => b.name);
85
+
86
+ // ─────────── Scopes ───────────
87
+
88
+ const SCOPE_TYPES: ReadonlySet<string> = new Set([
89
+ 'Program',
90
+ 'BlockStatement',
91
+ 'StaticBlock',
92
+ 'SwitchStatement',
93
+ 'ForStatement',
94
+ 'ForInStatement',
95
+ 'ForOfStatement',
96
+ ]);
97
+
98
+ /** Nearest scope enclosing the last node of `ancestors` (which `fullAncestor` includes in the list). */
99
+ export function enclosingScope(ancestors: AnyNode[]): Node {
100
+ for (let i = ancestors.length - 2; i > 0; i--) {
101
+ if (SCOPE_TYPES.has(ancestors[i].type)) {
102
+ return ancestors[i];
103
+ }
104
+ }
105
+ return ancestors[0];
106
+ }
@@ -1,16 +1,22 @@
1
1
  import { simple as walkSimple } from 'acorn-walk';
2
2
  import { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS } from '@mondaydotcomorg/z2h-shared-utils';
3
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';
4
+ import { collectApiDomains, collectApiUsages } from './parser/api-usage';
5
+ import { collectBindings } from './parser/bindings';
6
+ import { findHandlerDeclaration, handlerContextParamName, NEVER_A_FUNCTION } from './parser/handler-declaration';
7
+ import { createPathResolver } from './parser/member-path';
8
+ import { messages } from './parser/messages';
9
+ import { parseOrThrow } from './parser/parse';
10
+ import type { ApiUsage } from './parser/types';
11
+
12
+ export type { ApiCall, ApiUsage } from './parser/types';
8
13
 
9
14
  const KNOWN_API_DOMAINS: readonly string[] = [...ALWAYS_ON_CHANNELS, ...DECLARED_INTEGRATIONS];
10
15
 
11
16
  export interface HandlerValidation {
12
17
  /** Every `ctx.api.v1.<domain>` the handler touches (known or not), sorted and unique. */
13
18
  apiDomains: string[];
19
+ apiUsages: ApiUsage;
14
20
  }
15
21
 
16
22
  /**
@@ -23,9 +29,6 @@ export interface HandlerValidation {
23
29
  * Unknown `ctx.api.v1.<domain>` usage is a warning, not a failure: this pass is
24
30
  * build-time DX feedback for agent-generated code, never a security boundary —
25
31
  * the isolate has no ambient capabilities regardless of what a handler contains.
26
- *
27
- * Returns the `ctx.api.v1.*` domains the handler touches so callers (integration
28
- * detection) can reuse the parse instead of re-scanning the source.
29
32
  */
30
33
  export function validateHandlerSource(file: string, source: string): HandlerValidation {
31
34
  const program = parseOrThrow(file, source);
@@ -53,6 +56,7 @@ export function validateHandlerSource(file: string, source: string): HandlerVali
53
56
 
54
57
  const paths = createPathResolver(bindings, handlerContextParamName(declaration));
55
58
  const apiDomains = collectApiDomains(program, paths);
59
+ const apiUsages = collectApiUsages(program, bindings, paths, source);
56
60
 
57
61
  for (const domain of apiDomains) {
58
62
  if (!KNOWN_API_DOMAINS.includes(domain)) {
@@ -60,5 +64,5 @@ export function validateHandlerSource(file: string, source: string): HandlerVali
60
64
  }
61
65
  }
62
66
 
63
- return { apiDomains: [...apiDomains].sort() };
67
+ return { apiDomains: [...apiDomains].sort(), apiUsages };
64
68
  }
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
- import { mkdtemp, rm, ensureDir, writeFile } from 'fs-extra';
3
+ import { mkdtemp, rm, ensureDir, writeFile, writeJson, readJson, pathExists } from 'fs-extra';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import { deployBackend, hasHandlers } from '../backend';
@@ -23,7 +23,11 @@ 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' }, apiDomains: [] });
26
+ readHandlersMock.mockResolvedValue({
27
+ handlers: { call_snowflake: '// code' },
28
+ apiDomains: [],
29
+ apiUsages: { call_snowflake: {} },
30
+ });
27
31
  });
28
32
 
29
33
  describe('hasHandlers', () => {
@@ -60,7 +64,7 @@ describe('deployBackend (live)', () => {
60
64
  });
61
65
 
62
66
  it('skips upload when the handlers directory holds nothing', async () => {
63
- readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [] });
67
+ readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [], apiUsages: {} });
64
68
 
65
69
  await expect(
66
70
  deployBackend({
@@ -79,8 +83,9 @@ describe('deployBackend (live)', () => {
79
83
  await ensureDir(path.join(tmp, 'backend', 'handlers'));
80
84
  await writeFile(path.join(tmp, 'backend', 'handlers', 'call_snowflake.ts'), '// placeholder');
81
85
  readHandlersMock.mockResolvedValue({
82
- handlers: { call_snowflake: 'async function handler(ctx) { return ctx.api.v1.snowflake.runQuery("x"); }' },
86
+ handlers: { call_snowflake: 'async function handler(ctx) { return ctx.api.v1.snowflake.query("x"); }' },
83
87
  apiDomains: ['snowflake'],
88
+ apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
84
89
  });
85
90
 
86
91
  // snowflake is always-on — not a declared integration
@@ -97,6 +102,63 @@ describe('deployBackend (live)', () => {
97
102
  expect(mockSend).toHaveBeenCalled();
98
103
  const keys: string[] = mockSend.mock.calls.map((c: unknown[]) => (c[0] as { input: { Key: string } }).input.Key);
99
104
  expect(keys.some(k => k.includes('/backend-1/'))).toBe(true);
105
+ // usage.json stays local-only — the runner's S3 discovery still comes from meta.json.
106
+ expect(keys.some(k => k.endsWith('/usage.json'))).toBe(false);
107
+ expect(keys.some(k => k.endsWith('/meta.json'))).toBe(true);
108
+
109
+ const metaCall = mockSend.mock.calls.find((c: unknown[]) =>
110
+ (c[0] as { input: { Key: string } }).input.Key.endsWith('/meta.json')
111
+ );
112
+ const meta = JSON.parse((metaCall![0] as { input: { Body: string } }).input.Body);
113
+ expect(meta).toEqual({ version: 1, deployedBy: 'test-user', handlers: ['call_snowflake'] });
114
+
115
+ await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
116
+ appName: 'mf-my-app',
117
+ deployedBy: 'test-user',
118
+ apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
119
+ });
120
+ });
121
+
122
+ it('writes usage.json locally even for apps that had none yet', async () => {
123
+ await ensureDir(path.join(tmp, 'backend', 'handlers'));
124
+ await expect(pathExists(path.join(tmp, 'usage.json'))).resolves.toBe(false);
125
+
126
+ await deployBackend({
127
+ appDir: tmp,
128
+ appName: 'mf-my-app',
129
+ mfKeyPrefix: 'mf-my-app/1',
130
+ version: 1,
131
+ deployedBy: 'test-user',
132
+ isPreview: false,
133
+ });
134
+
135
+ expect(mockSend).toHaveBeenCalled();
136
+ await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
137
+ appName: 'mf-my-app',
138
+ deployedBy: 'test-user',
139
+ apiUsages: { call_snowflake: {} },
140
+ });
141
+ });
142
+
143
+ it('merges the local usage.json instead of overwriting it, and writes the merged result back locally', async () => {
144
+ await ensureDir(path.join(tmp, 'backend', 'handlers'));
145
+ await writeJson(path.join(tmp, 'usage.json'), { someOtherFeatureField: 'keep-me' });
146
+
147
+ await deployBackend({
148
+ appDir: tmp,
149
+ appName: 'mf-my-app',
150
+ mfKeyPrefix: 'mf-my-app/1',
151
+ version: 1,
152
+ deployedBy: 'test-user',
153
+ isPreview: false,
154
+ });
155
+
156
+ await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
157
+ someOtherFeatureField: 'keep-me',
158
+ appName: 'mf-my-app',
159
+ deployedBy: 'test-user',
160
+ apiUsages: { call_snowflake: {} },
161
+ });
100
162
  });
101
163
 
102
164
  it('tags a handler upload failure as deploy_backend_error, not register_error', async () => {
@@ -130,7 +192,7 @@ describe('deployBackend (preview)', () => {
130
192
  });
131
193
 
132
194
  it('skips upload when the handlers directory holds nothing', async () => {
133
- readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [] });
195
+ readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [], apiUsages: {} });
134
196
 
135
197
  await expect(
136
198
  deployBackend({
@@ -151,6 +213,7 @@ describe('deployBackend (preview)', () => {
151
213
  readHandlersMock.mockResolvedValue({
152
214
  handlers: { call_snowflake: 'async function handler(ctx) { return ctx.api.v1.monday.query("x"); }' },
153
215
  apiDomains: ['monday'],
216
+ apiUsages: { call_snowflake: { monday: { query: [{ query: 'x' }] } } },
154
217
  });
155
218
 
156
219
  await expect(
@@ -166,5 +229,7 @@ describe('deployBackend (preview)', () => {
166
229
  expect(mockSend).toHaveBeenCalled();
167
230
  const keys: string[] = mockSend.mock.calls.map((c: unknown[]) => (c[0] as { input: { Key: string } }).input.Key);
168
231
  expect(keys.some(k => k.includes('/MS/'))).toBe(true);
232
+ expect(keys.some(k => k.endsWith('/usage.json'))).toBe(false);
233
+ expect(keys.some(k => k.endsWith('/meta.json'))).toBe(true);
169
234
  });
170
235
  });
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
- import { mkdtemp, rm, ensureDir, writeFile } from 'fs-extra';
3
+ import { mkdtemp, rm, ensureDir, writeFile, writeJson, readJson } from 'fs-extra';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import { backendValidateCommand } from '../backend';
@@ -14,6 +14,9 @@ vi.mock('../../util/tracker', () => ({
14
14
  trackEvent: vi.fn().mockResolvedValue(undefined),
15
15
  trackDuration: vi.fn(),
16
16
  }));
17
+ vi.mock('../../util/machine-identity', () => ({
18
+ whoAmI: () => 'test-user',
19
+ }));
17
20
 
18
21
  const mockSend = vi.fn();
19
22
  vi.mock('../../util/s3/client', () => ({
@@ -44,6 +47,7 @@ describe('backendValidateCommand', () => {
44
47
  });
45
48
 
46
49
  it('reports valid handlers and detected integrations without uploading', async () => {
50
+ await writeJson(path.join(tmp, 'package.json'), { name: 'my-app' });
47
51
  const dir = path.join(tmp, 'backend', 'handlers');
48
52
  await ensureDir(dir);
49
53
  await writeFile(
@@ -58,6 +62,12 @@ describe('backendValidateCommand', () => {
58
62
  integrations: ['monday'],
59
63
  });
60
64
  expect(mockSend).not.toHaveBeenCalled();
65
+
66
+ await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
67
+ appName: 'mf-my-app',
68
+ deployedBy: 'test-user',
69
+ apiUsages: { list_items: { monday: { query: [{ query: 'query { me { id } }' }] } } },
70
+ });
61
71
  });
62
72
 
63
73
  it('throws the same validation error deploy would', async () => {
@@ -95,6 +95,7 @@ describe('create', () => {
95
95
  expect(await pathExists(path.join(appDir, '.gitignore'))).toBe(true);
96
96
  expect(await pathExists(path.join(appDir, '.claude', 'settings.json'))).toBe(true);
97
97
  expect(await pathExists(path.join(appDir, 'CLAUDE.md'))).toBe(true);
98
+ await expect(readJson(path.join(appDir, 'usage.json'))).resolves.toEqual({});
98
99
 
99
100
  const pkg = await readJson(path.join(appDir, 'package.json'));
100
101
  expect(pkg.name).toBe('demo-app');
@@ -1,8 +1,10 @@
1
1
  import path from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
+ import { readJson, writeJson } from 'fs-extra';
3
4
  import { PutObjectCommand } from '@aws-sdk/client-s3';
4
5
  import { info, output } from '../util/logger';
5
6
  import { mfAppName, getCwdAppName } from '../util/app-name';
7
+ import { whoAmI } from '../util/machine-identity';
6
8
  import { readHandlers } from '../backend/handlers';
7
9
  import { detectIntegrations } from '../backend/detect-integrations';
8
10
  import { getS3Client } from '../util/s3/client';
@@ -18,24 +20,36 @@ function currentAppName(): string {
18
20
  return mfAppName(raw);
19
21
  }
20
22
 
21
- /**
22
- * True if the app ships an app-level backend — a `backend/handlers/` directory.
23
- * Callers gate on this so the deploy path is explicit about whether a backend is in
24
- * play.
25
- */
26
23
  export function hasHandlers(appDir: string): boolean {
27
24
  return existsSync(path.join(appDir, 'backend', 'handlers'));
28
25
  }
29
26
 
27
+ /** Other features may add top-level fields, so writes below merge into this copy instead of replacing it. */
28
+ async function readExistingUsage(appDir: string): Promise<Record<string, unknown>> {
29
+ try {
30
+ return (await readJson(path.join(appDir, 'usage.json'))) as Record<string, unknown>;
31
+ } catch (err) {
32
+ if ((err as { code?: string }).code === 'ENOENT') {
33
+ return {};
34
+ }
35
+ throw err;
36
+ }
37
+ }
38
+
39
+ /** Keeps the app's git-tracked `usage.json` current with every handler parse (validate or deploy). */
40
+ async function writeUsageJson(appDir: string, patch: Record<string, unknown>): Promise<void> {
41
+ const existing = await readExistingUsage(appDir);
42
+ await writeJson(path.join(appDir, 'usage.json'), { ...existing, ...patch }, { spaces: 2 });
43
+ }
44
+
30
45
  /**
31
- * Read handlers, detect declared integrations, and upload to `prefix` when there
32
- * is anything to upload. Returns `[]` when there is no `backend/handlers/` dir
33
- * (or it is empty of sources) so callers can always pass the result to
34
- * `registerOrUpdateApp` without a separate gate.
46
+ * Returns `[]` when there is no `backend/handlers/` dir (or it is empty of sources)
47
+ * so callers can always pass the result to `registerOrUpdateApp` without a separate gate.
35
48
  */
36
49
  async function readDetectAndUpload(
37
50
  appDir: string,
38
51
  prefix: string,
52
+ appName: string,
39
53
  version: number,
40
54
  deployedBy: string,
41
55
  logLabel: string
@@ -44,11 +58,12 @@ async function readDetectAndUpload(
44
58
  return [];
45
59
  }
46
60
  info(`reading backend handlers ${logLabel}`);
47
- const { handlers, apiDomains } = await readHandlers(appDir);
61
+ const { handlers, apiDomains, apiUsages } = await readHandlers(appDir);
48
62
  const integrations = detectIntegrations(apiDomains);
49
63
  if (Object.keys(handlers).length === 0) {
50
64
  return integrations;
51
65
  }
66
+ await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
52
67
  const s3 = getS3Client();
53
68
  const meta = JSON.stringify({ version, deployedBy, handlers: Object.keys(handlers) });
54
69
  const put = (key: string, body: string, contentType: string) =>
@@ -65,11 +80,6 @@ async function readDetectAndUpload(
65
80
  * from their sources. Each `.js` handler is uploaded as its own flat file — no
66
81
  * compilation, no bundling, no npm deps.
67
82
  *
68
- * Live and preview differ only in where the handlers land, so that is the only
69
- * thing `isPreview` switches:
70
- * - live: `<appName>/backend-<version>` — the app's immutable per-version slot.
71
- * - preview: `<mfKeyPrefix without /MF>/MS` — beside the preview's `/MF` frontend.
72
- *
73
83
  * Integration sync note: the returned set is re-derived from the CURRENT backend
74
84
  * sources on every deploy, and the platform set is reconciled to exactly match
75
85
  * it (there is no stored declaration to fall back on). A deploy therefore always
@@ -88,25 +98,26 @@ export async function deployBackend(opts: {
88
98
  const prefix = isPreview ? `${mfKeyPrefix.replace(/\/MF$/, '')}/MS` : `${appName}/backend-${version}`;
89
99
  const logLabel = isPreview ? 'for preview' : `for "${appName}"`;
90
100
  try {
91
- return await readDetectAndUpload(appDir, prefix, version, deployedBy, logLabel);
101
+ return await readDetectAndUpload(appDir, prefix, appName, version, deployedBy, logLabel);
92
102
  } catch (err) {
93
103
  throw tagDeployError('deploy_backend_error', err);
94
104
  }
95
105
  }
96
106
 
97
107
  /**
98
- * Run the same handler validation deploy runs (syntax, isolate constraints,
99
- * handler rules, integration detection) against the working tree — without
100
- * uploading anything. Throws on the first invalid handler.
108
+ * Runs the same checks deploy does against the working tree, refreshing the
109
+ * local `usage.json` — no upload to S3. Throws on the first invalid handler.
101
110
  */
102
111
  export async function backendValidateCommand(appDir: string = process.cwd()): Promise<void> {
103
112
  if (!hasHandlers(appDir)) {
104
113
  output('[z2h-cli] no backend/handlers/ directory — nothing to validate', { handlers: [], integrations: [] });
105
114
  return;
106
115
  }
107
- const { handlers, apiDomains } = await readHandlers(appDir);
116
+ const { handlers, apiDomains, apiUsages } = await readHandlers(appDir);
108
117
  const names = Object.keys(handlers).sort();
109
118
  const integrations = detectIntegrations(apiDomains);
119
+ const { name } = (await readJson(path.join(appDir, 'package.json'))) as { name?: string };
120
+ await writeUsageJson(appDir, { appName: name ? mfAppName(name) : undefined, deployedBy: whoAmI(), apiUsages });
110
121
  const list = (xs: string[]) => (xs.length ? xs.join(', ') : 'none');
111
122
  output(`[z2h-cli] ${names.length} handler(s) valid: ${list(names)}\n[z2h-cli] integrations: ${list(integrations)}`, {
112
123
  handlers: names,
@@ -108,6 +108,7 @@ export async function createCommand(appName: string, opts: CreateOptions = {}):
108
108
  await copy(path.join(appTemplatesDir, 'gitignore'), path.join(appDir, '.gitignore'));
109
109
  await copy(path.join(appTemplatesDir, 'claude-settings.json'), path.join(appDir, '.claude', 'settings.json'));
110
110
  await copy(path.join(appTemplatesDir, 'CLAUDE.md'), path.join(appDir, 'CLAUDE.md'));
111
+ await copy(path.join(appTemplatesDir, 'usage.json'), path.join(appDir, 'usage.json'));
111
112
 
112
113
  // html-embed: also copy the source HTML.
113
114
  if (template === 'html-embed' && opts.htmlFile) {
@@ -1,4 +1,3 @@
1
- import os from 'node:os';
2
1
  import path from 'node:path';
3
2
  import { copy, ensureDir, readJson } from 'fs-extra';
4
3
  import { info, warn, output, getOutputMode } from '../util/logger';
@@ -19,7 +18,7 @@ import { getRemoteUrl, guardCleanTree, pullAndResolve, pushToRemote, initRepo }
19
18
  import { resolveGitEnv } from '../util/git/git-env';
20
19
  import { type RemoteType, validateRemoteOptions, resolveRemoteUrl } from '../util/git/remote-options';
21
20
  import { setupS3Client } from '../util/s3/client';
22
- import { getMachineAppName } from '../util/machine-identity';
21
+ import { whoAmI } from '../util/machine-identity';
23
22
  import { fetchApp, registerOrUpdateApp } from '../util/broker/app';
24
23
  import { openInBrowser } from '../util/open-browser';
25
24
  import { resolvePortAndMetadata } from '../shadow/prepare';
@@ -30,11 +29,6 @@ import { promptForTags } from './tag';
30
29
  import type { ConsumerPaths, DeployFailureReason } from '../types';
31
30
  import type { ManifestEntry, AssetManifestFile } from '../util/manifest';
32
31
 
33
- /** Who is publishing this version — the machine identity, else the OS user. */
34
- function whoDeployed(): string {
35
- return getMachineAppName() ?? os.userInfo().username;
36
- }
37
-
38
32
  export interface DeployOptions {
39
33
  dryRun?: boolean;
40
34
  remote?: RemoteType;
@@ -106,7 +100,7 @@ async function buildAndStamp(opts: {
106
100
  } else {
107
101
  ({ assetManifest } = await buildCommand({ publicUrl: opts.publicUrl }));
108
102
  }
109
- const deployedBy = whoDeployed();
103
+ const deployedBy = whoAmI();
110
104
 
111
105
  return {
112
106
  ...assetManifest,
@@ -148,7 +142,7 @@ async function uploadAndRegister(opts: {
148
142
  appName,
149
143
  mfKeyPrefix: keyPrefix,
150
144
  version: nextVersion,
151
- deployedBy: whoDeployed(),
145
+ deployedBy: whoAmI(),
152
146
  isPreview,
153
147
  });
154
148
 
@@ -0,0 +1 @@
1
+ {}
@@ -1,10 +1,14 @@
1
+ import os from 'node:os';
2
+
1
3
  /**
2
4
  * Returns the machine app name when running as a service (e.g. agent-runner).
3
5
  * Available when the process is a monday.com microservice with `APP_NAME` set
4
- * by the platform runtime.
5
- *
6
- * Returns `undefined` for interactive human users (fallback to os.userInfo).
6
+ * by the platform runtime. Returns `undefined` for interactive human users.
7
7
  */
8
8
  export function getMachineAppName(): string | undefined {
9
9
  return process.env.APP_NAME || undefined;
10
10
  }
11
+
12
+ export function whoAmI(): string {
13
+ return getMachineAppName() ?? os.userInfo().username;
14
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"ast.test.d.ts","sourceRoot":"","sources":["../../../src/backend/__tests__/ast.test.ts"],"names":[],"mappings":""}
@@ -1 +0,0 @@
1
- {"version":3,"file":"parse.test.d.ts","sourceRoot":"","sources":["../../../src/backend/__tests__/parse.test.ts"],"names":[],"mappings":""}
@@ -1,39 +0,0 @@
1
- import type { AnyNode, Expression, Pattern, Program, VariableDeclaration } from 'acorn';
2
- export interface Declaration {
3
- kind: VariableDeclaration['kind'];
4
- init: Expression | null | undefined;
5
- /**
6
- * Property path from `init` to this binding: `[]` for `const x = init`,
7
- * `['a', 'b']` for `const { a: { b: x } } = init`, `undefined` when the value
8
- * is not a plain property read (array/rest/default patterns).
9
- */
10
- path: string[] | undefined;
11
- }
12
- export interface Bindings {
13
- /** Every `var|let|const <name> = …` in the file, by name. */
14
- declarations: Map<string, Declaration[]>;
15
- /** Names whose value cannot be pinned: reassigned, updated, or bound as parameters. */
16
- unpinnable: Set<string>;
17
- }
18
- /** Every identifier a binding pattern introduces (`{ a, b: [c] } = …` → a, c). */
19
- export declare const patternNames: (pattern: Pattern) => string[];
20
- export declare function collectBindings(program: Program): Bindings;
21
- export interface PathResolver {
22
- /**
23
- * Dotted path of a member chain, following `const` aliases back to their
24
- * source: with `const sf = ctx.api.v1.snowflake`, `sf.query` resolves to
25
- * `ctx.api.v1.snowflake.query`. Destructuring counts too
26
- * (`const { snowflake: sf } = ctx.api.v1`). `undefined` if any link is dynamic.
27
- *
28
- * Only single, never-reassigned, never-shadowed `const` bindings are followed —
29
- * anything else is left as written, so a `let` alias or a parameter of the same
30
- * name is never mistaken for the channel. The handler's first parameter is
31
- * treated as `ctx` even when renamed (`handler(context)` → `context.api.v1.db`
32
- * resolves as `ctx.api.v1.db`).
33
- */
34
- pathOf(node: AnyNode): string | undefined;
35
- }
36
- export declare function createPathResolver(bindings: Bindings, contextParam?: string): PathResolver;
37
- /** Every `ctx.api.v1.<domain>` the program touches (known or not), through `paths`. */
38
- export declare function collectApiDomains(program: Program, paths: PathResolver): Set<string>;
39
- //# sourceMappingURL=ast.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ast.d.ts","sourceRoot":"","sources":["../../src/backend/ast.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,MAAM,OAAO,CAAC;AAKxF,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,mBAAmB,CAAC,MAAM,CAAC,CAAC;IAClC,IAAI,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,CAAC;IACpC;;;;OAIG;IACH,IAAI,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;CAC5B;AAED,MAAM,WAAW,QAAQ;IACvB,6DAA6D;IAC7D,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;IACzC,uFAAuF;IACvF,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB;AA8CD,kFAAkF;AAClF,eAAO,MAAM,YAAY,GAAI,SAAS,OAAO,KAAG,MAAM,EAAmD,CAAC;AAE1G,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,CA4C1D;AA2BD,MAAM,WAAW,YAAY;IAC3B;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAAC;CAC3C;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,YAAY,CA6B1F;AAED,uFAAuF;AACvF,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,YAAY,GAAG,GAAG,CAAC,MAAM,CAAC,CAWpF"}