@sqaoss/flowy 1.3.0 → 1.3.1
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.ts +12 -2
- 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 +8 -0
- package/src/commands/task.ts +11 -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 }) => {
|
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) {
|
|
@@ -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
|
|
57
|
-
mockSpawnSync.mockReturnValue({ status:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
expect.
|
|
67
|
-
|
|
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' })
|
package/src/commands/setup.ts
CHANGED
|
@@ -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
|
|
13
|
+
.description('Set up Flowy with a native local server (no Docker)')
|
|
68
14
|
.action(async () => {
|
|
69
15
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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({
|
|
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
|
}
|
|
@@ -31,6 +31,14 @@ describe('task command', () => {
|
|
|
31
31
|
expect(names).toContain('unblock')
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
+
test('create exposes both --description and --description-file options', async () => {
|
|
35
|
+
const { taskCommand } = await import('./task.ts')
|
|
36
|
+
const createCmd = taskCommand.commands.find((c) => c.name() === 'create')!
|
|
37
|
+
const optionFlags = createCmd.options.map((o) => o.long)
|
|
38
|
+
expect(optionFlags).toContain('--description')
|
|
39
|
+
expect(optionFlags).toContain('--description-file')
|
|
40
|
+
})
|
|
41
|
+
|
|
34
42
|
test('create calls outputError when no active feature', async () => {
|
|
35
43
|
const { taskCommand } = await import('./task.ts')
|
|
36
44
|
const { outputError } = await import('../util/format.ts')
|
package/src/commands/task.ts
CHANGED
|
@@ -12,14 +12,21 @@ taskCommand
|
|
|
12
12
|
.command('create')
|
|
13
13
|
.description('Create a task in the active feature')
|
|
14
14
|
.requiredOption('--title <title>', 'Task title')
|
|
15
|
-
.
|
|
16
|
-
'--description <
|
|
17
|
-
'Task description (
|
|
15
|
+
.option(
|
|
16
|
+
'--description <text>',
|
|
17
|
+
'Task description, used verbatim (never read as a file path)',
|
|
18
|
+
)
|
|
19
|
+
.option(
|
|
20
|
+
'--description-file <path>',
|
|
21
|
+
'Read the task description from a file, or "-" for stdin',
|
|
18
22
|
)
|
|
19
23
|
.action(async (opts) => {
|
|
20
24
|
try {
|
|
21
25
|
const featureId = requireFeature()
|
|
22
|
-
const description = await resolveDescription(
|
|
26
|
+
const description = await resolveDescription({
|
|
27
|
+
description: opts.description,
|
|
28
|
+
descriptionFile: opts.descriptionFile,
|
|
29
|
+
})
|
|
23
30
|
const data = await graphql<{ createNode: { id: string } }>(
|
|
24
31
|
`mutation CreateTask($type: String!, $title: String!, $description: String) {
|
|
25
32
|
createNode(type: $type, title: $title, description: $description) {
|
package/src/index.test.ts
CHANGED
|
@@ -39,6 +39,9 @@ vi.mock('./commands/billing.ts', () => ({
|
|
|
39
39
|
vi.mock('./commands/key.ts', () => ({
|
|
40
40
|
keyCommand: { name: () => 'key' },
|
|
41
41
|
}))
|
|
42
|
+
vi.mock('./commands/serve.ts', () => ({
|
|
43
|
+
serveCommand: { name: () => 'serve' },
|
|
44
|
+
}))
|
|
42
45
|
|
|
43
46
|
describe('index.ts command registration', () => {
|
|
44
47
|
test('registers billing and key commands', async () => {
|
|
@@ -57,4 +60,17 @@ describe('index.ts command registration', () => {
|
|
|
57
60
|
expect(indexSource).toContain('program.addCommand(billingCommand)')
|
|
58
61
|
expect(indexSource).toContain('program.addCommand(keyCommand)')
|
|
59
62
|
})
|
|
63
|
+
|
|
64
|
+
test('registers the serve command', async () => {
|
|
65
|
+
const { readFileSync } = await import('node:fs')
|
|
66
|
+
const indexSource = readFileSync(
|
|
67
|
+
new URL('./index.ts', import.meta.url).pathname,
|
|
68
|
+
'utf-8',
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
expect(indexSource).toContain(
|
|
72
|
+
"import { serveCommand } from './commands/serve.ts'",
|
|
73
|
+
)
|
|
74
|
+
expect(indexSource).toContain('program.addCommand(serveCommand)')
|
|
75
|
+
})
|
|
60
76
|
})
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { initCommand } from './commands/init.ts'
|
|
|
19
19
|
import { keyCommand } from './commands/key.ts'
|
|
20
20
|
import { projectCommand } from './commands/project.ts'
|
|
21
21
|
import { searchCommand } from './commands/search.ts'
|
|
22
|
+
import { serveCommand } from './commands/serve.ts'
|
|
22
23
|
import { setupCommand } from './commands/setup.ts'
|
|
23
24
|
import { statusCommand } from './commands/status.ts'
|
|
24
25
|
import { taskCommand } from './commands/task.ts'
|
|
@@ -32,6 +33,7 @@ const program = new Command()
|
|
|
32
33
|
|
|
33
34
|
program.addCommand(initCommand)
|
|
34
35
|
program.addCommand(setupCommand)
|
|
36
|
+
program.addCommand(serveCommand)
|
|
35
37
|
program.addCommand(clientCommand)
|
|
36
38
|
program.addCommand(projectCommand)
|
|
37
39
|
program.addCommand(featureCommand)
|