@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 = (await gql(
172
- 'query ($id: String!) { node(id: $id) { id } }',
173
- {
174
- id,
175
- },
176
- )) as { data: { node: unknown } }
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 () => {
@@ -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
  })
@@ -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?: { dbPath?: string; port?: number }) {
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
- console.log(`Flowy local server running on http://localhost:${port}`)
43
- console.log(` GraphQL: http://localhost:${port}/graphql`)
44
- console.log(` Health: http://localhost:${port}/health`)
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('returns null for non-existent id', () => {
169
- const found = resolvers.Query.node(null, { id: 'nonexistent' })
170
- expect(found).toBeNull()
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
- expect(resolvers.Query.node(null, { id: node.id })).toBeNull()
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', () => {
@@ -122,7 +122,9 @@ export function createResolvers(db: Db) {
122
122
  return {
123
123
  Query: {
124
124
  node: (_: unknown, args: { id: string }) => {
125
- return selectNode(db, args.id)
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
  })
@@ -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
- .requiredOption('--description <description>', 'Feature description')
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(opts.description)
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')