@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.
@@ -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(projectCommand.commands).toHaveLength(4)
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
  })
@@ -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
+ })
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ let mockSpawnSync: ReturnType<typeof vi.fn>
4
+ let mockOutputError: ReturnType<typeof vi.fn>
5
+
6
+ beforeEach(() => {
7
+ mockSpawnSync = vi.fn(() => ({ status: 0 }))
8
+ mockOutputError = vi.fn()
9
+
10
+ vi.doMock('node:child_process', () => ({
11
+ spawnSync: mockSpawnSync,
12
+ }))
13
+ // Pretend the bundled server entry and its installed deps both exist so the
14
+ // command takes its normal path deterministically — independent of whether
15
+ // server/node_modules happens to be populated in this environment (CI runs
16
+ // the root test job before installing server deps).
17
+ vi.doMock('node:fs', async () => {
18
+ const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
19
+ return { ...actual, existsSync: () => true }
20
+ })
21
+ vi.doMock('../util/format.ts', () => ({
22
+ output: vi.fn(),
23
+ outputError: mockOutputError,
24
+ }))
25
+ })
26
+
27
+ afterEach(() => {
28
+ vi.resetModules()
29
+ vi.restoreAllMocks()
30
+ })
31
+
32
+ describe('serve command', () => {
33
+ test('exports a command named "serve"', async () => {
34
+ const { serveCommand } = await import('./serve.ts')
35
+ expect(serveCommand.name()).toBe('serve')
36
+ })
37
+
38
+ test('declares --port, --host and --db options', async () => {
39
+ const { serveCommand } = await import('./serve.ts')
40
+ const flags = serveCommand.options.map((o) => o.long)
41
+ expect(flags).toContain('--port')
42
+ expect(flags).toContain('--host')
43
+ expect(flags).toContain('--db')
44
+ })
45
+
46
+ test('runs the bundled server with bun (no docker) on the chosen port/host', async () => {
47
+ const { serveCommand } = await import('./serve.ts')
48
+ await serveCommand.parseAsync(['--port', '4111', '--host', '127.0.0.1'], {
49
+ from: 'user',
50
+ })
51
+
52
+ // The run invocation is the `bun` call whose args point at the bundled
53
+ // server entry — not e.g. a `bun install` call. Target it explicitly so
54
+ // the assertion does not depend on call ordering or environment state.
55
+ const runCall = mockSpawnSync.mock.calls.find(
56
+ (call) =>
57
+ call[0] === 'bun' &&
58
+ Array.isArray(call[1]) &&
59
+ call[1].some((a: string) => a.endsWith('index.ts')),
60
+ )
61
+ expect(runCall).toBeDefined()
62
+ const [, args, options] = runCall!
63
+ // never invokes docker
64
+ expect(mockSpawnSync.mock.calls.some((call) => call[0] === 'docker')).toBe(
65
+ false,
66
+ )
67
+ // points bun at the bundled server entry
68
+ expect(args.some((a: string) => a.endsWith('index.ts'))).toBe(true)
69
+ expect(options.env.PORT).toBe('4111')
70
+ expect(options.env.HOST).toBe('127.0.0.1')
71
+ })
72
+ })
73
+
74
+ describe('pinnedInstallSpec', () => {
75
+ test('derives the pinned package spec from package.json version', async () => {
76
+ const { pinnedInstallSpec } = await import('./serve.ts')
77
+ const { readFileSync } = await import('node:fs')
78
+ const pkg = JSON.parse(
79
+ readFileSync(
80
+ new URL('../../package.json', import.meta.url).pathname,
81
+ 'utf-8',
82
+ ),
83
+ ) as { version: string }
84
+
85
+ expect(pinnedInstallSpec()).toBe(`@sqaoss/flowy@${pkg.version}`)
86
+ // never an unpinned install
87
+ expect(pinnedInstallSpec()).not.toBe('@sqaoss/flowy')
88
+ })
89
+ })
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { Command } from 'commander'
6
+ import { outputError } from '../util/format.ts'
7
+
8
+ const here = dirname(fileURLToPath(import.meta.url))
9
+ const packageRoot = resolve(here, '..', '..')
10
+
11
+ /**
12
+ * The exact, version-pinned npm spec for this CLI's own package. Used by
13
+ * `setup local` so the installed server matches the CLI rather than drifting
14
+ * to whatever an unpinned `bun add @sqaoss/flowy` happens to resolve.
15
+ */
16
+ export function pinnedInstallSpec(): string {
17
+ const pkg = JSON.parse(
18
+ readFileSync(resolve(packageRoot, 'package.json'), 'utf-8'),
19
+ ) as { name: string; version: string }
20
+ return `${pkg.name}@${pkg.version}`
21
+ }
22
+
23
+ function serverDir(): string {
24
+ return resolve(packageRoot, 'server')
25
+ }
26
+
27
+ function serverEntry(): string {
28
+ return resolve(serverDir(), 'src', 'index.ts')
29
+ }
30
+
31
+ /** Install the bundled server's runtime deps once, if they're missing. */
32
+ function ensureServerDeps(dir: string): void {
33
+ if (existsSync(resolve(dir, 'node_modules', 'graphql-yoga'))) return
34
+ const install = spawnSync('bun', ['install', '--production'], {
35
+ cwd: dir,
36
+ stdio: 'inherit',
37
+ })
38
+ if (install.status !== 0) {
39
+ throw new Error('Failed to install the bundled server dependencies.')
40
+ }
41
+ }
42
+
43
+ export const serveCommand = new Command('serve')
44
+ .description('Run the bundled local Flowy server natively (no Docker)')
45
+ .option('-p, --port <port>', 'Port to bind', '4000')
46
+ .option('-H, --host <host>', 'Hostname to bind', '127.0.0.1')
47
+ .option('-d, --db <path>', 'SQLite database file path', './flowy.sqlite')
48
+ .action((opts: { port: string; host: string; db: string }) => {
49
+ try {
50
+ const dir = serverDir()
51
+ const entry = serverEntry()
52
+ if (!existsSync(entry)) {
53
+ throw new Error(
54
+ `Bundled server not found at ${entry}. Reinstall ${pinnedInstallSpec()}.`,
55
+ )
56
+ }
57
+
58
+ ensureServerDeps(dir)
59
+
60
+ const result = spawnSync('bun', [entry], {
61
+ cwd: dir,
62
+ stdio: 'inherit',
63
+ env: {
64
+ ...process.env,
65
+ PORT: String(opts.port),
66
+ HOST: opts.host,
67
+ FLOWY_DB_PATH: opts.db,
68
+ },
69
+ })
70
+ if (result.status !== 0 && result.status !== null) {
71
+ throw new Error(`Server exited with status ${result.status}.`)
72
+ }
73
+ } catch (error) {
74
+ outputError(error)
75
+ }
76
+ })
@@ -53,29 +53,27 @@ describe('setup command', () => {
53
53
  expect(setupCommand.commands).toHaveLength(2)
54
54
  })
55
55
 
56
- test('setup local checks for docker and errors if not found', async () => {
57
- mockSpawnSync.mockReturnValue({ status: 1 })
56
+ test('setup local installs the pinned package version (no docker)', async () => {
57
+ mockSpawnSync.mockReturnValue({ status: 0 })
58
58
 
59
59
  const { setupCommand } = await import('./setup.ts')
60
+ const { pinnedInstallSpec } = await import('./serve.ts')
60
61
  await setupCommand.parseAsync(['local'], { from: 'user' })
61
62
 
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
- }),
63
+ // installs the package pinned to the exact CLI version
64
+ expect(mockSpawnSync).toHaveBeenCalledWith(
65
+ 'bun',
66
+ ['add', pinnedInstallSpec()],
67
+ expect.anything(),
68
+ )
69
+ // never shells out to docker
70
+ expect(mockSpawnSync.mock.calls.some((call) => call[0] === 'docker')).toBe(
71
+ false,
69
72
  )
70
73
  })
71
74
 
72
75
  test('setup local saves config with mode "local" and apiUrl on success', async () => {
73
76
  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
77
 
80
78
  const { setupCommand } = await import('./setup.ts')
81
79
  await setupCommand.parseAsync(['local'], { from: 'user' })
@@ -1,62 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process'
2
- import { mkdirSync, writeFileSync } from 'node:fs'
3
- import { homedir } from 'node:os'
4
- import { resolve } from 'node:path'
5
2
  import { Command, Option } from 'commander'
6
3
  import { loadConfig, saveConfig } from '../util/config.ts'
7
4
  import { output, outputError } from '../util/format.ts'
8
-
9
- const COMPOSE_CONTENT = `services:
10
- server:
11
- build:
12
- context: .
13
- dockerfile_inline: |
14
- FROM oven/bun:1.3.11
15
- WORKDIR /app
16
- RUN bun init -y && bun add @sqaoss/flowy
17
- WORKDIR /app/node_modules/@sqaoss/flowy/server
18
- RUN bun install --production
19
- EXPOSE 4000
20
- VOLUME /data
21
- CMD ["bun", "src/index.ts"]
22
- ports:
23
- - "4000:4000"
24
- volumes:
25
- - flowy-data:/data
26
- environment:
27
- - FLOWY_DB_PATH=/data/flowy.sqlite
28
- - PORT=4000
29
- healthcheck:
30
- test: ["CMD", "bun", "-e", "fetch('http://localhost:4000/health').then(r => r.ok ? process.exit(0) : process.exit(1))"]
31
- interval: 5s
32
- timeout: 3s
33
- retries: 5
34
-
35
- volumes:
36
- flowy-data:
37
- `
38
-
39
- function ensureComposeFile(): string {
40
- const dir = resolve(homedir(), '.config', 'flowy')
41
- mkdirSync(dir, { recursive: true })
42
- const composePath = resolve(dir, 'docker-compose.yml')
43
- writeFileSync(composePath, COMPOSE_CONTENT)
44
- return composePath
45
- }
46
-
47
- async function pollHealth(url: string, timeoutMs = 60_000): Promise<void> {
48
- const start = Date.now()
49
- while (Date.now() - start < timeoutMs) {
50
- try {
51
- const res = await fetch(url)
52
- if (res.ok) return
53
- } catch {}
54
- await new Promise((r) => setTimeout(r, 1_000))
55
- }
56
- throw new Error(
57
- `Health check at ${url} did not respond within ${timeoutMs / 1_000}s.`,
58
- )
59
- }
5
+ import { pinnedInstallSpec } from './serve.ts'
60
6
 
61
7
  export const setupCommand = new Command('setup').description(
62
8
  'Configure the Flowy CLI \u2014 use "flowy setup local" or "flowy setup remote"',
@@ -64,28 +10,18 @@ export const setupCommand = new Command('setup').description(
64
10
 
65
11
  setupCommand
66
12
  .command('local')
67
- .description('Set up Flowy with a local Docker server')
13
+ .description('Set up Flowy with a native local server (no Docker)')
68
14
  .action(async () => {
69
15
  try {
70
- const dockerCheck = spawnSync('docker', ['--version'], {
71
- stdio: 'ignore',
72
- })
73
- if (dockerCheck.status !== 0) {
74
- throw new Error('Docker is required but was not found.')
16
+ // Pin the install to this CLI's exact version so the server can never
17
+ // drift to a stale npm release behind a cached Docker layer (F15).
18
+ const spec = pinnedInstallSpec()
19
+ const install = spawnSync('bun', ['add', spec], { stdio: 'inherit' })
20
+ if (install.status !== 0) {
21
+ throw new Error(`Failed to install ${spec}.`)
75
22
  }
76
23
 
77
- const composePath = ensureComposeFile()
78
- spawnSync(
79
- 'docker',
80
- ['compose', '-f', composePath, 'up', '-d', '--build'],
81
- {
82
- stdio: 'inherit',
83
- },
84
- )
85
-
86
24
  const apiUrl = 'http://localhost:4000/graphql'
87
- await pollHealth('http://localhost:4000/health')
88
-
89
25
  const config = loadConfig()
90
26
  config.mode = 'local'
91
27
  config.apiUrl = apiUrl
@@ -94,7 +30,12 @@ setupCommand
94
30
  stdio: 'inherit',
95
31
  })
96
32
 
97
- output({ mode: 'local', apiUrl })
33
+ output({
34
+ mode: 'local',
35
+ apiUrl,
36
+ installed: spec,
37
+ next: 'Run "flowy serve" to start the local server (binds 127.0.0.1:4000).',
38
+ })
98
39
  } catch (error) {
99
40
  outputError(error)
100
41
  }