@sqaoss/flowy 1.3.0 → 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/docker-compose.yml +7 -2
- package/package.json +1 -1
- package/server/src/index.errors.test.ts +37 -7
- package/server/src/index.test.ts +29 -0
- package/server/src/index.ts +15 -5
- package/server/src/resolvers.test.ts +15 -4
- package/server/src/resolvers.ts +3 -1
- package/src/commands/feature.test.ts +161 -1
- package/src/commands/feature.ts +81 -2
- package/src/commands/project.test.ts +169 -2
- package/src/commands/project.ts +60 -0
- package/src/commands/serve.test.ts +89 -0
- package/src/commands/serve.ts +76 -0
- package/src/commands/setup.test.ts +12 -14
- package/src/commands/setup.ts +14 -73
- package/src/commands/task.test.ts +178 -3
- package/src/commands/task.ts +68 -4
- package/src/index.test.ts +16 -0
- package/src/index.ts +2 -0
- package/src/util/client.test.ts +194 -81
- package/src/util/client.ts +127 -27
- package/src/util/description.test.ts +59 -7
- package/src/util/description.ts +59 -4
- package/src/util/format.test.ts +65 -0
- package/src/util/format.ts +22 -1
package/docker-compose.yml
CHANGED
|
@@ -5,19 +5,24 @@ services:
|
|
|
5
5
|
dockerfile_inline: |
|
|
6
6
|
FROM oven/bun:1.3.11
|
|
7
7
|
WORKDIR /app
|
|
8
|
-
RUN bun init -y && bun add @sqaoss/flowy
|
|
8
|
+
RUN bun init -y && bun add @sqaoss/flowy@1.3.0
|
|
9
9
|
WORKDIR /app/node_modules/@sqaoss/flowy/server
|
|
10
10
|
RUN bun install --production
|
|
11
11
|
EXPOSE 4000
|
|
12
12
|
VOLUME /data
|
|
13
13
|
CMD ["bun", "src/index.ts"]
|
|
14
|
+
# Publish on loopback only — the server is unauthenticated, so it must not
|
|
15
|
+
# be reachable from the LAN. Prefer the native `flowy serve` for local dev.
|
|
14
16
|
ports:
|
|
15
|
-
- "4000:4000"
|
|
17
|
+
- "127.0.0.1:4000:4000"
|
|
16
18
|
volumes:
|
|
17
19
|
- flowy-data:/data
|
|
18
20
|
environment:
|
|
19
21
|
- FLOWY_DB_PATH=/data/flowy.sqlite
|
|
20
22
|
- PORT=4000
|
|
23
|
+
# Bind all interfaces *inside* the container so Docker's loopback-only
|
|
24
|
+
# host publish above can reach it; the host port stays 127.0.0.1.
|
|
25
|
+
- HOST=0.0.0.0
|
|
21
26
|
healthcheck:
|
|
22
27
|
test: ["CMD", "bun", "-e", "fetch('http://localhost:4000/health').then(r => r.ok ? process.exit(0) : process.exit(1))"]
|
|
23
28
|
interval: 5s
|
package/package.json
CHANGED
|
@@ -75,6 +75,37 @@ describe('server error masking', () => {
|
|
|
75
75
|
expect(error.extensions?.code).toBe('VALIDATION_ERROR')
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
+
it('surfaces a not-found node query with real message and NOT_FOUND code (no silent null)', async () => {
|
|
79
|
+
instance = createServer({ dbPath: ':memory:', port: 0 })
|
|
80
|
+
|
|
81
|
+
const json = await gql('query ($id: String!) { node(id: $id) { id } }', {
|
|
82
|
+
id: 'task_nonexistent',
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(json.errors).toBeDefined()
|
|
86
|
+
const error = json.errors![0]
|
|
87
|
+
expect(error.message).toBe('Node task_nonexistent not found')
|
|
88
|
+
expect(error.extensions?.code).toBe('NOT_FOUND')
|
|
89
|
+
const data = json.data as { node: unknown } | null | undefined
|
|
90
|
+
expect(data?.node ?? null).toBeNull()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('still returns a node when it exists', async () => {
|
|
94
|
+
instance = createServer({ dbPath: ':memory:', port: 0 })
|
|
95
|
+
|
|
96
|
+
const created = (await gql(
|
|
97
|
+
'mutation { createNode(type: "task", title: "T") { id } }',
|
|
98
|
+
)) as { data: { createNode: { id: string } } }
|
|
99
|
+
const id = created.data.createNode.id
|
|
100
|
+
|
|
101
|
+
const json = (await gql('query ($id: String!) { node(id: $id) { id } }', {
|
|
102
|
+
id,
|
|
103
|
+
})) as { data: { node: { id: string } }; errors?: unknown[] }
|
|
104
|
+
|
|
105
|
+
expect(json.errors).toBeUndefined()
|
|
106
|
+
expect(json.data.node.id).toBe(id)
|
|
107
|
+
})
|
|
108
|
+
|
|
78
109
|
it('surfaces an approve-wrong-status error with CONFLICT code', async () => {
|
|
79
110
|
instance = createServer({ dbPath: ':memory:', port: 0 })
|
|
80
111
|
|
|
@@ -168,13 +199,12 @@ describe('server error masking', () => {
|
|
|
168
199
|
})) as { data: { deleteNode: boolean } }
|
|
169
200
|
expect(del.data.deleteNode).toBe(true)
|
|
170
201
|
|
|
171
|
-
const fetched =
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
))
|
|
177
|
-
expect(fetched.data.node).toBeNull()
|
|
202
|
+
const fetched = await gql('query ($id: String!) { node(id: $id) { id } }', {
|
|
203
|
+
id,
|
|
204
|
+
})
|
|
205
|
+
// The node is gone: querying it now fails loud with NOT_FOUND.
|
|
206
|
+
expect(fetched.errors).toBeDefined()
|
|
207
|
+
expect(fetched.errors![0].extensions?.code).toBe('NOT_FOUND')
|
|
178
208
|
})
|
|
179
209
|
|
|
180
210
|
it('refuses to delete a parent with children (CONFLICT)', async () => {
|
package/server/src/index.test.ts
CHANGED
|
@@ -22,4 +22,33 @@ describe('createServer', () => {
|
|
|
22
22
|
expect(res.status).toBe(200)
|
|
23
23
|
expect(json).toEqual({ status: 'ok' })
|
|
24
24
|
})
|
|
25
|
+
|
|
26
|
+
it('binds to 127.0.0.1 by default, not all interfaces', () => {
|
|
27
|
+
instance = createServer({ dbPath: ':memory:', port: 0 })
|
|
28
|
+
|
|
29
|
+
expect(instance.hostname).toBe('127.0.0.1')
|
|
30
|
+
expect(instance.server.hostname).toBe('127.0.0.1')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('allows the bind hostname to be overridden via opts', () => {
|
|
34
|
+
instance = createServer({
|
|
35
|
+
dbPath: ':memory:',
|
|
36
|
+
port: 0,
|
|
37
|
+
hostname: '0.0.0.0',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
expect(instance.hostname).toBe('0.0.0.0')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('allows the bind hostname to be overridden via HOST env', () => {
|
|
44
|
+
const prev = process.env.HOST
|
|
45
|
+
process.env.HOST = '0.0.0.0'
|
|
46
|
+
try {
|
|
47
|
+
instance = createServer({ dbPath: ':memory:', port: 0 })
|
|
48
|
+
expect(instance.hostname).toBe('0.0.0.0')
|
|
49
|
+
} finally {
|
|
50
|
+
if (prev === undefined) delete process.env.HOST
|
|
51
|
+
else process.env.HOST = prev
|
|
52
|
+
}
|
|
53
|
+
})
|
|
25
54
|
})
|
package/server/src/index.ts
CHANGED
|
@@ -3,9 +3,16 @@ import { createDb } from './db.ts'
|
|
|
3
3
|
import { createResolvers } from './resolvers.ts'
|
|
4
4
|
import { typeDefs } from './schema.ts'
|
|
5
5
|
|
|
6
|
-
export function createServer(opts?: {
|
|
6
|
+
export function createServer(opts?: {
|
|
7
|
+
dbPath?: string
|
|
8
|
+
port?: number
|
|
9
|
+
hostname?: string
|
|
10
|
+
}) {
|
|
7
11
|
const dbPath = opts?.dbPath ?? process.env.FLOWY_DB_PATH ?? './flowy.sqlite'
|
|
8
12
|
const port = opts?.port ?? Number(process.env.PORT ?? 4000)
|
|
13
|
+
// Bind loopback by default so the unauthenticated dev server is not exposed
|
|
14
|
+
// on the LAN. Override with the `hostname` opt or the HOST env var.
|
|
15
|
+
const hostname = opts?.hostname ?? process.env.HOST ?? '127.0.0.1'
|
|
9
16
|
|
|
10
17
|
const db = createDb(dbPath)
|
|
11
18
|
const resolvers = createResolvers(db)
|
|
@@ -17,6 +24,7 @@ export function createServer(opts?: { dbPath?: string; port?: number }) {
|
|
|
17
24
|
|
|
18
25
|
const server = Bun.serve({
|
|
19
26
|
port,
|
|
27
|
+
hostname,
|
|
20
28
|
fetch(req) {
|
|
21
29
|
const url = new URL(req.url)
|
|
22
30
|
if (url.pathname === '/health' && req.method === 'GET') {
|
|
@@ -29,6 +37,7 @@ export function createServer(opts?: { dbPath?: string; port?: number }) {
|
|
|
29
37
|
return {
|
|
30
38
|
server,
|
|
31
39
|
port: server.port,
|
|
40
|
+
hostname: server.hostname,
|
|
32
41
|
db,
|
|
33
42
|
close() {
|
|
34
43
|
server.stop()
|
|
@@ -38,8 +47,9 @@ export function createServer(opts?: { dbPath?: string; port?: number }) {
|
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
if (import.meta.main) {
|
|
41
|
-
const { port } = createServer()
|
|
42
|
-
|
|
43
|
-
console.log(`
|
|
44
|
-
console.log(`
|
|
50
|
+
const { port, hostname } = createServer()
|
|
51
|
+
const host = hostname === '0.0.0.0' ? 'localhost' : hostname
|
|
52
|
+
console.log(`Flowy local server running on http://${host}:${port}`)
|
|
53
|
+
console.log(` GraphQL: http://${host}:${port}/graphql`)
|
|
54
|
+
console.log(` Health: http://${host}:${port}/health`)
|
|
45
55
|
}
|
|
@@ -165,9 +165,17 @@ describe('createResolvers', () => {
|
|
|
165
165
|
})
|
|
166
166
|
})
|
|
167
167
|
|
|
168
|
-
it('
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
it('throws NOT_FOUND for a non-existent id (no silent null)', () => {
|
|
169
|
+
expect(() => resolvers.Query.node(null, { id: 'nonexistent' })).toThrow(
|
|
170
|
+
'Node nonexistent not found',
|
|
171
|
+
)
|
|
172
|
+
try {
|
|
173
|
+
resolvers.Query.node(null, { id: 'nonexistent' })
|
|
174
|
+
} catch (error) {
|
|
175
|
+
expect(
|
|
176
|
+
(error as { extensions?: { code?: string } }).extensions?.code,
|
|
177
|
+
).toBe('NOT_FOUND')
|
|
178
|
+
}
|
|
171
179
|
})
|
|
172
180
|
})
|
|
173
181
|
|
|
@@ -321,7 +329,10 @@ describe('createResolvers', () => {
|
|
|
321
329
|
const node = create(resolvers, { type: 'task', title: 'Leaf' })
|
|
322
330
|
const result = resolvers.Mutation.deleteNode(null, { id: node.id })
|
|
323
331
|
expect(result).toBe(true)
|
|
324
|
-
|
|
332
|
+
// After deletion the node is gone: Query.node now fails loud (NOT_FOUND).
|
|
333
|
+
expect(() => resolvers.Query.node(null, { id: node.id })).toThrow(
|
|
334
|
+
`Node ${node.id} not found`,
|
|
335
|
+
)
|
|
325
336
|
})
|
|
326
337
|
|
|
327
338
|
it('removes incident blocks edges when deleting a leaf', () => {
|
package/server/src/resolvers.ts
CHANGED
|
@@ -122,7 +122,9 @@ export function createResolvers(db: Db) {
|
|
|
122
122
|
return {
|
|
123
123
|
Query: {
|
|
124
124
|
node: (_: unknown, args: { id: string }) => {
|
|
125
|
-
|
|
125
|
+
const node = selectNode(db, args.id)
|
|
126
|
+
if (!node) throw notFoundError(`Node ${args.id} not found`)
|
|
127
|
+
return node
|
|
126
128
|
},
|
|
127
129
|
|
|
128
130
|
nodes: (_: unknown, args: { type?: string }) => {
|
|
@@ -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
|
@@ -16,11 +16,21 @@ featureCommand
|
|
|
16
16
|
.command('create')
|
|
17
17
|
.description('Create a feature in the active project')
|
|
18
18
|
.requiredOption('--title <title>', 'Feature title')
|
|
19
|
-
.
|
|
19
|
+
.option(
|
|
20
|
+
'--description <text>',
|
|
21
|
+
'Feature description, used verbatim (never read as a file path)',
|
|
22
|
+
)
|
|
23
|
+
.option(
|
|
24
|
+
'--description-file <path>',
|
|
25
|
+
'Read the feature description from a file, or "-" for stdin',
|
|
26
|
+
)
|
|
20
27
|
.action(async (opts) => {
|
|
21
28
|
try {
|
|
22
29
|
const project = requireProject()
|
|
23
|
-
const description = await resolveDescription(
|
|
30
|
+
const description = await resolveDescription({
|
|
31
|
+
description: opts.description,
|
|
32
|
+
descriptionFile: opts.descriptionFile,
|
|
33
|
+
})
|
|
24
34
|
const nodeData = await graphql<{ createNode: { id: string } }>(
|
|
25
35
|
`mutation CreateNode($type: String!, $title: String!, $description: String) {
|
|
26
36
|
createNode(type: $type, title: $title, description: $description) {
|
|
@@ -116,6 +126,75 @@ featureCommand
|
|
|
116
126
|
}
|
|
117
127
|
})
|
|
118
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
|
+
|
|
119
198
|
featureCommand
|
|
120
199
|
.command('show')
|
|
121
200
|
.description('Show feature details')
|