@mondaydotcomorg/z2h-cli 0.25.3 → 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.
- package/dist/auth.d.ts +2 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +2 -1
- package/dist/commands/__tests__/build.test.d.ts +2 -0
- package/dist/commands/__tests__/build.test.d.ts.map +1 -0
- package/dist/commands/backend.d.ts.map +1 -1
- package/dist/commands/backend.js +7 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +10 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +42 -19
- package/dist/commands/grant.d.ts.map +1 -1
- package/dist/commands/grant.js +8 -7
- package/dist/esm/auth.d.ts +2 -1
- package/dist/esm/auth.d.ts.map +1 -1
- package/dist/esm/auth.mjs +2 -1
- package/dist/esm/commands/__tests__/build.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/build.test.d.ts.map +1 -0
- package/dist/esm/commands/backend.d.ts.map +1 -1
- package/dist/esm/commands/backend.mjs +7 -1
- package/dist/esm/commands/build.d.ts.map +1 -1
- package/dist/esm/commands/build.mjs +10 -0
- package/dist/esm/commands/deploy.d.ts.map +1 -1
- package/dist/esm/commands/deploy.mjs +43 -20
- package/dist/esm/commands/grant.d.ts.map +1 -1
- package/dist/esm/commands/grant.mjs +8 -7
- package/dist/esm/index.mjs +41 -140
- package/dist/esm/types.d.ts +23 -0
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/util/__tests__/errors.test.d.ts +2 -0
- package/dist/esm/util/__tests__/errors.test.d.ts.map +1 -0
- package/dist/esm/util/__tests__/run-command.test.d.ts +2 -0
- package/dist/esm/util/__tests__/run-command.test.d.ts.map +1 -0
- package/dist/esm/util/auth/auth-store.d.ts +0 -4
- package/dist/esm/util/auth/auth-store.d.ts.map +1 -1
- package/dist/esm/util/auth/auth-store.mjs +2 -9
- package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -1
- package/dist/esm/util/auth/broker-credential-provider.mjs +16 -16
- package/dist/esm/util/broker/app.d.ts.map +1 -1
- package/dist/esm/util/broker/app.mjs +17 -9
- package/dist/esm/util/errors.d.ts +48 -0
- package/dist/esm/util/errors.d.ts.map +1 -0
- package/dist/esm/util/errors.mjs +91 -0
- package/dist/esm/util/git/repo.d.ts +0 -3
- package/dist/esm/util/git/repo.d.ts.map +1 -1
- package/dist/esm/util/git/repo.mjs +4 -9
- package/dist/esm/util/logger.d.ts +4 -1
- package/dist/esm/util/logger.d.ts.map +1 -1
- package/dist/esm/util/logger.mjs +11 -3
- package/dist/esm/util/run-command.d.ts +16 -0
- package/dist/esm/util/run-command.d.ts.map +1 -0
- package/dist/esm/util/run-command.mjs +47 -0
- package/dist/esm/util/s3/upload.d.ts.map +1 -1
- package/dist/esm/util/s3/upload.mjs +22 -16
- package/dist/esm/util/tracker.d.ts +9 -2
- package/dist/esm/util/tracker.d.ts.map +1 -1
- package/dist/esm/util/tracker.mjs +18 -15
- package/dist/index.js +40 -139
- package/dist/types.d.ts +23 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/util/__tests__/errors.test.d.ts +2 -0
- package/dist/util/__tests__/errors.test.d.ts.map +1 -0
- package/dist/util/__tests__/run-command.test.d.ts +2 -0
- package/dist/util/__tests__/run-command.test.d.ts.map +1 -0
- package/dist/util/auth/auth-store.d.ts +0 -4
- package/dist/util/auth/auth-store.d.ts.map +1 -1
- package/dist/util/auth/auth-store.js +2 -10
- package/dist/util/auth/broker-credential-provider.d.ts.map +1 -1
- package/dist/util/auth/broker-credential-provider.js +16 -16
- package/dist/util/broker/app.d.ts.map +1 -1
- package/dist/util/broker/app.js +17 -9
- package/dist/util/errors.d.ts +48 -0
- package/dist/util/errors.d.ts.map +1 -0
- package/dist/util/errors.js +102 -0
- package/dist/util/git/repo.d.ts +0 -3
- package/dist/util/git/repo.d.ts.map +1 -1
- package/dist/util/git/repo.js +4 -10
- package/dist/util/logger.d.ts +4 -1
- package/dist/util/logger.d.ts.map +1 -1
- package/dist/util/logger.js +11 -3
- package/dist/util/run-command.d.ts +16 -0
- package/dist/util/run-command.d.ts.map +1 -0
- package/dist/util/run-command.js +49 -0
- package/dist/util/s3/upload.d.ts.map +1 -1
- package/dist/util/s3/upload.js +22 -16
- package/dist/util/tracker.d.ts +9 -2
- package/dist/util/tracker.d.ts.map +1 -1
- package/dist/util/tracker.js +19 -14
- package/handler-api/backend-runner/channels/llm-channel/llm.channel.ts +3 -2
- package/handler-api/backend-runner/channels/snowflake-channel/run-query.ts +7 -6
- package/handler-api/backend-runner/channels/snowflake-channel/snowflake.channel.ts +1 -1
- package/handler-api/backend-runner/channels/snowflake-channel/stateless-part-b.ts +1 -4
- package/package.json +2 -2
- package/src/auth.ts +2 -1
- package/src/commands/__tests__/backend-deploy.test.ts +18 -0
- package/src/commands/__tests__/build.test.ts +83 -0
- package/src/commands/__tests__/deploy.test.ts +107 -0
- package/src/commands/backend.ts +6 -1
- package/src/commands/build.ts +10 -0
- package/src/commands/deploy.ts +42 -20
- package/src/commands/grant.ts +8 -7
- package/src/index.ts +84 -133
- package/src/types.ts +47 -0
- package/src/util/__tests__/errors.test.ts +90 -0
- package/src/util/__tests__/run-command.test.ts +98 -0
- package/src/util/auth/__tests__/auth-store.test.ts +2 -1
- package/src/util/auth/__tests__/broker-credential-provider.test.ts +44 -8
- package/src/util/auth/auth-store.ts +1 -7
- package/src/util/auth/broker-credential-provider.ts +18 -16
- package/src/util/broker/__tests__/app.test.ts +23 -20
- package/src/util/broker/app.ts +16 -9
- package/src/util/errors.ts +98 -0
- package/src/util/git/__tests__/repo.test.ts +5 -3
- package/src/util/git/repo.ts +3 -9
- package/src/util/logger.ts +16 -3
- package/src/util/run-command.ts +59 -0
- package/src/util/s3/upload.ts +27 -22
- 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
|
-
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
).
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
).
|
|
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
|
});
|
package/src/util/broker/app.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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('
|
|
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
|
|
package/src/util/git/repo.ts
CHANGED
|
@@ -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
|
|
package/src/util/logger.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/util/s3/upload.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
+
const s3 = getS3Client();
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
+
return files.map(f => f.split(path.sep).join('/'));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw tagDeployError('s3_error', err);
|
|
50
|
+
}
|
|
46
51
|
}
|