@inspecto-dev/cli 0.2.0-alpha.5 → 0.3.0-alpha.1

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 (49) hide show
  1. package/.turbo/turbo-build.log +19 -20
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +93 -11
  4. package/bin/inspecto.js +5 -1
  5. package/dist/bin.d.ts +5 -1
  6. package/dist/bin.js +530 -49
  7. package/dist/chunk-FZS2TLXQ.js +3140 -0
  8. package/dist/index.d.ts +233 -2
  9. package/dist/index.js +17 -3
  10. package/package.json +3 -2
  11. package/src/bin.ts +286 -66
  12. package/src/commands/apply.ts +118 -0
  13. package/src/commands/detect.ts +59 -0
  14. package/src/commands/doctor.ts +225 -72
  15. package/src/commands/init.ts +143 -183
  16. package/src/commands/integration-install.ts +452 -0
  17. package/src/commands/onboard.ts +50 -0
  18. package/src/commands/plan.ts +41 -0
  19. package/src/detect/build-tool.ts +107 -3
  20. package/src/index.ts +17 -2
  21. package/src/inject/ast-injector.ts +17 -6
  22. package/src/inject/extension.ts +40 -22
  23. package/src/inject/gitignore.ts +10 -3
  24. package/src/instructions.ts +60 -46
  25. package/src/onboarding/apply.ts +364 -0
  26. package/src/onboarding/context.ts +36 -0
  27. package/src/onboarding/planner.ts +284 -0
  28. package/src/onboarding/session.ts +434 -0
  29. package/src/onboarding/target-resolution.ts +116 -0
  30. package/src/prompts.ts +54 -11
  31. package/src/types.ts +184 -0
  32. package/src/utils/fs.ts +2 -1
  33. package/src/utils/logger.ts +9 -0
  34. package/src/utils/output.ts +40 -0
  35. package/tests/apply.test.ts +583 -0
  36. package/tests/ast-injector.test.ts +50 -0
  37. package/tests/build-tool.test.ts +3 -5
  38. package/tests/detect.test.ts +94 -0
  39. package/tests/doctor.test.ts +224 -0
  40. package/tests/init.test.ts +364 -0
  41. package/tests/install-wrapper.test.ts +76 -0
  42. package/tests/instructions.test.ts +61 -0
  43. package/tests/integration-install.test.ts +294 -0
  44. package/tests/logger.test.ts +100 -0
  45. package/tests/onboard.test.ts +258 -0
  46. package/tests/plan.test.ts +713 -0
  47. package/tests/workspace-build-tool.test.ts +75 -0
  48. package/.turbo/turbo-test.log +0 -16
  49. package/dist/chunk-MIHQGC3L.js +0 -1720
@@ -0,0 +1,76 @@
1
+ import { execFile } from 'node:child_process'
2
+ import fs from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { promisify } from 'node:util'
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+
8
+ const execFileAsync = promisify(execFile)
9
+
10
+ describe('assistant integration bootstrap wrapper', () => {
11
+ const tempDirs: string[] = []
12
+
13
+ afterEach(async () => {
14
+ await Promise.all(tempDirs.map(dir => fs.rm(dir, { recursive: true, force: true })))
15
+ tempDirs.length = 0
16
+ })
17
+
18
+ it('falls back to raw asset download and still honors --force', async () => {
19
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'inspecto-wrapper-'))
20
+ tempDirs.push(tempRoot)
21
+
22
+ const fakeBin = path.join(tempRoot, 'bin')
23
+ await fs.mkdir(fakeBin, { recursive: true })
24
+ await fs.mkdir(path.join(tempRoot, '.github'), { recursive: true })
25
+ await fs.writeFile(
26
+ path.join(tempRoot, '.github/copilot-instructions.md'),
27
+ 'old content\n',
28
+ 'utf8',
29
+ )
30
+
31
+ await fs.writeFile(path.join(fakeBin, 'npx'), '#!/usr/bin/env bash\nexit 127\n', 'utf8')
32
+ await fs.chmod(path.join(fakeBin, 'npx'), 0o755)
33
+
34
+ await fs.writeFile(
35
+ path.join(fakeBin, 'curl'),
36
+ [
37
+ '#!/usr/bin/env bash',
38
+ 'set -euo pipefail',
39
+ 'out=""',
40
+ 'while [[ $# -gt 0 ]]; do',
41
+ ' case "$1" in',
42
+ ' -o)',
43
+ ' out="$2"',
44
+ ' shift 2',
45
+ ' ;;',
46
+ ' *)',
47
+ ' shift',
48
+ ' ;;',
49
+ ' esac',
50
+ 'done',
51
+ 'printf "downloaded from wrapper\n" > "$out"',
52
+ ].join('\n'),
53
+ 'utf8',
54
+ )
55
+ await fs.chmod(path.join(fakeBin, 'curl'), 0o755)
56
+
57
+ const scriptPath = path.resolve(
58
+ __dirname,
59
+ '../../../assistant-integrations/scripts/install.sh',
60
+ )
61
+
62
+ await execFileAsync('bash', [scriptPath, 'copilot', '--force'], {
63
+ cwd: tempRoot,
64
+ env: {
65
+ ...process.env,
66
+ PATH: `${fakeBin}:${process.env.PATH ?? ''}`,
67
+ },
68
+ })
69
+
70
+ const installed = await fs.readFile(
71
+ path.join(tempRoot, '.github/copilot-instructions.md'),
72
+ 'utf8',
73
+ )
74
+ expect(installed).toBe('downloaded from wrapper\n')
75
+ })
76
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
2
+ import { printNextJsManualInstructions, printNuxtManualInstructions } from '../src/instructions.js'
3
+ import { log } from '../src/utils/logger.js'
4
+
5
+ vi.mock('../src/utils/logger.js', () => ({
6
+ log: {
7
+ blank: vi.fn(),
8
+ hint: vi.fn(),
9
+ copyableCodeBlock: vi.fn(),
10
+ },
11
+ }))
12
+
13
+ describe('manual framework instructions', () => {
14
+ beforeEach(() => {
15
+ vi.resetAllMocks()
16
+ })
17
+
18
+ it('prints detailed Next.js instructions with step-by-step code blocks', () => {
19
+ printNextJsManualInstructions()
20
+
21
+ expect(log.blank).toHaveBeenCalled()
22
+ expect(log.hint).toHaveBeenCalledWith('Next.js requires manual setup in the current version.')
23
+ expect(log.hint).toHaveBeenCalledWith(
24
+ '1. Update `next.config.mjs` to register the Inspecto webpack plugin:',
25
+ )
26
+ expect(log.copyableCodeBlock).toHaveBeenCalledWith(
27
+ expect.arrayContaining([
28
+ "import { webpackPlugin as inspecto } from '@inspecto-dev/plugin'",
29
+ 'export default nextConfig',
30
+ ]),
31
+ )
32
+ expect(log.hint).toHaveBeenCalledWith(
33
+ '2. Initialize `@inspecto-dev/core` from a client component such as `app/layout.tsx` or `pages/_app.tsx`:',
34
+ )
35
+ expect(log.hint).toHaveBeenCalledWith(
36
+ '3. Restart your Next.js dev server after updating the config.',
37
+ )
38
+ })
39
+
40
+ it('prints detailed Nuxt instructions with step-by-step code blocks', () => {
41
+ printNuxtManualInstructions()
42
+
43
+ expect(log.blank).toHaveBeenCalled()
44
+ expect(log.hint).toHaveBeenCalledWith('Nuxt requires manual setup in the current version.')
45
+ expect(log.hint).toHaveBeenCalledWith(
46
+ '1. Update `nuxt.config.ts` to register the Inspecto Vite plugin:',
47
+ )
48
+ expect(log.copyableCodeBlock).toHaveBeenCalledWith(
49
+ expect.arrayContaining([
50
+ "import { vitePlugin as inspecto } from '@inspecto-dev/plugin'",
51
+ 'export default defineNuxtConfig({',
52
+ ]),
53
+ )
54
+ expect(log.hint).toHaveBeenCalledWith(
55
+ '2. Create `plugins/inspecto.client.ts` to mount `@inspecto-dev/core` in development:',
56
+ )
57
+ expect(log.hint).toHaveBeenCalledWith(
58
+ '3. Restart your Nuxt dev server after updating the config.',
59
+ )
60
+ })
61
+ })
@@ -0,0 +1,294 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const writeFileMock = vi.fn()
4
+ const existsMock = vi.fn()
5
+ const chmodMock = vi.fn()
6
+ const logMock = {
7
+ header: vi.fn(),
8
+ info: vi.fn(),
9
+ success: vi.fn(),
10
+ warn: vi.fn(),
11
+ hint: vi.fn(),
12
+ ready: vi.fn(),
13
+ error: vi.fn(),
14
+ blank: vi.fn(),
15
+ }
16
+
17
+ vi.mock('../src/utils/fs.js', () => ({
18
+ writeFile: writeFileMock,
19
+ exists: existsMock,
20
+ }))
21
+
22
+ vi.mock('node:fs/promises', () => ({
23
+ default: {
24
+ chmod: chmodMock,
25
+ },
26
+ }))
27
+
28
+ vi.mock('node:os', () => ({
29
+ homedir: () => '/Users/tester',
30
+ }))
31
+
32
+ vi.mock('../src/utils/logger.js', () => ({
33
+ log: logMock,
34
+ }))
35
+
36
+ describe('integration install', () => {
37
+ beforeEach(() => {
38
+ vi.resetModules()
39
+ vi.clearAllMocks()
40
+ existsMock.mockResolvedValue(false)
41
+ vi.stubGlobal(
42
+ 'fetch',
43
+ vi.fn().mockResolvedValue({
44
+ ok: true,
45
+ text: async () => '# mock asset',
46
+ status: 200,
47
+ statusText: 'OK',
48
+ }),
49
+ )
50
+ })
51
+
52
+ it('installs the Claude Code skill into the user skill directory', async () => {
53
+ const { installIntegration } = await import('../src/commands/integration-install.js')
54
+
55
+ await installIntegration('claude-code', { scope: 'user' })
56
+
57
+ expect(writeFileMock).toHaveBeenCalledWith(
58
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/SKILL.md',
59
+ '# mock asset',
60
+ )
61
+ expect(writeFileMock).toHaveBeenCalledWith(
62
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/agents/openai.yaml',
63
+ '# mock asset',
64
+ )
65
+ expect(writeFileMock).toHaveBeenCalledWith(
66
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/scripts/run-inspecto.sh',
67
+ '# mock asset',
68
+ )
69
+ expect(chmodMock).toHaveBeenCalledWith(
70
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/scripts/run-inspecto.sh',
71
+ 0o755,
72
+ )
73
+ })
74
+
75
+ it('installs the Codex skill into the Codex skill directory', async () => {
76
+ const { installIntegration } = await import('../src/commands/integration-install.js')
77
+
78
+ await installIntegration('codex')
79
+
80
+ expect(writeFileMock).toHaveBeenCalledWith(
81
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/SKILL.md',
82
+ '# mock asset',
83
+ )
84
+ expect(writeFileMock).toHaveBeenCalledWith(
85
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/agents/openai.yaml',
86
+ '# mock asset',
87
+ )
88
+ expect(writeFileMock).toHaveBeenCalledWith(
89
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/scripts/run-inspecto.sh',
90
+ '# mock asset',
91
+ )
92
+ expect(chmodMock).toHaveBeenCalledWith(
93
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/scripts/run-inspecto.sh',
94
+ 0o755,
95
+ )
96
+ })
97
+
98
+ it('installs the Cursor AGENTS fallback when agents mode is selected', async () => {
99
+ const { installIntegration } = await import('../src/commands/integration-install.js')
100
+
101
+ await installIntegration('cursor', { mode: 'agents' })
102
+
103
+ expect(writeFileMock).toHaveBeenCalledTimes(1)
104
+ expect(writeFileMock).toHaveBeenCalledWith('AGENTS.md', '# mock asset')
105
+ })
106
+
107
+ it('refuses a multi-file Claude install before writing anything when one target already exists', async () => {
108
+ const { installIntegration } = await import('../src/commands/integration-install.js')
109
+
110
+ existsMock.mockImplementation(async filePath => {
111
+ return (
112
+ filePath ===
113
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/agents/openai.yaml'
114
+ )
115
+ })
116
+
117
+ await expect(installIntegration('claude-code', { scope: 'user' })).rejects.toThrow(
118
+ 'Refusing to overwrite existing file',
119
+ )
120
+
121
+ expect(writeFileMock).not.toHaveBeenCalled()
122
+ expect(chmodMock).not.toHaveBeenCalled()
123
+ })
124
+
125
+ it('downloads all Claude assets before writing any file', async () => {
126
+ const fetchMock = vi
127
+ .fn()
128
+ .mockResolvedValueOnce({
129
+ ok: true,
130
+ text: async () => '# skill',
131
+ status: 200,
132
+ statusText: 'OK',
133
+ })
134
+ .mockResolvedValueOnce({
135
+ ok: false,
136
+ text: async () => '',
137
+ status: 503,
138
+ statusText: 'Unavailable',
139
+ })
140
+
141
+ vi.stubGlobal('fetch', fetchMock)
142
+
143
+ const { installIntegration } = await import('../src/commands/integration-install.js')
144
+
145
+ await expect(installIntegration('claude-code', { scope: 'user' })).rejects.toThrow(
146
+ 'Failed to download',
147
+ )
148
+
149
+ expect(writeFileMock).not.toHaveBeenCalled()
150
+ expect(chmodMock).not.toHaveBeenCalled()
151
+ })
152
+
153
+ it('surfaces the asset URL when fetch itself fails', async () => {
154
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fetch failed')))
155
+
156
+ const { installIntegration } = await import('../src/commands/integration-install.js')
157
+
158
+ await expect(installIntegration('codex')).rejects.toThrow(
159
+ 'Failed to download https://raw.githubusercontent.com/inspecto-dev/inspecto/main/skills/inspecto-onboarding-codex/SKILL.md: fetch failed',
160
+ )
161
+
162
+ expect(writeFileMock).not.toHaveBeenCalled()
163
+ expect(chmodMock).not.toHaveBeenCalled()
164
+ })
165
+
166
+ it('lists supported integrations with their preferred install targets', async () => {
167
+ const { listIntegrationManifests } = await import('../src/commands/integration-install.js')
168
+
169
+ expect(listIntegrationManifests()).toEqual(
170
+ expect.arrayContaining([
171
+ expect.objectContaining({
172
+ assistant: 'codex',
173
+ type: 'native-skill',
174
+ installTarget: '~/.codex/skills/',
175
+ cliSupported: true,
176
+ }),
177
+ expect.objectContaining({
178
+ assistant: 'claude-code',
179
+ type: 'native-skill',
180
+ installTarget: '.claude/skills/ or ~/.claude/skills/',
181
+ }),
182
+ expect.objectContaining({
183
+ assistant: 'copilot',
184
+ type: 'instruction-template',
185
+ installTarget: '.github/copilot-instructions.md or AGENTS.md',
186
+ }),
187
+ ]),
188
+ )
189
+ })
190
+
191
+ it('describes the resolved install targets for a selected assistant variant', async () => {
192
+ const { describeIntegration } = await import('../src/commands/integration-install.js')
193
+
194
+ expect(describeIntegration('claude-code', { scope: 'user' })).toMatchObject({
195
+ assistant: 'claude-code',
196
+ targets: [
197
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/SKILL.md',
198
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/agents/openai.yaml',
199
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/scripts/run-inspecto.sh',
200
+ ],
201
+ preferredInstall: 'npx @inspecto-dev/cli integrations install claude-code --scope project',
202
+ })
203
+ })
204
+
205
+ it('describes codex using the same CLI-managed install target model', async () => {
206
+ const { describeIntegration } = await import('../src/commands/integration-install.js')
207
+
208
+ expect(describeIntegration('codex')).toMatchObject({
209
+ assistant: 'codex',
210
+ targets: [
211
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/SKILL.md',
212
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/agents/openai.yaml',
213
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/scripts/run-inspecto.sh',
214
+ ],
215
+ preferredInstall: 'npx @inspecto-dev/cli integrations install codex',
216
+ })
217
+ })
218
+
219
+ it('prints integration paths without using post-install hints', async () => {
220
+ const { printIntegrationPath } = await import('../src/commands/integration-install.js')
221
+
222
+ printIntegrationPath('claude-code', { scope: 'user' })
223
+
224
+ expect(logMock.info).toHaveBeenCalledWith(
225
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/SKILL.md',
226
+ )
227
+ expect(logMock.hint).toHaveBeenCalledWith(
228
+ 'Preferred install: npx @inspecto-dev/cli integrations install claude-code --scope project',
229
+ )
230
+ expect(logMock.hint).not.toHaveBeenCalledWith('Restart Claude Code to load the new skill.')
231
+ })
232
+
233
+ it('rejects unsupported extra args and options for list/path', async () => {
234
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
235
+
236
+ vi.doMock('../src/commands/init.js', () => ({ init: vi.fn() }))
237
+ vi.doMock('../src/commands/doctor.js', () => ({ doctor: vi.fn() }))
238
+ vi.doMock('../src/commands/teardown.js', () => ({ teardown: vi.fn() }))
239
+ vi.doMock('../src/commands/detect.js', () => ({ detect: vi.fn() }))
240
+ vi.doMock('../src/commands/plan.js', () => ({ plan: vi.fn() }))
241
+ vi.doMock('../src/commands/apply.js', () => ({ apply: vi.fn() }))
242
+
243
+ const { runCli } = await import('../src/bin.js')
244
+
245
+ await runCli(['node', 'inspecto', 'integrations', 'list', 'extra'])
246
+ await runCli(['node', 'inspecto', 'integrations', 'path', 'claude-code', '--force'])
247
+
248
+ expect(logMock.error).toHaveBeenCalledWith(
249
+ 'The `list` subcommand does not accept assistant names, --scope, --mode, or --force.',
250
+ )
251
+ expect(logMock.error).toHaveBeenCalledWith('The `path` subcommand does not support `--force`.')
252
+ expect(exitSpy).toHaveBeenCalledWith(1)
253
+
254
+ exitSpy.mockRestore()
255
+ })
256
+
257
+ it('wires the nested CLI command to the integration installer', async () => {
258
+ const installCommand = vi.fn().mockResolvedValue(undefined)
259
+ const listCommand = vi.fn().mockResolvedValue(undefined)
260
+ const pathCommand = vi.fn().mockResolvedValue(undefined)
261
+
262
+ vi.doMock('../src/commands/init.js', () => ({ init: vi.fn() }))
263
+ vi.doMock('../src/commands/doctor.js', () => ({ doctor: vi.fn() }))
264
+ vi.doMock('../src/commands/teardown.js', () => ({ teardown: vi.fn() }))
265
+ vi.doMock('../src/commands/detect.js', () => ({ detect: vi.fn() }))
266
+ vi.doMock('../src/commands/plan.js', () => ({ plan: vi.fn() }))
267
+ vi.doMock('../src/commands/apply.js', () => ({ apply: vi.fn() }))
268
+ vi.doMock('../src/commands/integration-install.js', () => ({
269
+ installIntegration: installCommand,
270
+ printIntegrationList: listCommand,
271
+ printIntegrationPath: pathCommand,
272
+ }))
273
+
274
+ const { runCli } = await import('../src/bin.js')
275
+
276
+ await runCli([
277
+ 'node',
278
+ 'inspecto',
279
+ 'integrations',
280
+ 'install',
281
+ 'copilot',
282
+ '--mode',
283
+ 'agents',
284
+ '--force',
285
+ ])
286
+
287
+ await runCli(['node', 'inspecto', 'integrations', 'list'])
288
+ await runCli(['node', 'inspecto', 'integrations', 'path', 'claude-code', '--scope', 'user'])
289
+
290
+ expect(installCommand).toHaveBeenCalledWith('copilot', { mode: 'agents', force: true })
291
+ expect(listCommand).toHaveBeenCalledWith()
292
+ expect(pathCommand).toHaveBeenCalledWith('claude-code', { scope: 'user' })
293
+ })
294
+ })
@@ -0,0 +1,100 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { log } from '../src/utils/logger.js'
3
+ import { reportCommandError, writeCommandOutput } from '../src/utils/output.js'
4
+
5
+ describe('logger copyable code blocks', () => {
6
+ beforeEach(() => {
7
+ vi.restoreAllMocks()
8
+ })
9
+
10
+ it('prints copyable code without box drawing characters', () => {
11
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
12
+
13
+ log.copyableCodeBlock(['const answer = 42', 'console.log(answer)'])
14
+
15
+ const output = consoleSpy.mock.calls.map(call => String(call[0]))
16
+
17
+ expect(output).not.toContain(expect.stringContaining('┌'))
18
+ expect(output).not.toContain(expect.stringContaining('│'))
19
+ expect(output).not.toContain(expect.stringContaining('└'))
20
+ expect(output).toContain(' const answer = 42')
21
+ expect(output).toContain(' console.log(answer)')
22
+ })
23
+ })
24
+
25
+ describe('command output utility', () => {
26
+ beforeEach(() => {
27
+ vi.restoreAllMocks()
28
+ })
29
+
30
+ it('prints formatted JSON and skips the plain-text renderer in json mode', () => {
31
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
32
+ const renderText = vi.fn()
33
+ const result = { status: 'ok', nested: { value: 1 } }
34
+
35
+ const returned = writeCommandOutput(result, true, renderText)
36
+
37
+ expect(returned).toBe(result)
38
+ expect(renderText).not.toHaveBeenCalled()
39
+ expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(result, null, 2))
40
+ })
41
+
42
+ it('uses the plain-text renderer when json mode is disabled', () => {
43
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
44
+ const renderText = vi.fn()
45
+ const result = { status: 'ok' }
46
+
47
+ const returned = writeCommandOutput(result, false, renderText)
48
+
49
+ expect(returned).toBe(result)
50
+ expect(renderText).toHaveBeenCalledWith(result)
51
+ expect(consoleSpy).not.toHaveBeenCalled()
52
+ })
53
+
54
+ it('prints machine-safe JSON errors when json mode is enabled', () => {
55
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
56
+ const loggerSpy = vi.spyOn(log, 'error').mockImplementation(() => {})
57
+ const error = new Error('boom')
58
+ error.stack = 'Error: boom\n at fake'
59
+
60
+ reportCommandError(error, { json: true })
61
+
62
+ expect(loggerSpy).not.toHaveBeenCalled()
63
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
64
+ JSON.stringify(
65
+ {
66
+ status: 'error',
67
+ error: {
68
+ message: 'boom',
69
+ },
70
+ },
71
+ null,
72
+ 2,
73
+ ),
74
+ )
75
+ })
76
+
77
+ it('includes the stack in JSON error output when debug mode is enabled', () => {
78
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
79
+ const loggerSpy = vi.spyOn(log, 'error').mockImplementation(() => {})
80
+ const error = new Error('boom')
81
+ error.stack = 'Error: boom\n at fake'
82
+
83
+ reportCommandError(error, { json: true, debug: true })
84
+
85
+ expect(loggerSpy).not.toHaveBeenCalled()
86
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
87
+ JSON.stringify(
88
+ {
89
+ status: 'error',
90
+ error: {
91
+ message: 'boom',
92
+ stack: 'Error: boom\n at fake',
93
+ },
94
+ },
95
+ null,
96
+ 2,
97
+ ),
98
+ )
99
+ })
100
+ })