@sqaoss/flowy 1.10.0 → 1.12.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.
@@ -0,0 +1,252 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
6
+
7
+ let mockOutput: ReturnType<typeof vi.fn>
8
+ let mockOutputError: ReturnType<typeof vi.fn>
9
+ let workDir: string
10
+
11
+ // `bun:sqlite` is unavailable under the Node-based root test runner, so these
12
+ // tests drive a real `bun` subprocess to seed and read SQLite files. That keeps
13
+ // the row-level assertions genuine (open the backup, compare rows) without
14
+ // importing `bun:sqlite` into Node — exactly how the CLI runs at runtime.
15
+
16
+ /** Create a populated SQLite DB at `path` via a real bun subprocess. */
17
+ function seedDb(
18
+ path: string,
19
+ rows: Array<{ id: string; title: string }> = [
20
+ { id: 'n1', title: 'Alpha' },
21
+ { id: 'n2', title: 'Beta' },
22
+ { id: 'n3', title: 'Gamma' },
23
+ ],
24
+ ): Array<{ id: string; title: string }> {
25
+ const script = `
26
+ const { Database } = require('bun:sqlite')
27
+ const db = new Database(process.argv[1])
28
+ db.run('PRAGMA journal_mode = WAL')
29
+ db.run('CREATE TABLE nodes (id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL)')
30
+ const rows = JSON.parse(process.argv[2])
31
+ for (const r of rows) db.run('INSERT INTO nodes (id, type, title) VALUES (?, ?, ?)', [r.id, 'task', r.title])
32
+ db.close()
33
+ `
34
+ const res = spawnSync('bun', ['-e', script, path, JSON.stringify(rows)], {
35
+ encoding: 'utf-8',
36
+ })
37
+ if (res.status !== 0) throw new Error(`seedDb failed: ${res.stderr}`)
38
+ return rows
39
+ }
40
+
41
+ /** Read all node {id,title} rows from a SQLite file via a real bun subprocess. */
42
+ function readNodes(path: string): Array<{ id: string; title: string }> {
43
+ const script = `
44
+ const { Database } = require('bun:sqlite')
45
+ const db = new Database(process.argv[1], { readonly: true })
46
+ console.log(JSON.stringify(db.query('SELECT id, title FROM nodes ORDER BY id').all()))
47
+ db.close()
48
+ `
49
+ const res = spawnSync('bun', ['-e', script, path], { encoding: 'utf-8' })
50
+ if (res.status !== 0) throw new Error(`readNodes failed: ${res.stderr}`)
51
+ return JSON.parse(res.stdout.trim())
52
+ }
53
+
54
+ beforeEach(() => {
55
+ mockOutput = vi.fn()
56
+ mockOutputError = vi.fn()
57
+ vi.doMock('../util/format.ts', () => ({
58
+ output: mockOutput,
59
+ outputError: mockOutputError,
60
+ }))
61
+ workDir = mkdtempSync(join(tmpdir(), 'flowy-backup-'))
62
+ delete process.env.FLOWY_DB_PATH
63
+ })
64
+
65
+ afterEach(() => {
66
+ rmSync(workDir, { recursive: true, force: true })
67
+ delete process.env.FLOWY_DB_PATH
68
+ vi.resetModules()
69
+ vi.restoreAllMocks()
70
+ })
71
+
72
+ describe('backup command', () => {
73
+ test('exports a command named "backup"', async () => {
74
+ const { backupCommand } = await import('./backup.ts')
75
+ expect(backupCommand.name()).toBe('backup')
76
+ })
77
+
78
+ test('declares a --db option', async () => {
79
+ const { backupCommand } = await import('./backup.ts')
80
+ const flags = backupCommand.options.map((o) => o.long)
81
+ expect(flags).toContain('--db')
82
+ })
83
+
84
+ test('writes a valid SQLite snapshot whose rows match the source DB', async () => {
85
+ const src = join(workDir, 'flowy.sqlite')
86
+ const dest = join(workDir, 'backup.sqlite')
87
+ const expected = seedDb(src)
88
+
89
+ const { backupCommand } = await import('./backup.ts')
90
+ await backupCommand.parseAsync([dest, '--db', src], { from: 'user' })
91
+
92
+ expect(mockOutputError).not.toHaveBeenCalled()
93
+ expect(existsSync(dest)).toBe(true)
94
+ // The backup is a real, openable SQLite database with identical rows.
95
+ expect(readNodes(dest)).toEqual(expected)
96
+ })
97
+
98
+ test('resolves the DB path from FLOWY_DB_PATH when --db is omitted', async () => {
99
+ const src = join(workDir, 'env.sqlite')
100
+ const dest = join(workDir, 'backup.sqlite')
101
+ const expected = seedDb(src)
102
+ process.env.FLOWY_DB_PATH = src
103
+
104
+ const { backupCommand } = await import('./backup.ts')
105
+ await backupCommand.parseAsync([dest], { from: 'user' })
106
+
107
+ expect(mockOutputError).not.toHaveBeenCalled()
108
+ expect(readNodes(dest)).toEqual(expected)
109
+ })
110
+
111
+ test('produces a consistent snapshot while the source DB is open (WAL)', async () => {
112
+ const src = join(workDir, 'flowy.sqlite')
113
+ const dest = join(workDir, 'backup.sqlite')
114
+ const expected = seedDb(src)
115
+
116
+ // A long-lived bun process holds the DB open to mimic a running server.
117
+ const holder = spawnSync(
118
+ 'bun',
119
+ [
120
+ '-e',
121
+ `const { Database } = require('bun:sqlite'); const db = new Database(process.argv[1]); db.query('SELECT 1').get(); Bun.sleepSync(50); db.close()`,
122
+ src,
123
+ ],
124
+ { encoding: 'utf-8' },
125
+ )
126
+ expect(holder.status).toBe(0)
127
+
128
+ const { backupCommand } = await import('./backup.ts')
129
+ await backupCommand.parseAsync([dest, '--db', src], { from: 'user' })
130
+
131
+ expect(mockOutputError).not.toHaveBeenCalled()
132
+ expect(readNodes(dest)).toEqual(expected)
133
+ })
134
+
135
+ test('errors when the source DB does not exist', async () => {
136
+ const dest = join(workDir, 'backup.sqlite')
137
+ const missing = join(workDir, 'nope.sqlite')
138
+
139
+ const { backupCommand } = await import('./backup.ts')
140
+ await backupCommand.parseAsync([dest, '--db', missing], { from: 'user' })
141
+
142
+ expect(mockOutputError).toHaveBeenCalled()
143
+ expect(existsSync(dest)).toBe(false)
144
+ })
145
+
146
+ test('reports the source and destination on success', async () => {
147
+ const src = join(workDir, 'flowy.sqlite')
148
+ const dest = join(workDir, 'backup.sqlite')
149
+ seedDb(src)
150
+
151
+ const { backupCommand } = await import('./backup.ts')
152
+ await backupCommand.parseAsync([dest, '--db', src], { from: 'user' })
153
+
154
+ expect(mockOutput).toHaveBeenCalledWith(
155
+ expect.objectContaining({ source: src, file: dest }),
156
+ )
157
+ })
158
+ })
159
+
160
+ describe('restore command', () => {
161
+ test('exports a command named "restore"', async () => {
162
+ const { restoreCommand } = await import('./backup.ts')
163
+ expect(restoreCommand.name()).toBe('restore')
164
+ })
165
+
166
+ test('declares --db and --force options', async () => {
167
+ const { restoreCommand } = await import('./backup.ts')
168
+ const flags = restoreCommand.options.map((o) => o.long)
169
+ expect(flags).toContain('--db')
170
+ expect(flags).toContain('--force')
171
+ })
172
+
173
+ test('round-trips: restoring a backup reproduces the original rows', async () => {
174
+ const src = join(workDir, 'flowy.sqlite')
175
+ const backup = join(workDir, 'backup.sqlite')
176
+ const target = join(workDir, 'restored.sqlite')
177
+ const expected = seedDb(src)
178
+
179
+ const { backupCommand, restoreCommand } = await import('./backup.ts')
180
+ await backupCommand.parseAsync([backup, '--db', src], { from: 'user' })
181
+ await restoreCommand.parseAsync([backup, '--db', target], { from: 'user' })
182
+
183
+ expect(mockOutputError).not.toHaveBeenCalled()
184
+ expect(readNodes(target)).toEqual(expected)
185
+ })
186
+
187
+ test('refuses to clobber an existing DB without --force', async () => {
188
+ const src = join(workDir, 'flowy.sqlite')
189
+ const backup = join(workDir, 'backup.sqlite')
190
+ const target = join(workDir, 'existing.sqlite')
191
+ // Pre-existing target whose content must NOT be overwritten.
192
+ const original = seedDb(target, [{ id: 'keep', title: 'Original' }])
193
+ // Source (and thus backup) differs from the existing target.
194
+ seedDb(src, [{ id: 'new', title: 'Incoming' }])
195
+
196
+ const { backupCommand, restoreCommand } = await import('./backup.ts')
197
+ await backupCommand.parseAsync([backup, '--db', src], { from: 'user' })
198
+
199
+ mockOutputError.mockClear()
200
+ await restoreCommand.parseAsync([backup, '--db', target], { from: 'user' })
201
+
202
+ expect(mockOutputError).toHaveBeenCalled()
203
+ // The existing DB is untouched.
204
+ expect(readNodes(target)).toEqual(original)
205
+ })
206
+
207
+ test('overwrites an existing DB when --force is given', async () => {
208
+ const src = join(workDir, 'flowy.sqlite')
209
+ const backup = join(workDir, 'backup.sqlite')
210
+ const target = join(workDir, 'existing.sqlite')
211
+ seedDb(target, [{ id: 'old', title: 'Stale' }]) // pre-existing, replaced
212
+ seedDb(src, [{ id: 'z9', title: 'New' }])
213
+
214
+ const { backupCommand, restoreCommand } = await import('./backup.ts')
215
+ await backupCommand.parseAsync([backup, '--db', src], { from: 'user' })
216
+ await restoreCommand.parseAsync([backup, '--db', target, '--force'], {
217
+ from: 'user',
218
+ })
219
+
220
+ expect(mockOutputError).not.toHaveBeenCalled()
221
+ expect(readNodes(target)).toEqual([{ id: 'z9', title: 'New' }])
222
+ })
223
+
224
+ test('errors when the backup source does not exist', async () => {
225
+ const target = join(workDir, 'restored.sqlite')
226
+ const missing = join(workDir, 'nope.sqlite')
227
+
228
+ const { restoreCommand } = await import('./backup.ts')
229
+ await restoreCommand.parseAsync([missing, '--db', target], { from: 'user' })
230
+
231
+ expect(mockOutputError).toHaveBeenCalled()
232
+ expect(existsSync(target)).toBe(false)
233
+ })
234
+
235
+ test('errors when the backup source is not a valid SQLite database', async () => {
236
+ const target = join(workDir, 'restored.sqlite')
237
+ const garbage = join(workDir, 'garbage.bin')
238
+ writeFileSync(garbage, 'this is not a sqlite database')
239
+
240
+ const { restoreCommand } = await import('./backup.ts')
241
+ await restoreCommand.parseAsync([garbage, '--db', target], { from: 'user' })
242
+
243
+ // A corrupt source must surface as a clean snapshot failure, not an
244
+ // incidental downstream error (e.g. a rename ENOENT).
245
+ expect(mockOutputError).toHaveBeenCalledWith(
246
+ expect.objectContaining({ code: 'BACKUP_ERROR' }),
247
+ )
248
+ expect(existsSync(target)).toBe(false)
249
+ // No staged temp file is left behind.
250
+ expect(existsSync(`${target}.restore-tmp`)).toBe(false)
251
+ })
252
+ })
@@ -0,0 +1,145 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, renameSync, rmSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { Command } from 'commander'
5
+ import { output, outputError } from '../util/format.ts'
6
+
7
+ /**
8
+ * Resolve the local SQLite database path the bundled server uses, from the same
9
+ * sources `server/src/index.ts` and `flowy serve` honour:
10
+ * 1. an explicit `--db <path>` flag
11
+ * 2. the `FLOWY_DB_PATH` env var
12
+ * 3. the default `./flowy.sqlite`
13
+ */
14
+ export function resolveDbPath(dbOpt?: string): string {
15
+ return dbOpt ?? process.env.FLOWY_DB_PATH ?? './flowy.sqlite'
16
+ }
17
+
18
+ /**
19
+ * Snapshot one SQLite database into another file with `VACUUM INTO`.
20
+ *
21
+ * `VACUUM INTO` takes a transactionally-consistent snapshot even while the
22
+ * source is open/being written by a running server (it reads under a read
23
+ * transaction), and writes a single fully-checkpointed file with no `-wal`/
24
+ * `-shm` sidecars — strictly safer than copying the file bytes. It also fails
25
+ * cleanly if `src` is not a valid SQLite database, which doubles as validation.
26
+ *
27
+ * Runs in a `bun` subprocess so this module never imports `bun:sqlite`
28
+ * (unavailable under the Node-based unit-test runner); the CLI itself always
29
+ * runs under bun, so the subprocess shares the same runtime.
30
+ */
31
+ function vacuumInto(src: string, dest: string): void {
32
+ // Note: `bun -e` does not reliably exit non-zero on an uncaught throw, so the
33
+ // script explicitly catches, writes the message to stderr, and exits 1 — that
34
+ // is what lets the parent distinguish a corrupt source from a clean snapshot.
35
+ const script = `
36
+ const { Database } = require('bun:sqlite')
37
+ try {
38
+ const db = new Database(process.argv[1], { readonly: true })
39
+ try {
40
+ db.run('VACUUM INTO ?', [process.argv[2]])
41
+ } finally {
42
+ db.close()
43
+ }
44
+ } catch (e) {
45
+ console.error(e && e.message ? e.message : String(e))
46
+ process.exit(1)
47
+ }
48
+ `
49
+ const result = spawnSync('bun', ['-e', script, src, dest], {
50
+ encoding: 'utf-8',
51
+ })
52
+ if (result.status !== 0) {
53
+ const detail = (result.stderr || result.stdout || '').trim()
54
+ const err = new Error(
55
+ `Failed to snapshot SQLite database ${src}${detail ? `: ${detail}` : '.'}`,
56
+ ) as Error & { code?: string }
57
+ err.code = 'BACKUP_ERROR'
58
+ throw err
59
+ }
60
+ }
61
+
62
+ /** Remove a SQLite file together with its `-wal`/`-shm` sidecars. */
63
+ function removeDbFiles(path: string): void {
64
+ for (const suffix of ['', '-wal', '-shm', '-journal']) {
65
+ rmSync(`${path}${suffix}`, { force: true })
66
+ }
67
+ }
68
+
69
+ export const backupCommand = new Command('backup')
70
+ .description(
71
+ 'Write a consistent file-level snapshot of the local SQLite database',
72
+ )
73
+ .argument('<dest>', 'Path to write the backup file to')
74
+ .option(
75
+ '-d, --db <path>',
76
+ 'SQLite database to back up (default: $FLOWY_DB_PATH or ./flowy.sqlite)',
77
+ )
78
+ .action((dest: string, opts: { db?: string }) => {
79
+ try {
80
+ const src = resolveDbPath(opts.db)
81
+ if (!existsSync(src)) {
82
+ const err = new Error(
83
+ `No SQLite database at ${resolve(src)}. ` +
84
+ `Set --db or FLOWY_DB_PATH, or start the server first.`,
85
+ ) as Error & { code?: string }
86
+ err.code = 'NOT_FOUND'
87
+ throw err
88
+ }
89
+ // VACUUM INTO refuses to write an existing file, so clear a stale dest.
90
+ removeDbFiles(dest)
91
+ vacuumInto(src, dest)
92
+ output({ source: src, file: dest })
93
+ } catch (error) {
94
+ outputError(error)
95
+ }
96
+ })
97
+
98
+ export const restoreCommand = new Command('restore')
99
+ .description('Restore the local SQLite database from a backup file')
100
+ .argument('<src>', 'Path to a backup file produced by "flowy backup"')
101
+ .option(
102
+ '-d, --db <path>',
103
+ 'SQLite database to restore into (default: $FLOWY_DB_PATH or ./flowy.sqlite)',
104
+ )
105
+ .option('-f, --force', 'Overwrite the target database if it already exists')
106
+ .action((src: string, opts: { db?: string; force?: boolean }) => {
107
+ try {
108
+ if (!existsSync(src)) {
109
+ const err = new Error(`No backup file at ${resolve(src)}.`) as Error & {
110
+ code?: string
111
+ }
112
+ err.code = 'NOT_FOUND'
113
+ throw err
114
+ }
115
+ const target = resolveDbPath(opts.db)
116
+ if (existsSync(target) && !opts.force) {
117
+ const err = new Error(
118
+ `Target database ${resolve(target)} already exists. ` +
119
+ `Re-run with --force to overwrite it (the current data will be lost).`,
120
+ ) as Error & { code?: string }
121
+ err.code = 'TARGET_EXISTS'
122
+ throw err
123
+ }
124
+ // VACUUM INTO validates that `src` is a real SQLite database and writes a
125
+ // clean single-file copy. Stage it next to the target first, so a corrupt
126
+ // backup never destroys the existing DB before validation succeeds.
127
+ const staged = `${target}.restore-tmp`
128
+ removeDbFiles(staged)
129
+ try {
130
+ vacuumInto(src, staged)
131
+ } catch (error) {
132
+ removeDbFiles(staged)
133
+ throw error
134
+ }
135
+ // Validation passed — swap the staged copy into place. `staged` sits in
136
+ // the same directory as `target`, so this rename stays on one filesystem.
137
+ // VACUUM INTO never creates `-wal`/`-shm` sidecars, so only the main file
138
+ // needs moving.
139
+ removeDbFiles(target)
140
+ renameSync(staged, target)
141
+ output({ restored: target, source: src })
142
+ } catch (error) {
143
+ outputError(error)
144
+ }
145
+ })
@@ -0,0 +1,90 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ let mockGraphql: ReturnType<typeof vi.fn>
4
+ let mockOutput: ReturnType<typeof vi.fn>
5
+ let mockOutputError: ReturnType<typeof vi.fn>
6
+ let stderr: ReturnType<typeof vi.spyOn>
7
+
8
+ beforeEach(() => {
9
+ mockOutput = vi.fn()
10
+ mockOutputError = vi.fn()
11
+ mockGraphql = vi.fn()
12
+ stderr = vi.spyOn(console, 'error').mockImplementation(() => {})
13
+
14
+ vi.doMock('../util/format.ts', () => ({
15
+ output: mockOutput,
16
+ outputError: mockOutputError,
17
+ }))
18
+ vi.doMock('../util/client.ts', () => ({
19
+ graphql: mockGraphql,
20
+ }))
21
+ })
22
+
23
+ afterEach(() => {
24
+ vi.resetModules()
25
+ vi.restoreAllMocks()
26
+ })
27
+
28
+ describe('search command', () => {
29
+ test('sends the SearchResult envelope query and outputs nodes + meta', async () => {
30
+ mockGraphql.mockResolvedValue({
31
+ search: {
32
+ nodes: [{ id: 'proj_1', title: 'Auth' }],
33
+ truncated: false,
34
+ total: 1,
35
+ },
36
+ })
37
+
38
+ const { searchCommand } = await import('./search.ts')
39
+ await searchCommand.parseAsync(['Auth'], { from: 'user' })
40
+
41
+ const query = mockGraphql.mock.calls[0]?.[0] as string
42
+ expect(query).toContain('nodes')
43
+ expect(query).toContain('truncated')
44
+ expect(query).toContain('total')
45
+
46
+ expect(mockOutput).toHaveBeenCalledWith({
47
+ nodes: [{ id: 'proj_1', title: 'Auth' }],
48
+ truncated: false,
49
+ total: 1,
50
+ })
51
+ // no truncation warning when not truncated
52
+ expect(stderr).not.toHaveBeenCalled()
53
+ })
54
+
55
+ test('renders a clear truncation marker when results are capped', async () => {
56
+ mockGraphql.mockResolvedValue({
57
+ search: {
58
+ nodes: new Array(50).fill({ id: 'x' }),
59
+ truncated: true,
60
+ total: 137,
61
+ },
62
+ })
63
+
64
+ const { searchCommand } = await import('./search.ts')
65
+ await searchCommand.parseAsync(['Task'], { from: 'user' })
66
+
67
+ const outputArg = mockOutput.mock.calls[0]?.[0] as {
68
+ truncated: boolean
69
+ total: number
70
+ }
71
+ expect(outputArg.truncated).toBe(true)
72
+ expect(outputArg.total).toBe(137)
73
+
74
+ expect(stderr).toHaveBeenCalledOnce()
75
+ const warning = stderr.mock.calls[0]?.[0] as string
76
+ expect(warning).toMatch(/truncated/i)
77
+ expect(warning).toContain('50')
78
+ expect(warning).toContain('137')
79
+ })
80
+
81
+ test('outputs error when the query fails', async () => {
82
+ mockGraphql.mockRejectedValue(new Error('boom'))
83
+ const { searchCommand } = await import('./search.ts')
84
+ await searchCommand.parseAsync(['Auth'], { from: 'user' })
85
+ expect(mockOutputError).toHaveBeenCalledWith(
86
+ expect.objectContaining({ message: 'boom' }),
87
+ )
88
+ expect(mockOutput).not.toHaveBeenCalled()
89
+ })
90
+ })
@@ -3,6 +3,12 @@ import { graphql } from '../util/client.ts'
3
3
  import { output, outputError } from '../util/format.ts'
4
4
  import { SEARCH } from '../util/operations.ts'
5
5
 
6
+ interface SearchResult {
7
+ nodes: unknown[]
8
+ truncated: boolean
9
+ total: number
10
+ }
11
+
6
12
  export const searchCommand = new Command('search')
7
13
  .description('Search nodes by text')
8
14
  .argument('<query>', 'Search query')
@@ -11,13 +17,23 @@ export const searchCommand = new Command('search')
11
17
  .option('--limit <n>', 'Limit results', '50')
12
18
  .action(async (query: string, opts) => {
13
19
  try {
14
- const data = await graphql<{ search: unknown[] }>(SEARCH, {
20
+ const limit = opts.limit ? Number.parseInt(opts.limit, 10) : undefined
21
+ const data = await graphql<{ search: SearchResult }>(SEARCH, {
15
22
  query,
16
23
  type: opts.type,
17
24
  status: opts.status,
18
- limit: opts.limit ? Number.parseInt(opts.limit, 10) : undefined,
25
+ limit,
19
26
  })
20
- output(data.search)
27
+ const { nodes, truncated, total } = data.search
28
+ // The truncation marker rides in the JSON envelope so callers can detect
29
+ // it programmatically; when capped we also warn on stderr so it is not
30
+ // silently lost in a human-read terminal.
31
+ output({ nodes, truncated, total })
32
+ if (truncated) {
33
+ console.error(
34
+ `Results truncated: showing ${nodes.length} of ${total} matches. Raise --limit to see more.`,
35
+ )
36
+ }
21
37
  } catch (error) {
22
38
  outputError(error)
23
39
  }
@@ -0,0 +1,71 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ let mockGraphql: ReturnType<typeof vi.fn>
4
+ let mockOutput: ReturnType<typeof vi.fn>
5
+ let mockOutputError: ReturnType<typeof vi.fn>
6
+
7
+ beforeEach(() => {
8
+ mockOutput = vi.fn()
9
+ mockOutputError = vi.fn()
10
+ mockGraphql = vi.fn().mockResolvedValue({
11
+ updateNode: { id: 'task_1', type: 'task', title: 'T', status: 'done' },
12
+ })
13
+
14
+ vi.doMock('../util/format.ts', () => ({
15
+ output: mockOutput,
16
+ outputError: mockOutputError,
17
+ }))
18
+ vi.doMock('../util/client.ts', () => ({
19
+ graphql: mockGraphql,
20
+ }))
21
+ })
22
+
23
+ afterEach(() => {
24
+ vi.resetModules()
25
+ vi.restoreAllMocks()
26
+ })
27
+
28
+ describe('status command', () => {
29
+ test('accepts a valid status and sends the update', async () => {
30
+ const { statusCommand } = await import('./status.ts')
31
+ await statusCommand.parseAsync(['task_1', 'in_progress'], { from: 'user' })
32
+
33
+ expect(mockGraphql).toHaveBeenCalledOnce()
34
+ expect(mockGraphql.mock.calls[0]?.[1]).toEqual({
35
+ id: 'task_1',
36
+ status: 'in_progress',
37
+ })
38
+ expect(mockOutput).toHaveBeenCalledOnce()
39
+ })
40
+
41
+ test('rejects an invalid status client-side (.choices) without a request', async () => {
42
+ const { statusCommand } = await import('./status.ts')
43
+ // Commander throws on an invalid choice; configure it not to exit the
44
+ // process so the test can assert.
45
+ statusCommand.exitOverride()
46
+ statusCommand.configureOutput({ writeErr: () => {}, writeOut: () => {} })
47
+
48
+ await expect(
49
+ statusCommand.parseAsync(['task_1', 'bogus'], { from: 'user' }),
50
+ ).rejects.toThrow(/Allowed choices|bogus/i)
51
+
52
+ expect(mockGraphql).not.toHaveBeenCalled()
53
+ })
54
+
55
+ test('exposes the full canonical status vocabulary as choices', async () => {
56
+ const { statusCommand, STATUS_CHOICES } = await import('./status.ts')
57
+ expect(STATUS_CHOICES).toEqual([
58
+ 'draft',
59
+ 'pending_review',
60
+ 'approved',
61
+ 'in_progress',
62
+ 'done',
63
+ 'blocked',
64
+ 'cancelled',
65
+ ])
66
+ const statusArg = statusCommand.registeredArguments.find(
67
+ (a) => a.name() === 'status',
68
+ )
69
+ expect(statusArg?.argChoices).toEqual([...STATUS_CHOICES])
70
+ })
71
+ })
@@ -1,14 +1,28 @@
1
- import { Command } from 'commander'
1
+ import { Argument, Command } from 'commander'
2
2
  import { graphql } from '../util/client.ts'
3
3
  import { output, outputError } from '../util/format.ts'
4
4
  import { UPDATE_STATUS } from '../util/operations.ts'
5
5
 
6
+ /**
7
+ * The canonical Flowy status vocabulary, mirrored from the server's
8
+ * VALID_STATUSES. Used for client-side `.choices()` validation so an invalid
9
+ * status is rejected before a request is ever sent.
10
+ */
11
+ export const STATUS_CHOICES = [
12
+ 'draft',
13
+ 'pending_review',
14
+ 'approved',
15
+ 'in_progress',
16
+ 'done',
17
+ 'blocked',
18
+ 'cancelled',
19
+ ] as const
20
+
6
21
  export const statusCommand = new Command('status')
7
22
  .description('Update a node status (shorthand)')
8
23
  .argument('<id>', 'Node ID')
9
- .argument(
10
- '<status>',
11
- 'New status (draft, pending_review, approved, in_progress, done, blocked, cancelled)',
24
+ .addArgument(
25
+ new Argument('<status>', 'New status').choices([...STATUS_CHOICES]),
12
26
  )
13
27
  .action(async (id: string, status: string) => {
14
28
  try {
package/src/index.test.ts CHANGED
@@ -51,6 +51,10 @@ vi.mock('./commands/import.ts', () => ({
51
51
  vi.mock('./commands/export.ts', () => ({
52
52
  exportCommand: { name: () => 'export' },
53
53
  }))
54
+ vi.mock('./commands/backup.ts', () => ({
55
+ backupCommand: { name: () => 'backup' },
56
+ restoreCommand: { name: () => 'restore' },
57
+ }))
54
58
 
55
59
  describe('index.ts command registration', () => {
56
60
  test('registers billing and key commands', async () => {
@@ -112,4 +116,18 @@ describe('index.ts command registration', () => {
112
116
  )
113
117
  expect(indexSource).toContain('program.addCommand(historyCommand)')
114
118
  })
119
+
120
+ test('registers the backup and restore commands', async () => {
121
+ const { readFileSync } = await import('node:fs')
122
+ const indexSource = readFileSync(
123
+ new URL('./index.ts', import.meta.url).pathname,
124
+ 'utf-8',
125
+ )
126
+
127
+ expect(indexSource).toContain(
128
+ "import { backupCommand, restoreCommand } from './commands/backup.ts'",
129
+ )
130
+ expect(indexSource).toContain('program.addCommand(backupCommand)')
131
+ expect(indexSource).toContain('program.addCommand(restoreCommand)')
132
+ })
115
133
  })
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ const pkgPath = resolve(
12
12
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
13
13
 
14
14
  import { approveCommand } from './commands/approve.ts'
15
+ import { backupCommand, restoreCommand } from './commands/backup.ts'
15
16
  import { billingCommand } from './commands/billing.ts'
16
17
  import { clientCommand } from './commands/client.ts'
17
18
  import { exportCommand } from './commands/export.ts'
@@ -51,5 +52,7 @@ program.addCommand(historyCommand)
51
52
  program.addCommand(whoamiCommand)
52
53
  program.addCommand(importCommand)
53
54
  program.addCommand(exportCommand)
55
+ program.addCommand(backupCommand)
56
+ program.addCommand(restoreCommand)
54
57
 
55
58
  program.parse()