@sqaoss/flowy 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/feature.test.ts +161 -1
- package/src/commands/feature.ts +69 -0
- package/src/commands/project.test.ts +169 -2
- package/src/commands/project.ts +60 -0
- package/src/commands/task.test.ts +170 -3
- package/src/commands/task.ts +57 -0
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from 'vitest'
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
const mockUpdateProjectConfig = vi.fn()
|
|
4
4
|
|
|
@@ -22,6 +22,10 @@ vi.mock('../util/format.ts', () => ({
|
|
|
22
22
|
}))
|
|
23
23
|
|
|
24
24
|
describe('feature command', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks()
|
|
27
|
+
})
|
|
28
|
+
|
|
25
29
|
test('exports a command group named "feature" with subcommands', async () => {
|
|
26
30
|
const { featureCommand } = await import('./feature.ts')
|
|
27
31
|
expect(featureCommand.name()).toBe('feature')
|
|
@@ -31,6 +35,8 @@ describe('feature command', () => {
|
|
|
31
35
|
expect(subcommandNames).toContain('unset')
|
|
32
36
|
expect(subcommandNames).toContain('list')
|
|
33
37
|
expect(subcommandNames).toContain('show')
|
|
38
|
+
expect(subcommandNames).toContain('update')
|
|
39
|
+
expect(subcommandNames).toContain('delete')
|
|
34
40
|
})
|
|
35
41
|
|
|
36
42
|
test('create calls outputError when no active project', async () => {
|
|
@@ -68,4 +74,158 @@ describe('feature command', () => {
|
|
|
68
74
|
|
|
69
75
|
expect(output).toHaveBeenCalledWith({ activeFeature: null })
|
|
70
76
|
})
|
|
77
|
+
|
|
78
|
+
test('update sends updateNode with only the title when title-only', async () => {
|
|
79
|
+
const { graphql } = await import('../util/client.ts')
|
|
80
|
+
const { output } = await import('../util/format.ts')
|
|
81
|
+
const { featureCommand } = await import('./feature.ts')
|
|
82
|
+
|
|
83
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
84
|
+
updateNode: { id: 'feat_1', title: 'New' },
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const updateCmd = featureCommand.commands.find(
|
|
88
|
+
(c) => c.name() === 'update',
|
|
89
|
+
)!
|
|
90
|
+
await updateCmd.parseAsync(['feat_1', '--title', 'New'], { from: 'user' })
|
|
91
|
+
|
|
92
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
93
|
+
expect.stringContaining('updateNode'),
|
|
94
|
+
{
|
|
95
|
+
id: 'feat_1',
|
|
96
|
+
title: 'New',
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
expect(output).toHaveBeenCalledWith({ id: 'feat_1', title: 'New' })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('update sends updateNode with only the description when description-only', async () => {
|
|
103
|
+
const { graphql } = await import('../util/client.ts')
|
|
104
|
+
const { featureCommand } = await import('./feature.ts')
|
|
105
|
+
|
|
106
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'feat_1' } })
|
|
107
|
+
|
|
108
|
+
const updateCmd = featureCommand.commands.find(
|
|
109
|
+
(c) => c.name() === 'update',
|
|
110
|
+
)!
|
|
111
|
+
await updateCmd.parseAsync(['feat_1', '--description', 'Body'], {
|
|
112
|
+
from: 'user',
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
116
|
+
expect.stringContaining('updateNode'),
|
|
117
|
+
{
|
|
118
|
+
id: 'feat_1',
|
|
119
|
+
description: 'Body',
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('update sends updateNode with only the metadata when metadata-only', async () => {
|
|
125
|
+
const { graphql } = await import('../util/client.ts')
|
|
126
|
+
const { featureCommand } = await import('./feature.ts')
|
|
127
|
+
|
|
128
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'feat_1' } })
|
|
129
|
+
|
|
130
|
+
const updateCmd = featureCommand.commands.find(
|
|
131
|
+
(c) => c.name() === 'update',
|
|
132
|
+
)!
|
|
133
|
+
await updateCmd.parseAsync(['feat_1', '--metadata', '{"k":"v"}'], {
|
|
134
|
+
from: 'user',
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
138
|
+
expect.stringContaining('updateNode'),
|
|
139
|
+
{
|
|
140
|
+
id: 'feat_1',
|
|
141
|
+
metadata: '{"k":"v"}',
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('update sends updateNode with combined fields', async () => {
|
|
147
|
+
const { graphql } = await import('../util/client.ts')
|
|
148
|
+
const { featureCommand } = await import('./feature.ts')
|
|
149
|
+
|
|
150
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'feat_1' } })
|
|
151
|
+
|
|
152
|
+
const updateCmd = featureCommand.commands.find(
|
|
153
|
+
(c) => c.name() === 'update',
|
|
154
|
+
)!
|
|
155
|
+
await updateCmd.parseAsync(
|
|
156
|
+
['feat_1', '--title', 'New', '--description', 'Body', '--metadata', '{}'],
|
|
157
|
+
{ from: 'user' },
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
161
|
+
expect.stringContaining('updateNode'),
|
|
162
|
+
{
|
|
163
|
+
id: 'feat_1',
|
|
164
|
+
title: 'New',
|
|
165
|
+
description: 'Body',
|
|
166
|
+
metadata: '{}',
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('delete sends deleteNode mutation', async () => {
|
|
172
|
+
const { graphql } = await import('../util/client.ts')
|
|
173
|
+
const { output } = await import('../util/format.ts')
|
|
174
|
+
const { featureCommand } = await import('./feature.ts')
|
|
175
|
+
|
|
176
|
+
vi.mocked(graphql).mockResolvedValueOnce({ deleteNode: true })
|
|
177
|
+
|
|
178
|
+
const deleteCmd = featureCommand.commands.find(
|
|
179
|
+
(c) => c.name() === 'delete',
|
|
180
|
+
)!
|
|
181
|
+
await deleteCmd.parseAsync(['feat_1'], { from: 'user' })
|
|
182
|
+
|
|
183
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
184
|
+
expect.stringContaining('deleteNode'),
|
|
185
|
+
{
|
|
186
|
+
id: 'feat_1',
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
expect(output).toHaveBeenCalledWith({ deleted: true })
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('delete surfaces CONFLICT via outputError with its code', async () => {
|
|
193
|
+
const { graphql } = await import('../util/client.ts')
|
|
194
|
+
const { outputError } = await import('../util/format.ts')
|
|
195
|
+
const { featureCommand } = await import('./feature.ts')
|
|
196
|
+
|
|
197
|
+
const conflict = Object.assign(new Error('has children'), {
|
|
198
|
+
code: 'CONFLICT',
|
|
199
|
+
})
|
|
200
|
+
vi.mocked(graphql).mockRejectedValueOnce(conflict)
|
|
201
|
+
|
|
202
|
+
const deleteCmd = featureCommand.commands.find(
|
|
203
|
+
(c) => c.name() === 'delete',
|
|
204
|
+
)!
|
|
205
|
+
await deleteCmd.parseAsync(['feat_1'], { from: 'user' })
|
|
206
|
+
|
|
207
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
208
|
+
expect.objectContaining({ code: 'CONFLICT' }),
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('delete surfaces NOT_FOUND via outputError with its code', async () => {
|
|
213
|
+
const { graphql } = await import('../util/client.ts')
|
|
214
|
+
const { outputError } = await import('../util/format.ts')
|
|
215
|
+
const { featureCommand } = await import('./feature.ts')
|
|
216
|
+
|
|
217
|
+
const notFound = Object.assign(new Error('Node feat_x not found'), {
|
|
218
|
+
code: 'NOT_FOUND',
|
|
219
|
+
})
|
|
220
|
+
vi.mocked(graphql).mockRejectedValueOnce(notFound)
|
|
221
|
+
|
|
222
|
+
const deleteCmd = featureCommand.commands.find(
|
|
223
|
+
(c) => c.name() === 'delete',
|
|
224
|
+
)!
|
|
225
|
+
await deleteCmd.parseAsync(['feat_x'], { from: 'user' })
|
|
226
|
+
|
|
227
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
228
|
+
expect.objectContaining({ code: 'NOT_FOUND' }),
|
|
229
|
+
)
|
|
230
|
+
})
|
|
71
231
|
})
|
package/src/commands/feature.ts
CHANGED
|
@@ -126,6 +126,75 @@ featureCommand
|
|
|
126
126
|
}
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
+
featureCommand
|
|
130
|
+
.command('update')
|
|
131
|
+
.description('Update a feature')
|
|
132
|
+
.argument('[id]', 'Feature ID (defaults to active feature)')
|
|
133
|
+
.option('--title <title>', 'New title')
|
|
134
|
+
.option(
|
|
135
|
+
'--description <text>',
|
|
136
|
+
'New description, used verbatim (never read as a file path)',
|
|
137
|
+
)
|
|
138
|
+
.option(
|
|
139
|
+
'--description-file <path>',
|
|
140
|
+
'Read the new description from a file, or "-" for stdin',
|
|
141
|
+
)
|
|
142
|
+
.option('--metadata <json>', 'New metadata as a JSON string')
|
|
143
|
+
.action(async (id: string | undefined, opts) => {
|
|
144
|
+
try {
|
|
145
|
+
const featureId = id ?? resolveFeature()
|
|
146
|
+
if (!featureId) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'No feature specified. Pass an ID or set an active feature.',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
const variables: Record<string, unknown> = { id: featureId }
|
|
152
|
+
if (opts.title != null) variables.title = opts.title
|
|
153
|
+
if (opts.description != null || opts.descriptionFile != null) {
|
|
154
|
+
variables.description = await resolveDescription({
|
|
155
|
+
description: opts.description,
|
|
156
|
+
descriptionFile: opts.descriptionFile,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
if (opts.metadata != null) variables.metadata = opts.metadata
|
|
160
|
+
const data = await graphql<{ updateNode: unknown }>(
|
|
161
|
+
`mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
|
|
162
|
+
updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
|
|
163
|
+
id type title description status metadata createdAt updatedAt
|
|
164
|
+
}
|
|
165
|
+
}`,
|
|
166
|
+
variables,
|
|
167
|
+
)
|
|
168
|
+
output(data.updateNode)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
outputError(error)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
featureCommand
|
|
175
|
+
.command('delete')
|
|
176
|
+
.description('Delete a feature')
|
|
177
|
+
.argument('[id]', 'Feature ID (defaults to active feature)')
|
|
178
|
+
.action(async (id?: string) => {
|
|
179
|
+
try {
|
|
180
|
+
const featureId = id ?? resolveFeature()
|
|
181
|
+
if (!featureId) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
'No feature specified. Pass an ID or set an active feature.',
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
const data = await graphql<{ deleteNode: boolean }>(
|
|
187
|
+
`mutation DeleteNode($id: String!) {
|
|
188
|
+
deleteNode(id: $id)
|
|
189
|
+
}`,
|
|
190
|
+
{ id: featureId },
|
|
191
|
+
)
|
|
192
|
+
output({ deleted: data.deleteNode })
|
|
193
|
+
} catch (error) {
|
|
194
|
+
outputError(error)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
129
198
|
featureCommand
|
|
130
199
|
.command('show')
|
|
131
200
|
.description('Show feature details')
|
|
@@ -3,6 +3,10 @@ import { homedir } from 'node:os'
|
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
5
5
|
|
|
6
|
+
vi.mock('../util/client.ts', () => ({
|
|
7
|
+
graphql: vi.fn(),
|
|
8
|
+
}))
|
|
9
|
+
|
|
6
10
|
const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
|
|
7
11
|
|
|
8
12
|
describe('project command', () => {
|
|
@@ -24,7 +28,7 @@ describe('project command', () => {
|
|
|
24
28
|
vi.restoreAllMocks()
|
|
25
29
|
})
|
|
26
30
|
|
|
27
|
-
test('exports a command group named "project" with create, set, list, show subcommands', async () => {
|
|
31
|
+
test('exports a command group named "project" with create, set, list, show, update, delete subcommands', async () => {
|
|
28
32
|
const { projectCommand } = await import('./project.ts')
|
|
29
33
|
expect(projectCommand.name()).toBe('project')
|
|
30
34
|
const subcommandNames = projectCommand.commands.map((c) => c.name())
|
|
@@ -32,7 +36,9 @@ describe('project command', () => {
|
|
|
32
36
|
expect(subcommandNames).toContain('set')
|
|
33
37
|
expect(subcommandNames).toContain('list')
|
|
34
38
|
expect(subcommandNames).toContain('show')
|
|
35
|
-
expect(
|
|
39
|
+
expect(subcommandNames).toContain('update')
|
|
40
|
+
expect(subcommandNames).toContain('delete')
|
|
41
|
+
expect(projectCommand.commands).toHaveLength(6)
|
|
36
42
|
})
|
|
37
43
|
|
|
38
44
|
test('show without id calls requireProject which throws when no project configured', async () => {
|
|
@@ -80,4 +86,165 @@ describe('project command', () => {
|
|
|
80
86
|
name: 'Second',
|
|
81
87
|
})
|
|
82
88
|
})
|
|
89
|
+
|
|
90
|
+
test('update sends updateNode with only the title when title-only', async () => {
|
|
91
|
+
const { graphql } = await import('../util/client.ts')
|
|
92
|
+
const { projectCommand } = await import('./project.ts')
|
|
93
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
94
|
+
|
|
95
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
96
|
+
updateNode: { id: 'proj_1', title: 'New' },
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const updateCmd = projectCommand.commands.find(
|
|
100
|
+
(c) => c.name() === 'update',
|
|
101
|
+
)!
|
|
102
|
+
await updateCmd.parseAsync(['proj_1', '--title', 'New'], { from: 'user' })
|
|
103
|
+
|
|
104
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
105
|
+
expect.stringContaining('updateNode'),
|
|
106
|
+
{
|
|
107
|
+
id: 'proj_1',
|
|
108
|
+
title: 'New',
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('update sends updateNode with only the description when description-only', async () => {
|
|
114
|
+
const { graphql } = await import('../util/client.ts')
|
|
115
|
+
const { projectCommand } = await import('./project.ts')
|
|
116
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
117
|
+
|
|
118
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'proj_1' } })
|
|
119
|
+
|
|
120
|
+
const updateCmd = projectCommand.commands.find(
|
|
121
|
+
(c) => c.name() === 'update',
|
|
122
|
+
)!
|
|
123
|
+
await updateCmd.parseAsync(['proj_1', '--description', 'Body'], {
|
|
124
|
+
from: 'user',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
128
|
+
expect.stringContaining('updateNode'),
|
|
129
|
+
{
|
|
130
|
+
id: 'proj_1',
|
|
131
|
+
description: 'Body',
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('update sends updateNode with only the metadata when metadata-only', async () => {
|
|
137
|
+
const { graphql } = await import('../util/client.ts')
|
|
138
|
+
const { projectCommand } = await import('./project.ts')
|
|
139
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
140
|
+
|
|
141
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'proj_1' } })
|
|
142
|
+
|
|
143
|
+
const updateCmd = projectCommand.commands.find(
|
|
144
|
+
(c) => c.name() === 'update',
|
|
145
|
+
)!
|
|
146
|
+
await updateCmd.parseAsync(['proj_1', '--metadata', '{"k":"v"}'], {
|
|
147
|
+
from: 'user',
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
151
|
+
expect.stringContaining('updateNode'),
|
|
152
|
+
{
|
|
153
|
+
id: 'proj_1',
|
|
154
|
+
metadata: '{"k":"v"}',
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('update sends updateNode with combined fields', async () => {
|
|
160
|
+
const { graphql } = await import('../util/client.ts')
|
|
161
|
+
const { projectCommand } = await import('./project.ts')
|
|
162
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
163
|
+
|
|
164
|
+
vi.mocked(graphql).mockResolvedValueOnce({ updateNode: { id: 'proj_1' } })
|
|
165
|
+
|
|
166
|
+
const updateCmd = projectCommand.commands.find(
|
|
167
|
+
(c) => c.name() === 'update',
|
|
168
|
+
)!
|
|
169
|
+
await updateCmd.parseAsync(
|
|
170
|
+
['proj_1', '--title', 'New', '--description', 'Body', '--metadata', '{}'],
|
|
171
|
+
{ from: 'user' },
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
175
|
+
expect.stringContaining('updateNode'),
|
|
176
|
+
{
|
|
177
|
+
id: 'proj_1',
|
|
178
|
+
title: 'New',
|
|
179
|
+
description: 'Body',
|
|
180
|
+
metadata: '{}',
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('delete sends deleteNode mutation', async () => {
|
|
186
|
+
const { graphql } = await import('../util/client.ts')
|
|
187
|
+
const { projectCommand } = await import('./project.ts')
|
|
188
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
189
|
+
|
|
190
|
+
vi.mocked(graphql).mockResolvedValueOnce({ deleteNode: true })
|
|
191
|
+
|
|
192
|
+
const deleteCmd = projectCommand.commands.find(
|
|
193
|
+
(c) => c.name() === 'delete',
|
|
194
|
+
)!
|
|
195
|
+
await deleteCmd.parseAsync(['proj_1'], { from: 'user' })
|
|
196
|
+
|
|
197
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
198
|
+
expect.stringContaining('deleteNode'),
|
|
199
|
+
{
|
|
200
|
+
id: 'proj_1',
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('delete surfaces CONFLICT with exit code 1', async () => {
|
|
206
|
+
const { graphql } = await import('../util/client.ts')
|
|
207
|
+
const { projectCommand } = await import('./project.ts')
|
|
208
|
+
const mockExit = vi
|
|
209
|
+
.spyOn(process, 'exit')
|
|
210
|
+
.mockImplementation(() => undefined as never)
|
|
211
|
+
const mockStderr = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
212
|
+
|
|
213
|
+
const conflict = Object.assign(new Error('has children'), {
|
|
214
|
+
code: 'CONFLICT',
|
|
215
|
+
})
|
|
216
|
+
vi.mocked(graphql).mockRejectedValueOnce(conflict)
|
|
217
|
+
|
|
218
|
+
const deleteCmd = projectCommand.commands.find(
|
|
219
|
+
(c) => c.name() === 'delete',
|
|
220
|
+
)!
|
|
221
|
+
await deleteCmd.parseAsync(['proj_1'], { from: 'user' })
|
|
222
|
+
|
|
223
|
+
expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining('CONFLICT'))
|
|
224
|
+
expect(mockExit).toHaveBeenCalledWith(1)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('delete surfaces NOT_FOUND with exit code 2', async () => {
|
|
228
|
+
const { graphql } = await import('../util/client.ts')
|
|
229
|
+
const { projectCommand } = await import('./project.ts')
|
|
230
|
+
const mockExit = vi
|
|
231
|
+
.spyOn(process, 'exit')
|
|
232
|
+
.mockImplementation(() => undefined as never)
|
|
233
|
+
const mockStderr = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
234
|
+
|
|
235
|
+
const notFound = Object.assign(new Error('Node proj_x not found'), {
|
|
236
|
+
code: 'NOT_FOUND',
|
|
237
|
+
})
|
|
238
|
+
vi.mocked(graphql).mockRejectedValueOnce(notFound)
|
|
239
|
+
|
|
240
|
+
const deleteCmd = projectCommand.commands.find(
|
|
241
|
+
(c) => c.name() === 'delete',
|
|
242
|
+
)!
|
|
243
|
+
await deleteCmd.parseAsync(['proj_x'], { from: 'user' })
|
|
244
|
+
|
|
245
|
+
expect(mockStderr).toHaveBeenCalledWith(
|
|
246
|
+
expect.stringContaining('NOT_FOUND'),
|
|
247
|
+
)
|
|
248
|
+
expect(mockExit).toHaveBeenCalledWith(2)
|
|
249
|
+
})
|
|
83
250
|
})
|
package/src/commands/project.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
3
|
import { loadConfig, requireProject, saveConfig } from '../util/config.ts'
|
|
4
|
+
import { resolveDescription } from '../util/description.ts'
|
|
4
5
|
import { output, outputError } from '../util/format.ts'
|
|
5
6
|
|
|
6
7
|
export const projectCommand = new Command('project').description(
|
|
@@ -102,3 +103,62 @@ projectCommand
|
|
|
102
103
|
.description('Show project details')
|
|
103
104
|
.argument('[id]', 'Project ID (defaults to active project)')
|
|
104
105
|
.action(async (id?: string) => showProject(id))
|
|
106
|
+
|
|
107
|
+
projectCommand
|
|
108
|
+
.command('update')
|
|
109
|
+
.description('Update a project')
|
|
110
|
+
.argument('[id]', 'Project ID (defaults to active project)')
|
|
111
|
+
.option('--title <title>', 'New title')
|
|
112
|
+
.option(
|
|
113
|
+
'--description <text>',
|
|
114
|
+
'New description, used verbatim (never read as a file path)',
|
|
115
|
+
)
|
|
116
|
+
.option(
|
|
117
|
+
'--description-file <path>',
|
|
118
|
+
'Read the new description from a file, or "-" for stdin',
|
|
119
|
+
)
|
|
120
|
+
.option('--metadata <json>', 'New metadata as a JSON string')
|
|
121
|
+
.action(async (id: string | undefined, opts) => {
|
|
122
|
+
try {
|
|
123
|
+
const projectId = id ?? requireProject().id
|
|
124
|
+
const variables: Record<string, unknown> = { id: projectId }
|
|
125
|
+
if (opts.title != null) variables.title = opts.title
|
|
126
|
+
if (opts.description != null || opts.descriptionFile != null) {
|
|
127
|
+
variables.description = await resolveDescription({
|
|
128
|
+
description: opts.description,
|
|
129
|
+
descriptionFile: opts.descriptionFile,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
if (opts.metadata != null) variables.metadata = opts.metadata
|
|
133
|
+
const data = await graphql<{ updateNode: unknown }>(
|
|
134
|
+
`mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
|
|
135
|
+
updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
|
|
136
|
+
id type title description status metadata createdAt updatedAt
|
|
137
|
+
}
|
|
138
|
+
}`,
|
|
139
|
+
variables,
|
|
140
|
+
)
|
|
141
|
+
output(data.updateNode)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
outputError(error)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
projectCommand
|
|
148
|
+
.command('delete')
|
|
149
|
+
.description('Delete a project')
|
|
150
|
+
.argument('[id]', 'Project ID (defaults to active project)')
|
|
151
|
+
.action(async (id?: string) => {
|
|
152
|
+
try {
|
|
153
|
+
const projectId = id ?? requireProject().id
|
|
154
|
+
const data = await graphql<{ deleteNode: boolean }>(
|
|
155
|
+
`mutation DeleteNode($id: String!) {
|
|
156
|
+
deleteNode(id: $id)
|
|
157
|
+
}`,
|
|
158
|
+
{ id: projectId },
|
|
159
|
+
)
|
|
160
|
+
output({ deleted: data.deleteNode })
|
|
161
|
+
} catch (error) {
|
|
162
|
+
outputError(error)
|
|
163
|
+
}
|
|
164
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from 'vitest'
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
vi.mock('../util/config.ts', () => ({
|
|
4
4
|
requireFeature: vi.fn(() => {
|
|
@@ -18,10 +18,14 @@ vi.mock('../util/format.ts', () => ({
|
|
|
18
18
|
}))
|
|
19
19
|
|
|
20
20
|
describe('task command', () => {
|
|
21
|
-
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('exports a command group with 7 subcommands', async () => {
|
|
22
26
|
const { taskCommand } = await import('./task.ts')
|
|
23
27
|
expect(taskCommand.name()).toBe('task')
|
|
24
|
-
expect(taskCommand.commands).toHaveLength(
|
|
28
|
+
expect(taskCommand.commands).toHaveLength(7)
|
|
25
29
|
|
|
26
30
|
const names = taskCommand.commands.map((c) => c.name())
|
|
27
31
|
expect(names).toContain('create')
|
|
@@ -29,6 +33,8 @@ describe('task command', () => {
|
|
|
29
33
|
expect(names).toContain('show')
|
|
30
34
|
expect(names).toContain('block')
|
|
31
35
|
expect(names).toContain('unblock')
|
|
36
|
+
expect(names).toContain('update')
|
|
37
|
+
expect(names).toContain('delete')
|
|
32
38
|
})
|
|
33
39
|
|
|
34
40
|
test('create exposes both --description and --description-file options', async () => {
|
|
@@ -88,4 +94,165 @@ describe('task command', () => {
|
|
|
88
94
|
}),
|
|
89
95
|
)
|
|
90
96
|
})
|
|
97
|
+
|
|
98
|
+
test('update sends updateNode with only the title when title-only', async () => {
|
|
99
|
+
const { graphql } = await import('../util/client.ts')
|
|
100
|
+
const { output } = await import('../util/format.ts')
|
|
101
|
+
const { taskCommand } = await import('./task.ts')
|
|
102
|
+
|
|
103
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
104
|
+
updateNode: { id: 'task_1', title: 'New' },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
108
|
+
await updateCmd.parseAsync(['task_1', '--title', 'New'], { from: 'user' })
|
|
109
|
+
|
|
110
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
111
|
+
expect.stringContaining('updateNode'),
|
|
112
|
+
{ id: 'task_1', title: 'New' },
|
|
113
|
+
)
|
|
114
|
+
expect(output).toHaveBeenCalledWith({ id: 'task_1', title: 'New' })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('update sends updateNode with only the description when description-only', async () => {
|
|
118
|
+
const { graphql } = await import('../util/client.ts')
|
|
119
|
+
const { taskCommand } = await import('./task.ts')
|
|
120
|
+
|
|
121
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
122
|
+
updateNode: { id: 'task_1' },
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
126
|
+
await updateCmd.parseAsync(['task_1', '--description', 'Body'], {
|
|
127
|
+
from: 'user',
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
131
|
+
expect.stringContaining('updateNode'),
|
|
132
|
+
{
|
|
133
|
+
id: 'task_1',
|
|
134
|
+
description: 'Body',
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('update sends updateNode with only the metadata when metadata-only', async () => {
|
|
140
|
+
const { graphql } = await import('../util/client.ts')
|
|
141
|
+
const { taskCommand } = await import('./task.ts')
|
|
142
|
+
|
|
143
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
144
|
+
updateNode: { id: 'task_1' },
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
148
|
+
await updateCmd.parseAsync(['task_1', '--metadata', '{"k":"v"}'], {
|
|
149
|
+
from: 'user',
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
153
|
+
expect.stringContaining('updateNode'),
|
|
154
|
+
{
|
|
155
|
+
id: 'task_1',
|
|
156
|
+
metadata: '{"k":"v"}',
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('update sends updateNode with combined fields', async () => {
|
|
162
|
+
const { graphql } = await import('../util/client.ts')
|
|
163
|
+
const { taskCommand } = await import('./task.ts')
|
|
164
|
+
|
|
165
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
166
|
+
updateNode: { id: 'task_1' },
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
170
|
+
await updateCmd.parseAsync(
|
|
171
|
+
['task_1', '--title', 'New', '--description', 'Body', '--metadata', '{}'],
|
|
172
|
+
{ from: 'user' },
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
176
|
+
expect.stringContaining('updateNode'),
|
|
177
|
+
{
|
|
178
|
+
id: 'task_1',
|
|
179
|
+
title: 'New',
|
|
180
|
+
description: 'Body',
|
|
181
|
+
metadata: '{}',
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('update surfaces NOT_FOUND via outputError with its code', async () => {
|
|
187
|
+
const { graphql } = await import('../util/client.ts')
|
|
188
|
+
const { outputError } = await import('../util/format.ts')
|
|
189
|
+
const { taskCommand } = await import('./task.ts')
|
|
190
|
+
|
|
191
|
+
const notFound = Object.assign(new Error('Node task_x not found'), {
|
|
192
|
+
code: 'NOT_FOUND',
|
|
193
|
+
})
|
|
194
|
+
vi.mocked(graphql).mockRejectedValueOnce(notFound)
|
|
195
|
+
|
|
196
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
197
|
+
await updateCmd.parseAsync(['task_x', '--title', 'New'], { from: 'user' })
|
|
198
|
+
|
|
199
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
200
|
+
expect.objectContaining({ code: 'NOT_FOUND' }),
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('delete sends deleteNode mutation', async () => {
|
|
205
|
+
const { graphql } = await import('../util/client.ts')
|
|
206
|
+
const { output } = await import('../util/format.ts')
|
|
207
|
+
const { taskCommand } = await import('./task.ts')
|
|
208
|
+
|
|
209
|
+
vi.mocked(graphql).mockResolvedValueOnce({ deleteNode: true })
|
|
210
|
+
|
|
211
|
+
const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
|
|
212
|
+
await deleteCmd.parseAsync(['task_1'], { from: 'user' })
|
|
213
|
+
|
|
214
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
215
|
+
expect.stringContaining('deleteNode'),
|
|
216
|
+
{
|
|
217
|
+
id: 'task_1',
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
expect(output).toHaveBeenCalledWith({ deleted: true })
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('delete surfaces CONFLICT via outputError with its code', async () => {
|
|
224
|
+
const { graphql } = await import('../util/client.ts')
|
|
225
|
+
const { outputError } = await import('../util/format.ts')
|
|
226
|
+
const { taskCommand } = await import('./task.ts')
|
|
227
|
+
|
|
228
|
+
const conflict = Object.assign(new Error('has children'), {
|
|
229
|
+
code: 'CONFLICT',
|
|
230
|
+
})
|
|
231
|
+
vi.mocked(graphql).mockRejectedValueOnce(conflict)
|
|
232
|
+
|
|
233
|
+
const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
|
|
234
|
+
await deleteCmd.parseAsync(['task_1'], { from: 'user' })
|
|
235
|
+
|
|
236
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
237
|
+
expect.objectContaining({ code: 'CONFLICT' }),
|
|
238
|
+
)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('delete surfaces NOT_FOUND via outputError with its code', async () => {
|
|
242
|
+
const { graphql } = await import('../util/client.ts')
|
|
243
|
+
const { outputError } = await import('../util/format.ts')
|
|
244
|
+
const { taskCommand } = await import('./task.ts')
|
|
245
|
+
|
|
246
|
+
const notFound = Object.assign(new Error('Node task_x not found'), {
|
|
247
|
+
code: 'NOT_FOUND',
|
|
248
|
+
})
|
|
249
|
+
vi.mocked(graphql).mockRejectedValueOnce(notFound)
|
|
250
|
+
|
|
251
|
+
const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
|
|
252
|
+
await deleteCmd.parseAsync(['task_x'], { from: 'user' })
|
|
253
|
+
|
|
254
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
255
|
+
expect.objectContaining({ code: 'NOT_FOUND' }),
|
|
256
|
+
)
|
|
257
|
+
})
|
|
91
258
|
})
|
package/src/commands/task.ts
CHANGED
|
@@ -93,6 +93,63 @@ taskCommand
|
|
|
93
93
|
}
|
|
94
94
|
})
|
|
95
95
|
|
|
96
|
+
taskCommand
|
|
97
|
+
.command('update')
|
|
98
|
+
.description('Update a task')
|
|
99
|
+
.argument('<id>', 'Task ID')
|
|
100
|
+
.option('--title <title>', 'New title')
|
|
101
|
+
.option(
|
|
102
|
+
'--description <text>',
|
|
103
|
+
'New description, used verbatim (never read as a file path)',
|
|
104
|
+
)
|
|
105
|
+
.option(
|
|
106
|
+
'--description-file <path>',
|
|
107
|
+
'Read the new description from a file, or "-" for stdin',
|
|
108
|
+
)
|
|
109
|
+
.option('--metadata <json>', 'New metadata as a JSON string')
|
|
110
|
+
.action(async (id: string, opts) => {
|
|
111
|
+
try {
|
|
112
|
+
const variables: Record<string, unknown> = { id }
|
|
113
|
+
if (opts.title != null) variables.title = opts.title
|
|
114
|
+
if (opts.description != null || opts.descriptionFile != null) {
|
|
115
|
+
variables.description = await resolveDescription({
|
|
116
|
+
description: opts.description,
|
|
117
|
+
descriptionFile: opts.descriptionFile,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
if (opts.metadata != null) variables.metadata = opts.metadata
|
|
121
|
+
const data = await graphql<{ updateNode: unknown }>(
|
|
122
|
+
`mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
|
|
123
|
+
updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
|
|
124
|
+
id type title description status metadata createdAt updatedAt
|
|
125
|
+
}
|
|
126
|
+
}`,
|
|
127
|
+
variables,
|
|
128
|
+
)
|
|
129
|
+
output(data.updateNode)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
outputError(error)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
taskCommand
|
|
136
|
+
.command('delete')
|
|
137
|
+
.description('Delete a task')
|
|
138
|
+
.argument('<id>', 'Task ID')
|
|
139
|
+
.action(async (id: string) => {
|
|
140
|
+
try {
|
|
141
|
+
const data = await graphql<{ deleteNode: boolean }>(
|
|
142
|
+
`mutation DeleteNode($id: String!) {
|
|
143
|
+
deleteNode(id: $id)
|
|
144
|
+
}`,
|
|
145
|
+
{ id },
|
|
146
|
+
)
|
|
147
|
+
output({ deleted: data.deleteNode })
|
|
148
|
+
} catch (error) {
|
|
149
|
+
outputError(error)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
96
153
|
taskCommand
|
|
97
154
|
.command('block')
|
|
98
155
|
.description('Mark a task as blocking another')
|