@mondaydotcomorg/z2h-cli 0.13.1 → 0.14.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.
@@ -0,0 +1,393 @@
1
+ import { createRequire } from 'node:module';
2
+ import { readFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { execa } from 'execa';
6
+ import semver from 'semver';
7
+ import { pathExists, readJson } from 'fs-extra';
8
+ import pc from 'picocolors';
9
+ import { setOutputMode, output } from '../util/logger';
10
+ import { isZ2hWorkspace, getWorkspaceHome } from '../util/workspace';
11
+ import { Z2H_AUTH_FILE, tryReadAuthTokenSync } from '../util/auth/auth-store';
12
+ import { brokerNpmRegistryUrl, routingHeaders } from '../constants';
13
+
14
+ const HOME = os.homedir();
15
+ const Z2H_REPO_ROOT = path.join(HOME, 'Development', 'bigbrain-z2h');
16
+ const BOOTSTRAP_CMD = `bash ${path.join(Z2H_REPO_ROOT, 'z2h/setup/bootstrap.sh')}`;
17
+ const UPDATE_CMD = `bash ${path.join(Z2H_REPO_ROOT, 'z2h/setup/update.sh')}`;
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
+
29
+ export interface DoctorOptions {
30
+ json?: boolean;
31
+ }
32
+
33
+ type Status = 'ok' | 'warn' | 'fail';
34
+
35
+ interface CheckResult {
36
+ label: string;
37
+ status: Status;
38
+ detail?: string;
39
+ fix?: string;
40
+ }
41
+
42
+ interface InstalledPluginsJson {
43
+ plugins?: Record<string, { installPath?: string }[]>;
44
+ }
45
+
46
+ // ── helpers ───────────────────────────────────────────────────────────────────
47
+
48
+ async function checkTool(
49
+ label: string,
50
+ opts: {
51
+ cmd: string;
52
+ args: string[];
53
+ failStatus?: Status;
54
+ getVersion?: (stdout: string) => string | undefined | Promise<string | undefined>;
55
+ fix?: string;
56
+ }
57
+ ): Promise<CheckResult> {
58
+ const { cmd, args, failStatus = 'fail', getVersion, fix } = opts;
59
+ let stdout: string;
60
+ try {
61
+ ({ stdout } = await execa(cmd, args));
62
+ stdout = stdout.trim();
63
+ } catch {
64
+ return { label, status: failStatus, detail: 'not found', fix };
65
+ }
66
+ let version: string | undefined;
67
+ try {
68
+ version = getVersion ? await getVersion(stdout) : stdout;
69
+ } catch {
70
+ /* best-effort */
71
+ }
72
+ return { label, status: 'ok', detail: version };
73
+ }
74
+
75
+ // ── checks ───────────────────────────────────────────────────────────────────
76
+
77
+ function checkNvm(): Promise<CheckResult> {
78
+ const nvmScript = path.join(HOME, '.nvm', 'nvm.sh');
79
+ return checkTool('nvm', {
80
+ cmd: 'bash',
81
+ args: ['-c', `source "${nvmScript}" && nvm --version`],
82
+ failStatus: 'warn',
83
+ fix: BOOTSTRAP_CMD,
84
+ });
85
+ }
86
+
87
+ async function checkNode(): Promise<CheckResult> {
88
+ try {
89
+ const { stdout } = await execa('node', ['--version']);
90
+ const current = stdout.trim();
91
+
92
+ let requiredNodeVersion: string | undefined;
93
+ try {
94
+ const nvmrc = (await readFile(path.join(Z2H_REPO_ROOT, '.nvmrc'), 'utf8')).trim();
95
+ requiredNodeVersion = semver.coerce(nvmrc)?.version;
96
+ } catch {
97
+ // repo not cloned — skip version comparison
98
+ }
99
+
100
+ if (!requiredNodeVersion) {
101
+ return { label: 'node', status: 'ok', detail: current };
102
+ }
103
+
104
+ const matches = semver.satisfies(current, `~${requiredNodeVersion}`);
105
+ return {
106
+ label: 'node',
107
+ status: matches ? 'ok' : 'fail',
108
+ detail: matches ? current : `${current} (expected ${requiredNodeVersion})`,
109
+ fix: matches ? undefined : BOOTSTRAP_CMD,
110
+ };
111
+ } catch {
112
+ return { label: 'node', status: 'fail', detail: 'not found', fix: BOOTSTRAP_CMD };
113
+ }
114
+ }
115
+
116
+ function checkYarn(): Promise<CheckResult> {
117
+ return checkTool('yarn', { cmd: 'yarn', args: ['--version'], fix: BOOTSTRAP_CMD });
118
+ }
119
+
120
+ function checkAws(): Promise<CheckResult> {
121
+ return checkTool('aws', { cmd: 'aws', args: ['--version'], getVersion: s => s.split(/\s/)[0], fix: BOOTSTRAP_CMD });
122
+ }
123
+
124
+ function checkGitRemoteS3(): Promise<CheckResult> {
125
+ return checkTool('git-remote-s3', {
126
+ cmd: 'which',
127
+ args: ['git-remote-s3'],
128
+ getVersion: async () => {
129
+ const { stdout: pipxOut } = await execa('pipx', ['runpip', 'git-remote-s3', 'show', 'git-remote-s3']);
130
+ return pipxOut.match(/^Version:\s+(.+)/m)?.[1]?.trim();
131
+ },
132
+ fix: BOOTSTRAP_CMD,
133
+ });
134
+ }
135
+
136
+ async function checkZ2hCli(): Promise<CheckResult> {
137
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
138
+
139
+ let latestVersion: string | undefined;
140
+ try {
141
+ const token = tryReadAuthTokenSync();
142
+ if (token) {
143
+ const res = await fetch(`${brokerNpmRegistryUrl()}/@mondaydotcomorg%2Fz2h-cli`, {
144
+ headers: { Authorization: `Bearer ${token}`, ...routingHeaders() },
145
+ signal: AbortSignal.timeout(5000),
146
+ });
147
+ if (res.ok) {
148
+ const data = (await res.json()) as { 'dist-tags'?: { latest?: string } };
149
+ latestVersion = data['dist-tags']?.latest;
150
+ }
151
+ }
152
+ } catch {
153
+ // version check is best-effort — no network / token not ready
154
+ }
155
+
156
+ if (latestVersion && latestVersion !== version) {
157
+ return { label: 'z2h-cli', status: 'warn', detail: `${version} (latest: ${latestVersion})`, fix: UPDATE_CMD };
158
+ }
159
+
160
+ return { label: 'z2h-cli', status: 'ok', detail: latestVersion ? `${version} (up to date)` : version };
161
+ }
162
+
163
+ async function checkAuthToken(): Promise<CheckResult> {
164
+ try {
165
+ const parsed = (await readJson(Z2H_AUTH_FILE)) as { token?: string };
166
+ if (!parsed.token) {
167
+ return {
168
+ label: 'auth token',
169
+ status: 'fail',
170
+ detail: `${Z2H_AUTH_FILE} missing token field`,
171
+ fix: 'z2h-cli generate-z2h-token',
172
+ };
173
+ }
174
+ return { label: 'auth token', status: 'ok', detail: Z2H_AUTH_FILE };
175
+ } catch (err) {
176
+ let detail: string;
177
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
178
+ detail = `${Z2H_AUTH_FILE} not found`;
179
+ } else if (err instanceof SyntaxError) {
180
+ detail = `${Z2H_AUTH_FILE} could not be parsed`;
181
+ } else {
182
+ detail = `${Z2H_AUTH_FILE} is unreadable`;
183
+ }
184
+ return { label: 'auth token', status: 'fail', detail, fix: 'z2h-cli generate-z2h-token' };
185
+ }
186
+ }
187
+
188
+ async function checkNpmTokenInProfile(): Promise<CheckResult> {
189
+ const profiles = [
190
+ path.join(HOME, 'dotfiles', '.bash_profile'),
191
+ path.join(HOME, '.zshrc'),
192
+ path.join(HOME, '.bash_profile'),
193
+ ];
194
+
195
+ for (const profile of profiles) {
196
+ if (!(await pathExists(profile))) {
197
+ continue;
198
+ }
199
+ try {
200
+ const content = await readFile(profile, 'utf8');
201
+ if (content.includes('export Z2H_NPM_TOKEN')) {
202
+ return { label: 'npm token in profile', status: 'ok', detail: path.basename(profile) };
203
+ }
204
+ } catch {
205
+ // skip unreadable profile
206
+ }
207
+ }
208
+
209
+ return {
210
+ label: 'npm token in profile',
211
+ status: 'fail',
212
+ detail: 'export Z2H_NPM_TOKEN not found in any shell profile',
213
+ fix: BOOTSTRAP_CMD,
214
+ };
215
+ }
216
+
217
+ async function checkWorkspace(): Promise<CheckResult> {
218
+ const home = getWorkspaceHome();
219
+ const ok = await isZ2hWorkspace(home);
220
+ return {
221
+ label: 'workspace',
222
+ status: ok ? 'ok' : 'fail',
223
+ detail: ok ? home : `${home} not initialized`,
224
+ fix: ok ? undefined : 'z2h-cli create-workspace',
225
+ };
226
+ }
227
+
228
+ async function checkReactVersion(): Promise<CheckResult> {
229
+ const REACT_VERSION_LABEL = 'react version';
230
+ const REQUIRED_REACT_VERSION = '18.x';
231
+
232
+ const reactVersionFail: (detail: string) => CheckResult = detail => ({
233
+ label: REACT_VERSION_LABEL,
234
+ status: 'fail',
235
+ detail,
236
+ fix: BOOTSTRAP_CMD,
237
+ });
238
+
239
+ const home = getWorkspaceHome();
240
+ let pkg: { dependencies?: Record<string, string> };
241
+
242
+ try {
243
+ pkg = (await readJson(path.join(home, 'package.json'))) as { dependencies?: Record<string, string> };
244
+ } catch {
245
+ return reactVersionFail('could not read workspace package.json');
246
+ }
247
+
248
+ const reactVersion = pkg.dependencies?.react;
249
+ if (!reactVersion) {
250
+ return reactVersionFail('react not in workspace dependencies');
251
+ }
252
+
253
+ const coerced = semver.coerce(reactVersion)?.version;
254
+ if (!coerced || !semver.satisfies(coerced, REQUIRED_REACT_VERSION)) {
255
+ return reactVersionFail(`invalid react version: ${reactVersion} (expected ${REQUIRED_REACT_VERSION})`);
256
+ }
257
+
258
+ return {
259
+ label: REACT_VERSION_LABEL,
260
+ status: 'ok',
261
+ detail: reactVersion,
262
+ };
263
+ }
264
+
265
+ async function checkSslCert(): Promise<CheckResult> {
266
+ const ok = await pathExists(SSL_CERT_PATH);
267
+ return {
268
+ label: 'SSL cert',
269
+ status: ok ? 'ok' : 'warn',
270
+ detail: ok ? 'webpack.llama.fan' : 'not found — auto-installs on first z2h-cli dev',
271
+ fix: ok ? undefined : 'z2h-cli dev',
272
+ };
273
+ }
274
+
275
+ async function checkPlugin(
276
+ pluginKey: string,
277
+ label: string,
278
+ isMandatory: boolean,
279
+ pluginsJson: InstalledPluginsJson | null
280
+ ): Promise<CheckResult> {
281
+ const missingStatus: Status = isMandatory ? 'fail' : 'warn';
282
+
283
+ if (!pluginsJson) {
284
+ return { label, status: missingStatus, detail: `${CLAUDE_PLUGINS_JSON} not found`, fix: BOOTSTRAP_CMD };
285
+ }
286
+ const entries = pluginsJson.plugins?.[pluginKey];
287
+ if (!entries?.length) {
288
+ return { label, status: missingStatus, detail: `plugin ${pluginKey} not registered`, fix: BOOTSTRAP_CMD };
289
+ }
290
+ const installPath = entries[0].installPath;
291
+ if (!installPath) {
292
+ return { label, status: missingStatus, detail: 'installPath missing', fix: BOOTSTRAP_CMD };
293
+ }
294
+ const exists = await pathExists(installPath);
295
+ return {
296
+ label,
297
+ status: exists ? 'ok' : missingStatus,
298
+ detail: exists ? installPath : `${installPath} not found`,
299
+ fix: exists ? undefined : BOOTSTRAP_CMD,
300
+ };
301
+ }
302
+
303
+ // ── rendering ─────────────────────────────────────────────────────────────────
304
+
305
+ const STATUS_ICON: Record<Status, string> = {
306
+ ok: pc.green('✅'),
307
+ warn: pc.yellow('⚠️ '),
308
+ fail: pc.red('❌'),
309
+ };
310
+
311
+ function renderCheck(c: CheckResult): void {
312
+ const icon = STATUS_ICON[c.status];
313
+ const label = c.label.padEnd(22);
314
+ const detail = c.detail ? pc.dim(c.detail) : '';
315
+ process.stdout.write(` ${icon} ${label}${detail}\n`);
316
+ }
317
+
318
+ // ── command ───────────────────────────────────────────────────────────────────
319
+
320
+ export async function doctorCommand(opts: DoctorOptions = {}): Promise<void> {
321
+ if (opts.json) {
322
+ setOutputMode('json');
323
+ }
324
+
325
+ let pluginsJson: InstalledPluginsJson | null = null;
326
+ try {
327
+ if (await pathExists(CLAUDE_PLUGINS_JSON)) {
328
+ pluginsJson = (await readJson(CLAUDE_PLUGINS_JSON)) as InstalledPluginsJson;
329
+ }
330
+ } catch {
331
+ // treat as missing
332
+ }
333
+
334
+ const systemChecks = await Promise.all([checkNvm(), checkNode(), checkYarn(), checkAws(), checkGitRemoteS3()]);
335
+ const cliChecks = await Promise.all([checkZ2hCli(), checkAuthToken(), checkNpmTokenInProfile()]);
336
+ const workspaceChecks = await Promise.all([checkWorkspace(), checkReactVersion(), checkSslCert()]);
337
+ const pluginChecks = await Promise.all([
338
+ checkPlugin('z2h@bigbrain-z2h', 'z2h plugin', true, pluginsJson),
339
+ checkPlugin('cf-external-claude-plugin@client-foundations-ai-tools', 'cf-external plugin', false, pluginsJson),
340
+ checkPlugin('pr-guardrails@agentic-builders-hub', 'pr-guardrails plugin', false, pluginsJson),
341
+ ]);
342
+
343
+ const sections = [
344
+ { title: 'System tools', checks: systemChecks },
345
+ { title: 'CLI & auth', checks: cliChecks },
346
+ { title: 'Workspace', checks: workspaceChecks },
347
+ { title: 'Plugins', checks: pluginChecks },
348
+ ];
349
+
350
+ const allChecks = sections.flatMap(s => s.checks);
351
+ const issues = allChecks.filter(c => c.status !== 'ok');
352
+
353
+ if (opts.json) {
354
+ output('', {
355
+ ok: issues.filter(c => c.status === 'fail').length === 0,
356
+ checks: allChecks,
357
+ });
358
+ return;
359
+ }
360
+
361
+ for (const section of sections) {
362
+ process.stdout.write(`\n${pc.bold(section.title)}\n`);
363
+ for (const check of section.checks) {
364
+ renderCheck(check);
365
+ }
366
+ }
367
+
368
+ process.stdout.write('\n');
369
+
370
+ if (issues.length === 0) {
371
+ process.stdout.write(pc.bold(pc.green('No issues detected 🚀\n')));
372
+ } else {
373
+ const fails = issues.filter(c => c.status === 'fail').length;
374
+ const warns = issues.filter(c => c.status === 'warn').length;
375
+ const parts = [
376
+ fails && `${fails} failure${fails > 1 ? 's' : ''}`,
377
+ warns && `${warns} warning${warns > 1 ? 's' : ''}`,
378
+ ].filter(Boolean);
379
+ process.stdout.write(pc.yellow(`${parts.join(', ')} found\n`));
380
+
381
+ // Deduplicated fixes in the order they were first encountered
382
+ const fixes = [...new Set(issues.map(c => c.fix).filter(Boolean))] as string[];
383
+ if (fixes.length > 0) {
384
+ process.stdout.write('\n');
385
+ process.stdout.write(pc.bold(pc.red('In order to fix these issues, run the following commands:')));
386
+ process.stdout.write('\n');
387
+ for (const fix of fixes) {
388
+ process.stdout.write(` ${pc.cyan(fix)}\n`);
389
+ }
390
+ }
391
+ process.stdout.write('\n');
392
+ }
393
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { devCommand } from './commands/dev';
8
8
  import { buildCommand } from './commands/build';
9
9
  import { cleanCommand } from './commands/clean';
10
10
  import { deployCommand } from './commands/deploy';
11
+ import { deleteCommand } from './commands/delete';
11
12
  import { grantCommand, revokeCommand, transferOwnerCommand } from './commands/grant';
12
13
  import { createWorkspaceCommand } from './commands/create-workspace';
13
14
  import { createCommand, CreateOptions } from './commands/create';
@@ -15,6 +16,7 @@ import { generateZ2HTokenCommand } from './commands/generate-z2h-token';
15
16
  import { migrateCommand } from './commands/migrate';
16
17
  import { tagCommand } from './commands/tag';
17
18
  import { editCommand } from './commands/edit';
19
+ import { doctorCommand } from './commands/doctor';
18
20
  import { errorLog, fail, getOutputMode } from './util/logger';
19
21
  import { trackDuration } from './util/tracker';
20
22
  import { getCwdAppName } from './util/app-name';
@@ -254,6 +256,21 @@ async function main(): Promise<void> {
254
256
  }
255
257
  });
256
258
 
259
+ program
260
+ .command('delete')
261
+ .description('Permanently delete the current app and take it offline (owners/admins only)')
262
+ .option('--yes', 'skip the confirmation prompt (required for --json / non-interactive)')
263
+ .option('--json', 'emit structured JSON on stdout')
264
+ .action(async (opts: { yes?: boolean; json?: boolean }) => {
265
+ try {
266
+ await trackDuration('z2h_app_delete_success', () => deleteCommand(opts), {
267
+ app_name: getCwdAppName(),
268
+ });
269
+ } catch (err) {
270
+ await fail(`delete failed: ${(err as Error).message}`, { command: 'delete' }, 1);
271
+ }
272
+ });
273
+
257
274
  program
258
275
  .command('migrate')
259
276
  .description('Run pending workspace migrations')
@@ -267,6 +284,18 @@ async function main(): Promise<void> {
267
284
  }
268
285
  });
269
286
 
287
+ program
288
+ .command('doctor')
289
+ .description('Check that all Z2H tools, auth, workspace, and plugins are correctly set up')
290
+ .option('--json', 'emit structured JSON on stdout')
291
+ .action(async (opts: { json?: boolean }) => {
292
+ try {
293
+ await doctorCommand(opts);
294
+ } catch (err) {
295
+ await fail(`doctor failed: ${(err as Error).message}`, { command: 'doctor' }, 1);
296
+ }
297
+ });
298
+
270
299
  await program.parseAsync(process.argv);
271
300
  }
272
301
 
@@ -179,13 +179,35 @@ describe('safeBackupIfDirty', () => {
179
179
 
180
180
  describe('pullAndResolve', () => {
181
181
  it('resolves when pull fast-forwards cleanly', async () => {
182
- mockExeca.mockResolvedValueOnce(execaOk()); // git pull --ff-only
182
+ mockExeca
183
+ .mockResolvedValueOnce(execaOk('deadbeef\trefs/heads/master')) // ls-remote --heads origin master
184
+ .mockResolvedValueOnce(execaOk()); // git pull --ff-only
183
185
 
184
186
  await expect(pullAndResolve(DIR)).resolves.toBeUndefined();
185
187
  });
186
188
 
189
+ it('skips the pull (first-deploy) when the remote has no master ref', async () => {
190
+ mockExeca.mockResolvedValueOnce(execaOk('')); // ls-remote --heads origin master → empty (gone/new remote)
191
+
192
+ await expect(pullAndResolve(DIR)).resolves.toBeUndefined();
193
+
194
+ const calls = mockExeca.mock.calls.map(c => c.slice(0, 2));
195
+ expect(calls).toContainEqual(['git', ['ls-remote', '--heads', 'origin', 'master']]);
196
+ expect(calls).not.toContainEqual(['git', ['pull', '--ff-only']]);
197
+ });
198
+
199
+ it('treats an unreachable ls-remote as no remote and skips the pull', async () => {
200
+ mockExeca.mockRejectedValueOnce(new Error('fatal: could not read from remote')); // ls-remote fails
201
+
202
+ await expect(pullAndResolve(DIR)).resolves.toBeUndefined();
203
+
204
+ const calls = mockExeca.mock.calls.map(c => c.slice(0, 2));
205
+ expect(calls).not.toContainEqual(['git', ['pull', '--ff-only']]);
206
+ });
207
+
187
208
  it('throws GitConflictError with structured JSON on non-fast-forward', async () => {
188
209
  mockExeca
210
+ .mockResolvedValueOnce(execaOk('deadbeef\trefs/heads/master')) // ls-remote --heads origin master
189
211
  .mockRejectedValueOnce(new Error('fatal: Not possible to fast-forward')) // git pull
190
212
  .mockResolvedValueOnce(execaOk('')) // safeBackupIfDirty: git status --porcelain (clean)
191
213
  .mockResolvedValueOnce(execaOk()) // gatherConflictInfo: git fetch origin
@@ -86,6 +86,18 @@ export async function safeBackupIfDirty(dir: string): Promise<void> {
86
86
 
87
87
  export async function pullAndResolve(dir: string, env?: Record<string, string>): Promise<void> {
88
88
  const gitEnv = env ? { ...process.env, ...env } : undefined;
89
+
90
+ // If the remote has no master branch there's nothing to pull — treat it as a
91
+ // first deploy and let the subsequent push create it. This covers a brand-new
92
+ // remote as well as one whose objects were removed (e.g. the app was deleted,
93
+ // wiping its S3 git prefix), which would otherwise surface as a bogus conflict.
94
+ const hasRemoteMaster = await execGit(['ls-remote', '--heads', 'origin', 'master'], dir, gitEnv)
95
+ .then(r => Boolean(r.stdout.trim()))
96
+ .catch(() => false);
97
+ if (!hasRemoteMaster) {
98
+ return;
99
+ }
100
+
89
101
  try {
90
102
  await execGit(['pull', '--ff-only'], dir, gitEnv);
91
103
  } catch (err) {