@mondaydotcomorg/z2h-cli 0.25.4 → 0.25.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/auth.d.ts +2 -1
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/auth.js +2 -1
  4. package/dist/commands/__tests__/build.test.d.ts +2 -0
  5. package/dist/commands/__tests__/build.test.d.ts.map +1 -0
  6. package/dist/commands/backend.d.ts.map +1 -1
  7. package/dist/commands/backend.js +7 -1
  8. package/dist/commands/build.d.ts.map +1 -1
  9. package/dist/commands/build.js +10 -0
  10. package/dist/commands/deploy.d.ts.map +1 -1
  11. package/dist/commands/deploy.js +42 -19
  12. package/dist/commands/grant.d.ts.map +1 -1
  13. package/dist/commands/grant.js +8 -7
  14. package/dist/esm/auth.d.ts +2 -1
  15. package/dist/esm/auth.d.ts.map +1 -1
  16. package/dist/esm/auth.mjs +2 -1
  17. package/dist/esm/commands/__tests__/build.test.d.ts +2 -0
  18. package/dist/esm/commands/__tests__/build.test.d.ts.map +1 -0
  19. package/dist/esm/commands/backend.d.ts.map +1 -1
  20. package/dist/esm/commands/backend.mjs +7 -1
  21. package/dist/esm/commands/build.d.ts.map +1 -1
  22. package/dist/esm/commands/build.mjs +10 -0
  23. package/dist/esm/commands/deploy.d.ts.map +1 -1
  24. package/dist/esm/commands/deploy.mjs +43 -20
  25. package/dist/esm/commands/grant.d.ts.map +1 -1
  26. package/dist/esm/commands/grant.mjs +8 -7
  27. package/dist/esm/index.mjs +41 -140
  28. package/dist/esm/types.d.ts +23 -0
  29. package/dist/esm/types.d.ts.map +1 -1
  30. package/dist/esm/util/__tests__/errors.test.d.ts +2 -0
  31. package/dist/esm/util/__tests__/errors.test.d.ts.map +1 -0
  32. package/dist/esm/util/__tests__/run-command.test.d.ts +2 -0
  33. package/dist/esm/util/__tests__/run-command.test.d.ts.map +1 -0
  34. package/dist/esm/util/auth/auth-store.d.ts +0 -4
  35. package/dist/esm/util/auth/auth-store.d.ts.map +1 -1
  36. package/dist/esm/util/auth/auth-store.mjs +2 -9
  37. package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -1
  38. package/dist/esm/util/auth/broker-credential-provider.mjs +16 -16
  39. package/dist/esm/util/broker/app.d.ts.map +1 -1
  40. package/dist/esm/util/broker/app.mjs +17 -9
  41. package/dist/esm/util/errors.d.ts +48 -0
  42. package/dist/esm/util/errors.d.ts.map +1 -0
  43. package/dist/esm/util/errors.mjs +91 -0
  44. package/dist/esm/util/git/repo.d.ts +0 -3
  45. package/dist/esm/util/git/repo.d.ts.map +1 -1
  46. package/dist/esm/util/git/repo.mjs +4 -9
  47. package/dist/esm/util/logger.d.ts +4 -1
  48. package/dist/esm/util/logger.d.ts.map +1 -1
  49. package/dist/esm/util/logger.mjs +11 -3
  50. package/dist/esm/util/run-command.d.ts +16 -0
  51. package/dist/esm/util/run-command.d.ts.map +1 -0
  52. package/dist/esm/util/run-command.mjs +47 -0
  53. package/dist/esm/util/s3/upload.d.ts.map +1 -1
  54. package/dist/esm/util/s3/upload.mjs +22 -16
  55. package/dist/esm/util/tracker.d.ts +9 -2
  56. package/dist/esm/util/tracker.d.ts.map +1 -1
  57. package/dist/esm/util/tracker.mjs +18 -15
  58. package/dist/index.js +40 -139
  59. package/dist/types.d.ts +23 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/util/__tests__/errors.test.d.ts +2 -0
  62. package/dist/util/__tests__/errors.test.d.ts.map +1 -0
  63. package/dist/util/__tests__/run-command.test.d.ts +2 -0
  64. package/dist/util/__tests__/run-command.test.d.ts.map +1 -0
  65. package/dist/util/auth/auth-store.d.ts +0 -4
  66. package/dist/util/auth/auth-store.d.ts.map +1 -1
  67. package/dist/util/auth/auth-store.js +2 -10
  68. package/dist/util/auth/broker-credential-provider.d.ts.map +1 -1
  69. package/dist/util/auth/broker-credential-provider.js +16 -16
  70. package/dist/util/broker/app.d.ts.map +1 -1
  71. package/dist/util/broker/app.js +17 -9
  72. package/dist/util/errors.d.ts +48 -0
  73. package/dist/util/errors.d.ts.map +1 -0
  74. package/dist/util/errors.js +102 -0
  75. package/dist/util/git/repo.d.ts +0 -3
  76. package/dist/util/git/repo.d.ts.map +1 -1
  77. package/dist/util/git/repo.js +4 -10
  78. package/dist/util/logger.d.ts +4 -1
  79. package/dist/util/logger.d.ts.map +1 -1
  80. package/dist/util/logger.js +11 -3
  81. package/dist/util/run-command.d.ts +16 -0
  82. package/dist/util/run-command.d.ts.map +1 -0
  83. package/dist/util/run-command.js +49 -0
  84. package/dist/util/s3/upload.d.ts.map +1 -1
  85. package/dist/util/s3/upload.js +22 -16
  86. package/dist/util/tracker.d.ts +9 -2
  87. package/dist/util/tracker.d.ts.map +1 -1
  88. package/dist/util/tracker.js +19 -14
  89. package/handler-api/backend-runner/channels/llm-channel/llm.channel.ts +3 -2
  90. package/handler-api/backend-runner/channels/snowflake-channel/run-query.ts +7 -6
  91. package/handler-api/backend-runner/channels/snowflake-channel/snowflake.channel.ts +1 -1
  92. package/handler-api/backend-runner/channels/snowflake-channel/stateless-part-b.ts +1 -4
  93. package/package.json +2 -2
  94. package/src/auth.ts +2 -1
  95. package/src/commands/__tests__/backend-deploy.test.ts +18 -0
  96. package/src/commands/__tests__/build.test.ts +83 -0
  97. package/src/commands/__tests__/deploy.test.ts +107 -0
  98. package/src/commands/backend.ts +6 -1
  99. package/src/commands/build.ts +10 -0
  100. package/src/commands/deploy.ts +42 -20
  101. package/src/commands/grant.ts +8 -7
  102. package/src/index.ts +84 -133
  103. package/src/types.ts +47 -0
  104. package/src/util/__tests__/errors.test.ts +90 -0
  105. package/src/util/__tests__/run-command.test.ts +98 -0
  106. package/src/util/auth/__tests__/auth-store.test.ts +2 -1
  107. package/src/util/auth/__tests__/broker-credential-provider.test.ts +44 -8
  108. package/src/util/auth/auth-store.ts +1 -7
  109. package/src/util/auth/broker-credential-provider.ts +18 -16
  110. package/src/util/broker/__tests__/app.test.ts +23 -20
  111. package/src/util/broker/app.ts +16 -9
  112. package/src/util/errors.ts +98 -0
  113. package/src/util/git/__tests__/repo.test.ts +5 -3
  114. package/src/util/git/repo.ts +3 -9
  115. package/src/util/logger.ts +16 -3
  116. package/src/util/run-command.ts +59 -0
  117. package/src/util/s3/upload.ts +27 -22
  118. package/src/util/tracker.ts +19 -19
package/src/index.ts CHANGED
@@ -18,10 +18,11 @@ import { editCommand } from './commands/edit';
18
18
  import { backendInvokeCommand } from './commands/backend';
19
19
  import { doctorCommand } from './commands/doctor';
20
20
  import { errorLog, fail, getOutputMode, setOutputMode } from './util/logger';
21
- import { trackDuration } from './util/tracker';
21
+ import { runCommand } from './util/run-command';
22
+ import { successEvent } from './util/tracker';
22
23
  import { getCwdAppName } from './util/app-name';
23
24
 
24
- const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
25
+ const { version } = createRequire(import.meta.url)('@mondaydotcomorg/z2h-cli/package.json') as { version: string };
25
26
 
26
27
  const UPDATE_SCRIPT = path.join(os.homedir(), 'Development', 'bigbrain-z2h', 'z2h', 'setup', 'update.sh');
27
28
 
@@ -49,6 +50,24 @@ function collectTag(value: string, previous: string[]): string[] {
49
50
  return previous.concat(value);
50
51
  }
51
52
 
53
+ const cwdApp = () => ({ app_name: getCwdAppName() });
54
+
55
+ interface CreateCliOptions {
56
+ template?: string;
57
+ htmlFile?: string;
58
+ remote?: string;
59
+ repoUrl?: string;
60
+ }
61
+
62
+ interface DeployCliOptions {
63
+ dryRun?: boolean;
64
+ preview?: boolean | string;
65
+ open?: boolean;
66
+ remote?: string;
67
+ repoUrl?: string;
68
+ description?: string;
69
+ }
70
+
52
71
  async function main(): Promise<void> {
53
72
  stripConflictingEnvVars();
54
73
 
@@ -80,24 +99,15 @@ async function main(): Promise<void> {
80
99
  program
81
100
  .command('build')
82
101
  .description('Build the consumer into its .zth/ shadow and copy artifacts to ./build/')
83
- .action(async () => {
84
- try {
85
- await trackDuration('z2h_app_build_success', () => buildCommand(), { app_name: getCwdAppName() });
86
- } catch (err) {
87
- await fail(`build failed: ${(err as Error).message}`, { command: 'build' }, 2);
88
- }
89
- });
102
+ // A standalone build failure is the consumer's own code, not a CLI/infra problem — don't
103
+ // track it. buildCommand still tags the reason on its thrown error, since deploy's own
104
+ // (tracked) failure event benefits from knowing when a build broke it.
105
+ .action(runCommand({ track: false, data: cwdApp, exitCode: 2 }, () => buildCommand()));
90
106
 
91
107
  program
92
108
  .command('clean')
93
109
  .description('Delete <consumer>/.zth/ and <consumer>/build/')
94
- .action(async () => {
95
- try {
96
- await cleanCommand();
97
- } catch (err) {
98
- await fail(`clean failed: ${(err as Error).message}`, { command: 'clean' }, 2);
99
- }
100
- });
110
+ .action(runCommand({ track: false, exitCode: 2 }, () => cleanCommand()));
101
111
 
102
112
  program
103
113
  .command('deploy')
@@ -110,7 +120,7 @@ async function main(): Promise<void> {
110
120
  .option('--open', 'open the resulting app/preview URL in the browser on success')
111
121
  .option(
112
122
  '--remote <type>',
113
- 'First deploy only: source control remote to initialise — s3 (shared storage) or github (your repo). Error if the app already has a git remote.'
123
+ 'First deploy only: source control remote to initialise — s3 (shared storage, recommended, auto-backed-up) or github (manage it yourself in your own repo). Error if the app already has a git remote.'
114
124
  )
115
125
  .option(
116
126
  '--repo-url <url>',
@@ -118,53 +128,36 @@ async function main(): Promise<void> {
118
128
  )
119
129
  .option('--description <text>', 'short (<=200 char) description of the app')
120
130
  .action(
121
- async (opts: {
122
- dryRun?: boolean;
123
- preview?: boolean | string;
124
- open?: boolean;
125
- remote?: string;
126
- repoUrl?: string;
127
- description?: string;
128
- }) => {
129
- try {
130
- await trackDuration(
131
- opts.preview ? 'z2h_app_preview_success' : 'z2h_app_deploy_success',
132
- () => deployCommand(opts as Parameters<typeof deployCommand>[0]),
133
- {
134
- app_name: getCwdAppName(),
135
- }
136
- );
137
- } catch (err) {
138
- const label = opts.preview ? 'preview' : 'deploy';
139
- await fail(`${label} failed: ${(err as Error).message}`, { command: label }, 2);
140
- }
141
- }
131
+ runCommand(
132
+ {
133
+ // A preview is a real deploy for observability, tracked as its own event since
134
+ // the command name alone (`deploy`) can't distinguish it from a live deploy.
135
+ event: ([opts]: [DeployCliOptions]) => successEvent(opts.preview ? 'preview' : 'deploy'),
136
+ label: ([opts]: [DeployCliOptions]) => (opts.preview ? 'preview' : 'deploy'),
137
+ data: cwdApp,
138
+ exitCode: 2,
139
+ },
140
+ (opts: DeployCliOptions) => deployCommand(opts as Parameters<typeof deployCommand>[0])
141
+ )
142
142
  );
143
143
 
144
144
  program
145
145
  .command('tag [app-name]')
146
146
  .description("Set an app's category tags — app type & team (interactive unless --tag or --json is given)")
147
147
  .option('--tag <tag>', 'set a tag (repeatable); full-replaces the tag set non-interactively', collectTag, [])
148
- .action(async (appName: string | undefined, opts: { tag?: string[] }) => {
149
- try {
150
- await trackDuration('z2h_app_tag_success', () => tagCommand(appName, opts), {
151
- app_name: appName ?? getCwdAppName(),
152
- });
153
- } catch (err) {
154
- await fail(`tag failed: ${(err as Error).message}`, { command: 'tag' }, 1);
155
- }
156
- });
148
+ .action(
149
+ runCommand(
150
+ {
151
+ data: ([appName]: [string | undefined, unknown]) => ({ app_name: appName ?? getCwdAppName() }),
152
+ },
153
+ (appName: string | undefined, opts: { tag?: string[] }) => tagCommand(appName, opts)
154
+ )
155
+ );
157
156
 
158
157
  program
159
158
  .command('create-workspace')
160
159
  .description(`Bootstrap the ${getWorkspaceHome()}/ yarn workspace (one-time setup)`)
161
- .action(async () => {
162
- try {
163
- await createWorkspaceCommand();
164
- } catch (err) {
165
- await fail(`create-workspace failed: ${(err as Error).message}`, { command: 'create-workspace' }, 1);
166
- }
167
- });
160
+ .action(runCommand({ track: false }, () => createWorkspaceCommand()));
168
161
 
169
162
  program
170
163
  .command('generate-z2h-token')
@@ -173,97 +166,69 @@ async function main(): Promise<void> {
173
166
  )
174
167
  .option('--no-browser', "don't auto-open the browser; print the URL and prompt for manual paste")
175
168
  .option('--url <url>', 'Override the token page URL (e.g. for staging)')
176
- .action(async (opts: { noBrowser?: boolean; url?: string }) => {
177
- try {
178
- await generateZ2HTokenCommand(opts);
179
- } catch (err) {
180
- await fail(`generate-z2h-token failed: ${(err as Error).message}`, { command: 'generate-z2h-token' }, 1);
181
- }
182
- });
169
+ .action(runCommand({}, (opts: { noBrowser?: boolean; url?: string }) => generateZ2HTokenCommand(opts)));
183
170
 
184
171
  program
185
172
  .command('create <app-name>')
186
173
  .description(`Scaffold a new Z2H consumer app inside ${getWorkspaceHome()}/`)
187
174
  .option('--template <name>', 'Template name (default | html-embed)')
188
175
  .option('--html-file <path>', 'Path to an HTML file to embed (implies --template html-embed)')
189
- .option('--remote <type>', 'Source control remote: s3 (shared storage) or github (your repo)')
176
+ .option(
177
+ '--remote <type>',
178
+ 'Source control remote: s3 (shared storage, recommended, auto-backed-up) or github (manage it yourself in your own repo)'
179
+ )
190
180
  .option('--repo-url <url>', 'GitHub repo URL — required when --remote=github')
191
181
  .action(
192
- async (appName: string, opts: { template?: string; htmlFile?: string; remote?: string; repoUrl?: string }) => {
193
- try {
194
- await trackDuration('z2h_app_created', () => createCommand(appName, opts as CreateOptions), {
182
+ runCommand(
183
+ {
184
+ data: ([appName, opts]: [string, CreateCliOptions]) => ({
195
185
  app_name: appName,
196
186
  template: opts.template ?? 'default',
197
187
  remote: opts.remote ?? 'unknown',
198
- });
199
- } catch (err) {
200
- await fail(`create failed: ${(err as Error).message}`, { command: 'create' }, 1);
201
- }
202
- }
188
+ }),
189
+ },
190
+ (appName: string, opts: CreateCliOptions) => createCommand(appName, opts as CreateOptions)
191
+ )
203
192
  );
204
193
 
205
194
  program
206
195
  .command('edit <app-name>')
207
196
  .description(`Pull the latest version of an app into ${getWorkspaceHome()}/<app-name>, cloning it first if needed`)
208
- .action(async (appName: string) => {
209
- try {
210
- await trackDuration('z2h_app_edit_pulled', () => editCommand(appName), {
211
- app_name: appName,
212
- });
213
- } catch (err) {
214
- await fail(`edit failed: ${(err as Error).message}`, { command: 'edit' }, 1);
215
- }
216
- });
197
+ .action(
198
+ runCommand({ data: ([appName]: [string]) => ({ app_name: appName }) }, (appName: string) => editCommand(appName))
199
+ );
217
200
 
218
201
  program
219
202
  .command('grant [email]')
220
203
  .description('Grant access to the current app (email, --public, or both)')
221
204
  .option('--public', 'make the app visible to all monday.com employees')
222
205
  .option('--level <level>', 'access level for user grants: owner, editor, or viewer (default: viewer)')
223
- .action(async (email: string | undefined, opts: { public?: boolean; level?: string }) => {
224
- try {
225
- await grantCommand(email, opts as Parameters<typeof grantCommand>[1]);
226
- } catch (err) {
227
- await fail(`grant failed: ${(err as Error).message}`, { command: 'grant' }, 1);
228
- }
229
- });
206
+ .action(
207
+ runCommand({}, (email: string | undefined, opts: { public?: boolean; level?: string }) =>
208
+ grantCommand(email, opts as Parameters<typeof grantCommand>[1])
209
+ )
210
+ );
230
211
 
231
212
  program
232
213
  .command('revoke [email]')
233
214
  .description('Revoke access to the current app (email or --public; owners/admins only)')
234
215
  .option('--public', 'remove the public grant, making the app visible to owners/admins only')
235
- .action(async (email: string | undefined, opts: { public?: boolean }) => {
236
- try {
237
- await revokeCommand(email, opts as Parameters<typeof revokeCommand>[1]);
238
- } catch (err) {
239
- await fail(`revoke failed: ${(err as Error).message}`, { command: 'revoke' }, 1);
240
- }
241
- });
216
+ .action(
217
+ runCommand({}, (email: string | undefined, opts: { public?: boolean }) =>
218
+ revokeCommand(email, opts as Parameters<typeof revokeCommand>[1])
219
+ )
220
+ );
242
221
 
243
222
  program
244
223
  .command('transfer-owner [email]')
245
224
  .description('Transfer ownership of the current app to another monday.com user')
246
- .action(async (email: string | undefined) => {
247
- try {
248
- await transferOwnerCommand(email);
249
- } catch (err) {
250
- await fail(`transfer-owner failed: ${(err as Error).message}`, { command: 'transfer-owner' }, 1);
251
- }
252
- });
225
+ .action(runCommand({}, (email: string | undefined) => transferOwnerCommand(email)));
253
226
 
254
227
  program
255
228
  .command('delete')
256
229
  .description('Permanently delete the current app and take it offline (owners/admins only)')
257
230
  .option('--yes', 'skip the confirmation prompt (required for --json / non-interactive)')
258
- .action(async (opts: { yes?: boolean }) => {
259
- try {
260
- await trackDuration('z2h_app_delete_success', () => deleteCommand(opts), {
261
- app_name: getCwdAppName(),
262
- });
263
- } catch (err) {
264
- await fail(`delete failed: ${(err as Error).message}`, { command: 'delete' }, 1);
265
- }
266
- });
231
+ .action(runCommand({ data: cwdApp }, (opts: { yes?: boolean }) => deleteCommand(opts)));
267
232
 
268
233
  const backend = program
269
234
  .command('backend')
@@ -275,39 +240,25 @@ async function main(): Promise<void> {
275
240
  'Invoke a deployed backend handler by name (the filename without .ts) — mainly for testing a handler without going through the frontend'
276
241
  )
277
242
  .option('--input <json>', 'JSON payload passed as ctx.input')
278
- .action(async (handlerName: string, opts: { input?: string }) => {
279
- try {
280
- await trackDuration('z2h_backend_invoke_success', () => backendInvokeCommand(handlerName, opts), {
281
- app_name: getCwdAppName(),
282
- handler: handlerName,
283
- });
284
- } catch (err) {
285
- await fail(`backend invoke failed: ${(err as Error).message}`, { command: 'backend invoke' }, 1);
286
- }
287
- });
243
+ .action(
244
+ runCommand(
245
+ {
246
+ data: ([handlerName]: [string, unknown]) => ({ app_name: getCwdAppName(), handler: handlerName }),
247
+ },
248
+ (handlerName: string, opts: { input?: string }) => backendInvokeCommand(handlerName, opts)
249
+ )
250
+ );
288
251
 
289
252
  program
290
253
  .command('migrate')
291
254
  .description('Run pending workspace migrations')
292
255
  .option('--dry-run', 'show pending migrations without applying any changes')
293
- .action(async (opts: { dryRun?: boolean }) => {
294
- try {
295
- await migrateCommand(version, opts);
296
- } catch (err) {
297
- await fail(`migrate failed: ${(err as Error).message}`, { command: 'migrate' }, 1);
298
- }
299
- });
256
+ .action(runCommand({}, (opts: { dryRun?: boolean }) => migrateCommand(version, opts)));
300
257
 
301
258
  program
302
259
  .command('doctor')
303
260
  .description('Check that all Z2H tools, auth, workspace, and plugins are correctly set up')
304
- .action(async () => {
305
- try {
306
- await doctorCommand();
307
- } catch (err) {
308
- await fail(`doctor failed: ${(err as Error).message}`, { command: 'doctor' }, 1);
309
- }
310
- });
261
+ .action(runCommand({ track: false }, () => doctorCommand()));
311
262
 
312
263
  await program.parseAsync(process.argv);
313
264
  }
package/src/types.ts CHANGED
@@ -62,3 +62,50 @@ export interface PreparedShadow {
62
62
  port: number;
63
63
  paths: ConsumerPaths;
64
64
  }
65
+
66
+ // ─────────── deploy failure reason ───────────
67
+ // Bounded reason codes tagged onto deploy failures — see DeployError in commands/deploy.ts.
68
+ export type DeployFailureReason =
69
+ | 'read_auth_token'
70
+ | 'broker_auth'
71
+ | 'broker_error'
72
+ | 'lock_timeout'
73
+ | 'build_error'
74
+ | 'git_error'
75
+ | 's3_error'
76
+ | 'deploy_backend_error'
77
+ | 'register_error'
78
+ | 'push_error'
79
+ | 'interrupted'
80
+ | 'unknown';
81
+
82
+ // ─────────── grant/revoke/transfer-owner failure reason ───────────
83
+ // Bounded reason codes tagged onto the shared z2h_cli_error event for these
84
+ // commands — see GrantError in commands/grant.ts.
85
+ export type GrantFailureReason = 'validation' | 'auth' | 'forbidden' | 'not_found' | 'server_error' | 'unknown';
86
+
87
+ // ─────────── command telemetry contract ───────────
88
+ // runCommand in util/run-command.ts reads these. It wraps every CLI command.
89
+
90
+ /**
91
+ * A spec value that is either a constant or a function of the command's own arguments.
92
+ * Callbacks take the args as one tuple rather than a spread, so a callback that reads only
93
+ * the first arg still typechecks. Destructure what you need.
94
+ */
95
+ export type PerArgs<A extends unknown[], T> = T | ((args: A) => T);
96
+
97
+ export interface CommandSpec<A extends unknown[]> {
98
+ /**
99
+ * Opt out of telemetry entirely — both the success event and the error event. Default `true`.
100
+ * Set `false` for a purely local command with no product-usage signal worth a round-trip.
101
+ */
102
+ track?: PerArgs<A, boolean>;
103
+ /** Success-event name when it depends on args (e.g. `deploy --preview`); defaults to `successEvent(<command>)`. */
104
+ event?: PerArgs<A, string>;
105
+ /** Prefix in the error message and the `action` dimension. Defaults to the command's own name. */
106
+ label?: PerArgs<A, string>;
107
+ /** Extra dimensions sent with both the success and the error event. */
108
+ data?: (args: A) => Record<string, unknown>;
109
+ /** Overrides the failing error's own exitCode. */
110
+ exitCode?: number;
111
+ }
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ Z2hError,
5
+ DeployError,
6
+ GrantError,
7
+ Z2hAuthMissingError,
8
+ GitConflictError,
9
+ reasonOf,
10
+ dataOf,
11
+ messageOf,
12
+ } from '../errors';
13
+ import { fail, setOutputMode } from '../logger';
14
+ import { trackEvent } from '../tracker';
15
+
16
+ vi.mock('../tracker', () => ({ trackEvent: vi.fn().mockResolvedValue(undefined) }));
17
+
18
+ describe('reasonOf: every classified error carries its own reason', () => {
19
+ it.each([
20
+ ['DeployError', new DeployError('build_error', 'boom'), 'build_error'],
21
+ ['GrantError', new GrantError('forbidden', 'nope'), 'forbidden'],
22
+ ['Z2hAuthMissingError', new Z2hAuthMissingError('/tmp/auth.json'), 'read_auth_token'],
23
+ ['GitConflictError', new GitConflictError('diverged'), 'git_error'],
24
+ ])('%s → %s', (_name, err, expected) => {
25
+ expect(reasonOf(err)).toBe(expected);
26
+ });
27
+
28
+ it('falls back to unknown for a plain Error and a non-Error throw', () => {
29
+ expect(reasonOf(new Error('plain'))).toBe('unknown');
30
+ expect(reasonOf('a string')).toBe('unknown');
31
+ });
32
+
33
+ it('keeps the subclass name so telemetry and stack traces stay readable', () => {
34
+ expect(new DeployError('s3_error', 'x').name).toBe('DeployError');
35
+ expect(new GitConflictError('x').name).toBe('GitConflictError');
36
+ });
37
+ });
38
+
39
+ describe('dataOf / messageOf', () => {
40
+ it('surfaces extra dimensions the throw site attached', () => {
41
+ expect(dataOf(new Z2hError('x', 'unknown', { stage: 'upload', attempt: 2 }))).toEqual({
42
+ stage: 'upload',
43
+ attempt: 2,
44
+ });
45
+ });
46
+
47
+ it('returns an empty object when the error carries no data', () => {
48
+ expect(dataOf(new DeployError('read_auth_token', 'x'))).toEqual({});
49
+ expect(dataOf('not an error')).toEqual({});
50
+ });
51
+
52
+ it('stringifies a non-Error throw instead of yielding undefined', () => {
53
+ expect(messageOf('boom')).toBe('boom');
54
+ expect(messageOf(null)).toBe('null');
55
+ expect(messageOf(new Error('real'))).toBe('real');
56
+ });
57
+ });
58
+
59
+ describe('fail: telemetry is opt-out for unobserved commands', () => {
60
+ afterEach(() => {
61
+ vi.mocked(trackEvent).mockClear();
62
+ setOutputMode('human');
63
+ });
64
+
65
+ // process.exit would tear down the test run, so trap it as a throw.
66
+ const runFail = async (track: boolean) => {
67
+ const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
68
+ throw new Error('__exit__');
69
+ });
70
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
71
+ await expect(fail('doctor failed: x', { command: 'doctor', reason: 'unknown' }, 1, track)).rejects.toThrow(
72
+ '__exit__'
73
+ );
74
+ exit.mockRestore();
75
+ vi.mocked(process.stderr.write).mockRestore();
76
+ };
77
+
78
+ it('emits z2h_cli_error when the command is observed', async () => {
79
+ await runFail(true);
80
+ expect(trackEvent).toHaveBeenCalledWith(
81
+ 'z2h_cli_error',
82
+ expect.objectContaining({ action: 'doctor', reason: 'unknown' })
83
+ );
84
+ });
85
+
86
+ it('stays silent when the command has no success event', async () => {
87
+ await runFail(false);
88
+ expect(trackEvent).not.toHaveBeenCalled();
89
+ });
90
+ });
@@ -0,0 +1,98 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import { runCommand } from '../run-command';
4
+ import { DeployError } from '../errors';
5
+ import { setOutputMode } from '../logger';
6
+ import { trackEvent, trackDuration } from '../tracker';
7
+
8
+ vi.mock('../tracker', () => ({
9
+ trackEvent: vi.fn().mockResolvedValue(undefined),
10
+ trackDuration: vi.fn((_name: string, fn: () => Promise<unknown>) => fn()),
11
+ successEvent: (command: string) => `z2h_cli_${command.replace(/[\s-]+/g, '_')}_success`,
12
+ }));
13
+
14
+ describe('runCommand: build_error is never reported, other reasons still are', () => {
15
+ afterEach(() => {
16
+ vi.mocked(trackEvent).mockClear();
17
+ setOutputMode('human');
18
+ });
19
+
20
+ // process.exit would tear down the test run, so trap it as a throw.
21
+ const runFailing = async (err: unknown, cmd = new Command('deploy')) => {
22
+ const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
23
+ throw new Error('__exit__');
24
+ });
25
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
26
+ const handler = runCommand({}, () => {
27
+ throw err;
28
+ });
29
+ await expect(handler(cmd)).rejects.toThrow('__exit__');
30
+ exit.mockRestore();
31
+ vi.mocked(process.stderr.write).mockRestore();
32
+ };
33
+
34
+ it('drops the error event entirely when the deploy failed because the build broke', async () => {
35
+ await runFailing(new DeployError('build_error', 'tsc exploded'));
36
+ expect(trackEvent).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it('still reports a non-build failure reason', async () => {
40
+ await runFailing(new DeployError('s3_error', 'upload failed'));
41
+ expect(trackEvent).toHaveBeenCalledWith('z2h_cli_error', expect.objectContaining({ reason: 's3_error' }));
42
+ });
43
+ });
44
+
45
+ describe('runCommand: naming/tracking convention', () => {
46
+ afterEach(() => {
47
+ vi.mocked(trackEvent).mockClear();
48
+ vi.mocked(trackDuration).mockClear();
49
+ setOutputMode('human');
50
+ });
51
+
52
+ it('derives its name (and label) from the Command instance, no name param needed', async () => {
53
+ const cmd = new Command('migrate');
54
+ const handler = runCommand({}, () => Promise.resolve('ok'));
55
+ await handler(cmd);
56
+ expect(trackDuration).toHaveBeenCalledWith('z2h_cli_migrate_success', expect.any(Function), {});
57
+ });
58
+
59
+ it('composes a nested command\'s name from its parent, e.g. "backend invoke"', async () => {
60
+ const backend = new Command('backend');
61
+ const invoke = backend.command('invoke');
62
+ const handler = runCommand({}, () => Promise.resolve('ok'));
63
+ await handler(invoke);
64
+ expect(trackDuration).toHaveBeenCalledWith('z2h_cli_backend_invoke_success', expect.any(Function), {});
65
+ });
66
+
67
+ it('track: false skips telemetry entirely, success or failure', async () => {
68
+ const cmd = new Command('doctor');
69
+ await runCommand({ track: false }, () => Promise.resolve('ok'))(cmd);
70
+ expect(trackDuration).not.toHaveBeenCalled();
71
+ expect(trackEvent).not.toHaveBeenCalled();
72
+
73
+ const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
74
+ throw new Error('__exit__');
75
+ });
76
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
77
+ const failing = runCommand({ track: false }, () => {
78
+ throw new Error('boom');
79
+ });
80
+ await expect(failing(cmd)).rejects.toThrow('__exit__');
81
+ expect(trackEvent).not.toHaveBeenCalled();
82
+ exit.mockRestore();
83
+ vi.mocked(process.stderr.write).mockRestore();
84
+ });
85
+
86
+ it('a per-args event override beats the derived name, e.g. deploy --preview', async () => {
87
+ const cmd = new Command('deploy');
88
+ const handler = runCommand(
89
+ {
90
+ event: ([opts]: [{ preview?: boolean }]) =>
91
+ opts.preview ? 'z2h_cli_preview_success' : 'z2h_cli_deploy_success',
92
+ },
93
+ (_opts: { preview?: boolean }) => Promise.resolve('ok')
94
+ );
95
+ await handler({ preview: true }, cmd);
96
+ expect(trackDuration).toHaveBeenCalledWith('z2h_cli_preview_success', expect.any(Function), {});
97
+ });
98
+ });
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4
4
  import { ensureDir, remove, writeJson } from 'fs-extra';
5
+ import { Z2hAuthMissingError } from '../../errors';
5
6
 
6
7
  let scratchHome: string;
7
8
  let originalHome: string | undefined;
@@ -29,7 +30,7 @@ afterEach(async () => {
29
30
 
30
31
  describe('readZthAuth', () => {
31
32
  it('throws Z2hAuthMissingError with a remediation hint when the file is absent', async () => {
32
- const { readZthAuth, Z2hAuthMissingError } = await reload();
33
+ const { readZthAuth } = await reload();
33
34
  await expect(readZthAuth()).rejects.toBeInstanceOf(Z2hAuthMissingError);
34
35
  });
35
36