@mondaydotcomorg/z2h-cli 0.33.1 → 0.33.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.
@@ -1,9 +1,10 @@
1
1
  export declare function hasHandlers(appDir: string): boolean;
2
+ export interface ReadDetectAndUploadResult {
3
+ integrations: string[];
4
+ handlerNames: string[];
5
+ apiUsages: Record<string, unknown>;
6
+ }
2
7
  /**
3
- * Deploy the app's backend handlers to S3 and return the integrations detected
4
- * from their sources. Each `.js` handler is uploaded as its own flat file — no
5
- * compilation, no bundling, no npm deps.
6
- *
7
8
  * Integration sync note: the returned set is re-derived from the CURRENT backend
8
9
  * sources on every deploy, and the platform set is reconciled to exactly match
9
10
  * it (there is no stored declaration to fall back on). A deploy therefore always
@@ -17,7 +18,7 @@ export declare function deployBackend(opts: {
17
18
  version: number;
18
19
  deployedBy: string;
19
20
  isPreview: boolean;
20
- }): Promise<string[]>;
21
+ }): Promise<ReadDetectAndUploadResult>;
21
22
  /**
22
23
  * Runs the same checks deploy does against the working tree, refreshing the
23
24
  * local `usage.json` — no upload to S3. Throws on the first invalid handler.
@@ -1 +1 @@
1
- {"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../../src/commands/backend.ts"],"names":[],"mappings":"AAyBA,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAqDD;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CASpB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,GAAE,MAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1F;AAsBD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,WAAW,EAAE,MAAM,EACnB,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,EAC5C,MAAM,GAAE,MAAsB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAqBf"}
1
+ {"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../../src/commands/backend.ts"],"names":[],"mappings":"AAyBA,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAoBD,MAAM,WAAW,yBAAyB;IACxC,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAiCD;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,yBAAyB,CAAC,CASrC;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,GAAE,MAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1F;AAsBD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,WAAW,EAAE,MAAM,EACnB,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,EAC5C,MAAM,GAAE,MAAsB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAqBf"}
@@ -48,21 +48,19 @@ async function writeUsageJson(appDir, patch) {
48
48
  const existing = await readExistingUsage(appDir);
49
49
  await fs$1.writeJson(path__default.default.join(appDir, 'usage.json'), { ...existing, ...patch }, { spaces: 2 });
50
50
  }
51
- /**
52
- * Returns `[]` when there is no `backend/handlers/` dir (or it is empty of sources)
53
- * so callers can always pass the result to `registerOrUpdateApp` without a separate gate.
54
- */
55
51
  async function readDetectAndUpload(appDir, prefix, appName, version, deployedBy, logLabel) {
56
52
  if (!hasHandlers(appDir)) {
57
- return [];
53
+ await writeUsageJson(appDir, { appName, deployedBy, apiUsages: {} });
54
+ return { integrations: [], handlerNames: [], apiUsages: {} };
58
55
  }
59
56
  util_logger.info(`reading backend handlers ${logLabel}`);
60
57
  const { handlers, apiDomains, apiUsages } = await backend_handlers.readHandlers(appDir);
61
58
  const integrations = backend_detectIntegrations.detectIntegrations(apiDomains);
59
+ const handlerNames = Object.keys(handlers);
60
+ await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
62
61
  if (Object.keys(handlers).length === 0) {
63
- return integrations;
62
+ return { integrations, handlerNames, apiUsages };
64
63
  }
65
- await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
66
64
  const s3 = util_s3_client.getS3Client();
67
65
  const meta = JSON.stringify({ version, deployedBy, handlers: Object.keys(handlers) });
68
66
  const put = (key, body, contentType) => s3.send(new clientS3.PutObjectCommand({ Bucket: constants.BUCKET_WRITE_NAME, Key: key, Body: body, ContentType: contentType }));
@@ -70,13 +68,9 @@ async function readDetectAndUpload(appDir, prefix, appName, version, deployedBy,
70
68
  ...Object.entries(handlers).map(([name, code]) => put(`${prefix}/${name}.js`, code, 'application/javascript')),
71
69
  put(`${prefix}/meta.json`, meta, 'application/json'),
72
70
  ]);
73
- return integrations;
71
+ return { integrations, handlerNames, apiUsages };
74
72
  }
75
73
  /**
76
- * Deploy the app's backend handlers to S3 and return the integrations detected
77
- * from their sources. Each `.js` handler is uploaded as its own flat file — no
78
- * compilation, no bundling, no npm deps.
79
- *
80
74
  * Integration sync note: the returned set is re-derived from the CURRENT backend
81
75
  * sources on every deploy, and the platform set is reconciled to exactly match
82
76
  * it (there is no stored declaration to fall back on). A deploy therefore always
@@ -85,12 +85,9 @@ async function buildAndStamp(opts) {
85
85
  */
86
86
  async function uploadAndRegister(opts) {
87
87
  const { paths, keyPrefix, appName, token, nextVersion, entry, gitRemote, isPreview, description, masterAppName } = opts;
88
- util_logger.info(`uploading ${isPreview ? 'preview ' : ''}build → s3://${constants.BUCKET_WRITE_NAME}/${keyPrefix}/`);
89
- await util_s3_upload.uploadDir(paths.shadowBuild, keyPrefix);
90
- // `integrations` is inferred from the backend sources in the working tree and
91
- // reconciled to exactly match on register — see `deployBackend` for why this
92
- // depends on deploying from the app's latest merged state.
93
- const integrations = await commands_backend.deployBackend({
88
+ // Runs before the frontend upload so a `deployBackend` failure can't leave a
89
+ // preview's unversioned `MF` dir overwritten with no rollback.
90
+ const { integrations, handlerNames, apiUsages } = await commands_backend.deployBackend({
94
91
  appDir: process.cwd(),
95
92
  appName,
96
93
  mfKeyPrefix: keyPrefix,
@@ -98,16 +95,32 @@ async function uploadAndRegister(opts) {
98
95
  deployedBy: util_machineIdentity.whoAmI(),
99
96
  isPreview,
100
97
  });
101
- await util_broker_app.registerOrUpdateApp({
102
- appName,
103
- token,
104
- nextVersion,
105
- entry,
106
- gitRemote,
107
- integrations,
108
- description,
109
- masterAppName,
110
- });
98
+ try {
99
+ util_logger.info(`uploading ${isPreview ? 'preview ' : ''}build → s3://${constants.BUCKET_WRITE_NAME}/${keyPrefix}/`);
100
+ await util_s3_upload.uploadDir(paths.shadowBuild, keyPrefix);
101
+ await util_broker_app.registerOrUpdateApp({
102
+ appName,
103
+ token,
104
+ nextVersion,
105
+ entry,
106
+ gitRemote,
107
+ integrations,
108
+ handlerNames,
109
+ apiUsages,
110
+ description,
111
+ masterAppName,
112
+ });
113
+ }
114
+ catch (err) {
115
+ // A live deploy is safe either way — RDS's version gates what's actually served, so
116
+ // an orphaned S3 object is harmless — but a preview is served straight from its
117
+ // unversioned prefix with no such gate, so only it needs the warning below.
118
+ if (isPreview) {
119
+ util_logger.warn(`preview backend was already deployed before this failed — re-run "z2h-cli deploy --preview" to retry ` +
120
+ `(every step overwrites, so a retry fully repairs it)`);
121
+ }
122
+ throw err;
123
+ }
111
124
  }
112
125
  async function deployPreview({ paths, consumerName, appName, token, opts }) {
113
126
  // A preview inherits from the live app it previews, resolved by name server-side —
@@ -1,9 +1,10 @@
1
1
  export declare function hasHandlers(appDir: string): boolean;
2
+ export interface ReadDetectAndUploadResult {
3
+ integrations: string[];
4
+ handlerNames: string[];
5
+ apiUsages: Record<string, unknown>;
6
+ }
2
7
  /**
3
- * Deploy the app's backend handlers to S3 and return the integrations detected
4
- * from their sources. Each `.js` handler is uploaded as its own flat file — no
5
- * compilation, no bundling, no npm deps.
6
- *
7
8
  * Integration sync note: the returned set is re-derived from the CURRENT backend
8
9
  * sources on every deploy, and the platform set is reconciled to exactly match
9
10
  * it (there is no stored declaration to fall back on). A deploy therefore always
@@ -17,7 +18,7 @@ export declare function deployBackend(opts: {
17
18
  version: number;
18
19
  deployedBy: string;
19
20
  isPreview: boolean;
20
- }): Promise<string[]>;
21
+ }): Promise<ReadDetectAndUploadResult>;
21
22
  /**
22
23
  * Runs the same checks deploy does against the working tree, refreshing the
23
24
  * local `usage.json` — no upload to S3. Throws on the first invalid handler.
@@ -1 +1 @@
1
- {"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../../../src/commands/backend.ts"],"names":[],"mappings":"AAyBA,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAqDD;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CASpB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,GAAE,MAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1F;AAsBD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,WAAW,EAAE,MAAM,EACnB,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,EAC5C,MAAM,GAAE,MAAsB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAqBf"}
1
+ {"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../../../src/commands/backend.ts"],"names":[],"mappings":"AAyBA,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAoBD,MAAM,WAAW,yBAAyB;IACxC,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAiCD;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,yBAAyB,CAAC,CASrC;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,GAAE,MAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1F;AAsBD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,WAAW,EAAE,MAAM,EACnB,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,EAC5C,MAAM,GAAE,MAAsB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAqBf"}
@@ -42,21 +42,19 @@ async function writeUsageJson(appDir, patch) {
42
42
  const existing = await readExistingUsage(appDir);
43
43
  await writeJson(path.join(appDir, 'usage.json'), { ...existing, ...patch }, { spaces: 2 });
44
44
  }
45
- /**
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.
48
- */
49
45
  async function readDetectAndUpload(appDir, prefix, appName, version, deployedBy, logLabel) {
50
46
  if (!hasHandlers(appDir)) {
51
- return [];
47
+ await writeUsageJson(appDir, { appName, deployedBy, apiUsages: {} });
48
+ return { integrations: [], handlerNames: [], apiUsages: {} };
52
49
  }
53
50
  info(`reading backend handlers ${logLabel}`);
54
51
  const { handlers, apiDomains, apiUsages } = await readHandlers(appDir);
55
52
  const integrations = detectIntegrations(apiDomains);
53
+ const handlerNames = Object.keys(handlers);
54
+ await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
56
55
  if (Object.keys(handlers).length === 0) {
57
- return integrations;
56
+ return { integrations, handlerNames, apiUsages };
58
57
  }
59
- await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
60
58
  const s3 = getS3Client();
61
59
  const meta = JSON.stringify({ version, deployedBy, handlers: Object.keys(handlers) });
62
60
  const put = (key, body, contentType) => s3.send(new PutObjectCommand({ Bucket: BUCKET_WRITE_NAME, Key: key, Body: body, ContentType: contentType }));
@@ -64,13 +62,9 @@ async function readDetectAndUpload(appDir, prefix, appName, version, deployedBy,
64
62
  ...Object.entries(handlers).map(([name, code]) => put(`${prefix}/${name}.js`, code, 'application/javascript')),
65
63
  put(`${prefix}/meta.json`, meta, 'application/json'),
66
64
  ]);
67
- return integrations;
65
+ return { integrations, handlerNames, apiUsages };
68
66
  }
69
67
  /**
70
- * Deploy the app's backend handlers to S3 and return the integrations detected
71
- * from their sources. Each `.js` handler is uploaded as its own flat file — no
72
- * compilation, no bundling, no npm deps.
73
- *
74
68
  * Integration sync note: the returned set is re-derived from the CURRENT backend
75
69
  * sources on every deploy, and the platform set is reconciled to exactly match
76
70
  * it (there is no stored declaration to fall back on). A deploy therefore always
@@ -79,12 +79,9 @@ async function buildAndStamp(opts) {
79
79
  */
80
80
  async function uploadAndRegister(opts) {
81
81
  const { paths, keyPrefix, appName, token, nextVersion, entry, gitRemote, isPreview, description, masterAppName } = opts;
82
- info(`uploading ${isPreview ? 'preview ' : ''}build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
83
- await uploadDir(paths.shadowBuild, keyPrefix);
84
- // `integrations` is inferred from the backend sources in the working tree and
85
- // reconciled to exactly match on register — see `deployBackend` for why this
86
- // depends on deploying from the app's latest merged state.
87
- const integrations = await deployBackend({
82
+ // Runs before the frontend upload so a `deployBackend` failure can't leave a
83
+ // preview's unversioned `MF` dir overwritten with no rollback.
84
+ const { integrations, handlerNames, apiUsages } = await deployBackend({
88
85
  appDir: process.cwd(),
89
86
  appName,
90
87
  mfKeyPrefix: keyPrefix,
@@ -92,16 +89,32 @@ async function uploadAndRegister(opts) {
92
89
  deployedBy: whoAmI(),
93
90
  isPreview,
94
91
  });
95
- await registerOrUpdateApp({
96
- appName,
97
- token,
98
- nextVersion,
99
- entry,
100
- gitRemote,
101
- integrations,
102
- description,
103
- masterAppName,
104
- });
92
+ try {
93
+ info(`uploading ${isPreview ? 'preview ' : ''}build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
94
+ await uploadDir(paths.shadowBuild, keyPrefix);
95
+ await registerOrUpdateApp({
96
+ appName,
97
+ token,
98
+ nextVersion,
99
+ entry,
100
+ gitRemote,
101
+ integrations,
102
+ handlerNames,
103
+ apiUsages,
104
+ description,
105
+ masterAppName,
106
+ });
107
+ }
108
+ catch (err) {
109
+ // A live deploy is safe either way — RDS's version gates what's actually served, so
110
+ // an orphaned S3 object is harmless — but a preview is served straight from its
111
+ // unversioned prefix with no such gate, so only it needs the warning below.
112
+ if (isPreview) {
113
+ warn(`preview backend was already deployed before this failed — re-run "z2h-cli deploy --preview" to retry ` +
114
+ `(every step overwrites, so a retry fully repairs it)`);
115
+ }
116
+ throw err;
117
+ }
105
118
  }
106
119
  async function deployPreview({ paths, consumerName, appName, token, opts }) {
107
120
  // A preview inherits from the live app it previews, resolved by name server-side —
@@ -29,6 +29,8 @@ export interface RegisterOrUpdateOptions {
29
29
  entry: ManifestEntry;
30
30
  gitRemote: string;
31
31
  integrations?: string[];
32
+ handlerNames?: string[];
33
+ apiUsages?: Record<string, unknown>;
32
34
  description?: string;
33
35
  /**
34
36
  * The base app a preview is for, sent only on first registration — the link
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AA6CD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BtF"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AAED,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AA8DD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBtF"}
@@ -47,11 +47,23 @@ async function claimApp(appName, gitRemote) {
47
47
  throw new Error(`Claiming app name failed (${res.status}): ${text.slice(0, 1024)}`);
48
48
  }
49
49
  }
50
- async function registerApp(appName, token, version, entry, visibility, gitRemote, integrations, description, masterAppName) {
50
+ async function registerApp(params) {
51
+ const { appName, token, nextVersion: version, entry, visibility, gitRemote, integrations, handlerNames, apiUsages, description, masterAppName, } = params;
51
52
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/register`, {
52
53
  method: 'POST',
53
54
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
54
- body: JSON.stringify({ appName, version, entry, visibility, gitRemote, integrations, description, masterAppName }),
55
+ body: JSON.stringify({
56
+ appName,
57
+ version,
58
+ entry,
59
+ visibility,
60
+ gitRemote,
61
+ integrations,
62
+ handlerNames,
63
+ apiUsages,
64
+ description,
65
+ masterAppName,
66
+ }),
55
67
  });
56
68
  if (!res.ok) {
57
69
  const text = await res.text();
@@ -59,11 +71,12 @@ async function registerApp(appName, token, version, entry, visibility, gitRemote
59
71
  }
60
72
  }
61
73
  /** Releases a new version onto an app that already exists. */
62
- async function updateApp(appName, token, version, entry, gitRemote, integrations, description) {
74
+ async function updateApp(params) {
75
+ const { appName, token, nextVersion: version, entry, gitRemote, integrations, handlerNames, apiUsages, description, } = params;
63
76
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/update`, {
64
77
  method: 'POST',
65
78
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
66
- body: JSON.stringify({ appName, version, entry, gitRemote, integrations, description }),
79
+ body: JSON.stringify({ appName, version, entry, gitRemote, integrations, handlerNames, apiUsages, description }),
67
80
  });
68
81
  if (!res.ok) {
69
82
  const text = await res.text();
@@ -77,16 +90,16 @@ async function updateApp(appName, token, version, entry, gitRemote, integrations
77
90
  * deploy and preview so this branching logic lives in exactly one place.
78
91
  */
79
92
  async function registerOrUpdateApp(opts) {
80
- const { appName, token, nextVersion, entry, gitRemote, integrations, description, masterAppName } = opts;
93
+ const { appName, nextVersion } = opts;
81
94
  try {
82
95
  if (nextVersion === 1) {
83
96
  const visibility = await resolveAppVisibility(appName);
84
97
  info(`registering "${appName}" → v${nextVersion} (${visibility})`);
85
- await registerApp(appName, token, nextVersion, entry, visibility, gitRemote, integrations, description, masterAppName);
98
+ await registerApp({ ...opts, visibility });
86
99
  }
87
100
  else {
88
101
  info(`updating "${appName}" → v${nextVersion}`);
89
- await updateApp(appName, token, nextVersion, entry, gitRemote, integrations, description);
102
+ await updateApp(opts);
90
103
  }
91
104
  }
92
105
  catch (err) {
@@ -29,6 +29,8 @@ export interface RegisterOrUpdateOptions {
29
29
  entry: ManifestEntry;
30
30
  gitRemote: string;
31
31
  integrations?: string[];
32
+ handlerNames?: string[];
33
+ apiUsages?: Record<string, unknown>;
32
34
  description?: string;
33
35
  /**
34
36
  * The base app a preview is for, sent only on first registration — the link
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AA6CD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BtF"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../src/util/broker/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,uGAAuG;IACvG,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBvE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWhF;AAED,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,aAAa,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AA8DD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBtF"}
@@ -49,11 +49,23 @@ async function claimApp(appName, gitRemote) {
49
49
  throw new Error(`Claiming app name failed (${res.status}): ${text.slice(0, 1024)}`);
50
50
  }
51
51
  }
52
- async function registerApp(appName, token, version, entry, visibility, gitRemote, integrations, description, masterAppName) {
52
+ async function registerApp(params) {
53
+ const { appName, token, nextVersion: version, entry, visibility, gitRemote, integrations, handlerNames, apiUsages, description, masterAppName, } = params;
53
54
  const res = await fetch(`${constants.brokerBaseUrl()}/z2h-cli/apps/register`, {
54
55
  method: 'POST',
55
56
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...constants.routingHeaders() },
56
- body: JSON.stringify({ appName, version, entry, visibility, gitRemote, integrations, description, masterAppName }),
57
+ body: JSON.stringify({
58
+ appName,
59
+ version,
60
+ entry,
61
+ visibility,
62
+ gitRemote,
63
+ integrations,
64
+ handlerNames,
65
+ apiUsages,
66
+ description,
67
+ masterAppName,
68
+ }),
57
69
  });
58
70
  if (!res.ok) {
59
71
  const text = await res.text();
@@ -61,11 +73,12 @@ async function registerApp(appName, token, version, entry, visibility, gitRemote
61
73
  }
62
74
  }
63
75
  /** Releases a new version onto an app that already exists. */
64
- async function updateApp(appName, token, version, entry, gitRemote, integrations, description) {
76
+ async function updateApp(params) {
77
+ const { appName, token, nextVersion: version, entry, gitRemote, integrations, handlerNames, apiUsages, description, } = params;
65
78
  const res = await fetch(`${constants.brokerBaseUrl()}/z2h-cli/apps/update`, {
66
79
  method: 'POST',
67
80
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...constants.routingHeaders() },
68
- body: JSON.stringify({ appName, version, entry, gitRemote, integrations, description }),
81
+ body: JSON.stringify({ appName, version, entry, gitRemote, integrations, handlerNames, apiUsages, description }),
69
82
  });
70
83
  if (!res.ok) {
71
84
  const text = await res.text();
@@ -79,16 +92,16 @@ async function updateApp(appName, token, version, entry, gitRemote, integrations
79
92
  * deploy and preview so this branching logic lives in exactly one place.
80
93
  */
81
94
  async function registerOrUpdateApp(opts) {
82
- const { appName, token, nextVersion, entry, gitRemote, integrations, description, masterAppName } = opts;
95
+ const { appName, nextVersion } = opts;
83
96
  try {
84
97
  if (nextVersion === 1) {
85
98
  const visibility = await util_promptVisibility.resolveAppVisibility(appName);
86
99
  util_logger.info(`registering "${appName}" → v${nextVersion} (${visibility})`);
87
- await registerApp(appName, token, nextVersion, entry, visibility, gitRemote, integrations, description, masterAppName);
100
+ await registerApp({ ...opts, visibility });
88
101
  }
89
102
  else {
90
103
  util_logger.info(`updating "${appName}" → v${nextVersion}`);
91
- await updateApp(appName, token, nextVersion, entry, gitRemote, integrations, description);
104
+ await updateApp(opts);
92
105
  }
93
106
  }
94
107
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.33.1",
3
+ "version": "0.33.2",
4
4
  "bin": "./bin/z2h-cli.js",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -63,7 +63,29 @@ describe('deployBackend (live)', () => {
63
63
  await rm(tmp, { recursive: true, force: true });
64
64
  });
65
65
 
66
- it('skips upload when the handlers directory holds nothing', async () => {
66
+ it('skips upload and resets usage.json when there is no handlers directory', async () => {
67
+ // hasHandlers is false here, so readHandlers is never reached — no mock needed.
68
+ await expect(
69
+ deployBackend({
70
+ appDir: tmp,
71
+ appName: 'mf-my-app',
72
+ mfKeyPrefix: 'mf-my-app/1',
73
+ version: 1,
74
+ deployedBy: 'test-user',
75
+ isPreview: false,
76
+ })
77
+ ).resolves.toEqual({ integrations: [], handlerNames: [], apiUsages: {} });
78
+ expect(mockSend).not.toHaveBeenCalled();
79
+ await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
80
+ appName: 'mf-my-app',
81
+ deployedBy: 'test-user',
82
+ apiUsages: {},
83
+ });
84
+ });
85
+
86
+ it('skips upload and resets usage.json when backend/handlers exists but has no handler sources', async () => {
87
+ await ensureDir(path.join(tmp, 'backend', 'handlers'));
88
+ await writeJson(path.join(tmp, 'usage.json'), { apiUsages: { deleted_handler: {} }, keepMe: 1 });
67
89
  readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [], apiUsages: {} });
68
90
 
69
91
  await expect(
@@ -75,8 +97,14 @@ describe('deployBackend (live)', () => {
75
97
  deployedBy: 'test-user',
76
98
  isPreview: false,
77
99
  })
78
- ).resolves.toEqual([]);
100
+ ).resolves.toEqual({ integrations: [], handlerNames: [], apiUsages: {} });
79
101
  expect(mockSend).not.toHaveBeenCalled();
102
+ await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
103
+ keepMe: 1,
104
+ appName: 'mf-my-app',
105
+ deployedBy: 'test-user',
106
+ apiUsages: {},
107
+ });
80
108
  });
81
109
 
82
110
  it('transpiles and uploads handlers when backend/handlers exists', async () => {
@@ -98,11 +126,16 @@ describe('deployBackend (live)', () => {
98
126
  deployedBy: 'test-user',
99
127
  isPreview: false,
100
128
  })
101
- ).resolves.toEqual([]);
129
+ ).resolves.toEqual({
130
+ integrations: [],
131
+ handlerNames: ['call_snowflake'],
132
+ apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
133
+ });
102
134
  expect(mockSend).toHaveBeenCalled();
103
135
  const keys: string[] = mockSend.mock.calls.map((c: unknown[]) => (c[0] as { input: { Key: string } }).input.Key);
104
136
  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.
137
+ // usage.json stays local-only. meta.json is still uploaded as the S3 fallback,
138
+ // used only for a row whose `apps.handler_names` column is still null.
106
139
  expect(keys.some(k => k.endsWith('/usage.json'))).toBe(false);
107
140
  expect(keys.some(k => k.endsWith('/meta.json'))).toBe(true);
108
141
 
@@ -191,22 +224,6 @@ describe('deployBackend (preview)', () => {
191
224
  await rm(tmp, { recursive: true, force: true });
192
225
  });
193
226
 
194
- it('skips upload when the handlers directory holds nothing', async () => {
195
- readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [], apiUsages: {} });
196
-
197
- await expect(
198
- deployBackend({
199
- appDir: tmp,
200
- appName: 'mf-zth-preview-abc12345-my-app',
201
- mfKeyPrefix: 'previews/mf-zth-preview-abc12345-my-app/MF',
202
- version: 1,
203
- deployedBy: 'test-user',
204
- isPreview: true,
205
- })
206
- ).resolves.toEqual([]);
207
- expect(mockSend).not.toHaveBeenCalled();
208
- });
209
-
210
227
  it('uploads handlers under the MS prefix for preview', async () => {
211
228
  await ensureDir(path.join(tmp, 'backend', 'handlers'));
212
229
  await writeFile(path.join(tmp, 'backend', 'handlers', 'call_snowflake.ts'), '// placeholder');
@@ -225,7 +242,11 @@ describe('deployBackend (preview)', () => {
225
242
  deployedBy: 'test-user',
226
243
  isPreview: true,
227
244
  })
228
- ).resolves.toEqual(['monday']);
245
+ ).resolves.toEqual({
246
+ integrations: ['monday'],
247
+ handlerNames: ['call_snowflake'],
248
+ apiUsages: { call_snowflake: { monday: { query: [{ query: 'x' }] } } },
249
+ });
229
250
  expect(mockSend).toHaveBeenCalled();
230
251
  const keys: string[] = mockSend.mock.calls.map((c: unknown[]) => (c[0] as { input: { Key: string } }).input.Key);
231
252
  expect(keys.some(k => k.includes('/MS/'))).toBe(true);
@@ -7,7 +7,9 @@ import { deployCommand } from '../deploy';
7
7
  import { DeployError, reasonOf } from '../../util/errors';
8
8
  import { buildPreviewSlug } from '../../util/app-name';
9
9
  import { setOutputMode } from '../../util/logger';
10
+ import * as loggerModule from '../../util/logger';
10
11
  import { buildCommand } from '../build';
12
+ import * as backendCmd from '../backend';
11
13
  import { uploadDir } from '../../util/s3/upload';
12
14
  import { setupS3Client } from '../../util/s3/client';
13
15
  import { openInBrowser } from '../../util/open-browser';
@@ -20,6 +22,15 @@ vi.mock('../build', () => ({
20
22
  buildCommand: vi.fn(),
21
23
  }));
22
24
 
25
+ // Wraps the real `deployBackend` so most tests exercise it unchanged (no
26
+ // backend/handlers dir in the tmp fixture, so it resolves trivially), while
27
+ // letting individual tests override it to prove the upload/register ordering
28
+ // and passthrough of real handlerNames/apiUsages.
29
+ vi.mock('../backend', async importOriginal => {
30
+ const actual = await importOriginal<typeof import('../backend')>();
31
+ return { ...actual, deployBackend: vi.fn(actual.deployBackend) };
32
+ });
33
+
23
34
  vi.mock('../../util/s3/upload', () => ({
24
35
  uploadDir: vi.fn().mockResolvedValue([]),
25
36
  }));
@@ -97,6 +108,7 @@ describe('deploy --preview', () => {
97
108
  buildDir: path.join(tmp, '.zth', 'build'),
98
109
  assetManifest: ASSET_MANIFEST,
99
110
  });
111
+ vi.mocked(backendCmd.deployBackend).mockClear();
100
112
  vi.mocked(uploadDir).mockClear();
101
113
  vi.mocked(setupS3Client).mockClear();
102
114
  vi.mocked(openInBrowser).mockClear();
@@ -162,6 +174,8 @@ describe('deploy --preview', () => {
162
174
  entry: expect.objectContaining({ ...ASSET_MANIFEST, version: 3, gitRemote: '' }),
163
175
  gitRemote: '',
164
176
  integrations: [],
177
+ handlerNames: [],
178
+ apiUsages: {},
165
179
  masterAppName: 'mf-my-app',
166
180
  });
167
181
  });
@@ -180,6 +194,8 @@ describe('deploy --preview', () => {
180
194
  entry: expect.objectContaining({ ...ASSET_MANIFEST, version: 1, gitRemote: '' }),
181
195
  gitRemote: '',
182
196
  integrations: [],
197
+ handlerNames: [],
198
+ apiUsages: {},
183
199
  masterAppName: 'mf-my-app',
184
200
  });
185
201
  });
@@ -274,6 +290,43 @@ describe('deploy --preview', () => {
274
290
 
275
291
  writeSpy.mockRestore();
276
292
  });
293
+
294
+ it('never uploads the frontend or registers when deployBackend fails', async () => {
295
+ vi.mocked(backendCmd.deployBackend).mockRejectedValueOnce(new Error('backend upload exploded'));
296
+
297
+ await expect(deployCommand({ preview: true })).rejects.toThrow('backend upload exploded');
298
+
299
+ expect(uploadDir).not.toHaveBeenCalled();
300
+ expect(brokerApp.registerOrUpdateApp).not.toHaveBeenCalled();
301
+ });
302
+
303
+ it('warns that a retry will repair it when the frontend upload fails after the backend already deployed', async () => {
304
+ vi.mocked(uploadDir).mockRejectedValueOnce(new Error('S3 exploded'));
305
+ const warnSpy = vi.spyOn(loggerModule, 'warn').mockImplementation(() => {});
306
+
307
+ await expect(deployCommand({ preview: true })).rejects.toThrow('S3 exploded');
308
+
309
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('re-run "z2h-cli deploy --preview"'));
310
+ warnSpy.mockRestore();
311
+ });
312
+
313
+ it('forwards the real handlerNames and apiUsages deployBackend reports to registerOrUpdateApp', async () => {
314
+ vi.mocked(backendCmd.deployBackend).mockResolvedValueOnce({
315
+ integrations: ['snowflake'],
316
+ handlerNames: ['call_snowflake'],
317
+ apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
318
+ });
319
+
320
+ await deployCommand({ preview: true });
321
+
322
+ expect(brokerApp.registerOrUpdateApp).toHaveBeenCalledWith(
323
+ expect.objectContaining({
324
+ integrations: ['snowflake'],
325
+ handlerNames: ['call_snowflake'],
326
+ apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
327
+ })
328
+ );
329
+ });
277
330
  });
278
331
 
279
332
  describe('deploy failure reasons', () => {
@@ -45,10 +45,12 @@ async function writeUsageJson(appDir: string, patch: Record<string, unknown>): P
45
45
  await writeJson(path.join(appDir, 'usage.json'), { ...existing, ...patch }, { spaces: 2 });
46
46
  }
47
47
 
48
- /**
49
- * Returns `[]` when there is no `backend/handlers/` dir (or it is empty of sources)
50
- * so callers can always pass the result to `registerOrUpdateApp` without a separate gate.
51
- */
48
+ export interface ReadDetectAndUploadResult {
49
+ integrations: string[];
50
+ handlerNames: string[];
51
+ apiUsages: Record<string, unknown>;
52
+ }
53
+
52
54
  async function readDetectAndUpload(
53
55
  appDir: string,
54
56
  prefix: string,
@@ -56,17 +58,19 @@ async function readDetectAndUpload(
56
58
  version: number,
57
59
  deployedBy: string,
58
60
  logLabel: string
59
- ): Promise<string[]> {
61
+ ): Promise<ReadDetectAndUploadResult> {
60
62
  if (!hasHandlers(appDir)) {
61
- return [];
63
+ await writeUsageJson(appDir, { appName, deployedBy, apiUsages: {} });
64
+ return { integrations: [], handlerNames: [], apiUsages: {} };
62
65
  }
63
66
  info(`reading backend handlers ${logLabel}`);
64
67
  const { handlers, apiDomains, apiUsages } = await readHandlers(appDir);
65
68
  const integrations = detectIntegrations(apiDomains);
69
+ const handlerNames = Object.keys(handlers);
70
+ await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
66
71
  if (Object.keys(handlers).length === 0) {
67
- return integrations;
72
+ return { integrations, handlerNames, apiUsages };
68
73
  }
69
- await writeUsageJson(appDir, { appName, deployedBy, apiUsages });
70
74
  const s3 = getS3Client();
71
75
  const meta = JSON.stringify({ version, deployedBy, handlers: Object.keys(handlers) });
72
76
  const put = (key: string, body: string, contentType: string) =>
@@ -75,14 +79,10 @@ async function readDetectAndUpload(
75
79
  ...Object.entries(handlers).map(([name, code]) => put(`${prefix}/${name}.js`, code, 'application/javascript')),
76
80
  put(`${prefix}/meta.json`, meta, 'application/json'),
77
81
  ]);
78
- return integrations;
82
+ return { integrations, handlerNames, apiUsages };
79
83
  }
80
84
 
81
85
  /**
82
- * Deploy the app's backend handlers to S3 and return the integrations detected
83
- * from their sources. Each `.js` handler is uploaded as its own flat file — no
84
- * compilation, no bundling, no npm deps.
85
- *
86
86
  * Integration sync note: the returned set is re-derived from the CURRENT backend
87
87
  * sources on every deploy, and the platform set is reconciled to exactly match
88
88
  * it (there is no stored declaration to fall back on). A deploy therefore always
@@ -96,7 +96,7 @@ export async function deployBackend(opts: {
96
96
  version: number;
97
97
  deployedBy: string;
98
98
  isPreview: boolean;
99
- }): Promise<string[]> {
99
+ }): Promise<ReadDetectAndUploadResult> {
100
100
  const { appDir, appName, mfKeyPrefix, version, deployedBy, isPreview } = opts;
101
101
  const prefix = isPreview ? `${mfKeyPrefix.replace(/\/MF$/, '')}/MS` : `${appName}/backend-${version}`;
102
102
  const logLabel = isPreview ? 'for preview' : `for "${appName}"`;
@@ -131,13 +131,9 @@ async function uploadAndRegister(opts: {
131
131
  const { paths, keyPrefix, appName, token, nextVersion, entry, gitRemote, isPreview, description, masterAppName } =
132
132
  opts;
133
133
 
134
- info(`uploading ${isPreview ? 'preview ' : ''}build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
135
- await uploadDir(paths.shadowBuild, keyPrefix);
136
-
137
- // `integrations` is inferred from the backend sources in the working tree and
138
- // reconciled to exactly match on register — see `deployBackend` for why this
139
- // depends on deploying from the app's latest merged state.
140
- const integrations = await deployBackend({
134
+ // Runs before the frontend upload so a `deployBackend` failure can't leave a
135
+ // preview's unversioned `MF` dir overwritten with no rollback.
136
+ const { integrations, handlerNames, apiUsages } = await deployBackend({
141
137
  appDir: process.cwd(),
142
138
  appName,
143
139
  mfKeyPrefix: keyPrefix,
@@ -146,16 +142,34 @@ async function uploadAndRegister(opts: {
146
142
  isPreview,
147
143
  });
148
144
 
149
- await registerOrUpdateApp({
150
- appName,
151
- token,
152
- nextVersion,
153
- entry,
154
- gitRemote,
155
- integrations,
156
- description,
157
- masterAppName,
158
- });
145
+ try {
146
+ info(`uploading ${isPreview ? 'preview ' : ''}build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
147
+ await uploadDir(paths.shadowBuild, keyPrefix);
148
+
149
+ await registerOrUpdateApp({
150
+ appName,
151
+ token,
152
+ nextVersion,
153
+ entry,
154
+ gitRemote,
155
+ integrations,
156
+ handlerNames,
157
+ apiUsages,
158
+ description,
159
+ masterAppName,
160
+ });
161
+ } catch (err) {
162
+ // A live deploy is safe either way — RDS's version gates what's actually served, so
163
+ // an orphaned S3 object is harmless — but a preview is served straight from its
164
+ // unversioned prefix with no such gate, so only it needs the warning below.
165
+ if (isPreview) {
166
+ warn(
167
+ `preview backend was already deployed before this failed — re-run "z2h-cli deploy --preview" to retry ` +
168
+ `(every step overwrites, so a retry fully repairs it)`
169
+ );
170
+ }
171
+ throw err;
172
+ }
159
173
  }
160
174
 
161
175
  async function deployPreview({ paths, consumerName, appName, token, opts }: DeployContext): Promise<void> {
@@ -64,21 +64,52 @@ export async function claimApp(appName: string, gitRemote: string): Promise<void
64
64
  }
65
65
  }
66
66
 
67
- async function registerApp(
68
- appName: string,
69
- token: string,
70
- version: number,
71
- entry: ManifestEntry,
72
- visibility: 'public' | 'private',
73
- gitRemote: string,
74
- integrations?: string[],
75
- description?: string,
76
- masterAppName?: string
77
- ): Promise<void> {
67
+ export interface RegisterOrUpdateOptions {
68
+ appName: string;
69
+ token: string;
70
+ nextVersion: number;
71
+ entry: ManifestEntry;
72
+ gitRemote: string;
73
+ integrations?: string[];
74
+ handlerNames?: string[];
75
+ apiUsages?: Record<string, unknown>;
76
+ description?: string;
77
+ /**
78
+ * The base app a preview is for, sent only on first registration — the link
79
+ * is set once and is immutable thereafter, so a later update doesn't need it.
80
+ */
81
+ masterAppName?: string;
82
+ }
83
+
84
+ async function registerApp(params: RegisterOrUpdateOptions & { visibility: 'public' | 'private' }): Promise<void> {
85
+ const {
86
+ appName,
87
+ token,
88
+ nextVersion: version,
89
+ entry,
90
+ visibility,
91
+ gitRemote,
92
+ integrations,
93
+ handlerNames,
94
+ apiUsages,
95
+ description,
96
+ masterAppName,
97
+ } = params;
78
98
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/register`, {
79
99
  method: 'POST',
80
100
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
81
- body: JSON.stringify({ appName, version, entry, visibility, gitRemote, integrations, description, masterAppName }),
101
+ body: JSON.stringify({
102
+ appName,
103
+ version,
104
+ entry,
105
+ visibility,
106
+ gitRemote,
107
+ integrations,
108
+ handlerNames,
109
+ apiUsages,
110
+ description,
111
+ masterAppName,
112
+ }),
82
113
  });
83
114
  if (!res.ok) {
84
115
  const text = await res.text();
@@ -87,19 +118,22 @@ async function registerApp(
87
118
  }
88
119
 
89
120
  /** Releases a new version onto an app that already exists. */
90
- async function updateApp(
91
- appName: string,
92
- token: string,
93
- version: number,
94
- entry: ManifestEntry,
95
- gitRemote: string,
96
- integrations?: string[],
97
- description?: string
98
- ): Promise<void> {
121
+ 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;
99
133
  const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/update`, {
100
134
  method: 'POST',
101
135
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
102
- body: JSON.stringify({ appName, version, entry, gitRemote, integrations, description }),
136
+ body: JSON.stringify({ appName, version, entry, gitRemote, integrations, handlerNames, apiUsages, description }),
103
137
  });
104
138
  if (!res.ok) {
105
139
  const text = await res.text();
@@ -107,21 +141,6 @@ async function updateApp(
107
141
  }
108
142
  }
109
143
 
110
- export interface RegisterOrUpdateOptions {
111
- appName: string;
112
- token: string;
113
- nextVersion: number;
114
- entry: ManifestEntry;
115
- gitRemote: string;
116
- integrations?: string[];
117
- description?: string;
118
- /**
119
- * The base app a preview is for, sent only on first registration — the link
120
- * is set once and is immutable thereafter, so a later update doesn't need it.
121
- */
122
- masterAppName?: string;
123
- }
124
-
125
144
  /**
126
145
  * v1 creates the app row (+ owner grant, prompting for visibility); later
127
146
  * versions release onto it via update instead — the two are separate
@@ -129,26 +148,16 @@ export interface RegisterOrUpdateOptions {
129
148
  * deploy and preview so this branching logic lives in exactly one place.
130
149
  */
131
150
  export async function registerOrUpdateApp(opts: RegisterOrUpdateOptions): Promise<void> {
132
- const { appName, token, nextVersion, entry, gitRemote, integrations, description, masterAppName } = opts;
151
+ const { appName, nextVersion } = opts;
133
152
 
134
153
  try {
135
154
  if (nextVersion === 1) {
136
155
  const visibility = await resolveAppVisibility(appName);
137
156
  info(`registering "${appName}" → v${nextVersion} (${visibility})`);
138
- await registerApp(
139
- appName,
140
- token,
141
- nextVersion,
142
- entry,
143
- visibility,
144
- gitRemote,
145
- integrations,
146
- description,
147
- masterAppName
148
- );
157
+ await registerApp({ ...opts, visibility });
149
158
  } else {
150
159
  info(`updating "${appName}" → v${nextVersion}`);
151
- await updateApp(appName, token, nextVersion, entry, gitRemote, integrations, description);
160
+ await updateApp(opts);
152
161
  }
153
162
  } catch (err) {
154
163
  // registerApp/updateApp already tag the non-ok-response case; this catches anything