@mondaydotcomorg/z2h-cli 0.25.4 → 0.25.8

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 (120) hide show
  1. package/dist/auth.d.ts +2 -1
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/auth.js +2 -1
  4. package/dist/commands/__tests__/build.test.d.ts +2 -0
  5. package/dist/commands/__tests__/build.test.d.ts.map +1 -0
  6. package/dist/commands/backend.d.ts.map +1 -1
  7. package/dist/commands/backend.js +7 -1
  8. package/dist/commands/build.d.ts.map +1 -1
  9. package/dist/commands/build.js +10 -0
  10. package/dist/commands/deploy.d.ts.map +1 -1
  11. package/dist/commands/deploy.js +42 -19
  12. package/dist/commands/grant.d.ts.map +1 -1
  13. package/dist/commands/grant.js +8 -7
  14. package/dist/esm/auth.d.ts +2 -1
  15. package/dist/esm/auth.d.ts.map +1 -1
  16. package/dist/esm/auth.mjs +2 -1
  17. package/dist/esm/commands/__tests__/build.test.d.ts +2 -0
  18. package/dist/esm/commands/__tests__/build.test.d.ts.map +1 -0
  19. package/dist/esm/commands/backend.d.ts.map +1 -1
  20. package/dist/esm/commands/backend.mjs +7 -1
  21. package/dist/esm/commands/build.d.ts.map +1 -1
  22. package/dist/esm/commands/build.mjs +10 -0
  23. package/dist/esm/commands/deploy.d.ts.map +1 -1
  24. package/dist/esm/commands/deploy.mjs +43 -20
  25. package/dist/esm/commands/grant.d.ts.map +1 -1
  26. package/dist/esm/commands/grant.mjs +8 -7
  27. package/dist/esm/index.mjs +41 -140
  28. package/dist/esm/types.d.ts +23 -0
  29. package/dist/esm/types.d.ts.map +1 -1
  30. package/dist/esm/util/__tests__/errors.test.d.ts +2 -0
  31. package/dist/esm/util/__tests__/errors.test.d.ts.map +1 -0
  32. package/dist/esm/util/__tests__/run-command.test.d.ts +2 -0
  33. package/dist/esm/util/__tests__/run-command.test.d.ts.map +1 -0
  34. package/dist/esm/util/auth/auth-store.d.ts +0 -4
  35. package/dist/esm/util/auth/auth-store.d.ts.map +1 -1
  36. package/dist/esm/util/auth/auth-store.mjs +2 -9
  37. package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -1
  38. package/dist/esm/util/auth/broker-credential-provider.mjs +16 -16
  39. package/dist/esm/util/broker/app.d.ts.map +1 -1
  40. package/dist/esm/util/broker/app.mjs +17 -9
  41. package/dist/esm/util/errors.d.ts +48 -0
  42. package/dist/esm/util/errors.d.ts.map +1 -0
  43. package/dist/esm/util/errors.mjs +91 -0
  44. package/dist/esm/util/git/repo.d.ts +0 -3
  45. package/dist/esm/util/git/repo.d.ts.map +1 -1
  46. package/dist/esm/util/git/repo.mjs +4 -9
  47. package/dist/esm/util/logger.d.ts +4 -1
  48. package/dist/esm/util/logger.d.ts.map +1 -1
  49. package/dist/esm/util/logger.mjs +11 -3
  50. package/dist/esm/util/run-command.d.ts +16 -0
  51. package/dist/esm/util/run-command.d.ts.map +1 -0
  52. package/dist/esm/util/run-command.mjs +47 -0
  53. package/dist/esm/util/s3/upload.d.ts.map +1 -1
  54. package/dist/esm/util/s3/upload.mjs +22 -16
  55. package/dist/esm/util/tracker.d.ts +9 -2
  56. package/dist/esm/util/tracker.d.ts.map +1 -1
  57. package/dist/esm/util/tracker.mjs +18 -15
  58. package/dist/index.js +40 -139
  59. package/dist/types.d.ts +23 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/util/__tests__/errors.test.d.ts +2 -0
  62. package/dist/util/__tests__/errors.test.d.ts.map +1 -0
  63. package/dist/util/__tests__/run-command.test.d.ts +2 -0
  64. package/dist/util/__tests__/run-command.test.d.ts.map +1 -0
  65. package/dist/util/auth/auth-store.d.ts +0 -4
  66. package/dist/util/auth/auth-store.d.ts.map +1 -1
  67. package/dist/util/auth/auth-store.js +2 -10
  68. package/dist/util/auth/broker-credential-provider.d.ts.map +1 -1
  69. package/dist/util/auth/broker-credential-provider.js +16 -16
  70. package/dist/util/broker/app.d.ts.map +1 -1
  71. package/dist/util/broker/app.js +17 -9
  72. package/dist/util/errors.d.ts +48 -0
  73. package/dist/util/errors.d.ts.map +1 -0
  74. package/dist/util/errors.js +102 -0
  75. package/dist/util/git/repo.d.ts +0 -3
  76. package/dist/util/git/repo.d.ts.map +1 -1
  77. package/dist/util/git/repo.js +4 -10
  78. package/dist/util/logger.d.ts +4 -1
  79. package/dist/util/logger.d.ts.map +1 -1
  80. package/dist/util/logger.js +11 -3
  81. package/dist/util/run-command.d.ts +16 -0
  82. package/dist/util/run-command.d.ts.map +1 -0
  83. package/dist/util/run-command.js +49 -0
  84. package/dist/util/s3/upload.d.ts.map +1 -1
  85. package/dist/util/s3/upload.js +22 -16
  86. package/dist/util/tracker.d.ts +9 -2
  87. package/dist/util/tracker.d.ts.map +1 -1
  88. package/dist/util/tracker.js +19 -14
  89. package/handler-api/backend-runner/channels/channel-map.ts +6 -12
  90. package/handler-api/backend-runner/channels/channel-telemetry.ts +79 -0
  91. package/handler-api/backend-runner/channels/llm-channel/llm.channel.ts +3 -2
  92. package/handler-api/backend-runner/channels/snowflake-channel/run-query.ts +21 -7
  93. package/handler-api/backend-runner/channels/snowflake-channel/snowflake.channel.ts +14 -2
  94. package/handler-api/backend-runner/channels/snowflake-channel/stateless-part-b.ts +1 -4
  95. package/package.json +2 -2
  96. package/src/auth.ts +2 -1
  97. package/src/commands/__tests__/backend-deploy.test.ts +18 -0
  98. package/src/commands/__tests__/build.test.ts +83 -0
  99. package/src/commands/__tests__/deploy.test.ts +107 -0
  100. package/src/commands/backend.ts +6 -1
  101. package/src/commands/build.ts +10 -0
  102. package/src/commands/deploy.ts +42 -20
  103. package/src/commands/grant.ts +8 -7
  104. package/src/index.ts +84 -133
  105. package/src/types.ts +47 -0
  106. package/src/util/__tests__/errors.test.ts +90 -0
  107. package/src/util/__tests__/run-command.test.ts +98 -0
  108. package/src/util/auth/__tests__/auth-store.test.ts +2 -1
  109. package/src/util/auth/__tests__/broker-credential-provider.test.ts +44 -8
  110. package/src/util/auth/auth-store.ts +1 -7
  111. package/src/util/auth/broker-credential-provider.ts +18 -16
  112. package/src/util/broker/__tests__/app.test.ts +23 -20
  113. package/src/util/broker/app.ts +16 -9
  114. package/src/util/errors.ts +98 -0
  115. package/src/util/git/__tests__/repo.test.ts +5 -3
  116. package/src/util/git/repo.ts +3 -9
  117. package/src/util/logger.ts +16 -3
  118. package/src/util/run-command.ts +59 -0
  119. package/src/util/s3/upload.ts +27 -22
  120. package/src/util/tracker.ts +19 -19
@@ -33,7 +33,11 @@ export function makeSnowflakeChannel(appName: string) {
33
33
  *
34
34
  * @param sql - A SELECT statement. Non-SELECT is rejected with `400`.
35
35
  * @param params - Optional map of `:name` → `string | number | boolean | null` bindings.
36
- * @param options - Optional `{ timeoutMs?: number }` — clamped to 5 min (`MAX_TIMEOUT_MS`); effective ceiling is ~5.5 min including the host backstop.
36
+ * @param options - Optional `{ timeoutMs?: number, identifier?: string }`. `timeoutMs` is
37
+ * clamped to 5 min (`MAX_TIMEOUT_MS`); effective ceiling is ~5.5 min including the host
38
+ * backstop. `identifier` names this query in logs/metrics and the Snowflake pool identifier
39
+ * (e.g. a widget name) instead of the default query-shape hash — sanitized to `[a-z0-9-_]`,
40
+ * max 64 chars.
37
41
  * @returns Array of row objects with **camelCase keys** — SQL aliases are normalized
38
42
  * to camelCase regardless of their casing in the query
39
43
  * (e.g. `AS current_user` → `rows[0].currentUser`, `AS total_count` → `rows[0].totalCount`).
@@ -63,6 +67,14 @@ export function makeSnowflakeChannel(appName: string) {
63
67
  * { timeoutMs: 25_000 },
64
68
  * );
65
69
  *
70
+ * @example
71
+ * // Name this query in logs/metrics instead of the default hash
72
+ * const rows = await ctx.api.v1.snowflake.query(
73
+ * `SELECT COUNT(*) AS total FROM monday_items`,
74
+ * undefined,
75
+ * { identifier: 'total-items-kpi' },
76
+ * );
77
+ *
66
78
  * @throws `400` `snowflake_invalid_argument` — `sql` was not a non-empty string.
67
79
  * @throws `400` `snowflake_not_read_only` — the statement is not a SELECT.
68
80
  * @throws `400` `snowflake_invalid_sql` — the statement could not be parsed.
@@ -96,8 +108,8 @@ export function makeSnowflakeChannel(appName: string) {
96
108
  }
97
109
  return runSnowflakeQuery(
98
110
  query,
99
- params as Record<string, SnowflakeParamValue> | null | undefined,
100
111
  appName,
112
+ params as Record<string, SnowflakeParamValue> | null | undefined,
101
113
  options as RunQueryOptions | null | undefined,
102
114
  );
103
115
  },
@@ -23,10 +23,7 @@ export interface SnowflakeConnectionDetails {
23
23
  defaultDatabase?: string;
24
24
  }
25
25
 
26
- export function isStatelessPartBApp(appName: string | undefined): boolean {
27
- if (!appName) {
28
- return false;
29
- }
26
+ export function isStatelessPartBApp(appName: string): boolean {
30
27
  const { allowed } = getConfiguration(ConfigurationKey.Z2H_STATELESS_PART_B_APPS);
31
28
  return Array.isArray(allowed) && allowed.includes(appName);
32
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.25.4",
3
+ "version": "0.25.8",
4
4
  "bin": "./bin/z2h-cli.js",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -51,7 +51,7 @@
51
51
  ],
52
52
  "dependencies": {
53
53
  "@aws-sdk/client-s3": "^3.706.0",
54
- "@mondaydotcomorg/z2h-shared-utils": "^0.3.4",
54
+ "@mondaydotcomorg/z2h-shared-utils": "^0.4.2",
55
55
  "chokidar": "^3.6.0",
56
56
  "commander": "^12.1.0",
57
57
  "cors": "^2.8.6",
package/src/auth.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { generateZ2HTokenCommand, type GenerateZ2HTokenOptions } from './commands/generate-z2h-token';
2
- export { readZthAuth, Z2hAuthMissingError, Z2H_AUTH_FILE, type ZthAuth } from './util/auth/auth-store';
2
+ export { readZthAuth, Z2H_AUTH_FILE, type ZthAuth } from './util/auth/auth-store';
3
+ export { Z2hAuthMissingError } from './util/errors';
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import { deployBackend, hasHandlers } from '../backend';
7
7
  import { readHandlers } from '../../backend/handlers';
8
+ import { DeployError } from '../../util/errors';
8
9
 
9
10
  vi.mock('../../backend/handlers', () => ({
10
11
  readHandlers: vi.fn(),
@@ -96,6 +97,23 @@ describe('deployBackend (live)', () => {
96
97
  const keys: string[] = mockSend.mock.calls.map((c: unknown[]) => (c[0] as { input: { Key: string } }).input.Key);
97
98
  expect(keys.some(k => k.includes('/backend-1/'))).toBe(true);
98
99
  });
100
+
101
+ it('tags a handler upload failure as deploy_backend_error, not register_error', async () => {
102
+ await ensureDir(path.join(tmp, 'backend', 'handlers'));
103
+ mockSend.mockRejectedValueOnce(new Error('AccessDenied'));
104
+
105
+ const err = await deployBackend({
106
+ appDir: tmp,
107
+ appName: 'mf-my-app',
108
+ mfKeyPrefix: 'mf-my-app/1',
109
+ version: 1,
110
+ deployedBy: 'test-user',
111
+ isPreview: false,
112
+ }).catch((e: unknown) => e);
113
+ expect(err).toBeInstanceOf(DeployError);
114
+ expect((err as DeployError).reason).toBe('deploy_backend_error');
115
+ expect((err as DeployError).message).toBe('AccessDenied');
116
+ });
99
117
  });
100
118
 
101
119
  describe('deployBackend (preview)', () => {
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { execa } from 'execa';
3
+ import { pathExists } from 'fs-extra';
4
+ import { buildCommand } from '../build';
5
+ import { DeployError } from '../../util/errors';
6
+ import type { PreparedShadow } from '../../types';
7
+
8
+ vi.mock('execa', () => ({ execa: vi.fn() }));
9
+ vi.mock('fs-extra', () => ({ pathExists: vi.fn(), readJson: vi.fn() }));
10
+ vi.mock('../../shadow/prepare', () => ({ prepareShadow: vi.fn() }));
11
+
12
+ const { prepareShadow } = await import('../../shadow/prepare');
13
+ const mockExeca = vi.mocked(execa);
14
+ const mockPathExists = vi.mocked(pathExists);
15
+
16
+ const STUB_SHADOW: PreparedShadow = {
17
+ appName: 'my-app',
18
+ port: 13000,
19
+ paths: {
20
+ consumerDir: '/tmp/my-app',
21
+ consumerPkg: '/tmp/my-app/package.json',
22
+ consumerSrc: '/tmp/my-app/src',
23
+ consumerSrcEntry: '/tmp/my-app/src/index.tsx',
24
+ shadowDir: '/tmp/my-app/.zth',
25
+ shadowPkg: '/tmp/my-app/.zth/package.json',
26
+ shadowSrc: '/tmp/my-app/.zth/src',
27
+ shadowWrapper: '/tmp/my-app/.zth/src/wrapper.tsx',
28
+ shadowTridentrc: '/tmp/my-app/.zth/.tridentrc.js',
29
+ shadowNvmrc: '/tmp/my-app/.zth/.nvmrc',
30
+ shadowDts: '/tmp/my-app/.zth/global.d.ts',
31
+ shadowTsconfig: '/tmp/my-app/.zth/tsconfig.json',
32
+ shadowBuild: '/tmp/my-app/.zth/build',
33
+ shadowDepsHash: '/tmp/my-app/.zth/.deps-hash',
34
+ shadowMetadata: '/tmp/my-app/.zth/metadata.json',
35
+ },
36
+ };
37
+
38
+ beforeEach(() => {
39
+ vi.mocked(prepareShadow).mockReset().mockResolvedValue(STUB_SHADOW);
40
+ mockExeca.mockReset();
41
+ mockPathExists.mockReset();
42
+ });
43
+
44
+ // Both execa calls (typecheck, then build) can fail for the same reasons — cover the
45
+ // tagging once against the typecheck call rather than duplicating it for both. buildCommand
46
+ // itself doesn't report to telemetry (the standalone `build` command opts out in index.ts) —
47
+ // it just needs to be informative, and to carry a reason for when it surfaces through `deploy`.
48
+ describe('buildCommand failure tagging', () => {
49
+ it('tags a compile/bundle failure as build_error', async () => {
50
+ mockExeca.mockRejectedValueOnce(new Error('tsc exploded'));
51
+
52
+ const err = await buildCommand().catch((e: unknown) => e);
53
+ expect(err).toBeInstanceOf(DeployError);
54
+ expect((err as DeployError).reason).toBe('build_error');
55
+ expect((err as DeployError).message).toBe('tsc exploded');
56
+ });
57
+
58
+ it('tags a signal-killed build as interrupted, not build_error', async () => {
59
+ mockExeca.mockRejectedValueOnce(Object.assign(new Error('killed'), { signal: 'SIGINT' }));
60
+
61
+ const err = await buildCommand().catch((e: unknown) => e);
62
+ expect(err).toBeInstanceOf(DeployError);
63
+ expect((err as DeployError).reason).toBe('interrupted');
64
+ });
65
+
66
+ it('survives a non-Error throw instead of masking it with a TypeError', async () => {
67
+ mockExeca.mockRejectedValueOnce('boom');
68
+
69
+ const err = await buildCommand().catch((e: unknown) => e);
70
+ expect(err).toBeInstanceOf(DeployError);
71
+ expect((err as DeployError).reason).toBe('build_error');
72
+ expect((err as DeployError).message).toBe('boom');
73
+ });
74
+
75
+ it('tags missing build output as build_error', async () => {
76
+ mockExeca.mockResolvedValue({ stdout: '' } as never);
77
+ mockPathExists.mockResolvedValue(false as never);
78
+
79
+ const err = await buildCommand().catch((e: unknown) => e);
80
+ expect(err).toBeInstanceOf(DeployError);
81
+ expect((err as DeployError).reason).toBe('build_error');
82
+ });
83
+ });
@@ -4,6 +4,7 @@ import { mkdtemp, rm, ensureDir, writeJson } from 'fs-extra';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import { deployCommand } from '../deploy';
7
+ import { DeployError, reasonOf } from '../../util/errors';
7
8
  import { buildPreviewSlug } from '../../util/app-name';
8
9
  import { setOutputMode } from '../../util/logger';
9
10
  import { buildCommand } from '../build';
@@ -201,6 +202,24 @@ describe('deploy --preview', () => {
201
202
  expect(openInBrowser).not.toHaveBeenCalled();
202
203
  });
203
204
 
205
+ it('propagates an unclassified failure as-is; runCommand reports it as unknown', async () => {
206
+ vi.mocked(brokerProvider.fetchDeployVersion).mockRejectedValue(new Error('something nobody tagged'));
207
+
208
+ const err = await deployCommand({ preview: true }).catch((e: unknown) => e);
209
+ expect(err).toBeInstanceOf(Error);
210
+ expect(err).not.toBeInstanceOf(DeployError);
211
+ expect(reasonOf(err)).toBe('unknown');
212
+ });
213
+
214
+ it('propagates a stage-tagged reason untouched', async () => {
215
+ vi.mocked(brokerApp.registerOrUpdateApp).mockRejectedValue(
216
+ new DeployError('register_error', 'App registration failed')
217
+ );
218
+
219
+ const err = await deployCommand({ preview: true }).catch((e: unknown) => e);
220
+ expect((err as DeployError).reason).toBe('register_error');
221
+ });
222
+
204
223
  it('rejects deploying a preview for an app already named with the preview prefix', async () => {
205
224
  await writeJson(path.join(tmp, 'package.json'), { name: 'zth-preview-abc123-my-app' });
206
225
  await expect(deployCommand({ preview: true })).rejects.toThrow(/reserved for the ephemeral preview mechanism/);
@@ -228,3 +247,91 @@ describe('deploy --preview', () => {
228
247
  writeSpy.mockRestore();
229
248
  });
230
249
  });
250
+
251
+ describe('deploy failure reasons', () => {
252
+ let tmp: string;
253
+ let cwd: string;
254
+
255
+ beforeEach(async () => {
256
+ tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-reason-'));
257
+ cwd = process.cwd();
258
+ process.chdir(tmp);
259
+ tmp = process.cwd();
260
+ await writeJson(path.join(tmp, 'package.json'), { name: '@mondaydotcomorg/my-app' });
261
+ await ensureDir(path.join(tmp, '.zth', 'build'));
262
+
263
+ vi.spyOn(authStore, 'readZthAuth').mockResolvedValue({ token: 'jwt-stub' });
264
+ vi.mocked(brokerProvider.fetchDeployVersion).mockReset().mockResolvedValue(0);
265
+ vi.mocked(gitRepo.getRemoteUrl).mockReset().mockResolvedValue('https://github.com/org/repo.git');
266
+ vi.mocked(gitRepo.guardCleanTree).mockReset().mockResolvedValue(undefined);
267
+ vi.mocked(gitRepo.pullAndResolve).mockReset().mockResolvedValue(undefined);
268
+ vi.mocked(buildCommand)
269
+ .mockReset()
270
+ .mockResolvedValue({
271
+ appName: 'my-app',
272
+ port: 13000,
273
+ buildDir: path.join(tmp, '.zth', 'build'),
274
+ assetManifest: ASSET_MANIFEST,
275
+ });
276
+ });
277
+
278
+ afterEach(async () => {
279
+ process.chdir(cwd);
280
+ vi.unstubAllGlobals();
281
+ await rm(tmp, { recursive: true, force: true });
282
+ });
283
+
284
+ /** Runs a live deploy expected to fail, and returns the tagged error. */
285
+ const failedDeploy = async (): Promise<DeployError> => {
286
+ const err = await deployCommand().catch((e: unknown) => e);
287
+ expect(err).toBeInstanceOf(DeployError);
288
+ return err as DeployError;
289
+ };
290
+
291
+ it('tags a missing local token as read_auth_token', async () => {
292
+ vi.mocked(authStore.readZthAuth).mockRejectedValue(new Error('no auth.json'));
293
+ expect((await failedDeploy()).reason).toBe('read_auth_token');
294
+ });
295
+
296
+ it('tags a dirty working tree as git_error, not unknown', async () => {
297
+ vi.mocked(gitRepo.guardCleanTree).mockRejectedValue(new Error('working tree has uncommitted changes'));
298
+ expect((await failedDeploy()).reason).toBe('git_error');
299
+ });
300
+
301
+ it('tags a divergence during sync as git_error', async () => {
302
+ vi.mocked(gitRepo.pullAndResolve).mockRejectedValue(new Error('conflict'));
303
+ expect((await failedDeploy()).reason).toBe('git_error');
304
+ });
305
+
306
+ /** Stubs the lock endpoint (raw `fetch`) to answer `status`; the release call is best-effort and ignored. */
307
+ const stubLockResponse = (status: number) =>
308
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: status < 400, status, text: async () => `lock ${status}` }));
309
+
310
+ it('tags lock contention (409) as lock_timeout', async () => {
311
+ stubLockResponse(409);
312
+ expect((await failedDeploy()).reason).toBe('lock_timeout');
313
+ });
314
+
315
+ it('tags a lock-endpoint outage as broker_error, not lock contention or unknown', async () => {
316
+ stubLockResponse(503);
317
+ const err = await failedDeploy();
318
+ expect(err.reason).toBe('broker_error');
319
+ expect(err.message).toMatch(/Failed to acquire deploy lock \(503\)/);
320
+ });
321
+
322
+ it('tags a network failure reaching the lock endpoint as broker_error', async () => {
323
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
324
+ expect((await failedDeploy()).reason).toBe('broker_error');
325
+ });
326
+
327
+ // buildCommand throws plain errors (see commands/__tests__/build.test.ts), so a build
328
+ // failure surfaces as untagged here — see the propagation test below. The STS 401/403 →
329
+ // auth tagging lives in broker-credential-provider.ts — see its own test file.
330
+
331
+ it('a preview propagates its build failure the same way a live deploy does', async () => {
332
+ vi.mocked(buildCommand).mockRejectedValue(new Error('tsc exploded'));
333
+ const err = await deployCommand({ preview: true }).catch((e: unknown) => e);
334
+ expect(err).toBeInstanceOf(Error);
335
+ expect((err as Error).message).toMatch(/tsc exploded/);
336
+ });
337
+ });
@@ -8,6 +8,7 @@ import { detectIntegrationsFromHandlers } from '../backend/detect-integrations';
8
8
  import { getS3Client } from '../util/s3/client';
9
9
  import { BUCKET_WRITE_NAME } from '../constants';
10
10
  import { invokeBackend } from '../backend/client';
11
+ import { tagDeployError } from '../util/errors';
11
12
 
12
13
  function currentAppName(): string {
13
14
  const raw = getCwdAppName();
@@ -86,7 +87,11 @@ export async function deployBackend(opts: {
86
87
  const { appDir, appName, mfKeyPrefix, version, deployedBy, isPreview } = opts;
87
88
  const prefix = isPreview ? `${mfKeyPrefix.replace(/\/MF$/, '')}/MS` : `${appName}/backend-${version}`;
88
89
  const logLabel = isPreview ? 'for preview' : `for "${appName}"`;
89
- return readDetectAndUpload(appDir, prefix, version, deployedBy, logLabel);
90
+ try {
91
+ return await readDetectAndUpload(appDir, prefix, version, deployedBy, logLabel);
92
+ } catch (err) {
93
+ throw tagDeployError('deploy_backend_error', err);
94
+ }
90
95
  }
91
96
 
92
97
  export async function backendInvokeCommand(handlerName: string, opts: { input?: string } = {}): Promise<void> {
@@ -3,6 +3,7 @@ import { pathExists, readJson } from 'fs-extra';
3
3
  import { execa } from 'execa';
4
4
  import { info, output } from '../util/logger';
5
5
  import { prepareShadow } from '../shadow/prepare';
6
+ import { DeployError, messageOf } from '../util/errors';
6
7
  import type { AssetManifestFile } from '../util/manifest';
7
8
 
8
9
  export interface BuildOptions {
@@ -18,6 +19,15 @@ export interface BuildResult {
18
19
 
19
20
  // Should be run from the consumer app directory — uses process.cwd() to locate it.
20
21
  export async function buildCommand(opts: BuildOptions = {}): Promise<BuildResult> {
22
+ try {
23
+ return await runBuild(opts);
24
+ } catch (err) {
25
+ const signal = err instanceof Error ? (err as Error & { signal?: string }).signal : undefined;
26
+ throw new DeployError(signal ? 'interrupted' : 'build_error', messageOf(err));
27
+ }
28
+ }
29
+
30
+ async function runBuild(opts: BuildOptions): Promise<BuildResult> {
21
31
  const ctx = await prepareShadow();
22
32
 
23
33
  // Typecheck against the shadow's tsconfig (the consumer dir has none).
@@ -22,10 +22,11 @@ import { getMachineAppName } from '../util/machine-identity';
22
22
  import { registerOrUpdateApp } from '../util/broker/app';
23
23
  import { openInBrowser } from '../util/open-browser';
24
24
  import { resolvePortAndMetadata } from '../shadow/prepare';
25
+ import { DeployError, tagDeployError, grantReasonForStatus } from '../util/errors';
25
26
  import { deployBackend } from './backend';
26
27
  import { buildCommand } from './build';
27
28
  import { promptForTags } from './tag';
28
- import type { ConsumerPaths } from '../types';
29
+ import type { ConsumerPaths, DeployFailureReason } from '../types';
29
30
  import type { ManifestEntry } from '../util/manifest';
30
31
 
31
32
  /** Who is publishing this version — the machine identity, else the OS user. */
@@ -63,7 +64,12 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
63
64
 
64
65
  // Validate the user's deploy token is on disk before doing any expensive
65
66
  // work. Throws Z2hAuthMissingError with a remediation hint if not.
66
- const { token } = await readZthAuth();
67
+ let token: string;
68
+ try {
69
+ ({ token } = await readZthAuth());
70
+ } catch (err) {
71
+ throw tagDeployError('read_auth_token', err);
72
+ }
67
73
 
68
74
  const ctx: DeployContext = { paths, consumerName: consumerPkg.name, appName, token, opts };
69
75
 
@@ -145,6 +151,8 @@ async function deployPreview({ paths, consumerName, appName, token, opts }: Depl
145
151
  // entry's `url` verbatim as the asset base, never joined with its own mfUrl.
146
152
  const url = `${BUCKET_READ_DNS_URL}/${keyPrefix}`;
147
153
 
154
+ // No catch-all: every stage below tags its own DeployError reason at the throw site,
155
+ // and anything unclassified reports as `unknown` via reasonOf() in runCommand.
148
156
  const nextVersion = (await fetchDeployVersion(previewAppName)) + 1;
149
157
 
150
158
  info('building');
@@ -199,10 +207,10 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
199
207
  if (!opts.remote) {
200
208
  throw new Error(
201
209
  `This app has no git remote. On first deploy, tell Z2H how to track source:\n\n` +
202
- ` z2h-cli deploy --remote s3 (default)\n` +
203
- ` Stores source history in S3 alongside your app. No extra setup.\n\n` +
210
+ ` z2h-cli deploy --remote s3 (default, recommended)\n` +
211
+ ` We store and back up your code for you automatically. No extra setup.\n\n` +
204
212
  ` z2h-cli deploy --remote github --repo-url https://github.com/org/repo.git\n` +
205
- ` Uses an existing GitHub repo (must exist before deploying).`
213
+ ` Manage it yourself in an existing GitHub repo (must exist before deploying).`
206
214
  );
207
215
  }
208
216
  validateRemoteOptions(opts);
@@ -223,9 +231,13 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
223
231
  // The skill commits the working tree (with an LLM-authored message) before
224
232
  // invoking deploy, so the tree should be clean here.
225
233
  if (gitRemote) {
226
- await guardCleanTree(process.cwd());
227
- info('syncing with git remote');
228
- await pullAndResolve(process.cwd(), gitEnv);
234
+ try {
235
+ await guardCleanTree(process.cwd());
236
+ info('syncing with git remote');
237
+ await pullAndResolve(process.cwd(), gitEnv);
238
+ } catch (err) {
239
+ throw tagDeployError('git_error', err);
240
+ }
229
241
  }
230
242
 
231
243
  info('reading current Z2H manifest');
@@ -249,7 +261,6 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
249
261
  return;
250
262
  }
251
263
 
252
- // lock → git push → bundle upload → manifest PUT (the manifest write is the single commit point).
253
264
  try {
254
265
  // Acquire the deploy lock explicitly. Done after the build
255
266
  // so the lock hold stays tiny — it wraps only push → upload → manifest.
@@ -279,6 +290,8 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
279
290
  description: opts.description,
280
291
  });
281
292
  } finally {
293
+ // Same as preview: each stage tags its own reason, so no catch-all here — the try
294
+ // exists only so the lock is released on every exit path.
282
295
  info('releasing deploy lock');
283
296
  await releaseLockBestEffort(appName, token);
284
297
  }
@@ -331,20 +344,33 @@ async function handleDeployTags(appSlug: string): Promise<void> {
331
344
  }
332
345
  }
333
346
 
334
- // Acquire the per-app deploy lock. Throws a friendly error on 409 (someone else is deploying)
335
- // so the deploy stops and the caller can retry; the backend TTL backstops a crashed holder.
336
- async function acquireDeployLock(appName: string, token: string): Promise<void> {
337
- const res = await fetch(`${brokerBaseUrl()}/z2h-cli/lock/acquire`, {
347
+ function postToLockEndpoint(path: string, appName: string, token: string): Promise<Response> {
348
+ return fetch(`${brokerBaseUrl()}${path}`, {
338
349
  method: 'POST',
339
350
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
340
351
  body: JSON.stringify({ appName }),
341
352
  });
353
+ }
354
+
355
+ // Acquire the per-app deploy lock. Throws a friendly error on 409 (someone else is deploying)
356
+ // so the deploy stops and the caller can retry; the backend TTL backstops a crashed holder.
357
+ async function acquireDeployLock(appName: string, token: string): Promise<void> {
358
+ let res: Response;
359
+ try {
360
+ res = await postToLockEndpoint('/z2h-cli/lock/acquire', appName, token);
361
+ } catch (err) {
362
+ throw tagDeployError('broker_error', err);
363
+ }
342
364
  if (res.status === 409) {
343
- throw new Error(`Someone is deploying "${appName}" right now — try again in a moment.`);
365
+ throw new DeployError('lock_timeout', `Someone is deploying "${appName}" right now — try again in a moment.`);
344
366
  }
345
367
  if (!res.ok) {
368
+ // Only 409 is contention — don't report a broker outage or a stale token as lock contention.
346
369
  const text = await res.text();
347
- throw new Error(`Failed to acquire deploy lock (${res.status}): ${text.slice(0, 512)}`);
370
+ const grantReason = grantReasonForStatus(res.status);
371
+ const reason: DeployFailureReason =
372
+ grantReason === 'auth' || grantReason === 'forbidden' ? 'broker_auth' : 'broker_error';
373
+ throw new DeployError(reason, `Failed to acquire deploy lock (${res.status}): ${text.slice(0, 512)}`);
348
374
  }
349
375
  info(`deploy lock acquired for ${appName}`);
350
376
  }
@@ -352,11 +378,7 @@ async function acquireDeployLock(appName: string, token: string): Promise<void>
352
378
  // best-effort — the 2-min TTL backstop handles failures
353
379
  async function releaseLockBestEffort(appName: string, token: string): Promise<void> {
354
380
  try {
355
- const res = await fetch(`${brokerBaseUrl()}/z2h-cli/lock/release`, {
356
- method: 'POST',
357
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
358
- body: JSON.stringify({ appName }),
359
- });
381
+ const res = await postToLockEndpoint('/z2h-cli/lock/release', appName, token);
360
382
  if (!res.ok) {
361
383
  warn(`failed to release lock for ${appName} (${res.status})`);
362
384
  return;
@@ -2,6 +2,7 @@ import { output } from '../util/logger';
2
2
  import { getCwdAppName, mfAppName } from '../util/app-name';
3
3
  import { brokerBaseUrl, routingHeaders } from '../constants';
4
4
  import { readZthAuth } from '../util/auth/auth-store';
5
+ import { GrantError } from '../util/errors';
5
6
 
6
7
  export interface GrantOptions {
7
8
  public?: boolean;
@@ -20,18 +21,18 @@ async function postGrant(
20
21
  });
21
22
  if (!res.ok) {
22
23
  const text = await res.text();
23
- throw new Error(`Grant failed (${res.status}): ${text.slice(0, 1024)}`);
24
+ throw new GrantError(res.status, `Grant failed (${res.status}): ${text.slice(0, 1024)}`);
24
25
  }
25
26
  }
26
27
 
27
28
  export async function grantCommand(email: string | undefined, opts: GrantOptions): Promise<void> {
28
29
  if (!opts.public && !email) {
29
- throw new Error('Specify what to grant — e.g. --public or provide an email');
30
+ throw new GrantError('validation', 'Specify what to grant — e.g. --public or provide an email');
30
31
  }
31
32
 
32
33
  const level = opts.level ?? 'viewer';
33
34
  if (!['owner', 'editor', 'viewer'].includes(level)) {
34
- throw new Error(`Invalid --level "${level}". Must be owner, editor, or viewer`);
35
+ throw new GrantError('validation', `Invalid --level "${level}". Must be owner, editor, or viewer`);
35
36
  }
36
37
 
37
38
  const appName = mfAppName(getCwdAppName()!);
@@ -56,7 +57,7 @@ export async function grantCommand(email: string | undefined, opts: GrantOptions
56
57
 
57
58
  export async function revokeCommand(email: string | undefined, opts: GrantOptions): Promise<void> {
58
59
  if (!opts.public && !email) {
59
- throw new Error('Specify what to revoke — e.g. --public or provide an email');
60
+ throw new GrantError('validation', 'Specify what to revoke — e.g. --public or provide an email');
60
61
  }
61
62
 
62
63
  const appName = mfAppName(getCwdAppName()!);
@@ -69,7 +70,7 @@ export async function revokeCommand(email: string | undefined, opts: GrantOption
69
70
  });
70
71
  if (!res.ok) {
71
72
  const text = await res.text();
72
- throw new Error(`Revoke failed (${res.status}): ${text.slice(0, 1024)}`);
73
+ throw new GrantError(res.status, `Revoke failed (${res.status}): ${text.slice(0, 1024)}`);
73
74
  }
74
75
 
75
76
  const revoked = email ?? 'public';
@@ -81,7 +82,7 @@ export async function revokeCommand(email: string | undefined, opts: GrantOption
81
82
 
82
83
  export async function transferOwnerCommand(email: string | undefined): Promise<void> {
83
84
  if (!email) {
84
- throw new Error('Specify the new owner — e.g. z2h-cli transfer-owner someone@monday.com');
85
+ throw new GrantError('validation', 'Specify the new owner — e.g. z2h-cli transfer-owner someone@monday.com');
85
86
  }
86
87
 
87
88
  const appName = mfAppName(getCwdAppName()!);
@@ -94,7 +95,7 @@ export async function transferOwnerCommand(email: string | undefined): Promise<v
94
95
  });
95
96
  if (!res.ok) {
96
97
  const text = await res.text();
97
- throw new Error(`Transfer owner failed (${res.status}): ${text.slice(0, 1024)}`);
98
+ throw new GrantError(res.status, `Transfer owner failed (${res.status}): ${text.slice(0, 1024)}`);
98
99
  }
99
100
 
100
101
  output(`[z2h-cli] "${appName}" ownership transferred to ${email}`, { ok: true, appName, newOwner: email });