@mondaydotcomorg/z2h-cli 0.25.4 → 0.25.7

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 (118) 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/llm-channel/llm.channel.ts +3 -2
  90. package/handler-api/backend-runner/channels/snowflake-channel/run-query.ts +7 -6
  91. package/handler-api/backend-runner/channels/snowflake-channel/snowflake.channel.ts +1 -1
  92. package/handler-api/backend-runner/channels/snowflake-channel/stateless-part-b.ts +1 -4
  93. package/package.json +2 -2
  94. package/src/auth.ts +2 -1
  95. package/src/commands/__tests__/backend-deploy.test.ts +18 -0
  96. package/src/commands/__tests__/build.test.ts +83 -0
  97. package/src/commands/__tests__/deploy.test.ts +107 -0
  98. package/src/commands/backend.ts +6 -1
  99. package/src/commands/build.ts +10 -0
  100. package/src/commands/deploy.ts +42 -20
  101. package/src/commands/grant.ts +8 -7
  102. package/src/index.ts +84 -133
  103. package/src/types.ts +47 -0
  104. package/src/util/__tests__/errors.test.ts +90 -0
  105. package/src/util/__tests__/run-command.test.ts +98 -0
  106. package/src/util/auth/__tests__/auth-store.test.ts +2 -1
  107. package/src/util/auth/__tests__/broker-credential-provider.test.ts +44 -8
  108. package/src/util/auth/auth-store.ts +1 -7
  109. package/src/util/auth/broker-credential-provider.ts +18 -16
  110. package/src/util/broker/__tests__/app.test.ts +23 -20
  111. package/src/util/broker/app.ts +16 -9
  112. package/src/util/errors.ts +98 -0
  113. package/src/util/git/__tests__/repo.test.ts +5 -3
  114. package/src/util/git/repo.ts +3 -9
  115. package/src/util/logger.ts +16 -3
  116. package/src/util/run-command.ts +59 -0
  117. package/src/util/s3/upload.ts +27 -22
  118. package/src/util/tracker.ts +19 -19
@@ -6,6 +6,7 @@ import {
6
6
  STS_BROKER_PATH,
7
7
  } from '../broker-credential-provider';
8
8
  import { DEFAULT_BROKER_BASE_URL } from '../../../constants';
9
+ import { DeployError } from '../../errors';
9
10
  import * as authStore from '../auth-store';
10
11
 
11
12
  const STUB_CREDS = {
@@ -58,15 +59,22 @@ describe('fetchDeployVersion', () => {
58
59
  await expect(fetchDeployVersion('my-app')).resolves.toBe(0);
59
60
  });
60
61
 
61
- it('surfaces a remediation hint on 401', async () => {
62
+ it('surfaces a remediation hint on 401, tagged broker_auth', async () => {
62
63
  mockFetchOnce({}, { status: 401, text: 'Unauthorized' });
63
64
  await expect(fetchDeployVersion('my-app')).rejects.toThrow(/token rejected by the broker/);
65
+ mockFetchOnce({}, { status: 401, text: 'Unauthorized' });
66
+ const err = await fetchDeployVersion('my-app').catch((e: unknown) => e);
67
+ expect(err).toBeInstanceOf(DeployError);
68
+ expect((err as DeployError).reason).toBe('broker_auth');
64
69
  });
65
70
 
66
- it('surfaces an access-denied hint on 403', async () => {
71
+ it('surfaces an access-denied hint on 403, tagged broker_auth', async () => {
67
72
  mockFetchOnce({}, { status: 403, text: 'Forbidden' });
68
73
  await expect(fetchDeployVersion('my-app')).rejects.toThrow(/Access denied/);
69
- await expect(fetchDeployVersion('my-app')).rejects.toThrow(/z2h-cli grant/);
74
+ mockFetchOnce({}, { status: 403, text: 'Forbidden' });
75
+ const err = await fetchDeployVersion('my-app').catch((e: unknown) => e);
76
+ expect(err).toBeInstanceOf(DeployError);
77
+ expect((err as DeployError).reason).toBe('broker_auth');
70
78
  });
71
79
  });
72
80
 
@@ -131,18 +139,28 @@ describe('brokerCredentialProvider', () => {
131
139
  expect(fetchMock).toHaveBeenCalledTimes(2);
132
140
  });
133
141
 
134
- it('surfaces a remediation hint on 401', async () => {
142
+ it('surfaces a remediation hint on 401, tagged broker_auth', async () => {
135
143
  mockFetchOnce({}, { status: 401, text: 'Unauthorized' });
136
144
  const provider = brokerCredentialProvider({ appName: 'my-app' });
137
145
  await expect(provider()).rejects.toThrow(/token rejected by the broker/);
138
146
  await expect(provider()).rejects.toThrow(/generate-z2h-token/);
147
+
148
+ mockFetchOnce({}, { status: 401, text: 'Unauthorized' });
149
+ const err = await brokerCredentialProvider({ appName: 'my-app2' })().catch((e: unknown) => e);
150
+ expect(err).toBeInstanceOf(DeployError);
151
+ expect((err as DeployError).reason).toBe('broker_auth');
139
152
  });
140
153
 
141
- it('surfaces an access-denied hint on 403', async () => {
154
+ it('surfaces an access-denied hint on 403, tagged broker_auth', async () => {
142
155
  mockFetchOnce({}, { status: 403, text: 'Forbidden' });
143
156
  const provider = brokerCredentialProvider({ appName: 'my-app' });
144
157
  await expect(provider()).rejects.toThrow(/Access denied/);
145
158
  await expect(provider()).rejects.toThrow(/z2h-cli grant/);
159
+
160
+ mockFetchOnce({}, { status: 403, text: 'Forbidden' });
161
+ const err = await brokerCredentialProvider({ appName: 'my-app3' })().catch((e: unknown) => e);
162
+ expect(err).toBeInstanceOf(DeployError);
163
+ expect((err as DeployError).reason).toBe('broker_auth');
146
164
  });
147
165
 
148
166
  it('honors Z2H_BROKER_BASE_URL override', async () => {
@@ -153,10 +171,28 @@ describe('brokerCredentialProvider', () => {
153
171
  expect(fetchMock).toHaveBeenCalledWith(`https://staging.example.com${STS_BROKER_PATH}`, expect.anything());
154
172
  });
155
173
 
156
- it('rejects responses missing required credential fields', async () => {
174
+ it('rejects responses missing required credential fields, tagged broker_error', async () => {
157
175
  mockFetchOnce({ accessKeyId: 'AKIA-stub' });
158
- const provider = brokerCredentialProvider({ appName: 'my-app' });
159
- await expect(provider()).rejects.toThrow(/missing required credential fields/);
176
+ const err = await brokerCredentialProvider({ appName: 'my-app' })().catch((e: unknown) => e);
177
+ expect(err).toBeInstanceOf(DeployError);
178
+ expect((err as DeployError).message).toMatch(/missing required credential fields/);
179
+ expect((err as DeployError).reason).toBe('broker_error');
180
+ });
181
+
182
+ it('tags a non-auth broker status as broker_error, not broker_auth or unknown', async () => {
183
+ mockFetchOnce({}, { status: 502, text: 'Bad Gateway' });
184
+ const err = await brokerCredentialProvider({ appName: 'my-app' })().catch((e: unknown) => e);
185
+ expect(err).toBeInstanceOf(DeployError);
186
+ expect((err as DeployError).message).toMatch(/broker returned 502: Bad Gateway/);
187
+ expect((err as DeployError).reason).toBe('broker_error');
188
+ });
189
+
190
+ it('tags a network failure reaching the broker as broker_error', async () => {
191
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
192
+ const err = await brokerCredentialProvider({ appName: 'my-app' })().catch((e: unknown) => e);
193
+ expect(err).toBeInstanceOf(DeployError);
194
+ expect((err as DeployError).message).toMatch(/broker request failed: fetch failed/);
195
+ expect((err as DeployError).reason).toBe('broker_error');
160
196
  });
161
197
 
162
198
  describe('with previewId', () => {
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import { pathExists, readJson, readJsonSync } from 'fs-extra';
4
+ import { Z2hAuthMissingError } from '../errors';
4
5
 
5
6
  // npm-style convention: per-user config under ~/.zth (mirrors ~/.npmrc, ~/.aws/).
6
7
  // Kept out of the workspace dir so accidental `git init` in ~/zth-projects can't
@@ -12,13 +13,6 @@ export interface ZthAuth {
12
13
  token: string;
13
14
  }
14
15
 
15
- export class Z2hAuthMissingError extends Error {
16
- constructor(public readonly authFilePath: string) {
17
- super(`No Z2H deploy token found at ${authFilePath}. Please run 'z2h-cli generate-z2h-token' command.`);
18
- this.name = 'Z2hAuthMissingError';
19
- }
20
- }
21
-
22
16
  export async function readZthAuth(): Promise<ZthAuth> {
23
17
  if (process.env.Z2H_AUTH_TOKEN) {
24
18
  const token = process.env.Z2H_AUTH_TOKEN;
@@ -1,4 +1,5 @@
1
1
  import { brokerBaseUrl, routingHeaders } from '../../constants';
2
+ import { DeployError, messageOf } from '../errors';
2
3
  import { readZthAuth } from './auth-store';
3
4
  import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@aws-sdk/types';
4
5
 
@@ -34,22 +35,31 @@ async function postToBroker(path: string, body: unknown): Promise<Response> {
34
35
  body: JSON.stringify(body),
35
36
  });
36
37
  } catch (err) {
37
- const message = err instanceof Error ? err.message : String(err);
38
- throw new Error(`Z2H deploy broker request failed: ${message}`);
38
+ throw new DeployError('broker_error', `Z2H deploy broker request failed: ${messageOf(err)}`);
39
39
  }
40
40
  }
41
41
 
42
- function assertBrokerOk(res: Response): void {
42
+ /**
43
+ * Classifies a broker response: 401/403 are `broker_auth` (the user can fix them), any
44
+ * other non-ok status is `broker_error` (the broker or the network, not the user).
45
+ */
46
+ async function assertBrokerOk(res: Response, errorLabel: string): Promise<void> {
43
47
  if (res.status === 401) {
44
- throw new Error(
48
+ throw new DeployError(
49
+ 'broker_auth',
45
50
  `Z2H deploy token rejected by the broker (401). Refresh user token by running 'z2h-cli generate-z2h-token'.`
46
51
  );
47
52
  }
48
53
  if (res.status === 403) {
49
- throw new Error(
54
+ throw new DeployError(
55
+ 'broker_auth',
50
56
  `Access denied (403). You do not have editor access to this app. Ask the app owner to grant you access via 'z2h-cli grant'.`
51
57
  );
52
58
  }
59
+ if (!res.ok) {
60
+ const text = await res.text();
61
+ throw new DeployError('broker_error', `Z2H ${errorLabel} broker returned ${res.status}: ${text.slice(0, 1024)}`);
62
+ }
53
63
  }
54
64
 
55
65
  /**
@@ -59,11 +69,7 @@ function assertBrokerOk(res: Response): void {
59
69
  */
60
70
  export async function fetchDeployVersion(appName: string): Promise<number> {
61
71
  const res = await postToBroker(DEPLOY_VERSION_PATH, { appName });
62
- assertBrokerOk(res);
63
- if (!res.ok) {
64
- const text = await res.text();
65
- throw new Error(`Z2H deploy broker returned ${res.status}: ${text.slice(0, 1024)}`);
66
- }
72
+ await assertBrokerOk(res, 'deploy');
67
73
  const body = (await res.json()) as VersionResponse;
68
74
 
69
75
  return body.version ?? 0;
@@ -71,11 +77,7 @@ export async function fetchDeployVersion(appName: string): Promise<number> {
71
77
 
72
78
  async function requestSts(path: string, body: unknown, errorLabel: string): Promise<AwsCredentialIdentity> {
73
79
  const res = await postToBroker(path, body);
74
- assertBrokerOk(res);
75
- if (!res.ok) {
76
- const text = await res.text();
77
- throw new Error(`Z2H ${errorLabel} broker returned ${res.status}: ${text.slice(0, 1024)}`);
78
- }
80
+ await assertBrokerOk(res, errorLabel);
79
81
 
80
82
  const responseBody = (await res.json()) as CredentialsResponse;
81
83
  if (
@@ -84,7 +86,7 @@ async function requestSts(path: string, body: unknown, errorLabel: string): Prom
84
86
  !responseBody.sessionToken ||
85
87
  !responseBody.expiration
86
88
  ) {
87
- throw new Error(`Z2H ${errorLabel} broker response missing required credential fields`);
89
+ throw new DeployError('broker_error', `Z2H ${errorLabel} broker response missing required credential fields`);
88
90
  }
89
91
 
90
92
  return {
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { registerOrUpdateApp } from '../app';
3
3
  import * as promptVisibility from '../../prompt-visibility';
4
+ import { DeployError } from '../../errors';
4
5
  import type { ManifestEntry } from '../../manifest';
5
6
 
6
7
  vi.mock('../../prompt-visibility', () => ({
@@ -74,31 +75,33 @@ describe('registerOrUpdateApp', () => {
74
75
  expect(JSON.parse(init.body as string)).toMatchObject({ appName: 'mf-my-app', version: 3 });
75
76
  });
76
77
 
77
- it('propagates a registration failure', async () => {
78
+ it('propagates a registration failure, tagged register_error', async () => {
78
79
  mockFetchOnce(409);
79
80
 
80
- await expect(
81
- registerOrUpdateApp({
82
- appName: 'mf-my-app',
83
- token: 'jwt',
84
- nextVersion: 1,
85
- entry: ENTRY,
86
- gitRemote: '',
87
- })
88
- ).rejects.toThrow(/App registration failed/);
81
+ const err = await registerOrUpdateApp({
82
+ appName: 'mf-my-app',
83
+ token: 'jwt',
84
+ nextVersion: 1,
85
+ entry: ENTRY,
86
+ gitRemote: '',
87
+ }).catch((e: unknown) => e);
88
+ expect(err).toBeInstanceOf(DeployError);
89
+ expect((err as DeployError).message).toMatch(/App registration failed/);
90
+ expect((err as DeployError).reason).toBe('register_error');
89
91
  });
90
92
 
91
- it('propagates an update failure', async () => {
93
+ it('propagates an update failure, tagged register_error', async () => {
92
94
  mockFetchOnce(500);
93
95
 
94
- await expect(
95
- registerOrUpdateApp({
96
- appName: 'mf-my-app',
97
- token: 'jwt',
98
- nextVersion: 2,
99
- entry: ENTRY,
100
- gitRemote: '',
101
- })
102
- ).rejects.toThrow(/App update failed/);
96
+ const err = await registerOrUpdateApp({
97
+ appName: 'mf-my-app',
98
+ token: 'jwt',
99
+ nextVersion: 2,
100
+ entry: ENTRY,
101
+ gitRemote: '',
102
+ }).catch((e: unknown) => e);
103
+ expect(err).toBeInstanceOf(DeployError);
104
+ expect((err as DeployError).message).toMatch(/App update failed/);
105
+ expect((err as DeployError).reason).toBe('register_error');
103
106
  });
104
107
  });
@@ -2,6 +2,7 @@ import { brokerBaseUrl, routingHeaders } from '../../constants';
2
2
  import { readZthAuth } from '../auth/auth-store';
3
3
  import { info } from '../logger';
4
4
  import { resolveAppVisibility } from '../prompt-visibility';
5
+ import { DeployError, tagDeployError } from '../errors';
5
6
  import type { ManifestEntry } from '../manifest';
6
7
 
7
8
  export interface AppInfo {
@@ -58,7 +59,7 @@ async function registerApp(
58
59
  });
59
60
  if (!res.ok) {
60
61
  const text = await res.text();
61
- throw new Error(`App registration failed (${res.status}): ${text.slice(0, 1024)}`);
62
+ throw new DeployError('register_error', `App registration failed (${res.status}): ${text.slice(0, 1024)}`);
62
63
  }
63
64
  }
64
65
 
@@ -79,7 +80,7 @@ async function updateApp(
79
80
  });
80
81
  if (!res.ok) {
81
82
  const text = await res.text();
82
- throw new Error(`App update failed (${res.status}): ${text.slice(0, 1024)}`);
83
+ throw new DeployError('register_error', `App update failed (${res.status}): ${text.slice(0, 1024)}`);
83
84
  }
84
85
  }
85
86
 
@@ -102,12 +103,18 @@ export interface RegisterOrUpdateOptions {
102
103
  export async function registerOrUpdateApp(opts: RegisterOrUpdateOptions): Promise<void> {
103
104
  const { appName, token, nextVersion, entry, gitRemote, integrations, description } = opts;
104
105
 
105
- if (nextVersion === 1) {
106
- const visibility = await resolveAppVisibility(appName);
107
- info(`registering "${appName}" → v${nextVersion} (${visibility})`);
108
- await registerApp(appName, token, nextVersion, entry, visibility, gitRemote, integrations, description);
109
- } else {
110
- info(`updating "${appName}" → v${nextVersion}`);
111
- await updateApp(appName, token, nextVersion, entry, gitRemote, integrations, description);
106
+ try {
107
+ if (nextVersion === 1) {
108
+ const visibility = await resolveAppVisibility(appName);
109
+ info(`registering "${appName}" → v${nextVersion} (${visibility})`);
110
+ await registerApp(appName, token, nextVersion, entry, visibility, gitRemote, integrations, description);
111
+ } else {
112
+ info(`updating "${appName}" → v${nextVersion}`);
113
+ await updateApp(appName, token, nextVersion, entry, gitRemote, integrations, description);
114
+ }
115
+ } catch (err) {
116
+ // registerApp/updateApp already tag the non-ok-response case; this catches anything
117
+ // else (a network throw, a resolveAppVisibility failure) under the same reason.
118
+ throw tagDeployError('register_error', err);
112
119
  }
113
120
  }
@@ -0,0 +1,98 @@
1
+ import type { DeployFailureReason, GrantFailureReason } from '../types';
2
+
3
+ /**
4
+ * Base for every error the CLI classifies. runCommand in ./run-command.ts reads `reason`
5
+ * and `data` straight off the error, so the throw site is the only place that needs to know
6
+ * why it failed. Nothing re-derives it from the error type at the top level.
7
+ *
8
+ * `exitCode` lets a throw site override the CLI's --help contract, 1 for user-resolvable
9
+ * and 2 for system failures, per error instead of per command. Nothing sets it yet. The
10
+ * command table still pins exit codes, which keeps today's behavior.
11
+ */
12
+ export class Z2hError extends Error {
13
+ constructor(
14
+ message: string,
15
+ readonly reason: string,
16
+ readonly data?: Record<string, unknown>,
17
+ readonly exitCode: number = 1
18
+ ) {
19
+ super(message);
20
+ this.name = new.target.name;
21
+ }
22
+ }
23
+
24
+ /** Tags a deploy/preview stage failure with a bounded reason code instead of a freeform message. */
25
+ export class DeployError extends Z2hError {
26
+ constructor(override readonly reason: DeployFailureReason, message: string, data?: Record<string, unknown>) {
27
+ super(message, reason, data);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Tags a grant/revoke/transfer-owner failure with a bounded reason code instead of a freeform message.
33
+ * `reason` also accepts a raw HTTP status, which gets mapped to a bounded reason internally.
34
+ */
35
+ export class GrantError extends Z2hError {
36
+ constructor(reason: GrantFailureReason | number, message: string, data?: Record<string, unknown>) {
37
+ super(message, typeof reason === 'number' ? grantReasonForStatus(reason) : reason, data);
38
+ }
39
+ }
40
+
41
+ /** No z2h JWT found — see auth-store.ts for the discovery order (env var, then ~/.zth/auth.json). */
42
+ export class Z2hAuthMissingError extends Z2hError {
43
+ constructor(public readonly authFilePath: string) {
44
+ super(
45
+ `No Z2H deploy token found at ${authFilePath}. Please run 'z2h-cli generate-z2h-token' command.`,
46
+ 'read_auth_token'
47
+ );
48
+ }
49
+ }
50
+
51
+ /** A git pull diverged or a push was rejected; `reason` distinguishes which stage hit it. */
52
+ export class GitConflictError extends Z2hError {
53
+ constructor(message: string, reason: DeployFailureReason = 'git_error') {
54
+ super(message, reason);
55
+ }
56
+ }
57
+
58
+ /** Maps an HTTP status to a bounded grant/revoke/transfer-owner failure reason. */
59
+ export function grantReasonForStatus(status: number): GrantFailureReason {
60
+ // 401 is an expired/invalid token (re-auth fixes it); 403 is a real permission denial.
61
+ if (status === 401) {
62
+ return 'auth';
63
+ }
64
+ if (status === 403) {
65
+ return 'forbidden';
66
+ }
67
+ if (status === 404) {
68
+ return 'not_found';
69
+ }
70
+ if (status >= 500) {
71
+ return 'server_error';
72
+ }
73
+ return 'unknown';
74
+ }
75
+
76
+ /** Tags a failure with `reason`, unless `err` is already classified; `signal` overrides `reason` for a killed child process. */
77
+ export function tagDeployError(reason: DeployFailureReason, err: unknown): Z2hError {
78
+ if (err instanceof Z2hError) {
79
+ return err;
80
+ }
81
+ const signal = err instanceof Error ? (err as Error & { signal?: string }).signal : undefined;
82
+ return new DeployError(signal ? 'interrupted' : reason, messageOf(err));
83
+ }
84
+
85
+ /** Reads the bounded reason off a classified error; anything unclassified is `unknown`. */
86
+ export function reasonOf(err: unknown): string {
87
+ return err instanceof Z2hError ? err.reason : 'unknown';
88
+ }
89
+
90
+ /** Extra telemetry dimensions the throw site attached, if any. */
91
+ export function dataOf(err: unknown): Record<string, unknown> {
92
+ return err instanceof Z2hError ? err.data ?? {} : {};
93
+ }
94
+
95
+ /** Safe message for any thrown value. A non-Error throw must not break the error path. */
96
+ export function messageOf(err: unknown): string {
97
+ return err instanceof Error ? err.message : String(err);
98
+ }
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { execa } from 'execa';
5
5
  import fs from 'fs-extra';
6
6
  import {
7
- GitConflictError,
8
7
  defaultS3Remote,
9
8
  initRepo,
10
9
  cloneRepo,
@@ -15,6 +14,7 @@ import {
15
14
  remoteHead,
16
15
  localHead,
17
16
  } from '../repo';
17
+ import { GitConflictError } from '../../errors';
18
18
 
19
19
  vi.mock('execa', () => ({ execa: vi.fn() }));
20
20
  vi.mock('fs-extra', () => ({
@@ -236,7 +236,7 @@ describe('pushToRemote', () => {
236
236
  expect(mockExeca).toHaveBeenCalledWith('git', ['push', '-u', 'origin', 'master'], expect.objectContaining({}));
237
237
  });
238
238
 
239
- it('throws GitConflictError with structured JSON when push is rejected', async () => {
239
+ it('throws GitConflictError tagged push_error (not git_error) with structured JSON when push is rejected', async () => {
240
240
  mockExeca
241
241
  .mockRejectedValueOnce(new Error('![rejected] master -> master (non-fast-forward)')) // git push
242
242
  .mockResolvedValueOnce(execaOk()) // gatherConflictInfo: git fetch origin
@@ -247,17 +247,19 @@ describe('pushToRemote', () => {
247
247
  const err = await pushToRemote(DIR).catch(e => e);
248
248
 
249
249
  expect(err).toBeInstanceOf(GitConflictError);
250
+ expect(err.reason).toBe('push_error');
250
251
  const info = JSON.parse(err.message.split('Git conflict detected: ')[1]);
251
252
  expect(info.localCommits).toEqual(['abc local']);
252
253
  expect(info.remoteCommits).toEqual(['def remote']);
253
254
  expect(info.conflictingFiles).toEqual(['file.ts']);
254
255
  });
255
256
 
256
- it('re-throws non-rejection push errors as-is', async () => {
257
+ it('tags a non-rejection push error push_error, keeping the original message', async () => {
257
258
  mockExeca.mockRejectedValueOnce(new Error('Connection refused'));
258
259
 
259
260
  const err = await pushToRemote(DIR).catch(e => e);
260
261
  expect(err).not.toBeInstanceOf(GitConflictError);
262
+ expect(err.reason).toBe('push_error');
261
263
  expect(err.message).toMatch(/Connection refused/);
262
264
  });
263
265
 
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
3
3
  import { execa } from 'execa';
4
4
  import { BUCKET_WRITE_NAME } from '../../constants';
5
5
  import { mfAppName } from '../app-name';
6
+ import { DeployError, GitConflictError, messageOf } from '../errors';
6
7
 
7
8
  type GitEnv = Record<string, string | undefined>;
8
9
 
@@ -16,13 +17,6 @@ async function execGitLines(args: string[], dir: string, env?: GitEnv): Promise<
16
17
  .catch(() => []);
17
18
  }
18
19
 
19
- export class GitConflictError extends Error {
20
- constructor(message: string) {
21
- super(message);
22
- this.name = 'GitConflictError';
23
- }
24
- }
25
-
26
20
  export interface ConflictInfo {
27
21
  localCommits: string[];
28
22
  remoteCommits: string[];
@@ -137,9 +131,9 @@ export async function pushToRemote(dir: string, env?: Record<string, string>): P
137
131
  }
138
132
  if (isRejected) {
139
133
  const info = await gatherConflictInfo(dir, gitEnv);
140
- throw new GitConflictError(`Push rejected: Git conflict detected: ${JSON.stringify(info)}`);
134
+ throw new GitConflictError(`Push rejected: Git conflict detected: ${JSON.stringify(info)}`, 'push_error');
141
135
  }
142
- throw err;
136
+ throw new DeployError('push_error', messageOf(err));
143
137
  }
144
138
  }
145
139
 
@@ -1,4 +1,5 @@
1
1
  import pc from 'picocolors';
2
+ import { Z2hCliEvents } from '@mondaydotcomorg/z2h-shared-utils/observability';
2
3
  import { trackEvent } from './tracker';
3
4
 
4
5
  export type OutputMode = 'human' | 'json';
@@ -51,16 +52,28 @@ export function output(human: string, data: unknown): void {
51
52
  /**
52
53
  * Emit a structured failure payload (JSON mode) or a red error line (human mode),
53
54
  * then exit with the given code.
55
+ *
56
+ * `track: false` skips the telemetry event but still prints and exits. Commands with no
57
+ * success event pass it, so their failures don't skew per-command error rates.
54
58
  */
55
- export async function fail(message: string, payload: Record<string, unknown>, exitCode = 1): Promise<never> {
59
+ export async function fail(
60
+ message: string,
61
+ payload: Record<string, unknown>,
62
+ exitCode = 1,
63
+ track = true
64
+ ): Promise<never> {
65
+ // Canonical fields go last so a payload key can never shadow `error`/`action`.
66
+ const { command, ...rest } = payload;
56
67
  if (mode === 'json') {
57
- process.stdout.write(`${JSON.stringify({ ok: false, error: message, ...payload })}\n`);
68
+ process.stdout.write(`${JSON.stringify({ ok: false, ...payload, error: message })}\n`);
58
69
  } else {
59
70
  process.stderr.write(pc.red(`[z2h-cli] ${message}\n`));
60
71
  if (typeof payload.suggestedCommand === 'string') {
61
72
  process.stderr.write(pc.dim(` → ${payload.suggestedCommand}\n`));
62
73
  }
63
74
  }
64
- await trackEvent('z2h_cli_error', { action: payload.command ?? 'unknown', error: message });
75
+ if (track) {
76
+ await trackEvent(Z2hCliEvents.error, { ...rest, action: command ?? 'unknown', error: message });
77
+ }
65
78
  process.exit(exitCode);
66
79
  }
@@ -0,0 +1,59 @@
1
+ import { fail } from './logger';
2
+ import { trackDuration, successEvent } from './tracker';
3
+ import { Z2hError, reasonOf, dataOf, messageOf } from './errors';
4
+ import type { Command } from 'commander';
5
+ import type { CommandSpec, PerArgs } from '../types';
6
+
7
+ type SpecFieldFn<A extends unknown[], T> = (args: A) => T;
8
+ type SpecFieldResolver<A extends unknown[]> = <T>(field: PerArgs<A, T> | undefined, fallback: T) => T;
9
+ type ResolveSpecField = <A extends unknown[], T>(field: PerArgs<A, T> | undefined, args: A, fallback: T) => T;
10
+
11
+ /** A spec field is a constant, a callback over the args, or absent — in which case `fallback` applies. */
12
+ const resolveSpecField: ResolveSpecField = (field, args, fallback) => {
13
+ if (field === undefined) {
14
+ return fallback;
15
+ }
16
+ return typeof field === 'function' ? (field as SpecFieldFn<typeof args, typeof fallback>)(args) : field;
17
+ };
18
+
19
+ /**
20
+ * Wraps a command body with the whole telemetry and failure contract so no command
21
+ * hand-rolls it. It times the happy path. On a throw it reads `reason` and `data` straight
22
+ * off the error, defined in ./errors, instead of re-deriving them per command.
23
+ *
24
+ * Returns a commander action handler, chainable straight off `.command(...)` — commander always
25
+ * appends the Command instance as the action handler's last argument, so this splits it back off
26
+ * to read the command's own name/parent instead of asking the caller to pass it in.
27
+ *
28
+ * NoInfer keeps the arg types coming from `fn` alone. A `data` or `label` callback that
29
+ * only needs the first arg must not narrow the handler's own signature.
30
+ */
31
+ export function runCommand<A extends unknown[]>(
32
+ spec: CommandSpec<NoInfer<A>>,
33
+ fn: (...args: A) => Promise<unknown>
34
+ ): (...args: [...A, Command]) => Promise<void> {
35
+ return async (...allArgs: [...A, Command]): Promise<void> => {
36
+ const cmd = allArgs[allArgs.length - 1] as Command;
37
+ const args = allArgs.slice(0, -1) as A;
38
+ const name = cmd.parent ? `${cmd.parent.name()} ${cmd.name()}` : cmd.name();
39
+ const resolve: SpecFieldResolver<A> = (v, fallback) => resolveSpecField(v, args, fallback);
40
+ const track = resolve(spec.track, true);
41
+ const label = resolve(spec.label, name);
42
+ const event = resolve(spec.event, successEvent(name));
43
+ const data = spec.data?.(args) ?? {};
44
+ try {
45
+ await (track ? trackDuration(event, () => fn(...args), data) : fn(...args));
46
+ } catch (err) {
47
+ const reason = reasonOf(err);
48
+ // A deploy/preview that failed because the consumer's own build broke isn't a CLI/infra
49
+ // problem — don't report it at all, not even as `unknown`.
50
+ const shouldTrack = track && reason !== 'build_error';
51
+ await fail(
52
+ `${label} failed: ${messageOf(err)}`,
53
+ { command: label, reason, ...data, ...dataOf(err) },
54
+ spec.exitCode ?? (err instanceof Z2hError ? err.exitCode : 1),
55
+ shouldTrack
56
+ );
57
+ }
58
+ };
59
+ }
@@ -5,6 +5,7 @@ import { lookup as lookupMime } from 'mime-types';
5
5
  import pMap from 'p-map';
6
6
  import { PutObjectCommand } from '@aws-sdk/client-s3';
7
7
  import { BUCKET_WRITE_NAME } from '../../constants';
8
+ import { tagDeployError } from '../errors';
8
9
  import { getS3Client } from './client';
9
10
 
10
11
  // Each deploy uploads to a version-prefixed path (<app>/<version>/...), so
@@ -18,29 +19,33 @@ export async function uploadDir(
18
19
  keyPrefix: string,
19
20
  bucket: string = BUCKET_WRITE_NAME
20
21
  ): Promise<string[]> {
21
- const entries = await readdir(localDir, { recursive: true, withFileTypes: true });
22
- const files = entries.filter(e => e.isFile()).map(e => path.relative(localDir, path.join(e.parentPath, e.name)));
22
+ try {
23
+ const entries = await readdir(localDir, { recursive: true, withFileTypes: true });
24
+ const files = entries.filter(e => e.isFile()).map(e => path.relative(localDir, path.join(e.parentPath, e.name)));
23
25
 
24
- const s3 = getS3Client();
26
+ const s3 = getS3Client();
25
27
 
26
- await pMap(
27
- files,
28
- async rel => {
29
- const body = await readFile(path.join(localDir, rel));
30
- const contentType = lookupMime(rel) || 'application/octet-stream';
31
- const key = `${keyPrefix}/${rel.split(path.sep).join('/')}`;
32
- await s3.send(
33
- new PutObjectCommand({
34
- Bucket: bucket,
35
- Key: key,
36
- Body: body,
37
- ContentType: contentType,
38
- CacheControl: FOREVER,
39
- })
40
- );
41
- },
42
- { concurrency: UPLOAD_CONCURRENCY }
43
- );
28
+ await pMap(
29
+ files,
30
+ async rel => {
31
+ const body = await readFile(path.join(localDir, rel));
32
+ const contentType = lookupMime(rel) || 'application/octet-stream';
33
+ const key = `${keyPrefix}/${rel.split(path.sep).join('/')}`;
34
+ await s3.send(
35
+ new PutObjectCommand({
36
+ Bucket: bucket,
37
+ Key: key,
38
+ Body: body,
39
+ ContentType: contentType,
40
+ CacheControl: FOREVER,
41
+ })
42
+ );
43
+ },
44
+ { concurrency: UPLOAD_CONCURRENCY }
45
+ );
44
46
 
45
- return files.map(f => f.split(path.sep).join('/'));
47
+ return files.map(f => f.split(path.sep).join('/'));
48
+ } catch (err) {
49
+ throw tagDeployError('s3_error', err);
50
+ }
46
51
  }