@sqaoss/flowy 0.1.0 → 1.0.2

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,71 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+
3
+ const mockUpdateProjectConfig = vi.fn()
4
+
5
+ vi.mock('../util/config.ts', () => ({
6
+ requireProject: vi.fn(() => {
7
+ throw new Error(
8
+ 'No active project. Run "flowy project set <name>" or set FLOWY_PROJECT.',
9
+ )
10
+ }),
11
+ resolveFeature: vi.fn(() => null),
12
+ updateProjectConfig: (...args: unknown[]) => mockUpdateProjectConfig(...args),
13
+ }))
14
+
15
+ vi.mock('../util/client.ts', () => ({
16
+ graphql: vi.fn(),
17
+ }))
18
+
19
+ vi.mock('../util/format.ts', () => ({
20
+ output: vi.fn(),
21
+ outputError: vi.fn(),
22
+ }))
23
+
24
+ describe('feature command', () => {
25
+ test('exports a command group named "feature" with subcommands', async () => {
26
+ const { featureCommand } = await import('./feature.ts')
27
+ expect(featureCommand.name()).toBe('feature')
28
+ const subcommandNames = featureCommand.commands.map((c) => c.name())
29
+ expect(subcommandNames).toContain('create')
30
+ expect(subcommandNames).toContain('set')
31
+ expect(subcommandNames).toContain('unset')
32
+ expect(subcommandNames).toContain('list')
33
+ expect(subcommandNames).toContain('show')
34
+ })
35
+
36
+ test('create calls outputError when no active project', async () => {
37
+ const { featureCommand } = await import('./feature.ts')
38
+ const { outputError } = await import('../util/format.ts')
39
+
40
+ const createCmd = featureCommand.commands.find((c) => c.name() === 'create')
41
+ expect(createCmd).toBeDefined()
42
+ await createCmd?.parseAsync(['--title', 'Test', '--description', 'Desc'], {
43
+ from: 'user',
44
+ })
45
+
46
+ expect(outputError).toHaveBeenCalledWith(
47
+ expect.objectContaining({
48
+ message: expect.stringContaining('No active project'),
49
+ }),
50
+ )
51
+ })
52
+
53
+ test('unset calls updateProjectConfig to delete activeFeature', async () => {
54
+ const { featureCommand } = await import('./feature.ts')
55
+ const { output } = await import('../util/format.ts')
56
+
57
+ const unsetCmd = featureCommand.commands.find((c) => c.name() === 'unset')
58
+ expect(unsetCmd).toBeDefined()
59
+ await unsetCmd?.parseAsync([], { from: 'user' })
60
+
61
+ expect(mockUpdateProjectConfig).toHaveBeenCalledWith(expect.any(Function))
62
+
63
+ // Verify the updater function deletes activeFeature
64
+ const updater = mockUpdateProjectConfig.mock.calls[0]?.[0]
65
+ const fakeProject = { id: 'p1', name: 'Test', activeFeature: 'feat_abc' }
66
+ updater(fakeProject)
67
+ expect(fakeProject.activeFeature).toBeUndefined()
68
+
69
+ expect(output).toHaveBeenCalledWith({ activeFeature: null })
70
+ })
71
+ })
@@ -0,0 +1,143 @@
1
+ import { Command } from 'commander'
2
+ import { graphql } from '../util/client.ts'
3
+ import {
4
+ requireProject,
5
+ resolveFeature,
6
+ updateProjectConfig,
7
+ } from '../util/config.ts'
8
+ import { resolveDescription } from '../util/description.ts'
9
+ import { output, outputError } from '../util/format.ts'
10
+
11
+ export const featureCommand = new Command('feature').description(
12
+ 'Manage features in the active project',
13
+ )
14
+
15
+ featureCommand
16
+ .command('create')
17
+ .description('Create a feature in the active project')
18
+ .requiredOption('--title <title>', 'Feature title')
19
+ .requiredOption('--description <description>', 'Feature description')
20
+ .action(async (opts) => {
21
+ try {
22
+ const project = requireProject()
23
+ const description = await resolveDescription(opts.description)
24
+ const nodeData = await graphql<{ createNode: { id: string } }>(
25
+ `mutation CreateNode($type: String!, $title: String!, $description: String) {
26
+ createNode(type: $type, title: $title, description: $description) {
27
+ id type title description status createdAt updatedAt
28
+ }
29
+ }`,
30
+ { type: 'feature', title: opts.title, description },
31
+ )
32
+ const featureId = nodeData.createNode.id
33
+ await graphql(
34
+ `mutation CreateEdge($sourceId: String!, $targetId: String!, $relation: String!) {
35
+ createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
36
+ sourceId targetId relation createdAt
37
+ }
38
+ }`,
39
+ { sourceId: featureId, targetId: project.id, relation: 'part_of' },
40
+ )
41
+ output(nodeData.createNode)
42
+ } catch (error) {
43
+ outputError(error)
44
+ }
45
+ })
46
+
47
+ featureCommand
48
+ .command('set')
49
+ .description('Set the active feature')
50
+ .argument('<name-or-id>', 'Feature name or ID')
51
+ .action(async (nameOrId: string) => {
52
+ try {
53
+ const project = requireProject()
54
+ const data = await graphql<{
55
+ descendants: Array<{ id: string; type: string; title: string }>
56
+ }>(
57
+ `query Descendants($nodeId: String!, $relation: String, $maxDepth: Int) {
58
+ descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
59
+ id type title status
60
+ }
61
+ }`,
62
+ { nodeId: project.id, relation: 'part_of', maxDepth: 1 },
63
+ )
64
+ const features = data.descendants.filter((n) => n.type === 'feature')
65
+ const match = features.find(
66
+ (f) => f.id === nameOrId || f.title === nameOrId,
67
+ )
68
+ if (!match) {
69
+ throw new Error(
70
+ `Feature "${nameOrId}" not found in project "${project.name}".`,
71
+ )
72
+ }
73
+ updateProjectConfig((p) => {
74
+ p.activeFeature = match.id
75
+ })
76
+ output(match)
77
+ } catch (error) {
78
+ outputError(error)
79
+ }
80
+ })
81
+
82
+ featureCommand
83
+ .command('unset')
84
+ .description('Clear the active feature')
85
+ .action(async () => {
86
+ try {
87
+ updateProjectConfig((p) => {
88
+ delete p.activeFeature
89
+ })
90
+ output({ activeFeature: null })
91
+ } catch (error) {
92
+ outputError(error)
93
+ }
94
+ })
95
+
96
+ featureCommand
97
+ .command('list')
98
+ .description('List features in the active project')
99
+ .action(async () => {
100
+ try {
101
+ const project = requireProject()
102
+ const data = await graphql<{
103
+ descendants: Array<{ id: string; type: string }>
104
+ }>(
105
+ `query Descendants($nodeId: String!, $relation: String, $maxDepth: Int) {
106
+ descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
107
+ id type title description status createdAt updatedAt
108
+ }
109
+ }`,
110
+ { nodeId: project.id, relation: 'part_of', maxDepth: 1 },
111
+ )
112
+ const features = data.descendants.filter((n) => n.type === 'feature')
113
+ output(features)
114
+ } catch (error) {
115
+ outputError(error)
116
+ }
117
+ })
118
+
119
+ featureCommand
120
+ .command('show')
121
+ .description('Show feature details')
122
+ .argument('[id]', 'Feature ID (defaults to active feature)')
123
+ .action(async (id?: string) => {
124
+ try {
125
+ const featureId = id ?? resolveFeature()
126
+ if (!featureId) {
127
+ throw new Error(
128
+ 'No feature specified. Pass an ID or set an active feature.',
129
+ )
130
+ }
131
+ const data = await graphql<{ node: unknown }>(
132
+ `query GetNode($id: String!) {
133
+ node(id: $id) {
134
+ id type title description status metadata createdAt updatedAt
135
+ }
136
+ }`,
137
+ { id: featureId },
138
+ )
139
+ output(data.node)
140
+ } catch (error) {
141
+ outputError(error)
142
+ }
143
+ })
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { resolve } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
5
+
6
+ const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
7
+
8
+ describe('project command', () => {
9
+ let originalConfig: string | null = null
10
+
11
+ beforeEach(() => {
12
+ originalConfig = existsSync(CONFIG_PATH)
13
+ ? readFileSync(CONFIG_PATH, 'utf-8')
14
+ : null
15
+ })
16
+
17
+ afterEach(async () => {
18
+ if (originalConfig !== null) {
19
+ const { writeFileSync } = await import('node:fs')
20
+ writeFileSync(CONFIG_PATH, originalConfig)
21
+ } else if (existsSync(CONFIG_PATH)) {
22
+ rmSync(CONFIG_PATH)
23
+ }
24
+ vi.restoreAllMocks()
25
+ })
26
+
27
+ test('exports a command group named "project" with create, set, list, show subcommands', async () => {
28
+ const { projectCommand } = await import('./project.ts')
29
+ expect(projectCommand.name()).toBe('project')
30
+ const subcommandNames = projectCommand.commands.map((c) => c.name())
31
+ expect(subcommandNames).toContain('create')
32
+ expect(subcommandNames).toContain('set')
33
+ expect(subcommandNames).toContain('list')
34
+ expect(subcommandNames).toContain('show')
35
+ expect(projectCommand.commands).toHaveLength(4)
36
+ })
37
+
38
+ test('show without id calls requireProject which throws when no project configured', async () => {
39
+ const mockExit = vi
40
+ .spyOn(process, 'exit')
41
+ .mockImplementation(() => undefined as never)
42
+ const mockStderr = vi.spyOn(console, 'error').mockImplementation(() => {})
43
+
44
+ const { showProject } = await import('./project.ts')
45
+ await showProject(undefined)
46
+
47
+ expect(mockStderr).toHaveBeenCalledWith(
48
+ expect.stringContaining('No active project'),
49
+ )
50
+ expect(mockExit).toHaveBeenCalledWith(1)
51
+ })
52
+
53
+ test('setProject saves cwd-to-project mapping in config', async () => {
54
+ const { setProject } = await import('./project.ts')
55
+ const { loadConfig } = await import('../util/config.ts')
56
+ vi.spyOn(console, 'log').mockImplementation(() => {})
57
+
58
+ await setProject('proj_42', 'My Project')
59
+
60
+ const config = loadConfig()
61
+ const cwd = process.cwd()
62
+ expect(config.projects[cwd]).toEqual({
63
+ id: 'proj_42',
64
+ name: 'My Project',
65
+ })
66
+ })
67
+
68
+ test('setProject overwrites existing mapping for same directory', async () => {
69
+ const { setProject } = await import('./project.ts')
70
+ const { loadConfig } = await import('../util/config.ts')
71
+ vi.spyOn(console, 'log').mockImplementation(() => {})
72
+
73
+ await setProject('proj_1', 'First')
74
+ await setProject('proj_2', 'Second')
75
+
76
+ const config = loadConfig()
77
+ const cwd = process.cwd()
78
+ expect(config.projects[cwd]).toEqual({
79
+ id: 'proj_2',
80
+ name: 'Second',
81
+ })
82
+ })
83
+ })
@@ -0,0 +1,104 @@
1
+ import { Command } from 'commander'
2
+ import { graphql } from '../util/client.ts'
3
+ import { loadConfig, requireProject, saveConfig } from '../util/config.ts'
4
+ import { output, outputError } from '../util/format.ts'
5
+
6
+ export const projectCommand = new Command('project').description(
7
+ 'Manage projects',
8
+ )
9
+
10
+ projectCommand
11
+ .command('create')
12
+ .description('Create a new project')
13
+ .argument('<name>', 'Project name')
14
+ .action(async (name: string) => {
15
+ try {
16
+ const data = await graphql<{ createNode: unknown }>(
17
+ `mutation CreateProject($type: String!, $title: String!) {
18
+ createNode(type: $type, title: $title) {
19
+ id type title description status metadata createdAt updatedAt
20
+ }
21
+ }`,
22
+ { type: 'project', title: name },
23
+ )
24
+ output(data.createNode)
25
+ } catch (error) {
26
+ outputError(error)
27
+ }
28
+ })
29
+
30
+ export async function setProject(id: string, name: string): Promise<void> {
31
+ const config = loadConfig()
32
+ const cwd = process.cwd()
33
+ config.projects[cwd] = { id, name }
34
+ saveConfig(config)
35
+ output({ id, name, directory: cwd })
36
+ }
37
+
38
+ projectCommand
39
+ .command('set')
40
+ .description('Map current directory to a project')
41
+ .argument('<name>', 'Project name')
42
+ .action(async (name: string) => {
43
+ try {
44
+ const data = await graphql<{
45
+ nodes: Array<{ id: string; title: string }>
46
+ }>(
47
+ `query ListProjects($type: String) {
48
+ nodes(type: $type) {
49
+ id title
50
+ }
51
+ }`,
52
+ { type: 'project' },
53
+ )
54
+ const project = data.nodes.find((n) => n.title === name)
55
+ if (!project) {
56
+ throw new Error(`Project "${name}" not found`)
57
+ }
58
+ await setProject(project.id, project.title)
59
+ } catch (error) {
60
+ outputError(error)
61
+ }
62
+ })
63
+
64
+ projectCommand
65
+ .command('list')
66
+ .description('List all projects')
67
+ .action(async () => {
68
+ try {
69
+ const data = await graphql<{ nodes: unknown[] }>(
70
+ `query ListProjects($type: String) {
71
+ nodes(type: $type) {
72
+ id type title description status createdAt updatedAt
73
+ }
74
+ }`,
75
+ { type: 'project' },
76
+ )
77
+ output(data.nodes)
78
+ } catch (error) {
79
+ outputError(error)
80
+ }
81
+ })
82
+
83
+ export async function showProject(id?: string): Promise<void> {
84
+ try {
85
+ const projectId = id ?? requireProject().id
86
+ const data = await graphql<{ node: unknown }>(
87
+ `query GetProject($id: String!) {
88
+ node(id: $id) {
89
+ id type title description status metadata createdAt updatedAt
90
+ }
91
+ }`,
92
+ { id: projectId },
93
+ )
94
+ output(data.node)
95
+ } catch (error) {
96
+ outputError(error)
97
+ }
98
+ }
99
+
100
+ projectCommand
101
+ .command('show')
102
+ .description('Show project details')
103
+ .argument('[id]', 'Project ID (defaults to active project)')
104
+ .action(async (id?: string) => showProject(id))
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ let mockLoadConfig: ReturnType<typeof vi.fn>
4
+ let mockSaveConfig: ReturnType<typeof vi.fn>
5
+ let mockOutput: ReturnType<typeof vi.fn>
6
+ let mockOutputError: ReturnType<typeof vi.fn>
7
+ let mockSpawnSync: ReturnType<typeof vi.fn>
8
+
9
+ beforeEach(() => {
10
+ mockLoadConfig = vi.fn(() => ({
11
+ mode: 'saas',
12
+ apiUrl: 'https://flowy-ai.fly.dev/graphql',
13
+ apiKey: '',
14
+ client: { name: '' },
15
+ projects: {},
16
+ }))
17
+ mockSaveConfig = vi.fn()
18
+ mockOutput = vi.fn()
19
+ mockOutputError = vi.fn()
20
+ mockSpawnSync = vi.fn()
21
+
22
+ vi.doMock('../util/config.ts', () => ({
23
+ loadConfig: mockLoadConfig,
24
+ saveConfig: mockSaveConfig,
25
+ }))
26
+
27
+ vi.doMock('../util/format.ts', () => ({
28
+ output: mockOutput,
29
+ outputError: mockOutputError,
30
+ }))
31
+
32
+ vi.doMock('node:child_process', () => ({
33
+ spawnSync: mockSpawnSync,
34
+ }))
35
+ })
36
+
37
+ afterEach(() => {
38
+ vi.resetModules()
39
+ vi.restoreAllMocks()
40
+ })
41
+
42
+ describe('setup command', () => {
43
+ test('exports a command named "setup"', async () => {
44
+ const { setupCommand } = await import('./setup.ts')
45
+ expect(setupCommand.name()).toBe('setup')
46
+ })
47
+
48
+ test('has local and remote subcommands', async () => {
49
+ const { setupCommand } = await import('./setup.ts')
50
+ const subcommandNames = setupCommand.commands.map((c) => c.name())
51
+ expect(subcommandNames).toContain('local')
52
+ expect(subcommandNames).toContain('remote')
53
+ expect(setupCommand.commands).toHaveLength(2)
54
+ })
55
+
56
+ test('setup local checks for docker and errors if not found', async () => {
57
+ mockSpawnSync.mockReturnValue({ status: 1 })
58
+
59
+ const { setupCommand } = await import('./setup.ts')
60
+ await setupCommand.parseAsync(['local'], { from: 'user' })
61
+
62
+ expect(mockSpawnSync).toHaveBeenCalledWith('docker', ['--version'], {
63
+ stdio: 'ignore',
64
+ })
65
+ expect(mockOutputError).toHaveBeenCalledWith(
66
+ expect.objectContaining({
67
+ message: expect.stringContaining('Docker is required'),
68
+ }),
69
+ )
70
+ })
71
+
72
+ test('setup local saves config with mode "local" and apiUrl on success', async () => {
73
+ mockSpawnSync.mockReturnValue({ status: 0 })
74
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
75
+ vi.doMock('node:fs', async () => {
76
+ const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
77
+ return { ...actual, existsSync: () => true }
78
+ })
79
+
80
+ const { setupCommand } = await import('./setup.ts')
81
+ await setupCommand.parseAsync(['local'], { from: 'user' })
82
+
83
+ expect(mockSaveConfig).toHaveBeenCalledWith(
84
+ expect.objectContaining({
85
+ mode: 'local',
86
+ apiUrl: 'http://localhost:4000/graphql',
87
+ }),
88
+ )
89
+ })
90
+
91
+ test('setup remote prints not-yet-implemented message', async () => {
92
+ const { setupCommand } = await import('./setup.ts')
93
+ await setupCommand.parseAsync(['remote'], { from: 'user' })
94
+
95
+ expect(mockOutput).toHaveBeenCalledWith(
96
+ expect.objectContaining({
97
+ message: expect.stringContaining('not yet implemented'),
98
+ }),
99
+ )
100
+ })
101
+ })
@@ -0,0 +1,83 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { Command } from 'commander'
6
+ import { loadConfig, saveConfig } from '../util/config.ts'
7
+ import { output, outputError } from '../util/format.ts'
8
+
9
+ function findComposeFile(): string {
10
+ let dir = dirname(fileURLToPath(import.meta.url))
11
+ while (dir !== dirname(dir)) {
12
+ const candidate = resolve(dir, 'docker-compose.yml')
13
+ if (existsSync(candidate)) return candidate
14
+ dir = dirname(dir)
15
+ }
16
+ throw new Error('docker-compose.yml not found in any parent directory.')
17
+ }
18
+
19
+ async function pollHealth(url: string, timeoutMs = 30_000): Promise<void> {
20
+ const start = Date.now()
21
+ while (Date.now() - start < timeoutMs) {
22
+ try {
23
+ const res = await fetch(url)
24
+ if (res.ok) return
25
+ } catch {}
26
+ await new Promise((r) => setTimeout(r, 1_000))
27
+ }
28
+ throw new Error(
29
+ `Health check at ${url} did not respond within ${timeoutMs / 1_000}s.`,
30
+ )
31
+ }
32
+
33
+ export const setupCommand = new Command('setup').description(
34
+ 'Configure the Flowy CLI — use "flowy setup local" or "flowy setup remote"',
35
+ )
36
+
37
+ setupCommand
38
+ .command('local')
39
+ .description('Set up Flowy with a local Docker server')
40
+ .action(async () => {
41
+ try {
42
+ const dockerCheck = spawnSync('docker', ['--version'], {
43
+ stdio: 'ignore',
44
+ })
45
+ if (dockerCheck.status !== 0) {
46
+ throw new Error('Docker is required but was not found.')
47
+ }
48
+
49
+ const composePath = findComposeFile()
50
+ spawnSync(
51
+ 'docker',
52
+ ['compose', '-f', composePath, 'up', '-d', '--build'],
53
+ {
54
+ stdio: 'inherit',
55
+ },
56
+ )
57
+
58
+ const apiUrl = 'http://localhost:4000/graphql'
59
+ await pollHealth('http://localhost:4000/health')
60
+
61
+ const config = loadConfig()
62
+ config.mode = 'local'
63
+ config.apiUrl = apiUrl
64
+ saveConfig(config)
65
+ output({ mode: 'local', apiUrl })
66
+ } catch (error) {
67
+ outputError(error)
68
+ }
69
+ })
70
+
71
+ setupCommand
72
+ .command('remote')
73
+ .description('Set up Flowy with the remote SaaS server')
74
+ .action(async () => {
75
+ try {
76
+ output({
77
+ message:
78
+ 'Remote mode is not yet implemented. It will support registration and API key setup via this CLI.',
79
+ })
80
+ } catch (error) {
81
+ outputError(error)
82
+ }
83
+ })
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+
3
+ vi.mock('../util/config.ts', () => ({
4
+ requireFeature: vi.fn(() => {
5
+ throw new Error(
6
+ 'No active feature. Run "flowy feature set <name-or-id>" or set FLOWY_FEATURE.',
7
+ )
8
+ }),
9
+ }))
10
+
11
+ vi.mock('../util/client.ts', () => ({
12
+ graphql: vi.fn(),
13
+ }))
14
+
15
+ vi.mock('../util/format.ts', () => ({
16
+ output: vi.fn(),
17
+ outputError: vi.fn(),
18
+ }))
19
+
20
+ describe('task command', () => {
21
+ test('exports a command group with 5 subcommands', async () => {
22
+ const { taskCommand } = await import('./task.ts')
23
+ expect(taskCommand.name()).toBe('task')
24
+ expect(taskCommand.commands).toHaveLength(5)
25
+
26
+ const names = taskCommand.commands.map((c) => c.name())
27
+ expect(names).toContain('create')
28
+ expect(names).toContain('list')
29
+ expect(names).toContain('show')
30
+ expect(names).toContain('block')
31
+ expect(names).toContain('unblock')
32
+ })
33
+
34
+ test('create calls outputError when no active feature', async () => {
35
+ const { taskCommand } = await import('./task.ts')
36
+ const { outputError } = await import('../util/format.ts')
37
+
38
+ const createCmd = taskCommand.commands.find((c) => c.name() === 'create')!
39
+ await createCmd.parseAsync(['--title', 'Test', '--description', 'desc'], {
40
+ from: 'user',
41
+ })
42
+
43
+ expect(outputError).toHaveBeenCalledWith(
44
+ expect.objectContaining({
45
+ message: expect.stringContaining('No active feature'),
46
+ }),
47
+ )
48
+ })
49
+
50
+ test('show calls outputError when graphql throws network error', async () => {
51
+ const { graphql } = await import('../util/client.ts')
52
+ const { outputError } = await import('../util/format.ts')
53
+ const { taskCommand } = await import('./task.ts')
54
+
55
+ vi.mocked(graphql).mockRejectedValueOnce(new TypeError('fetch failed'))
56
+
57
+ const showCmd = taskCommand.commands.find((c) => c.name() === 'show')!
58
+ await showCmd.parseAsync(['task_abc123'], { from: 'user' })
59
+
60
+ expect(outputError).toHaveBeenCalledWith(
61
+ expect.objectContaining({
62
+ message: 'fetch failed',
63
+ }),
64
+ )
65
+ })
66
+
67
+ test('show calls outputError when graphql returns error response', async () => {
68
+ const { graphql } = await import('../util/client.ts')
69
+ const { outputError } = await import('../util/format.ts')
70
+ const { taskCommand } = await import('./task.ts')
71
+
72
+ vi.mocked(graphql).mockRejectedValueOnce(new Error('Node not found'))
73
+
74
+ const showCmd = taskCommand.commands.find((c) => c.name() === 'show')!
75
+ await showCmd.parseAsync(['task_nonexistent'], { from: 'user' })
76
+
77
+ expect(outputError).toHaveBeenCalledWith(
78
+ expect.objectContaining({
79
+ message: 'Node not found',
80
+ }),
81
+ )
82
+ })
83
+ })