@mondaydotcomorg/z2h-cli 0.15.2 → 0.16.0
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/commands/__tests__/deploy.test.d.ts +2 -0
- package/dist/commands/__tests__/deploy.test.d.ts.map +1 -0
- package/dist/commands/build.d.ts +8 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +12 -0
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +1 -12
- package/dist/commands/deploy.d.ts +3 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +69 -72
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +1 -11
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +10 -5
- package/dist/esm/commands/__tests__/deploy.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/deploy.test.d.ts.map +1 -0
- package/dist/esm/commands/build.d.ts +8 -1
- package/dist/esm/commands/build.d.ts.map +1 -1
- package/dist/esm/commands/build.mjs +9 -1
- package/dist/esm/commands/create.d.ts.map +1 -1
- package/dist/esm/commands/create.mjs +2 -13
- package/dist/esm/commands/deploy.d.ts +3 -0
- package/dist/esm/commands/deploy.d.ts.map +1 -1
- package/dist/esm/commands/deploy.mjs +72 -74
- package/dist/esm/commands/doctor.d.ts.map +1 -1
- package/dist/esm/commands/doctor.mjs +1 -11
- package/dist/esm/constants.d.ts +2 -0
- package/dist/esm/constants.d.ts.map +1 -1
- package/dist/esm/constants.mjs +9 -6
- package/dist/esm/index.mjs +9 -15
- package/dist/esm/migrations/006-remove-start-script.d.ts +3 -0
- package/dist/esm/migrations/006-remove-start-script.d.ts.map +1 -0
- package/dist/esm/migrations/006-remove-start-script.mjs +22 -0
- package/dist/esm/util/__tests__/app-name.test.d.ts +2 -0
- package/dist/esm/util/__tests__/app-name.test.d.ts.map +1 -0
- package/dist/esm/util/app-name.d.ts +17 -0
- package/dist/esm/util/app-name.d.ts.map +1 -1
- package/dist/esm/util/app-name.mjs +45 -1
- package/dist/esm/util/auth/broker-credential-provider.d.ts +7 -5
- package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -1
- package/dist/esm/util/auth/broker-credential-provider.mjs +28 -16
- package/dist/esm/util/broker/__tests__/app.test.d.ts +2 -0
- package/dist/esm/util/broker/__tests__/app.test.d.ts.map +1 -0
- package/dist/esm/util/broker/app.d.ts +15 -0
- package/dist/esm/util/broker/app.d.ts.map +1 -1
- package/dist/esm/util/broker/app.mjs +44 -1
- package/dist/esm/util/prompt-visibility.d.ts +2 -0
- package/dist/esm/util/prompt-visibility.d.ts.map +1 -0
- package/dist/esm/util/prompt-visibility.mjs +19 -0
- package/dist/index.js +9 -15
- package/dist/migrations/006-remove-start-script.d.ts +3 -0
- package/dist/migrations/006-remove-start-script.d.ts.map +1 -0
- package/dist/migrations/006-remove-start-script.js +24 -0
- package/dist/util/__tests__/app-name.test.d.ts +2 -0
- package/dist/util/__tests__/app-name.test.d.ts.map +1 -0
- package/dist/util/app-name.d.ts +17 -0
- package/dist/util/app-name.d.ts.map +1 -1
- package/dist/util/app-name.js +49 -2
- package/dist/util/auth/broker-credential-provider.d.ts +7 -5
- package/dist/util/auth/broker-credential-provider.d.ts.map +1 -1
- package/dist/util/auth/broker-credential-provider.js +28 -16
- package/dist/util/broker/__tests__/app.test.d.ts +2 -0
- package/dist/util/broker/__tests__/app.test.d.ts.map +1 -0
- package/dist/util/broker/app.d.ts +15 -0
- package/dist/util/broker/app.d.ts.map +1 -1
- package/dist/util/broker/app.js +44 -0
- package/dist/util/prompt-visibility.d.ts +2 -0
- package/dist/util/prompt-visibility.d.ts.map +1 -0
- package/dist/util/prompt-visibility.js +21 -0
- package/migrations.json +5 -0
- package/package.json +1 -1
- package/src/commands/__tests__/create.test.ts +5 -0
- package/src/commands/__tests__/deploy.test.ts +215 -0
- package/src/commands/__tests__/doctor.test.ts +0 -12
- package/src/commands/build.ts +20 -2
- package/src/commands/create.ts +2 -18
- package/src/commands/deploy.ts +113 -93
- package/src/commands/doctor.ts +1 -20
- package/src/constants.ts +10 -5
- package/src/index.ts +31 -25
- package/src/migrations/006-remove-start-script.ts +24 -0
- package/src/shadow/__tests__/merge-package-json.test.ts +0 -1
- package/src/templates/app/CLAUDE.md +3 -3
- package/src/templates/app/package.json.tmpl +0 -1
- package/src/templates/workspace/README.md +2 -2
- package/src/util/__tests__/app-name.test.ts +78 -0
- package/src/util/app-name.ts +55 -0
- package/src/util/auth/__tests__/broker-credential-provider.test.ts +53 -1
- package/src/util/auth/broker-credential-provider.ts +41 -16
- package/src/util/broker/__tests__/app.test.ts +104 -0
- package/src/util/broker/app.ts +68 -0
- package/src/util/prompt-visibility.ts +18 -0
- package/dist/commands/dev.d.ts +0 -5
- package/dist/commands/dev.d.ts.map +0 -1
- package/dist/commands/dev.js +0 -92
- package/dist/esm/commands/dev.d.ts +0 -5
- package/dist/esm/commands/dev.d.ts.map +0 -1
- package/dist/esm/commands/dev.mjs +0 -85
- package/src/commands/dev.ts +0 -93
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { mkdtemp, rm, ensureDir, writeJson } from 'fs-extra';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { deployCommand } from '../deploy';
|
|
7
|
+
import { buildPreviewSlug } from '../../util/app-name';
|
|
8
|
+
import { setOutputMode } from '../../util/logger';
|
|
9
|
+
import { buildCommand } from '../build';
|
|
10
|
+
import { uploadDir } from '../../util/s3/upload';
|
|
11
|
+
import { setupS3Client } from '../../util/s3/client';
|
|
12
|
+
import { openInBrowser } from '../../util/open-browser';
|
|
13
|
+
import * as brokerProvider from '../../util/auth/broker-credential-provider';
|
|
14
|
+
import * as brokerApp from '../../util/broker/app';
|
|
15
|
+
import * as authStore from '../../util/auth/auth-store';
|
|
16
|
+
import * as gitRepo from '../../util/git/repo';
|
|
17
|
+
|
|
18
|
+
vi.mock('../build', () => ({
|
|
19
|
+
buildCommand: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('../../util/s3/upload', () => ({
|
|
23
|
+
uploadDir: vi.fn().mockResolvedValue([]),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('../../util/s3/client', () => ({
|
|
27
|
+
setupS3Client: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('../../util/open-browser', () => ({
|
|
31
|
+
openInBrowser: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const FAKE_CREDS = { accessKeyId: 'AKID', secretAccessKey: 'SECRET', sessionToken: 'TOKEN' };
|
|
35
|
+
|
|
36
|
+
vi.mock('../../util/auth/broker-credential-provider', () => ({
|
|
37
|
+
brokerCredentialProvider: vi.fn(() => vi.fn().mockResolvedValue(FAKE_CREDS)),
|
|
38
|
+
fetchDeployVersion: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock('../../util/broker/app', () => ({
|
|
42
|
+
registerOrUpdateApp: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock('../../util/git/repo', () => ({
|
|
46
|
+
getRemoteUrl: vi.fn(),
|
|
47
|
+
guardCleanTree: vi.fn(),
|
|
48
|
+
pullAndResolve: vi.fn(),
|
|
49
|
+
pushToRemote: vi.fn(),
|
|
50
|
+
initRepo: vi.fn(),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const ASSET_MANIFEST = {
|
|
54
|
+
sameAsHost: false,
|
|
55
|
+
skipTranslationFetch: false,
|
|
56
|
+
shared: [],
|
|
57
|
+
entrypoints: ['main.js'],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe('deploy --preview', () => {
|
|
61
|
+
let tmp: string;
|
|
62
|
+
let cwd: string;
|
|
63
|
+
|
|
64
|
+
beforeEach(async () => {
|
|
65
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-preview-'));
|
|
66
|
+
cwd = process.cwd();
|
|
67
|
+
process.chdir(tmp);
|
|
68
|
+
// Re-read via process.cwd(): on macOS os.tmpdir() is under a /tmp symlink
|
|
69
|
+
// that resolves to /private/tmp once we chdir into it, and deployCommand
|
|
70
|
+
// computes its paths from process.cwd() — normalize so assertions match.
|
|
71
|
+
tmp = process.cwd();
|
|
72
|
+
await writeJson(path.join(tmp, 'package.json'), { name: '@mondaydotcomorg/my-app' });
|
|
73
|
+
await ensureDir(path.join(tmp, '.zth', 'build'));
|
|
74
|
+
|
|
75
|
+
vi.spyOn(authStore, 'readZthAuth').mockResolvedValue({ token: 'jwt-stub' });
|
|
76
|
+
vi.mocked(buildCommand)
|
|
77
|
+
.mockClear()
|
|
78
|
+
.mockResolvedValue({
|
|
79
|
+
appName: 'my-app',
|
|
80
|
+
port: 13000,
|
|
81
|
+
buildDir: path.join(tmp, '.zth', 'build'),
|
|
82
|
+
assetManifest: ASSET_MANIFEST,
|
|
83
|
+
});
|
|
84
|
+
vi.mocked(uploadDir).mockClear();
|
|
85
|
+
vi.mocked(setupS3Client).mockClear();
|
|
86
|
+
vi.mocked(openInBrowser).mockClear();
|
|
87
|
+
vi.mocked(brokerProvider.brokerCredentialProvider).mockClear();
|
|
88
|
+
vi.mocked(brokerProvider.fetchDeployVersion).mockReset().mockResolvedValue(0);
|
|
89
|
+
vi.mocked(brokerApp.registerOrUpdateApp).mockClear().mockResolvedValue(undefined);
|
|
90
|
+
vi.mocked(gitRepo.guardCleanTree).mockClear();
|
|
91
|
+
vi.mocked(gitRepo.pullAndResolve).mockClear();
|
|
92
|
+
vi.mocked(gitRepo.pushToRemote).mockClear();
|
|
93
|
+
vi.mocked(gitRepo.initRepo).mockClear();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
process.chdir(cwd);
|
|
98
|
+
setOutputMode('human');
|
|
99
|
+
await rm(tmp, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('throws when the consumer package.json has no name', async () => {
|
|
103
|
+
await writeJson(path.join(tmp, 'package.json'), {});
|
|
104
|
+
await expect(deployCommand({ preview: true })).rejects.toThrow(/must have a "name" field/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('mints a fresh previewId and uploads to previews/<mfApp>/<id>/MF', async () => {
|
|
108
|
+
await deployCommand({ preview: true });
|
|
109
|
+
|
|
110
|
+
expect(brokerProvider.brokerCredentialProvider).toHaveBeenCalledTimes(1);
|
|
111
|
+
const call = vi.mocked(brokerProvider.brokerCredentialProvider).mock.calls[0][0];
|
|
112
|
+
expect(call.appName).toBe('mf-my-app');
|
|
113
|
+
expect(call.previewId).toMatch(/^[0-9a-f-]{36}$/);
|
|
114
|
+
|
|
115
|
+
expect(uploadDir).toHaveBeenCalledWith(path.join(tmp, '.zth', 'build'), `previews/mf-my-app/${call.previewId}/MF`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('calls registerOrUpdateApp for a brand-new preview at version 1 with an empty gitRemote', async () => {
|
|
119
|
+
vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue(0);
|
|
120
|
+
|
|
121
|
+
await deployCommand({ preview: true, previewId: 'existing-preview-id' });
|
|
122
|
+
|
|
123
|
+
const previewSlug = buildPreviewSlug('my-app', 'existing-preview-id');
|
|
124
|
+
const previewAppName = `mf-${previewSlug}`;
|
|
125
|
+
|
|
126
|
+
expect(brokerApp.registerOrUpdateApp).toHaveBeenCalledWith({
|
|
127
|
+
appName: previewAppName,
|
|
128
|
+
token: 'jwt-stub',
|
|
129
|
+
nextVersion: 1,
|
|
130
|
+
entry: expect.objectContaining({ ...ASSET_MANIFEST, version: 1, gitRemote: '' }),
|
|
131
|
+
gitRemote: '',
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('reuses a supplied previewId and calls registerOrUpdateApp at version+1', async () => {
|
|
136
|
+
vi.mocked(brokerProvider.fetchDeployVersion).mockResolvedValue(2);
|
|
137
|
+
|
|
138
|
+
await deployCommand({ preview: true, previewId: 'existing-preview-id' });
|
|
139
|
+
|
|
140
|
+
const call = vi.mocked(brokerProvider.brokerCredentialProvider).mock.calls[0][0];
|
|
141
|
+
expect(call.previewId).toBe('existing-preview-id');
|
|
142
|
+
|
|
143
|
+
expect(uploadDir).toHaveBeenCalledWith(
|
|
144
|
+
path.join(tmp, '.zth', 'build'),
|
|
145
|
+
'previews/mf-my-app/existing-preview-id/MF'
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const previewSlug = buildPreviewSlug('my-app', 'existing-preview-id');
|
|
149
|
+
const previewAppName = `mf-${previewSlug}`;
|
|
150
|
+
expect(brokerApp.registerOrUpdateApp).toHaveBeenCalledWith({
|
|
151
|
+
appName: previewAppName,
|
|
152
|
+
token: 'jwt-stub',
|
|
153
|
+
nextVersion: 3,
|
|
154
|
+
entry: expect.objectContaining({ ...ASSET_MANIFEST, version: 3, gitRemote: '' }),
|
|
155
|
+
gitRemote: '',
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('passes a full CDN URL (not a relative path) as publicUrl to the build', async () => {
|
|
160
|
+
await deployCommand({ preview: true, previewId: 'existing-preview-id' });
|
|
161
|
+
|
|
162
|
+
expect(buildCommand).toHaveBeenCalledWith(
|
|
163
|
+
expect.objectContaining({
|
|
164
|
+
publicUrl: 'https://bigbrain-zth-mf-assets.bigbrain.me/previews/mf-my-app/existing-preview-id/MF',
|
|
165
|
+
})
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('never touches git', async () => {
|
|
170
|
+
await deployCommand({ preview: true });
|
|
171
|
+
|
|
172
|
+
expect(gitRepo.guardCleanTree).not.toHaveBeenCalled();
|
|
173
|
+
expect(gitRepo.pullAndResolve).not.toHaveBeenCalled();
|
|
174
|
+
expect(gitRepo.pushToRemote).not.toHaveBeenCalled();
|
|
175
|
+
expect(gitRepo.initRepo).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('--open opens the previewUrl in the browser on success', async () => {
|
|
179
|
+
await deployCommand({ preview: true, previewId: 'existing-preview-id', open: true });
|
|
180
|
+
|
|
181
|
+
const previewSlug = buildPreviewSlug('my-app', 'existing-preview-id');
|
|
182
|
+
expect(openInBrowser).toHaveBeenCalledWith(`https://bigbrain.me/bigbrain-vibe/${previewSlug}`);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('does not open a browser when --open is omitted', async () => {
|
|
186
|
+
await deployCommand({ preview: true });
|
|
187
|
+
expect(openInBrowser).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('rejects deploying a preview for an app already named with the preview prefix', async () => {
|
|
191
|
+
await writeJson(path.join(tmp, 'package.json'), { name: 'zth-preview-abc123-my-app' });
|
|
192
|
+
await expect(deployCommand({ preview: true })).rejects.toThrow(/reserved for the ephemeral preview mechanism/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('--json emits a machine-readable payload with an app-shaped previewUrl (not ?z2h_preview=)', async () => {
|
|
196
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
197
|
+
setOutputMode('json');
|
|
198
|
+
|
|
199
|
+
await deployCommand({ preview: true, previewId: 'existing-preview-id' });
|
|
200
|
+
|
|
201
|
+
const jsonLine = writeSpy.mock.calls.map(c => c[0]).find(line => typeof line === 'string' && line.includes('"ok"'));
|
|
202
|
+
expect(jsonLine).toBeDefined();
|
|
203
|
+
const payload = JSON.parse(jsonLine as string);
|
|
204
|
+
const previewSlug = buildPreviewSlug('my-app', 'existing-preview-id');
|
|
205
|
+
expect(payload).toMatchObject({
|
|
206
|
+
ok: true,
|
|
207
|
+
appName: 'mf-my-app',
|
|
208
|
+
previewId: 'existing-preview-id',
|
|
209
|
+
previewAppName: `mf-${previewSlug}`,
|
|
210
|
+
previewUrl: `https://bigbrain.me/bigbrain-vibe/${previewSlug}`,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
writeSpy.mockRestore();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -348,18 +348,6 @@ describe('doctorCommand', () => {
|
|
|
348
348
|
expect(out).toContain('❌');
|
|
349
349
|
});
|
|
350
350
|
|
|
351
|
-
it('shows warn for SSL cert when not found', async () => {
|
|
352
|
-
mockPathExists.mockImplementation(async (p: unknown) => {
|
|
353
|
-
if (String(p).includes('devcert')) {
|
|
354
|
-
return false as never;
|
|
355
|
-
}
|
|
356
|
-
return true as never;
|
|
357
|
-
});
|
|
358
|
-
const out = await captureStdout(() => doctorCommand());
|
|
359
|
-
expect(out).toContain('SSL cert');
|
|
360
|
-
expect(out).toContain('⚠️');
|
|
361
|
-
});
|
|
362
|
-
|
|
363
351
|
// ── Plugins ───────────────────────────────────────────────────────────────
|
|
364
352
|
|
|
365
353
|
it('shows fail for z2h plugin (mandatory) when installPath dir is missing', async () => {
|
package/src/commands/build.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathExists, readJson } from 'fs-extra';
|
|
2
3
|
import { execa } from 'execa';
|
|
3
4
|
import { info, output } from '../util/logger';
|
|
4
5
|
import { prepareShadow } from '../shadow/prepare';
|
|
6
|
+
import type { AssetManifestFile } from '../util/manifest';
|
|
5
7
|
|
|
6
8
|
export interface BuildOptions {
|
|
7
9
|
publicUrl?: string;
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
export interface BuildResult {
|
|
13
|
+
appName: string;
|
|
14
|
+
port: number;
|
|
15
|
+
buildDir: string;
|
|
16
|
+
assetManifest: AssetManifestFile;
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
// Should be run from the consumer app directory — uses process.cwd() to locate it.
|
|
11
|
-
export async function buildCommand(opts: BuildOptions = {}): Promise<
|
|
20
|
+
export async function buildCommand(opts: BuildOptions = {}): Promise<BuildResult> {
|
|
12
21
|
const ctx = await prepareShadow();
|
|
13
22
|
|
|
14
23
|
// Typecheck against the shadow's tsconfig (the consumer dir has none).
|
|
@@ -34,10 +43,19 @@ export async function buildCommand(opts: BuildOptions = {}): Promise<void> {
|
|
|
34
43
|
throw new Error(`Expected build output at ${ctx.paths.shadowBuild} — not found.`);
|
|
35
44
|
}
|
|
36
45
|
|
|
46
|
+
// Trident emits the canonical per-MF manifest at build/asset-manifest.json.
|
|
47
|
+
const assetManifestPath = path.join(ctx.paths.shadowBuild, 'asset-manifest.json');
|
|
48
|
+
if (!(await pathExists(assetManifestPath))) {
|
|
49
|
+
throw new Error(`Missing asset-manifest.json at ${assetManifestPath}.`);
|
|
50
|
+
}
|
|
51
|
+
const assetManifest = (await readJson(assetManifestPath)) as AssetManifestFile;
|
|
52
|
+
|
|
37
53
|
output(`[z2h-cli] build complete — artifacts at ${ctx.paths.shadowBuild}`, {
|
|
38
54
|
ok: true,
|
|
39
55
|
appName: ctx.appName,
|
|
40
56
|
port: ctx.port,
|
|
41
57
|
buildDir: ctx.paths.shadowBuild,
|
|
42
58
|
});
|
|
59
|
+
|
|
60
|
+
return { appName: ctx.appName, port: ctx.port, buildDir: ctx.paths.shadowBuild, assetManifest };
|
|
43
61
|
}
|
package/src/commands/create.ts
CHANGED
|
@@ -4,12 +4,11 @@ import { ensureDir, pathExists, readdir, writeFile, copy } from 'fs-extra';
|
|
|
4
4
|
import { info, warn, output } from '../util/logger';
|
|
5
5
|
import { assertWorkspace } from '../util/workspace';
|
|
6
6
|
import { getTemplatesDir, renderTemplate, APP_TEMPLATES, type AppTemplate } from '../util/templates';
|
|
7
|
-
import { APP_NAME_PATTERN } from '../constants';
|
|
8
7
|
import { runYarnInstall } from '../util/npm/install';
|
|
9
8
|
import { initRepo } from '../util/git/repo';
|
|
10
9
|
import { REMOTE_TYPES, type RemoteType, validateRemoteOptions, resolveRemoteUrl } from '../util/git/remote-options';
|
|
11
10
|
import { fetchApp } from '../util/broker/app';
|
|
12
|
-
import { mfAppName } from '../util/app-name';
|
|
11
|
+
import { mfAppName, validateAppName } from '../util/app-name';
|
|
13
12
|
import { prepareShadow } from '../shadow/prepare';
|
|
14
13
|
|
|
15
14
|
export { REMOTE_TYPES, type RemoteType };
|
|
@@ -21,11 +20,6 @@ export interface CreateOptions {
|
|
|
21
20
|
repoUrl?: string;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
// Names reserved by the host MF (mf-bigbrain-zth) for platform pages under
|
|
25
|
-
// /bigbrain-vibe/<name>. Creating an app with one of these would shadow the
|
|
26
|
-
// platform page in the URL, so we refuse upfront.
|
|
27
|
-
const RESERVED_APP_NAMES = new Set(['generate-z2h-token']);
|
|
28
|
-
|
|
29
23
|
/**
|
|
30
24
|
* Convert kebab-case to PascalCase. `demo-app` → `DemoApp`.
|
|
31
25
|
*/
|
|
@@ -53,17 +47,7 @@ export async function createCommand(appName: string, opts: CreateOptions = {}):
|
|
|
53
47
|
const workspace = await assertWorkspace();
|
|
54
48
|
|
|
55
49
|
// Validate app name.
|
|
56
|
-
|
|
57
|
-
throw new Error(
|
|
58
|
-
`App name "${appName}" is reserved by the Z2H host for a platform page under /bigbrain-vibe/${appName}. Pick a different name.`
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
if (!APP_NAME_PATTERN.test(appName)) {
|
|
62
|
-
throw new Error(
|
|
63
|
-
`App name "${appName}" is invalid. Use lowercase kebab-case (a-z, 0-9, -), ` +
|
|
64
|
-
`2-50 chars, starting with a letter.`
|
|
65
|
-
);
|
|
66
|
-
}
|
|
50
|
+
validateAppName(appName);
|
|
67
51
|
|
|
68
52
|
// Validate --remote up front before any file I/O.
|
|
69
53
|
validateRemoteOptions(opts, true);
|
package/src/commands/deploy.ts
CHANGED
|
@@ -1,28 +1,50 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
1
|
import os from 'node:os';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readJson } from 'fs-extra';
|
|
5
4
|
import { info, warn, output, getOutputMode } from '../util/logger';
|
|
6
5
|
import { resolveConsumerPaths } from '../shadow/paths';
|
|
7
6
|
import { fetchAppTags, fetchTagCatalog, setAppTags } from '../util/tags';
|
|
8
|
-
import { type AssetManifestFile, type ManifestEntry } from '../util/manifest';
|
|
9
7
|
import { uploadDir } from '../util/s3/upload';
|
|
10
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
BUCKET_WRITE_NAME,
|
|
10
|
+
BUCKET_READ_DNS_URL,
|
|
11
|
+
S3_PREVIEWS_FOLDER,
|
|
12
|
+
brokerBaseUrl,
|
|
13
|
+
routingHeaders,
|
|
14
|
+
} from '../constants';
|
|
11
15
|
import { brokerCredentialProvider, fetchDeployVersion } from '../util/auth/broker-credential-provider';
|
|
12
16
|
import { readZthAuth } from '../util/auth/auth-store';
|
|
13
|
-
import { cleanAppName, mfAppName } from '../util/app-name';
|
|
14
|
-
import { getMachineAppName } from '../util/machine-identity';
|
|
17
|
+
import { cleanAppName, mfAppName, validateAppName, buildPreviewSlug } from '../util/app-name';
|
|
15
18
|
import { getRemoteUrl, guardCleanTree, pullAndResolve, pushToRemote, initRepo } from '../util/git/repo';
|
|
16
19
|
import { resolveGitEnv } from '../util/git/git-env';
|
|
17
20
|
import { type RemoteType, validateRemoteOptions, resolveRemoteUrl } from '../util/git/remote-options';
|
|
18
21
|
import { setupS3Client } from '../util/s3/client';
|
|
19
|
-
import {
|
|
22
|
+
import { getMachineAppName } from '../util/machine-identity';
|
|
23
|
+
import { registerOrUpdateApp } from '../util/broker/app';
|
|
24
|
+
import { openInBrowser } from '../util/open-browser';
|
|
20
25
|
import { buildCommand } from './build';
|
|
26
|
+
import { promptForTags } from './tag';
|
|
27
|
+
import type { ConsumerPaths } from '../types';
|
|
28
|
+
import type { ManifestEntry } from '../util/manifest';
|
|
21
29
|
|
|
22
30
|
export interface DeployOptions {
|
|
23
31
|
dryRun?: boolean;
|
|
24
32
|
remote?: RemoteType;
|
|
25
33
|
repoUrl?: string;
|
|
34
|
+
// Registers a real, disposable `apps` row under a synthetic name instead of
|
|
35
|
+
// deploying — no git, no deploy lock (each previewId's slug is unique).
|
|
36
|
+
preview?: boolean;
|
|
37
|
+
// With --preview: update an existing preview instead of minting a new one.
|
|
38
|
+
previewId?: string;
|
|
39
|
+
open?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface DeployContext {
|
|
43
|
+
paths: ConsumerPaths;
|
|
44
|
+
consumerName: string;
|
|
45
|
+
appName: string;
|
|
46
|
+
token: string;
|
|
47
|
+
opts: DeployOptions;
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
// Should be run from the consumer app directory — uses process.cwd() to locate it.
|
|
@@ -32,12 +54,89 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
|
|
|
32
54
|
if (!consumerPkg.name) {
|
|
33
55
|
throw new Error('Consumer package.json must have a "name" field');
|
|
34
56
|
}
|
|
57
|
+
validateAppName(cleanAppName(consumerPkg.name));
|
|
35
58
|
const appName = mfAppName(consumerPkg.name);
|
|
36
59
|
|
|
37
60
|
// Validate the user's deploy token is on disk before doing any expensive
|
|
38
61
|
// work. Throws Z2hAuthMissingError with a remediation hint if not.
|
|
39
62
|
const { token } = await readZthAuth();
|
|
40
63
|
|
|
64
|
+
const ctx: DeployContext = { paths, consumerName: consumerPkg.name, appName, token, opts };
|
|
65
|
+
|
|
66
|
+
if (opts.preview) {
|
|
67
|
+
await deployPreview(ctx);
|
|
68
|
+
} else {
|
|
69
|
+
await deployLive(ctx);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Runs the build step and turns its output into a ManifestEntry ready to
|
|
75
|
+
* register/update — the one step a real deploy and a preview both need
|
|
76
|
+
* identically: build, then stamp version/url/deployedBy/gitRemote onto the
|
|
77
|
+
* asset manifest Trident emitted.
|
|
78
|
+
*/
|
|
79
|
+
async function buildAndStamp(opts: {
|
|
80
|
+
paths: ConsumerPaths;
|
|
81
|
+
publicUrl: string;
|
|
82
|
+
nextVersion: number;
|
|
83
|
+
gitRemote: string;
|
|
84
|
+
}): Promise<ManifestEntry> {
|
|
85
|
+
const { assetManifest } = await buildCommand({ publicUrl: opts.publicUrl });
|
|
86
|
+
const deployedBy = getMachineAppName() ?? os.userInfo().username;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
...assetManifest,
|
|
90
|
+
version: opts.nextVersion,
|
|
91
|
+
url: opts.publicUrl,
|
|
92
|
+
deployedBy,
|
|
93
|
+
gitRemote: opts.gitRemote,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function deployPreview({ paths, consumerName, appName, token, opts }: DeployContext): Promise<void> {
|
|
98
|
+
const previewId = opts.previewId ?? randomUUID();
|
|
99
|
+
const previewSlug = buildPreviewSlug(consumerName, previewId);
|
|
100
|
+
const previewAppName = mfAppName(previewSlug);
|
|
101
|
+
const keyPrefix = `${S3_PREVIEWS_FOLDER}/${appName}/${previewId}/MF`;
|
|
102
|
+
// Full CDN URL, not a relative path — trident-skeleton uses a manifest
|
|
103
|
+
// entry's `url` verbatim as the asset base, never joined with its own mfUrl.
|
|
104
|
+
const url = `${BUCKET_READ_DNS_URL}/${keyPrefix}`;
|
|
105
|
+
|
|
106
|
+
const nextVersion = (await fetchDeployVersion(previewAppName)) + 1;
|
|
107
|
+
|
|
108
|
+
info('building');
|
|
109
|
+
const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote: '' });
|
|
110
|
+
|
|
111
|
+
const creds = await brokerCredentialProvider({ appName, previewId })();
|
|
112
|
+
setupS3Client(creds);
|
|
113
|
+
|
|
114
|
+
info(`uploading preview build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
|
|
115
|
+
await uploadDir(paths.shadowBuild, keyPrefix);
|
|
116
|
+
|
|
117
|
+
await registerOrUpdateApp({ appName: previewAppName, token, nextVersion, entry, gitRemote: '' });
|
|
118
|
+
|
|
119
|
+
const previewUrl = `https://bigbrain.me/bigbrain-vibe/${previewSlug}`;
|
|
120
|
+
output(
|
|
121
|
+
`[z2h-cli] preview ready ✅\n app: ${appName}\n previewAppName: ${previewAppName}\n open: ${previewUrl}`,
|
|
122
|
+
{
|
|
123
|
+
ok: true,
|
|
124
|
+
appName,
|
|
125
|
+
previewId,
|
|
126
|
+
previewAppName,
|
|
127
|
+
bucket: BUCKET_WRITE_NAME,
|
|
128
|
+
keyPrefix,
|
|
129
|
+
url,
|
|
130
|
+
previewUrl,
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (opts.open) {
|
|
135
|
+
void openInBrowser(previewUrl);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function deployLive({ paths, consumerName, appName, token, opts }: DeployContext): Promise<void> {
|
|
41
140
|
let gitRemote = await getRemoteUrl(process.cwd());
|
|
42
141
|
|
|
43
142
|
if (gitRemote) {
|
|
@@ -60,7 +159,7 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
|
|
|
60
159
|
validateRemoteOptions(opts);
|
|
61
160
|
// Pass the raw consumer name (not the mf-prefixed appName) so defaultS3Remote
|
|
62
161
|
// produces the correct s3://bucket/mf-<name>/git path without double prefix.
|
|
63
|
-
const remote = resolveRemoteUrl(opts.remote as RemoteType,
|
|
162
|
+
const remote = resolveRemoteUrl(opts.remote as RemoteType, consumerName, opts.repoUrl);
|
|
64
163
|
info(`initializing git repo with ${opts.remote} remote...`);
|
|
65
164
|
await initRepo(process.cwd(), remote);
|
|
66
165
|
gitRemote = remote;
|
|
@@ -85,27 +184,7 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
|
|
|
85
184
|
const url = `${BUCKET_READ_DNS_URL}/${keyPrefix}`;
|
|
86
185
|
|
|
87
186
|
info('building');
|
|
88
|
-
await
|
|
89
|
-
|
|
90
|
-
if (!(await pathExists(paths.shadowBuild))) {
|
|
91
|
-
throw new Error(`No build output at ${paths.shadowBuild} after build step.`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Trident emits the canonical per-MF manifest at build/asset-manifest.json.
|
|
95
|
-
const assetManifestPath = path.join(paths.shadowBuild, 'asset-manifest.json');
|
|
96
|
-
if (!(await pathExists(assetManifestPath))) {
|
|
97
|
-
throw new Error(`Missing asset-manifest.json at ${assetManifestPath}.`);
|
|
98
|
-
}
|
|
99
|
-
const assetManifest = (await readJson(assetManifestPath)) as AssetManifestFile;
|
|
100
|
-
|
|
101
|
-
const deployedBy = getMachineAppName() ?? os.userInfo().username;
|
|
102
|
-
const entry: ManifestEntry = {
|
|
103
|
-
...assetManifest,
|
|
104
|
-
version: nextVersion,
|
|
105
|
-
url,
|
|
106
|
-
deployedBy,
|
|
107
|
-
gitRemote,
|
|
108
|
-
};
|
|
187
|
+
const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote });
|
|
109
188
|
|
|
110
189
|
if (opts.dryRun) {
|
|
111
190
|
output(`[z2h-cli] dry-run: would upload to s3://${BUCKET_WRITE_NAME}/${keyPrefix}/ as v${version}`, {
|
|
@@ -140,17 +219,7 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
|
|
|
140
219
|
info(`uploading build → s3://${BUCKET_WRITE_NAME}/${keyPrefix}/`);
|
|
141
220
|
await uploadDir(paths.shadowBuild, keyPrefix);
|
|
142
221
|
|
|
143
|
-
|
|
144
|
-
// The two are separate endpoints, so the CLI picks based on the version it
|
|
145
|
-
// was handed.
|
|
146
|
-
if (nextVersion === 1) {
|
|
147
|
-
const visibility = await resolveFirstDeployVisibility(appName);
|
|
148
|
-
info(`registering app "${appName}" → v${nextVersion} (${visibility})`);
|
|
149
|
-
await registerApp(appName, token, nextVersion, entry, visibility, gitRemote);
|
|
150
|
-
} else {
|
|
151
|
-
info(`releasing "${appName}" → v${nextVersion}`);
|
|
152
|
-
await updateApp(appName, token, nextVersion, entry, gitRemote);
|
|
153
|
-
}
|
|
222
|
+
await registerOrUpdateApp({ appName, token, nextVersion, entry, gitRemote });
|
|
154
223
|
} finally {
|
|
155
224
|
await releaseLockBestEffort(appName, token);
|
|
156
225
|
}
|
|
@@ -162,7 +231,7 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
|
|
|
162
231
|
// The host MF parses the app name from the URL and prepends `mf-` itself
|
|
163
232
|
// before looking it up in the manifest. So the user-facing URL must use
|
|
164
233
|
// the consumer's de-scoped package name (no `mf-` prefix, no `@scope/`).
|
|
165
|
-
const liveUrl = `https://bigbrain.me/bigbrain-vibe/${cleanAppName(
|
|
234
|
+
const liveUrl = `https://bigbrain.me/bigbrain-vibe/${cleanAppName(consumerName)}`;
|
|
166
235
|
output(`[z2h-cli] deployed ✅\n app: ${appName}\n version: ${nextVersion}\n open: ${liveUrl}`, {
|
|
167
236
|
ok: true,
|
|
168
237
|
appName,
|
|
@@ -173,24 +242,9 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
|
|
|
173
242
|
liveUrl,
|
|
174
243
|
entry,
|
|
175
244
|
});
|
|
176
|
-
}
|
|
177
245
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
token: string,
|
|
181
|
-
version: number,
|
|
182
|
-
entry: ManifestEntry,
|
|
183
|
-
visibility: 'public' | 'private',
|
|
184
|
-
gitRemote: string
|
|
185
|
-
): Promise<void> {
|
|
186
|
-
const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/register`, {
|
|
187
|
-
method: 'POST',
|
|
188
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
|
|
189
|
-
body: JSON.stringify({ appName, version, entry, visibility, gitRemote }),
|
|
190
|
-
});
|
|
191
|
-
if (!res.ok) {
|
|
192
|
-
const text = await res.text();
|
|
193
|
-
throw new Error(`App registration failed (${res.status}): ${text.slice(0, 1024)}`);
|
|
246
|
+
if (opts.open) {
|
|
247
|
+
void openInBrowser(liveUrl);
|
|
194
248
|
}
|
|
195
249
|
}
|
|
196
250
|
|
|
@@ -236,40 +290,6 @@ async function acquireDeployLock(appName: string, token: string): Promise<void>
|
|
|
236
290
|
info(`deploy lock acquired for ${appName}`);
|
|
237
291
|
}
|
|
238
292
|
|
|
239
|
-
async function updateApp(
|
|
240
|
-
appName: string,
|
|
241
|
-
token: string,
|
|
242
|
-
version: number,
|
|
243
|
-
entry: ManifestEntry,
|
|
244
|
-
gitRemote: string
|
|
245
|
-
): Promise<void> {
|
|
246
|
-
const res = await fetch(`${brokerBaseUrl()}/z2h-cli/apps/update`, {
|
|
247
|
-
method: 'POST',
|
|
248
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...routingHeaders() },
|
|
249
|
-
body: JSON.stringify({ appName, version, entry, gitRemote }),
|
|
250
|
-
});
|
|
251
|
-
if (!res.ok) {
|
|
252
|
-
const text = await res.text();
|
|
253
|
-
throw new Error(`App update failed (${res.status}): ${text.slice(0, 1024)}`);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// First deploy only: decide whether the app is born public or private. The
|
|
258
|
-
// choice is passed to the register endpoint (default private). Non-interactive
|
|
259
|
-
// runs (--json or no TTY) always default to private.
|
|
260
|
-
async function resolveFirstDeployVisibility(appName: string): Promise<'public' | 'private'> {
|
|
261
|
-
if (getOutputMode() === 'json' || !process.stdout.isTTY) {
|
|
262
|
-
return 'private';
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
266
|
-
const answer = await new Promise<string>(resolve => {
|
|
267
|
-
rl.question(`Make "${appName}" public to all monday.com employees? (y/N) `, resolve);
|
|
268
|
-
});
|
|
269
|
-
rl.close();
|
|
270
|
-
return answer.trim().toLowerCase() === 'y' ? 'public' : 'private';
|
|
271
|
-
}
|
|
272
|
-
|
|
273
293
|
// best-effort — the 2-min TTL backstop handles failures
|
|
274
294
|
async function releaseLockBestEffort(appName: string, token: string): Promise<void> {
|
|
275
295
|
try {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -16,15 +16,6 @@ const Z2H_REPO_ROOT = path.join(HOME, 'Development', 'bigbrain-z2h');
|
|
|
16
16
|
const BOOTSTRAP_CMD = `bash ${path.join(Z2H_REPO_ROOT, 'z2h/setup/bootstrap.sh')}`;
|
|
17
17
|
const UPDATE_CMD = `bash ${path.join(Z2H_REPO_ROOT, 'z2h/setup/update.sh')}`;
|
|
18
18
|
const CLAUDE_PLUGINS_JSON = path.join(HOME, '.claude', 'plugins', 'installed_plugins.json');
|
|
19
|
-
const SSL_CERT_PATH = path.join(
|
|
20
|
-
HOME,
|
|
21
|
-
'Library',
|
|
22
|
-
'Application Support',
|
|
23
|
-
'devcert',
|
|
24
|
-
'domains',
|
|
25
|
-
'webpack.llama.fan',
|
|
26
|
-
'certificate.crt'
|
|
27
|
-
);
|
|
28
19
|
|
|
29
20
|
type Status = 'ok' | 'warn' | 'fail';
|
|
30
21
|
|
|
@@ -258,16 +249,6 @@ async function checkReactVersion(): Promise<CheckResult> {
|
|
|
258
249
|
};
|
|
259
250
|
}
|
|
260
251
|
|
|
261
|
-
async function checkSslCert(): Promise<CheckResult> {
|
|
262
|
-
const ok = await pathExists(SSL_CERT_PATH);
|
|
263
|
-
return {
|
|
264
|
-
label: 'SSL cert',
|
|
265
|
-
status: ok ? 'ok' : 'warn',
|
|
266
|
-
detail: ok ? 'webpack.llama.fan' : 'not found — auto-installs on first z2h-cli dev',
|
|
267
|
-
fix: ok ? undefined : 'z2h-cli dev',
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
|
|
271
252
|
async function checkPlugin(
|
|
272
253
|
pluginKey: string,
|
|
273
254
|
label: string,
|
|
@@ -325,7 +306,7 @@ export async function doctorCommand(): Promise<void> {
|
|
|
325
306
|
|
|
326
307
|
const systemChecks = await Promise.all([checkNvm(), checkNode(), checkYarn(), checkAws(), checkGitRemoteS3()]);
|
|
327
308
|
const cliChecks = await Promise.all([checkZ2hCli(), checkAuthToken(), checkNpmTokenInProfile()]);
|
|
328
|
-
const workspaceChecks = await Promise.all([checkWorkspace(), checkReactVersion()
|
|
309
|
+
const workspaceChecks = await Promise.all([checkWorkspace(), checkReactVersion()]);
|
|
329
310
|
const pluginChecks = await Promise.all([
|
|
330
311
|
checkPlugin('z2h@bigbrain-z2h', 'z2h plugin', true, pluginsJson),
|
|
331
312
|
checkPlugin('cf-external-claude-plugin@client-foundations-ai-tools', 'cf-external plugin', false, pluginsJson),
|