@sqaoss/flowy 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docker-compose.yml +7 -2
- package/package.json +1 -1
- package/server/src/index.errors.test.ts +37 -7
- package/server/src/index.test.ts +29 -0
- package/server/src/index.ts +15 -5
- package/server/src/resolvers.test.ts +15 -4
- package/server/src/resolvers.ts +3 -1
- package/src/commands/feature.test.ts +161 -1
- package/src/commands/feature.ts +81 -2
- package/src/commands/project.test.ts +169 -2
- package/src/commands/project.ts +60 -0
- package/src/commands/serve.test.ts +89 -0
- package/src/commands/serve.ts +76 -0
- package/src/commands/setup.test.ts +12 -14
- package/src/commands/setup.ts +14 -73
- package/src/commands/task.test.ts +178 -3
- package/src/commands/task.ts +68 -4
- package/src/index.test.ts +16 -0
- package/src/index.ts +2 -0
- package/src/util/client.test.ts +194 -81
- package/src/util/client.ts +127 -27
- package/src/util/description.test.ts +59 -7
- package/src/util/description.ts +59 -4
- package/src/util/format.test.ts +65 -0
- package/src/util/format.ts +22 -1
package/src/util/client.ts
CHANGED
|
@@ -1,56 +1,156 @@
|
|
|
1
1
|
import { getConfig } from './config.ts'
|
|
2
2
|
|
|
3
|
+
export interface GraphqlOptions {
|
|
4
|
+
/** Per-request timeout in milliseconds. Default 15s. */
|
|
5
|
+
timeoutMs?: number
|
|
6
|
+
/** Max retry attempts on transient transport failures. Default 2. */
|
|
7
|
+
retries?: number
|
|
8
|
+
/** Base backoff delay in milliseconds (exponential). Default 300ms. */
|
|
9
|
+
retryDelayMs?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 15_000
|
|
13
|
+
const DEFAULT_RETRIES = 2
|
|
14
|
+
const DEFAULT_RETRY_DELAY_MS = 300
|
|
15
|
+
|
|
16
|
+
/** HTTP statuses worth retrying — transient server/proxy conditions. */
|
|
17
|
+
const TRANSIENT_STATUSES = new Set([429, 502, 503, 504])
|
|
18
|
+
|
|
19
|
+
type CodedError = Error & { code?: string }
|
|
20
|
+
|
|
21
|
+
function codedError(message: string, code: string): CodedError {
|
|
22
|
+
const err = new Error(message) as CodedError
|
|
23
|
+
err.code = code
|
|
24
|
+
return err
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isAbortError(error: unknown): boolean {
|
|
28
|
+
return (
|
|
29
|
+
error instanceof Error &&
|
|
30
|
+
(error.name === 'AbortError' || error.name === 'TimeoutError')
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sleep(ms: number): Promise<void> {
|
|
35
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readBody(res: Response): Promise<string> {
|
|
39
|
+
try {
|
|
40
|
+
return await res.text()
|
|
41
|
+
} catch {
|
|
42
|
+
return ''
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
3
46
|
export async function graphql<T = unknown>(
|
|
4
47
|
query: string,
|
|
5
48
|
variables?: Record<string, unknown>,
|
|
49
|
+
options: GraphqlOptions = {},
|
|
6
50
|
): Promise<T> {
|
|
7
51
|
const { apiUrl, apiKey } = getConfig()
|
|
52
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
53
|
+
const retries = options.retries ?? DEFAULT_RETRIES
|
|
54
|
+
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
|
|
8
55
|
|
|
9
56
|
const headers: Record<string, string> = {
|
|
10
57
|
'Content-Type': 'application/json',
|
|
11
58
|
}
|
|
12
|
-
|
|
13
59
|
if (apiKey) {
|
|
14
60
|
headers.Authorization = `Bearer ${apiKey}`
|
|
15
61
|
}
|
|
16
62
|
|
|
17
|
-
const
|
|
18
|
-
method: 'POST',
|
|
19
|
-
headers,
|
|
20
|
-
body: JSON.stringify({ query, variables }),
|
|
21
|
-
})
|
|
63
|
+
const body = JSON.stringify({ query, variables })
|
|
22
64
|
|
|
23
|
-
|
|
24
|
-
data?: T
|
|
25
|
-
errors?: Array<{ message: string; extensions?: { code?: string } }>
|
|
26
|
-
}
|
|
65
|
+
let lastError: CodedError | undefined
|
|
27
66
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
67
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
68
|
+
let res: Response
|
|
69
|
+
try {
|
|
70
|
+
res = await fetch(apiUrl, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers,
|
|
73
|
+
body,
|
|
74
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
75
|
+
})
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// Network-layer failure: DNS, connection refused, or our timeout abort.
|
|
78
|
+
const message = isAbortError(error)
|
|
79
|
+
? `Request timed out after ${timeoutMs}ms`
|
|
80
|
+
: `Network request failed: ${
|
|
81
|
+
error instanceof Error ? error.message : String(error)
|
|
82
|
+
}`
|
|
83
|
+
lastError = codedError(message, 'NETWORK_ERROR')
|
|
84
|
+
if (attempt < retries) {
|
|
85
|
+
await sleep(retryDelayMs * 2 ** attempt)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
throw lastError
|
|
89
|
+
}
|
|
31
90
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
91
|
+
// HTTP-level (non-2xx) failure — handled before attempting to parse JSON,
|
|
92
|
+
// so proxy HTML / empty bodies never crash with a SyntaxError.
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
if (TRANSIENT_STATUSES.has(res.status) && attempt < retries) {
|
|
95
|
+
lastError = codedError(
|
|
96
|
+
`Server returned HTTP ${res.status}`,
|
|
97
|
+
'SERVER_ERROR',
|
|
98
|
+
)
|
|
99
|
+
await sleep(retryDelayMs * 2 ** attempt)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
const snippet = (await readBody(res)).trim().slice(0, 200)
|
|
103
|
+
const detail = snippet ? `: ${snippet}` : ''
|
|
104
|
+
throw codedError(
|
|
105
|
+
`Server returned HTTP ${res.status}${detail}`,
|
|
106
|
+
'SERVER_ERROR',
|
|
35
107
|
)
|
|
36
108
|
}
|
|
37
109
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
110
|
+
type GraphqlBody = {
|
|
111
|
+
data?: T
|
|
112
|
+
errors?: Array<{ message: string; extensions?: { code?: string } }>
|
|
113
|
+
}
|
|
114
|
+
let json: GraphqlBody
|
|
115
|
+
try {
|
|
116
|
+
json = (await res.json()) as GraphqlBody
|
|
117
|
+
} catch {
|
|
118
|
+
const snippet = (await readBody(res)).trim().slice(0, 200)
|
|
119
|
+
const detail = snippet ? `: ${snippet}` : ''
|
|
120
|
+
throw codedError(
|
|
121
|
+
`Server returned a non-JSON response${detail}`,
|
|
122
|
+
'SERVER_ERROR',
|
|
41
123
|
)
|
|
42
124
|
}
|
|
43
125
|
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
126
|
+
if (json.errors?.length) {
|
|
127
|
+
const error = json.errors[0]
|
|
128
|
+
const code = error?.extensions?.code
|
|
129
|
+
|
|
130
|
+
if (code === 'SUBSCRIPTION_REQUIRED') {
|
|
131
|
+
throw new Error(
|
|
132
|
+
'An active subscription is required. Run `flowy billing checkout` to subscribe.',
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
if (code === 'SUBSCRIPTION_EXPIRED') {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'Your subscription has expired. Run `flowy billing checkout` to renew.',
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
if (code === 'SUBSCRIPTION_SUSPENDED') {
|
|
141
|
+
throw new Error(
|
|
142
|
+
'Your subscription is suspended. Please contact support to resolve this.',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const err = new Error(error?.message) as CodedError
|
|
147
|
+
if (code) err.code = code
|
|
148
|
+
throw err
|
|
48
149
|
}
|
|
49
150
|
|
|
50
|
-
|
|
51
|
-
if (code) err.code = code
|
|
52
|
-
throw err
|
|
151
|
+
return json.data as T
|
|
53
152
|
}
|
|
54
153
|
|
|
55
|
-
|
|
154
|
+
// Exhausted retries on a transient condition.
|
|
155
|
+
throw lastError ?? codedError('Request failed after retries', 'NETWORK_ERROR')
|
|
56
156
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { rmSync, writeFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
-
import { afterEach, describe, expect, test } from 'vitest'
|
|
4
|
+
import { afterEach, describe, expect, test, vi } from 'vitest'
|
|
5
5
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
7
7
|
|
|
@@ -12,18 +12,70 @@ describe('resolveDescription', () => {
|
|
|
12
12
|
try {
|
|
13
13
|
rmSync(TEST_FILE)
|
|
14
14
|
} catch {}
|
|
15
|
+
vi.restoreAllMocks()
|
|
15
16
|
})
|
|
16
17
|
|
|
17
|
-
test('returns
|
|
18
|
+
test('returns --description verbatim, never as a file path', async () => {
|
|
19
|
+
// A literal value that also happens to be an existing path must NOT be read.
|
|
20
|
+
writeFileSync(TEST_FILE, '# File body\nshould not be used')
|
|
21
|
+
expect(existsSync(TEST_FILE)).toBe(true)
|
|
22
|
+
|
|
23
|
+
const { resolveDescription } = await import('./description.ts')
|
|
24
|
+
const result = await resolveDescription({ description: TEST_FILE })
|
|
25
|
+
expect(result).toBe(TEST_FILE)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('returns the literal string identically regardless of CWD collisions', async () => {
|
|
29
|
+
const { resolveDescription } = await import('./description.ts')
|
|
30
|
+
const result = await resolveDescription({
|
|
31
|
+
description: 'Just a plain description',
|
|
32
|
+
})
|
|
33
|
+
expect(result).toBe('Just a plain description')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('reads file contents when --description-file is given', async () => {
|
|
18
37
|
writeFileSync(TEST_FILE, '# Test\nSome content')
|
|
19
38
|
const { resolveDescription } = await import('./description.ts')
|
|
20
|
-
const result = await resolveDescription(TEST_FILE)
|
|
39
|
+
const result = await resolveDescription({ descriptionFile: TEST_FILE })
|
|
21
40
|
expect(result).toBe('# Test\nSome content')
|
|
22
41
|
})
|
|
23
42
|
|
|
24
|
-
test('
|
|
43
|
+
test('reads stdin when --description-file is "-"', async () => {
|
|
25
44
|
const { resolveDescription } = await import('./description.ts')
|
|
26
|
-
const
|
|
27
|
-
|
|
45
|
+
const fakeStdin = (async function* () {
|
|
46
|
+
yield Buffer.from('piped ')
|
|
47
|
+
yield Buffer.from('description')
|
|
48
|
+
})()
|
|
49
|
+
const result = await resolveDescription(
|
|
50
|
+
{ descriptionFile: '-' },
|
|
51
|
+
// biome-ignore lint/suspicious/noExplicitAny: test stub for stdin
|
|
52
|
+
fakeStdin as any,
|
|
53
|
+
)
|
|
54
|
+
expect(result).toBe('piped description')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('errors when --description-file points at a missing file', async () => {
|
|
58
|
+
const { resolveDescription } = await import('./description.ts')
|
|
59
|
+
await expect(
|
|
60
|
+
resolveDescription({ descriptionFile: resolve(__dirname, '../../nope') }),
|
|
61
|
+
).rejects.toThrow(/not found|no such file|ENOENT/i)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('errors when both --description and --description-file are given', async () => {
|
|
65
|
+
writeFileSync(TEST_FILE, 'body')
|
|
66
|
+
const { resolveDescription } = await import('./description.ts')
|
|
67
|
+
await expect(
|
|
68
|
+
resolveDescription({
|
|
69
|
+
description: 'literal',
|
|
70
|
+
descriptionFile: TEST_FILE,
|
|
71
|
+
}),
|
|
72
|
+
).rejects.toThrow(/both|either|only one/i)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('errors when neither --description nor --description-file is given', async () => {
|
|
76
|
+
const { resolveDescription } = await import('./description.ts')
|
|
77
|
+
await expect(resolveDescription({})).rejects.toThrow(
|
|
78
|
+
/--description|description-file|required/i,
|
|
79
|
+
)
|
|
28
80
|
})
|
|
29
81
|
})
|
package/src/util/description.ts
CHANGED
|
@@ -1,8 +1,63 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export interface DescriptionInput {
|
|
4
|
+
/** Literal description text. Used verbatim — never interpreted as a path. */
|
|
5
|
+
description?: string
|
|
6
|
+
/**
|
|
7
|
+
* Path to a file whose contents become the description.
|
|
8
|
+
* Use `-` to read from stdin.
|
|
9
|
+
*/
|
|
10
|
+
descriptionFile?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type AsyncByteSource = AsyncIterable<Uint8Array | Buffer | string>
|
|
14
|
+
|
|
15
|
+
async function readStdin(source: AsyncByteSource): Promise<string> {
|
|
16
|
+
const chunks: string[] = []
|
|
17
|
+
for await (const chunk of source) {
|
|
18
|
+
chunks.push(
|
|
19
|
+
typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8'),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
return chunks.join('')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a node description from explicit options.
|
|
27
|
+
*
|
|
28
|
+
* - `--description <text>` is always literal (never read as a file).
|
|
29
|
+
* - `--description-file <path>` reads file contents; `-` reads stdin.
|
|
30
|
+
* - Supplying both, or neither, is an error.
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveDescription(
|
|
33
|
+
input: DescriptionInput,
|
|
34
|
+
stdin: AsyncByteSource = process.stdin,
|
|
35
|
+
): Promise<string> {
|
|
36
|
+
const hasLiteral = input.description != null
|
|
37
|
+
const hasFile = input.descriptionFile != null
|
|
38
|
+
|
|
39
|
+
if (hasLiteral && hasFile) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'Pass only one of --description or --description-file, not both.',
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (hasLiteral) {
|
|
46
|
+
return input.description as string
|
|
6
47
|
}
|
|
7
|
-
|
|
48
|
+
|
|
49
|
+
if (hasFile) {
|
|
50
|
+
const path = input.descriptionFile as string
|
|
51
|
+
if (path === '-') {
|
|
52
|
+
return readStdin(stdin)
|
|
53
|
+
}
|
|
54
|
+
if (!existsSync(path)) {
|
|
55
|
+
throw new Error(`--description-file not found: ${path}`)
|
|
56
|
+
}
|
|
57
|
+
return readFileSync(path, 'utf-8')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw new Error(
|
|
61
|
+
'A description is required: pass --description or --description-file.',
|
|
62
|
+
)
|
|
8
63
|
}
|
package/src/util/format.test.ts
CHANGED
|
@@ -45,4 +45,69 @@ describe('outputError', () => {
|
|
|
45
45
|
|
|
46
46
|
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
47
47
|
})
|
|
48
|
+
|
|
49
|
+
test('exits 1 for VALIDATION_ERROR (usage/validation class)', () => {
|
|
50
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
51
|
+
const exitSpy = vi
|
|
52
|
+
.spyOn(process, 'exit')
|
|
53
|
+
.mockImplementation(() => undefined as never)
|
|
54
|
+
|
|
55
|
+
outputError(
|
|
56
|
+
Object.assign(new Error('bad input'), { code: 'VALIDATION_ERROR' }),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('exits 1 for CONFLICT (usage/validation class)', () => {
|
|
63
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
64
|
+
const exitSpy = vi
|
|
65
|
+
.spyOn(process, 'exit')
|
|
66
|
+
.mockImplementation(() => undefined as never)
|
|
67
|
+
|
|
68
|
+
outputError(Object.assign(new Error('conflict'), { code: 'CONFLICT' }))
|
|
69
|
+
|
|
70
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('exits 2 for NOT_FOUND', () => {
|
|
74
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
75
|
+
const exitSpy = vi
|
|
76
|
+
.spyOn(process, 'exit')
|
|
77
|
+
.mockImplementation(() => undefined as never)
|
|
78
|
+
|
|
79
|
+
outputError(
|
|
80
|
+
Object.assign(new Error('Node bad-id not found'), { code: 'NOT_FOUND' }),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
expect(exitSpy).toHaveBeenCalledWith(2)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('exits 3 for SERVER_ERROR', () => {
|
|
87
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
88
|
+
const exitSpy = vi
|
|
89
|
+
.spyOn(process, 'exit')
|
|
90
|
+
.mockImplementation(() => undefined as never)
|
|
91
|
+
|
|
92
|
+
outputError(
|
|
93
|
+
Object.assign(new Error('Server returned HTTP 502'), {
|
|
94
|
+
code: 'SERVER_ERROR',
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(exitSpy).toHaveBeenCalledWith(3)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('exits 4 for NETWORK_ERROR', () => {
|
|
102
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
103
|
+
const exitSpy = vi
|
|
104
|
+
.spyOn(process, 'exit')
|
|
105
|
+
.mockImplementation(() => undefined as never)
|
|
106
|
+
|
|
107
|
+
outputError(
|
|
108
|
+
Object.assign(new Error('Request timed out'), { code: 'NETWORK_ERROR' }),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
expect(exitSpy).toHaveBeenCalledWith(4)
|
|
112
|
+
})
|
|
48
113
|
})
|
package/src/util/format.ts
CHANGED
|
@@ -2,6 +2,27 @@ export function output(data: unknown): void {
|
|
|
2
2
|
console.log(JSON.stringify(data, null, 2))
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Map a GraphQL `extensions.code` (or transport code) to a distinct process
|
|
7
|
+
* exit code so callers can branch on failure class:
|
|
8
|
+
* 1 — usage / validation / conflict (default)
|
|
9
|
+
* 2 — not found
|
|
10
|
+
* 3 — server error (non-2xx, masked, non-JSON)
|
|
11
|
+
* 4 — network / transport (timeout, connection failure)
|
|
12
|
+
*/
|
|
13
|
+
function exitCodeFor(code: string | undefined): number {
|
|
14
|
+
switch (code) {
|
|
15
|
+
case 'NOT_FOUND':
|
|
16
|
+
return 2
|
|
17
|
+
case 'SERVER_ERROR':
|
|
18
|
+
return 3
|
|
19
|
+
case 'NETWORK_ERROR':
|
|
20
|
+
return 4
|
|
21
|
+
default:
|
|
22
|
+
return 1
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
5
26
|
export function outputError(error: unknown): void {
|
|
6
27
|
const message = error instanceof Error ? error.message : String(error)
|
|
7
28
|
const rawCode =
|
|
@@ -10,5 +31,5 @@ export function outputError(error: unknown): void {
|
|
|
10
31
|
console.error(
|
|
11
32
|
JSON.stringify(code ? { error: message, code } : { error: message }),
|
|
12
33
|
)
|
|
13
|
-
process.exit(
|
|
34
|
+
process.exit(exitCodeFor(code))
|
|
14
35
|
}
|