@mondaydotcomorg/z2h-cli 0.34.0 → 0.34.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +2969 -0
  2. package/dist/backend/__tests__/ast.test.d.ts +2 -0
  3. package/dist/backend/__tests__/ast.test.d.ts.map +1 -0
  4. package/dist/backend/__tests__/parse.test.d.ts +2 -0
  5. package/dist/backend/__tests__/parse.test.d.ts.map +1 -0
  6. package/dist/backend/ast.d.ts +41 -0
  7. package/dist/backend/ast.d.ts.map +1 -0
  8. package/dist/backend/ast.js +152 -0
  9. package/dist/backend/handler-declaration.d.ts +17 -0
  10. package/dist/backend/handler-declaration.d.ts.map +1 -0
  11. package/dist/backend/handler-declaration.js +83 -0
  12. package/dist/backend/messages.d.ts +10 -0
  13. package/dist/backend/messages.d.ts.map +1 -0
  14. package/dist/backend/messages.js +14 -0
  15. package/dist/backend/parse.d.ts +11 -0
  16. package/dist/backend/parse.d.ts.map +1 -0
  17. package/dist/backend/parse.js +66 -0
  18. package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
  19. package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
  20. package/dist/backend/rules/index.d.ts +5 -0
  21. package/dist/backend/rules/index.d.ts.map +1 -0
  22. package/dist/backend/rules/index.js +8 -0
  23. package/dist/backend/rules/snowflake-static-sql.d.ts +15 -0
  24. package/dist/backend/rules/snowflake-static-sql.d.ts.map +1 -0
  25. package/dist/backend/rules/snowflake-static-sql.js +75 -0
  26. package/dist/backend/rules/types.d.ts +29 -0
  27. package/dist/backend/rules/types.d.ts.map +1 -0
  28. package/dist/backend/rules/types.js +1 -0
  29. package/dist/commands/__tests__/delete.test.d.ts +2 -0
  30. package/dist/commands/__tests__/delete.test.d.ts.map +1 -0
  31. package/dist/commands/backend.d.ts +0 -1
  32. package/dist/commands/backend.d.ts.map +1 -1
  33. package/dist/commands/backend.js +6 -6
  34. package/dist/commands/deploy.d.ts.map +1 -1
  35. package/dist/commands/deploy.js +16 -13
  36. package/dist/constants.d.ts +2 -13
  37. package/dist/constants.d.ts.map +1 -1
  38. package/dist/constants.js +60 -45
  39. package/dist/esm/backend/__tests__/ast.test.d.ts +2 -0
  40. package/dist/esm/backend/__tests__/ast.test.d.ts.map +1 -0
  41. package/dist/esm/backend/__tests__/parse.test.d.ts +2 -0
  42. package/dist/esm/backend/__tests__/parse.test.d.ts.map +1 -0
  43. package/dist/esm/backend/ast.d.ts +41 -0
  44. package/dist/esm/backend/ast.d.ts.map +1 -0
  45. package/dist/esm/backend/ast.mjs +146 -0
  46. package/dist/esm/backend/handler-declaration.d.ts +17 -0
  47. package/dist/esm/backend/handler-declaration.d.ts.map +1 -0
  48. package/dist/esm/backend/handler-declaration.mjs +78 -0
  49. package/dist/esm/backend/messages.d.ts +10 -0
  50. package/dist/esm/backend/messages.d.ts.map +1 -0
  51. package/dist/esm/backend/messages.mjs +12 -0
  52. package/dist/esm/backend/parse.d.ts +11 -0
  53. package/dist/esm/backend/parse.d.ts.map +1 -0
  54. package/dist/esm/backend/parse.mjs +64 -0
  55. package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
  56. package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
  57. package/dist/esm/backend/rules/index.d.ts +5 -0
  58. package/dist/esm/backend/rules/index.d.ts.map +1 -0
  59. package/dist/esm/backend/rules/index.mjs +6 -0
  60. package/dist/esm/backend/rules/snowflake-static-sql.d.ts +15 -0
  61. package/dist/esm/backend/rules/snowflake-static-sql.d.ts.map +1 -0
  62. package/dist/esm/backend/rules/snowflake-static-sql.mjs +73 -0
  63. package/dist/esm/backend/rules/types.d.ts +29 -0
  64. package/dist/esm/backend/rules/types.d.ts.map +1 -0
  65. package/dist/esm/backend/rules/types.mjs +1 -0
  66. package/dist/esm/commands/__tests__/delete.test.d.ts +2 -0
  67. package/dist/esm/commands/__tests__/delete.test.d.ts.map +1 -0
  68. package/dist/esm/commands/backend.d.ts +0 -1
  69. package/dist/esm/commands/backend.d.ts.map +1 -1
  70. package/dist/esm/commands/backend.mjs +5 -5
  71. package/dist/esm/commands/deploy.d.ts.map +1 -1
  72. package/dist/esm/commands/deploy.mjs +9 -6
  73. package/dist/esm/constants.d.ts +2 -13
  74. package/dist/esm/constants.d.ts.map +1 -1
  75. package/dist/esm/constants.mjs +5 -35
  76. package/dist/esm/util/__tests__/usage-upload.test.d.ts +2 -0
  77. package/dist/esm/util/__tests__/usage-upload.test.d.ts.map +1 -0
  78. package/dist/esm/util/app-name.mjs +1 -1
  79. package/dist/esm/util/auth/__tests__/index.test.d.ts +2 -0
  80. package/dist/esm/util/auth/__tests__/index.test.d.ts.map +1 -0
  81. package/dist/esm/util/auth/broker-credential-provider.d.ts +6 -1
  82. package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -1
  83. package/dist/esm/util/auth/broker-credential-provider.mjs +2 -2
  84. package/dist/esm/util/auth/index.d.ts +3 -0
  85. package/dist/esm/util/auth/index.d.ts.map +1 -0
  86. package/dist/esm/util/auth/index.mjs +2 -0
  87. package/dist/esm/util/broker/app.d.ts +0 -1
  88. package/dist/esm/util/broker/app.d.ts.map +1 -1
  89. package/dist/esm/util/broker/app.mjs +3 -4
  90. package/dist/esm/util/git/repo.mjs +1 -1
  91. package/dist/esm/util/s3/client.mjs +1 -1
  92. package/dist/esm/util/s3/upload.mjs +1 -1
  93. package/dist/esm/util/usage-json.d.ts +2 -2
  94. package/dist/esm/util/usage-json.d.ts.map +1 -1
  95. package/dist/esm/util/usage-json.mjs +5 -2
  96. package/dist/esm/util/usage-upload.d.ts +3 -0
  97. package/dist/esm/util/usage-upload.d.ts.map +1 -0
  98. package/dist/esm/util/usage-upload.mjs +29 -0
  99. package/dist/util/__tests__/usage-upload.test.d.ts +2 -0
  100. package/dist/util/__tests__/usage-upload.test.d.ts.map +1 -0
  101. package/dist/util/app-name.js +6 -6
  102. package/dist/util/auth/__tests__/index.test.d.ts +2 -0
  103. package/dist/util/auth/__tests__/index.test.d.ts.map +1 -0
  104. package/dist/util/auth/broker-credential-provider.d.ts +6 -1
  105. package/dist/util/auth/broker-credential-provider.d.ts.map +1 -1
  106. package/dist/util/auth/broker-credential-provider.js +2 -2
  107. package/dist/util/auth/index.d.ts +3 -0
  108. package/dist/util/auth/index.d.ts.map +1 -0
  109. package/dist/util/auth/index.js +11 -0
  110. package/dist/util/broker/app.d.ts +0 -1
  111. package/dist/util/broker/app.d.ts.map +1 -1
  112. package/dist/util/broker/app.js +3 -4
  113. package/dist/util/git/repo.js +2 -2
  114. package/dist/util/s3/client.js +2 -2
  115. package/dist/util/s3/upload.js +2 -2
  116. package/dist/util/usage-json.d.ts +2 -2
  117. package/dist/util/usage-json.d.ts.map +1 -1
  118. package/dist/util/usage-json.js +5 -2
  119. package/dist/util/usage-upload.d.ts +3 -0
  120. package/dist/util/usage-upload.d.ts.map +1 -0
  121. package/dist/util/usage-upload.js +31 -0
  122. package/package.json +3 -3
  123. package/src/commands/__tests__/backend-deploy.test.ts +2 -4
  124. package/src/commands/__tests__/deploy.test.ts +43 -11
  125. package/src/commands/backend.ts +4 -5
  126. package/src/commands/deploy.ts +9 -5
  127. package/src/constants.ts +25 -47
  128. package/src/util/__tests__/usage-upload.test.ts +58 -0
  129. package/src/util/auth/__tests__/broker-credential-provider.test.ts +4 -4
  130. package/src/util/auth/broker-credential-provider.ts +4 -4
  131. package/src/util/broker/app.ts +2 -15
  132. package/src/util/usage-json.ts +11 -4
  133. package/src/util/usage-upload.ts +38 -0
@@ -18,6 +18,9 @@ import * as brokerApp from '../../util/broker/app';
18
18
  import * as authStore from '../../util/auth/auth-store';
19
19
  import * as gitRepo from '../../util/git/repo';
20
20
  import { updateOrCreateUsageJson } from '../../util/usage-json';
21
+ import { putAppUsage } from '../../util/usage-upload';
22
+
23
+ vi.mock('../../util/usage-upload', () => ({ putAppUsage: vi.fn() }));
21
24
 
22
25
  vi.mock('../build', () => ({
23
26
  buildCommand: vi.fn(),
@@ -26,7 +29,7 @@ vi.mock('../build', () => ({
26
29
  // Wraps the real `deployBackend` so most tests exercise it unchanged (no
27
30
  // backend/handlers dir in the tmp fixture, so it resolves trivially), while
28
31
  // letting individual tests override it to prove the upload/register ordering
29
- // and passthrough of real handlerNames/apiUsages.
32
+ // and passthrough of real handlerNames.
30
33
  vi.mock('../backend', async importOriginal => {
31
34
  const actual = await importOriginal<typeof import('../backend')>();
32
35
  return { ...actual, deployBackend: vi.fn(actual.deployBackend) };
@@ -119,7 +122,7 @@ describe('deploy --preview', () => {
119
122
  vi.mocked(setupS3Client).mockClear();
120
123
  vi.mocked(openInBrowser).mockClear();
121
124
  vi.mocked(brokerProvider.brokerCredentialProvider).mockClear();
122
- vi.mocked(brokerProvider.fetchDeployVersion).mockReset().mockResolvedValue(0);
125
+ vi.mocked(brokerProvider.fetchDeployVersion).mockReset().mockResolvedValue({ version: 0, id: 7 });
123
126
  vi.mocked(brokerApp.registerOrUpdateApp).mockClear().mockResolvedValue(undefined);
124
127
  vi.mocked(brokerApp.fetchApp).mockClear().mockResolvedValue(STUB_MASTER_APP);
125
128
  vi.mocked(gitRepo.guardCleanTree).mockClear();
@@ -163,7 +166,7 @@ describe('deploy --preview', () => {
163
166
 
164
167
  it('reuses the sticky previewId from metadata', async () => {
165
168
  await writeMetadata(STUB_PREVIEW_ID);
166
- vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue(2);
169
+ vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue({ version: 2, id: 7 });
167
170
 
168
171
  await deployCommand({ preview: true });
169
172
 
@@ -181,14 +184,13 @@ describe('deploy --preview', () => {
181
184
  gitRemote: '',
182
185
  integrations: [],
183
186
  handlerNames: [],
184
- apiUsages: {},
185
187
  masterAppName: 'mf-my-app',
186
188
  });
187
189
  });
188
190
 
189
191
  it('calls registerOrUpdateApp for a brand-new preview at version 1 with an empty gitRemote', async () => {
190
192
  await writeMetadata(STUB_PREVIEW_ID);
191
- vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue(0);
193
+ vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue({ version: 0, id: 7 });
192
194
 
193
195
  await deployCommand({ preview: true });
194
196
 
@@ -201,7 +203,6 @@ describe('deploy --preview', () => {
201
203
  gitRemote: '',
202
204
  integrations: [],
203
205
  handlerNames: [],
204
- apiUsages: {},
205
206
  masterAppName: 'mf-my-app',
206
207
  });
207
208
  });
@@ -225,6 +226,7 @@ describe('deploy --preview', () => {
225
226
  expect(gitRepo.pullAndResolve).not.toHaveBeenCalled();
226
227
  expect(gitRepo.pushToRemote).not.toHaveBeenCalled();
227
228
  expect(updateOrCreateUsageJson).not.toHaveBeenCalled();
229
+ expect(putAppUsage).not.toHaveBeenCalled();
228
230
  expect(gitRepo.initRepo).not.toHaveBeenCalled();
229
231
  });
230
232
 
@@ -317,11 +319,10 @@ describe('deploy --preview', () => {
317
319
  warnSpy.mockRestore();
318
320
  });
319
321
 
320
- it('forwards the real handlerNames and apiUsages deployBackend reports to registerOrUpdateApp', async () => {
322
+ it('forwards the real handlerNames deployBackend reports to registerOrUpdateApp', async () => {
321
323
  vi.mocked(backendCmd.deployBackend).mockResolvedValueOnce({
322
324
  integrations: ['snowflake'],
323
325
  handlerNames: ['call_snowflake'],
324
- apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
325
326
  });
326
327
 
327
328
  await deployCommand({ preview: true });
@@ -330,7 +331,6 @@ describe('deploy --preview', () => {
330
331
  expect.objectContaining({
331
332
  integrations: ['snowflake'],
332
333
  handlerNames: ['call_snowflake'],
333
- apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
334
334
  })
335
335
  );
336
336
  });
@@ -349,7 +349,7 @@ describe('deploy failure reasons', () => {
349
349
  await ensureDir(path.join(tmp, '.zth', 'build'));
350
350
 
351
351
  vi.spyOn(authStore, 'readZthAuth').mockResolvedValue({ token: 'jwt-stub' });
352
- vi.mocked(brokerProvider.fetchDeployVersion).mockReset().mockResolvedValue(0);
352
+ vi.mocked(brokerProvider.fetchDeployVersion).mockReset().mockResolvedValue({ version: 0, id: 7 });
353
353
  vi.mocked(gitRepo.getRemoteUrl).mockReset().mockResolvedValue('https://github.com/org/repo.git');
354
354
  vi.mocked(gitRepo.guardCleanTree).mockReset().mockResolvedValue(undefined);
355
355
  vi.mocked(gitRepo.pullAndResolve).mockReset().mockResolvedValue(undefined);
@@ -414,7 +414,7 @@ describe('deploy failure reasons', () => {
414
414
 
415
415
  it('updates usage.json before pushing on a live deploy', async () => {
416
416
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => '' }));
417
- vi.mocked(updateOrCreateUsageJson).mockReset().mockResolvedValue(undefined);
417
+ vi.mocked(updateOrCreateUsageJson).mockReset().mockResolvedValue({});
418
418
  vi.mocked(gitRepo.pushToRemote).mockReset().mockRejectedValue(new Error('stop after push'));
419
419
  await deployCommand().catch(() => {});
420
420
  const [usageOrder] = vi.mocked(updateOrCreateUsageJson).mock.invocationCallOrder;
@@ -422,6 +422,38 @@ describe('deploy failure reasons', () => {
422
422
  expect(usageOrder).toBeLessThan(pushOrder);
423
423
  });
424
424
 
425
+ describe('usage upload', () => {
426
+ beforeEach(() => {
427
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => '' }));
428
+ vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue({ version: 4, id: 7 });
429
+ vi.mocked(updateOrCreateUsageJson).mockReset().mockResolvedValue({ deployedBy: 'me' });
430
+ vi.mocked(gitRepo.pushToRemote).mockReset().mockResolvedValue(undefined);
431
+ vi.mocked(putAppUsage).mockClear();
432
+ });
433
+
434
+ it('uploads under the new deploy version after the release is registered', async () => {
435
+ vi.mocked(brokerApp.registerOrUpdateApp).mockClear();
436
+ await deployCommand();
437
+ expect(putAppUsage).toHaveBeenCalledWith('mf-my-app', 7, { deployedBy: 'me' }, 5);
438
+ expect(vi.mocked(brokerApp.registerOrUpdateApp).mock.invocationCallOrder[0]).toBeLessThan(
439
+ vi.mocked(putAppUsage).mock.invocationCallOrder[0]
440
+ );
441
+ });
442
+
443
+ it('uploads nothing when registration fails', async () => {
444
+ vi.mocked(brokerApp.registerOrUpdateApp).mockRejectedValueOnce(new Error('broker down'));
445
+ await deployCommand().catch(() => {});
446
+ expect(putAppUsage).not.toHaveBeenCalled();
447
+ });
448
+
449
+ it('uploads nothing on a dry run or when the build fails', async () => {
450
+ await deployCommand({ dryRun: true });
451
+ vi.mocked(buildCommand).mockRejectedValueOnce(new Error('tsc exploded'));
452
+ await deployCommand().catch(() => {});
453
+ expect(putAppUsage).not.toHaveBeenCalled();
454
+ });
455
+ });
456
+
425
457
  // buildCommand throws plain errors (see commands/__tests__/build.test.ts), so a build
426
458
  // failure surfaces as untagged here — see the propagation test below. The STS 401/403 →
427
459
  // auth tagging lives in broker-credential-provider.ts — see its own test file.
@@ -26,7 +26,6 @@ function currentAppName(): string {
26
26
  export interface ReadDetectAndUploadResult {
27
27
  integrations: string[];
28
28
  handlerNames: string[];
29
- apiUsages: Record<string, unknown>;
30
29
  }
31
30
 
32
31
  async function readDetectAndUpload(
@@ -38,14 +37,14 @@ async function readDetectAndUpload(
38
37
  logLabel: string
39
38
  ): Promise<ReadDetectAndUploadResult> {
40
39
  if (!hasHandlers(appDir)) {
41
- return { integrations: [], handlerNames: [], apiUsages: {} };
40
+ return { integrations: [], handlerNames: [] };
42
41
  }
43
42
  info(`reading backend handlers ${logLabel}`);
44
- const { handlers, apiDomains, apiUsages } = await readHandlers(appDir);
43
+ const { handlers, apiDomains } = await readHandlers(appDir);
45
44
  const integrations = detectIntegrations(apiDomains);
46
45
  const handlerNames = Object.keys(handlers);
47
46
  if (Object.keys(handlers).length === 0) {
48
- return { integrations, handlerNames, apiUsages };
47
+ return { integrations, handlerNames };
49
48
  }
50
49
  const s3 = getS3Client();
51
50
  const meta = JSON.stringify({ version, deployedBy, handlers: Object.keys(handlers) });
@@ -55,7 +54,7 @@ async function readDetectAndUpload(
55
54
  ...Object.entries(handlers).map(([name, code]) => put(`${prefix}/${name}.js`, code, 'application/javascript')),
56
55
  put(`${prefix}/meta.json`, meta, 'application/json'),
57
56
  ]);
58
- return { integrations, handlerNames, apiUsages };
57
+ return { integrations, handlerNames };
59
58
  }
60
59
 
61
60
  /**
@@ -16,6 +16,7 @@ import { readZthAuth } from '../util/auth/auth-store';
16
16
  import { cleanAppName, mfAppName, validateAppName, buildPreviewSlug } from '../util/app-name';
17
17
  import { getRemoteUrl, guardCleanTree, pullAndResolve, pushToRemote, initRepo } from '../util/git/repo';
18
18
  import { updateOrCreateUsageJson } from '../util/usage-json';
19
+ import { putAppUsage } from '../util/usage-upload';
19
20
  import { resolveGitEnv } from '../util/git/git-env';
20
21
  import { type RemoteType, validateRemoteOptions, resolveRemoteUrl } from '../util/git/remote-options';
21
22
  import { setupS3Client } from '../util/s3/client';
@@ -134,7 +135,7 @@ async function uploadAndRegister(opts: {
134
135
 
135
136
  // Runs before the frontend upload so a `deployBackend` failure can't leave a
136
137
  // preview's unversioned `MF` dir overwritten with no rollback.
137
- const { integrations, handlerNames, apiUsages } = await deployBackend({
138
+ const { integrations, handlerNames } = await deployBackend({
138
139
  appDir: process.cwd(),
139
140
  appName,
140
141
  mfKeyPrefix: keyPrefix,
@@ -155,7 +156,6 @@ async function uploadAndRegister(opts: {
155
156
  gitRemote,
156
157
  integrations,
157
158
  handlerNames,
158
- apiUsages,
159
159
  description,
160
160
  masterAppName,
161
161
  });
@@ -197,7 +197,7 @@ async function deployPreview({ paths, consumerName, appName, token, opts }: Depl
197
197
 
198
198
  // No catch-all: every stage below tags its own DeployError reason at the throw site,
199
199
  // and anything unclassified reports as `unknown` via reasonOf() in runCommand.
200
- const nextVersion = (await fetchDeployVersion(previewAppName)) + 1;
200
+ const nextVersion = (await fetchDeployVersion(previewAppName)).version + 1;
201
201
 
202
202
  info('building');
203
203
  const entry = await buildAndStamp({
@@ -276,11 +276,13 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
276
276
  gitRemote = remote;
277
277
  }
278
278
 
279
- const version = await fetchDeployVersion(appName);
279
+ const { version, id: appId } = await fetchDeployVersion(appName);
280
280
  const nextVersion = version + 1;
281
281
 
282
282
  const gitEnv = await resolveGitEnv(appName, gitRemote);
283
283
 
284
+ let usage: Record<string, unknown> = {};
285
+
284
286
  // Sync git first so a divergence (GitConflictError) stops us.
285
287
  // The skill commits the working tree (with an LLM-authored message) before
286
288
  // invoking deploy, so the tree should be clean here.
@@ -289,7 +291,7 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
289
291
  await guardCleanTree(process.cwd());
290
292
  info('syncing with git remote');
291
293
  await pullAndResolve(process.cwd(), gitEnv);
292
- await updateOrCreateUsageJson(process.cwd(), appName, whoAmI());
294
+ usage = await updateOrCreateUsageJson(process.cwd(), appName, whoAmI());
293
295
  } catch (err) {
294
296
  throw tagDeployError('git_error', err);
295
297
  }
@@ -351,6 +353,8 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
351
353
  await releaseLockBestEffort(appName, token);
352
354
  }
353
355
 
356
+ await putAppUsage(appName, appId, usage, nextVersion);
357
+
354
358
  // Tags are a post-deploy concern: the S3 upload has already succeeded, so a
355
359
  // tag failure never fails the deploy (handled inside).
356
360
  await handleDeployTags(appName);
package/src/constants.ts CHANGED
@@ -28,53 +28,27 @@ export const MAX_PORT = 19999;
28
28
  // ─────────── Gitignore entries auto-appended to the consumer's file ────
29
29
  export const GITIGNORE_ENTRIES = ['.zth/'];
30
30
 
31
- // ─────────── Z2H deploy bucket / contract ─────────
32
- // Source of truth for the bucket layout. The CLI uses these when configuring
33
- // the S3 client + uploading; bigbrain-zth's STS broker imports them via
34
- // `@mondaydotcomorg/z2h-cli/constants` to scope the per-deploy session
35
- // policy to the same bucket + manifest key.
36
-
37
- export const BUCKET_WRITE_NAME = 'prod-use1-bigbrain-zth-mf-assets';
38
- export const REGION = 'us-east-1';
39
- export const BUCKET_WRITE_URL = `https://${BUCKET_WRITE_NAME}.s3.${REGION}.amazonaws.com`;
40
-
41
- export const BUCKET_READ_DNS_NAME = 'bigbrain-zth-mf-assets';
42
- export const BUCKET_READ_DNS_URL = `https://${BUCKET_READ_DNS_NAME}.bigbrain.me`;
43
-
44
- // Root prefix for ephemeral preview uploads: `previews/<previewAppName>/MF/...`,
45
- // where previewAppName is the preview's own `mf-zth-preview-<id>-<app>` name.
46
- // Shared by the CLI (upload path) and bigbrain-zth (STS policy + manifest overlay).
47
- export const S3_PREVIEWS_FOLDER = 'previews';
48
-
49
- // No `.json` extension — TridentSkeleton's `mfUrl` API appends `/mf-assets`
50
- // to the bucket base when fetching, so the manifest must live at this exact
51
- // key. See microfrontends/mf-bigbrain-zth/.../ZeroToHeroHost.tsx.
52
- export const MANIFEST_KEY = 'mf-assets';
53
- export const MANIFEST_READ_URL = `${BUCKET_READ_DNS_URL}/${MANIFEST_KEY}`;
54
-
55
- // ─────────── Skills bucket (read-only) ─────────
56
- // bigbrain-zth mints read-only STS creds scoped to this bucket (mintDatavizSkillsCredentials);
57
- // bigbrain-zth-agent syncs from it into its vendor/ at pod startup (syncDatavizVendor).
58
- // Both import from here so the two sides can't drift.
59
- export const S3_SKILLS_BUCKET_NAME = 'prod-use1-bigbrain-zth-skills';
60
-
61
- // Prefix the dataviz design-system skills are published under
62
- // (by data-cookbook's own CI on merge to master).
63
- export const DATAVIZ_SKILLS_PREFIX = 'dataviz/';
64
-
65
- // ─────────── App-name slug — backend & CLI both honor this ─────
66
- // Lowercase kebab-case, must start with a letter, 2-50 chars total. The
67
- // backend STS broker rejects anything else with 400; the CLI can validate
68
- // upfront for friendlier errors.
69
- export const APP_NAME_PATTERN = /^[a-z][a-z0-9-]{1,49}$/;
70
-
71
- export const PREVIEW_APP_NAME_PREFIX = 'zth-preview-';
72
-
73
- // ─────────── Backend channels — re-exported from shared-utils ─────
74
- // Single source of truth lives in `@mondaydotcomorg/z2h-shared-utils` so the
75
- // CLI detector and broker stay in lockstep. Kept here for existing
76
- // `@mondaydotcomorg/z2h-cli/constants` consumers.
77
- export { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS, type DeclaredIntegration } from '@mondaydotcomorg/z2h-shared-utils';
31
+ // ─────────── Re-exported from shared-utils (source of truth) ─────
32
+ // Kept here so existing CLI imports of ./constants keep working.
33
+ export {
34
+ ALWAYS_ON_CHANNELS,
35
+ APP_USAGE_BUCKET_NAME,
36
+ appUsageFolder,
37
+ APP_NAME_PATTERN,
38
+ BUCKET_READ_DNS_NAME,
39
+ BUCKET_READ_DNS_URL,
40
+ BUCKET_WRITE_NAME,
41
+ BUCKET_WRITE_URL,
42
+ DATAVIZ_SKILLS_PREFIX,
43
+ DECLARED_INTEGRATIONS,
44
+ MANIFEST_KEY,
45
+ MANIFEST_READ_URL,
46
+ PREVIEW_APP_NAME_PREFIX,
47
+ REGION,
48
+ S3_PREVIEWS_FOLDER,
49
+ S3_SKILLS_BUCKET_NAME,
50
+ type DeclaredIntegration,
51
+ } from '@mondaydotcomorg/z2h-shared-utils';
78
52
 
79
53
  // ─────────── Broker origin + npm registry (shared §3 contract) ─────
80
54
  // The bigbrain-zth broker fronts both the STS credential route and the npm
@@ -90,6 +64,10 @@ export function brokerBaseUrl(): string {
90
64
  return process.env.Z2H_BROKER_BASE_URL || DEFAULT_BROKER_BASE_URL;
91
65
  }
92
66
 
67
+ export function isProdBroker(): boolean {
68
+ return brokerBaseUrl() === DEFAULT_BROKER_BASE_URL;
69
+ }
70
+
93
71
  // Returns Baggage header for monday-mirror routing when Z2H_ROUTING_KEY is set.
94
72
  // No-op in production (returns {}) — only activates when the env var is set.
95
73
  export function routingHeaders(): Record<string, string> {
@@ -0,0 +1,58 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { warn } from '../logger';
4
+ import { putAppUsage } from '../usage-upload';
5
+
6
+ const { send } = vi.hoisted(() => ({ send: vi.fn() }));
7
+
8
+ vi.mock('../logger', () => ({ warn: vi.fn() }));
9
+ vi.mock('../s3/client', () => ({ getS3Client: () => ({ send }) }));
10
+
11
+ describe('putAppUsage', () => {
12
+ const USAGE = { appName: 'stale', deployedBy: 'me', apiUsages: { a: 1 }, uploadVersion: 1 };
13
+
14
+ beforeEach(() => {
15
+ send.mockReset().mockResolvedValue({});
16
+ });
17
+
18
+ afterEach(() => {
19
+ vi.unstubAllEnvs();
20
+ });
21
+
22
+ it('uploads the usage under the given version', async () => {
23
+ await putAppUsage('mf-my-app', 7, USAGE, 4);
24
+
25
+ const { Bucket, Key, Body } = send.mock.calls[0][0].input;
26
+ expect(Bucket).toBe('de-integration-bigbrain');
27
+ expect(Key).toBe('prod/studio/entity=app_usage/mf-my-app_7/4/usage.json');
28
+ expect(JSON.parse(Body)).toEqual({
29
+ appName: 'mf-my-app',
30
+ appId: 7,
31
+ deployedBy: 'me',
32
+ apiUsages: { a: 1 },
33
+ uploadVersion: 4,
34
+ });
35
+ });
36
+
37
+ it('skips with a warning when targeting a non-prod broker', async () => {
38
+ vi.stubEnv('Z2H_BROKER_BASE_URL', 'https://staging.example.com');
39
+
40
+ await putAppUsage('mf-my-app', 7, USAGE, 4);
41
+
42
+ expect(send).not.toHaveBeenCalled();
43
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('https://staging.example.com'));
44
+ });
45
+
46
+ it('skips when the app row id is unknown', async () => {
47
+ await putAppUsage('mf-my-app', null, USAGE, 4);
48
+
49
+ expect(send).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it('warns with the redeploy fix instead of throwing when the upload fails', async () => {
53
+ send.mockRejectedValue(new Error('denied'));
54
+
55
+ await expect(putAppUsage('mf-my-app', 7, USAGE, 4)).resolves.toBeUndefined();
56
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('z2h-cli deploy'));
57
+ });
58
+ });
@@ -40,11 +40,11 @@ function mockFetchOnce(body: unknown, init: { status?: number; text?: string } =
40
40
 
41
41
  describe('fetchDeployVersion', () => {
42
42
  it('POSTs the appName to the version endpoint and returns the version', async () => {
43
- const fetchMock = mockFetchOnce({ version: 4 });
43
+ const fetchMock = mockFetchOnce({ version: 4, id: 9 });
44
44
 
45
- const version = await fetchDeployVersion('my-app');
45
+ const result = await fetchDeployVersion('my-app');
46
46
 
47
- expect(version).toBe(4);
47
+ expect(result).toEqual({ version: 4, id: 9 });
48
48
  expect(fetchMock).toHaveBeenCalledWith(
49
49
  `${DEFAULT_BROKER_BASE_URL}${DEPLOY_VERSION_PATH}`,
50
50
  expect.objectContaining({
@@ -56,7 +56,7 @@ describe('fetchDeployVersion', () => {
56
56
 
57
57
  it('returns 0 when the broker returns no version field (new app)', async () => {
58
58
  mockFetchOnce({});
59
- await expect(fetchDeployVersion('my-app')).resolves.toBe(0);
59
+ await expect(fetchDeployVersion('my-app')).resolves.toEqual({ version: 0, id: null });
60
60
  });
61
61
 
62
62
  it('surfaces a remediation hint on 401, tagged broker_auth', async () => {
@@ -19,6 +19,7 @@ interface CredentialsResponse {
19
19
 
20
20
  interface VersionResponse {
21
21
  version?: number;
22
+ id?: number | null;
22
23
  }
23
24
 
24
25
  async function postToBroker(path: string, body: unknown): Promise<Response> {
@@ -67,12 +68,11 @@ async function assertBrokerOk(res: Response, errorLabel: string): Promise<void>
67
68
  * credentials call so version derivation lives on its own endpoint — the CLI
68
69
  * asks for the version first, then requests credentials scoped to it.
69
70
  */
70
- export async function fetchDeployVersion(appName: string): Promise<number> {
71
+ export async function fetchDeployVersion(appName: string): Promise<Required<VersionResponse>> {
71
72
  const res = await postToBroker(DEPLOY_VERSION_PATH, { appName });
72
73
  await assertBrokerOk(res, 'deploy');
73
- const body = (await res.json()) as VersionResponse;
74
-
75
- return body.version ?? 0;
74
+ const { version = 0, id = null } = (await res.json()) as VersionResponse;
75
+ return { version, id };
76
76
  }
77
77
 
78
78
  async function requestSts(path: string, body: unknown, errorLabel: string): Promise<AwsCredentialIdentity> {
@@ -72,7 +72,6 @@ export interface RegisterOrUpdateOptions {
72
72
  gitRemote: string;
73
73
  integrations?: string[];
74
74
  handlerNames?: string[];
75
- apiUsages?: Record<string, unknown>;
76
75
  description?: string;
77
76
  /**
78
77
  * The base app a preview is for, sent only on first registration — the link
@@ -91,7 +90,6 @@ async function registerApp(params: RegisterOrUpdateOptions & { visibility: 'publ
91
90
  gitRemote,
92
91
  integrations,
93
92
  handlerNames,
94
- apiUsages,
95
93
  description,
96
94
  masterAppName,
97
95
  } = params;
@@ -106,7 +104,6 @@ async function registerApp(params: RegisterOrUpdateOptions & { visibility: 'publ
106
104
  gitRemote,
107
105
  integrations,
108
106
  handlerNames,
109
- apiUsages,
110
107
  description,
111
108
  masterAppName,
112
109
  }),
@@ -119,21 +116,11 @@ async function registerApp(params: RegisterOrUpdateOptions & { visibility: 'publ
119
116
 
120
117
  /** Releases a new version onto an app that already exists. */
121
118
  async function updateApp(params: RegisterOrUpdateOptions): Promise<void> {
122
- const {
123
- appName,
124
- token,
125
- nextVersion: version,
126
- entry,
127
- gitRemote,
128
- integrations,
129
- handlerNames,
130
- apiUsages,
131
- description,
132
- } = params;
119
+ const { appName, token, nextVersion: version, entry, gitRemote, integrations, handlerNames, description } = params;
133
120
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/update`, {
134
121
  method: 'POST',
135
122
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
136
- body: JSON.stringify({ appName, version, entry, gitRemote, integrations, handlerNames, apiUsages, description }),
123
+ body: JSON.stringify({ appName, version, entry, gitRemote, integrations, handlerNames, description }),
137
124
  });
138
125
  if (!res.ok) {
139
126
  const text = await res.text();
@@ -10,7 +10,7 @@ export function hasHandlers(appDir: string): boolean {
10
10
  }
11
11
 
12
12
  /** Other features may add top-level fields, so writes merge into the existing file instead of replacing it. */
13
- export async function writeUsageJson(appDir: string, patch: Record<string, unknown>): Promise<void> {
13
+ export async function writeUsageJson(appDir: string, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
14
14
  const file = path.join(appDir, 'usage.json');
15
15
  let existing: Record<string, unknown> = {};
16
16
  try {
@@ -20,16 +20,23 @@ export async function writeUsageJson(appDir: string, patch: Record<string, unkno
20
20
  throw err;
21
21
  }
22
22
  }
23
- await writeJson(file, { ...existing, ...patch }, { spaces: 2 });
23
+ const merged = { ...existing, ...patch };
24
+ await writeJson(file, merged, { spaces: 2 });
25
+ return merged;
24
26
  }
25
27
 
26
28
  /** Refreshes the git-tracked `usage.json` and commits it if it changed, so a deploy's push carries it. */
27
- export async function updateOrCreateUsageJson(appDir: string, appName: string, deployedBy: string): Promise<void> {
29
+ export async function updateOrCreateUsageJson(
30
+ appDir: string,
31
+ appName: string,
32
+ deployedBy: string
33
+ ): Promise<Record<string, unknown>> {
28
34
  const apiUsages = hasHandlers(appDir) ? (await readHandlers(appDir)).apiUsages : {};
29
- await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
35
+ const usage = await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
30
36
  await execGit(['add', 'usage.json'], appDir);
31
37
  const { exitCode } = await execa('git', ['diff', '--cached', '--quiet'], { cwd: appDir, reject: false });
32
38
  if (exitCode !== 0) {
33
39
  await execGit(['commit', '-m', 'Update usage.json'], appDir);
34
40
  }
41
+ return usage;
35
42
  }
@@ -0,0 +1,38 @@
1
+ import { PutObjectCommand } from '@aws-sdk/client-s3';
2
+ import { APP_USAGE_BUCKET_NAME, appUsageFolder, brokerBaseUrl, isProdBroker } from '../constants';
3
+ import { warn } from './logger';
4
+ import { getS3Client } from './s3/client';
5
+
6
+ /** Snowpipe ignores re-uploads of an existing key, so the key is unique per app row id and deploy version. */
7
+ export async function putAppUsage(
8
+ appName: string,
9
+ appId: number | null,
10
+ usage: Record<string, unknown>,
11
+ uploadVersion: number
12
+ ): Promise<void> {
13
+ if (!isProdBroker()) {
14
+ warn(
15
+ `skipping app usage upload to Snowflake: ${brokerBaseUrl()} is not the production broker, and only production deploys are recorded.`
16
+ );
17
+ return;
18
+ }
19
+ if (appId === null) {
20
+ return;
21
+ }
22
+ try {
23
+ await getS3Client().send(
24
+ new PutObjectCommand({
25
+ Bucket: APP_USAGE_BUCKET_NAME,
26
+ Key: `${appUsageFolder(appName, appId)}/${uploadVersion}/usage.json`,
27
+ Body: JSON.stringify({ ...usage, appName, appId, uploadVersion }),
28
+ ContentType: 'application/json',
29
+ })
30
+ );
31
+ } catch (err) {
32
+ warn(
33
+ `could not record app usage for Snowflake: ${
34
+ (err as Error).message
35
+ }. Your app is live. To record it, run \`z2h-cli deploy\` again (no changes or commit needed).`
36
+ );
37
+ }
38
+ }