@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.
- 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/channel-map.ts +6 -12
- package/handler-api/backend-runner/channels/channel-telemetry.ts +79 -0
- package/handler-api/backend-runner/channels/llm-channel/llm.channel.ts +3 -2
- package/handler-api/backend-runner/channels/snowflake-channel/run-query.ts +21 -7
- package/handler-api/backend-runner/channels/snowflake-channel/snowflake.channel.ts +14 -2
- 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
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
+
});
|
package/src/commands/backend.ts
CHANGED
|
@@ -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
|
-
|
|
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> {
|
package/src/commands/build.ts
CHANGED
|
@@ -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).
|
package/src/commands/deploy.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|
package/src/commands/grant.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 });
|