@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/src/util/client.test.ts
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
2
|
|
|
3
|
+
function ok(json: unknown) {
|
|
4
|
+
return {
|
|
5
|
+
ok: true,
|
|
6
|
+
status: 200,
|
|
7
|
+
headers: { get: () => 'application/json' },
|
|
8
|
+
json: () => Promise.resolve(json),
|
|
9
|
+
text: () => Promise.resolve(JSON.stringify(json)),
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function httpError(status: number, body = '<html>error</html>') {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
status,
|
|
17
|
+
headers: { get: () => 'text/html' },
|
|
18
|
+
json: () => Promise.reject(new SyntaxError('Unexpected token <')),
|
|
19
|
+
text: () => Promise.resolve(body),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
3
23
|
beforeEach(() => {
|
|
4
24
|
vi.doMock('./config.ts', () => ({
|
|
5
25
|
getConfig: () => ({
|
|
@@ -18,12 +38,7 @@ describe('graphql client', () => {
|
|
|
18
38
|
test('returns data on successful response', async () => {
|
|
19
39
|
vi.stubGlobal(
|
|
20
40
|
'fetch',
|
|
21
|
-
vi.fn().mockResolvedValue({
|
|
22
|
-
json: () =>
|
|
23
|
-
Promise.resolve({
|
|
24
|
-
data: { whoami: { id: '1' } },
|
|
25
|
-
}),
|
|
26
|
-
}),
|
|
41
|
+
vi.fn().mockResolvedValue(ok({ data: { whoami: { id: '1' } } })),
|
|
27
42
|
)
|
|
28
43
|
|
|
29
44
|
const { graphql } = await import('./client.ts')
|
|
@@ -34,17 +49,16 @@ describe('graphql client', () => {
|
|
|
34
49
|
test('attaches extensions.code to the thrown error', async () => {
|
|
35
50
|
vi.stubGlobal(
|
|
36
51
|
'fetch',
|
|
37
|
-
vi.fn().mockResolvedValue(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}),
|
|
52
|
+
vi.fn().mockResolvedValue(
|
|
53
|
+
ok({
|
|
54
|
+
errors: [
|
|
55
|
+
{
|
|
56
|
+
message: 'Search query must be at least 3 characters',
|
|
57
|
+
extensions: { code: 'VALIDATION_ERROR' },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
48
62
|
)
|
|
49
63
|
|
|
50
64
|
const { graphql } = await import('./client.ts')
|
|
@@ -59,17 +73,16 @@ describe('graphql client', () => {
|
|
|
59
73
|
test('throws original server message for unknown error codes', async () => {
|
|
60
74
|
vi.stubGlobal(
|
|
61
75
|
'fetch',
|
|
62
|
-
vi.fn().mockResolvedValue(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}),
|
|
76
|
+
vi.fn().mockResolvedValue(
|
|
77
|
+
ok({
|
|
78
|
+
errors: [
|
|
79
|
+
{
|
|
80
|
+
message: 'Something broke',
|
|
81
|
+
extensions: { code: 'UNKNOWN_CODE' },
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
73
86
|
)
|
|
74
87
|
|
|
75
88
|
const { graphql } = await import('./client.ts')
|
|
@@ -81,12 +94,7 @@ describe('graphql client', () => {
|
|
|
81
94
|
test('throws error message when extensions are absent', async () => {
|
|
82
95
|
vi.stubGlobal(
|
|
83
96
|
'fetch',
|
|
84
|
-
vi.fn().mockResolvedValue({
|
|
85
|
-
json: () =>
|
|
86
|
-
Promise.resolve({
|
|
87
|
-
errors: [{ message: 'Auth required' }],
|
|
88
|
-
}),
|
|
89
|
-
}),
|
|
97
|
+
vi.fn().mockResolvedValue(ok({ errors: [{ message: 'Auth required' }] })),
|
|
90
98
|
)
|
|
91
99
|
|
|
92
100
|
const { graphql } = await import('./client.ts')
|
|
@@ -97,18 +105,12 @@ describe('graphql client', () => {
|
|
|
97
105
|
|
|
98
106
|
test('omits Authorization header when no apiKey configured', async () => {
|
|
99
107
|
vi.doMock('./config.ts', () => ({
|
|
100
|
-
getConfig: () => ({
|
|
101
|
-
apiUrl: 'http://test/graphql',
|
|
102
|
-
apiKey: '',
|
|
103
|
-
}),
|
|
108
|
+
getConfig: () => ({ apiUrl: 'http://test/graphql', apiKey: '' }),
|
|
104
109
|
}))
|
|
105
110
|
|
|
106
|
-
const mockFetch = vi
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
data: { whoami: { id: '1' } },
|
|
110
|
-
}),
|
|
111
|
-
})
|
|
111
|
+
const mockFetch = vi
|
|
112
|
+
.fn()
|
|
113
|
+
.mockResolvedValue(ok({ data: { whoami: { id: '1' } } }))
|
|
112
114
|
vi.stubGlobal('fetch', mockFetch)
|
|
113
115
|
|
|
114
116
|
const { graphql } = await import('./client.ts')
|
|
@@ -118,29 +120,30 @@ describe('graphql client', () => {
|
|
|
118
120
|
expect(callHeaders).not.toHaveProperty('Authorization')
|
|
119
121
|
})
|
|
120
122
|
|
|
121
|
-
test('throws on
|
|
123
|
+
test('throws a NETWORK_ERROR-coded error on fetch rejection', async () => {
|
|
122
124
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fetch failed')))
|
|
123
125
|
|
|
124
126
|
const { graphql } = await import('./client.ts')
|
|
125
|
-
await expect(
|
|
126
|
-
'
|
|
127
|
-
)
|
|
127
|
+
await expect(
|
|
128
|
+
graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
|
|
129
|
+
).rejects.toMatchObject({
|
|
130
|
+
code: 'NETWORK_ERROR',
|
|
131
|
+
})
|
|
128
132
|
})
|
|
129
133
|
|
|
130
134
|
test('throws friendly message for SUBSCRIPTION_REQUIRED error code', async () => {
|
|
131
135
|
vi.stubGlobal(
|
|
132
136
|
'fetch',
|
|
133
|
-
vi.fn().mockResolvedValue(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}),
|
|
137
|
+
vi.fn().mockResolvedValue(
|
|
138
|
+
ok({
|
|
139
|
+
errors: [
|
|
140
|
+
{
|
|
141
|
+
message: 'Active subscription required.',
|
|
142
|
+
extensions: { code: 'SUBSCRIPTION_REQUIRED' },
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
144
147
|
)
|
|
145
148
|
|
|
146
149
|
const { graphql } = await import('./client.ts')
|
|
@@ -152,17 +155,16 @@ describe('graphql client', () => {
|
|
|
152
155
|
test('throws friendly message for SUBSCRIPTION_EXPIRED error code', async () => {
|
|
153
156
|
vi.stubGlobal(
|
|
154
157
|
'fetch',
|
|
155
|
-
vi.fn().mockResolvedValue(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}),
|
|
158
|
+
vi.fn().mockResolvedValue(
|
|
159
|
+
ok({
|
|
160
|
+
errors: [
|
|
161
|
+
{
|
|
162
|
+
message: 'Subscription has expired.',
|
|
163
|
+
extensions: { code: 'SUBSCRIPTION_EXPIRED' },
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
166
168
|
)
|
|
167
169
|
|
|
168
170
|
const { graphql } = await import('./client.ts')
|
|
@@ -174,17 +176,16 @@ describe('graphql client', () => {
|
|
|
174
176
|
test('throws friendly message for SUBSCRIPTION_SUSPENDED error code', async () => {
|
|
175
177
|
vi.stubGlobal(
|
|
176
178
|
'fetch',
|
|
177
|
-
vi.fn().mockResolvedValue(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}),
|
|
179
|
+
vi.fn().mockResolvedValue(
|
|
180
|
+
ok({
|
|
181
|
+
errors: [
|
|
182
|
+
{
|
|
183
|
+
message: 'Subscription is suspended.',
|
|
184
|
+
extensions: { code: 'SUBSCRIPTION_SUSPENDED' },
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
188
189
|
)
|
|
189
190
|
|
|
190
191
|
const { graphql } = await import('./client.ts')
|
|
@@ -192,4 +193,116 @@ describe('graphql client', () => {
|
|
|
192
193
|
/suspended.*contact support/,
|
|
193
194
|
)
|
|
194
195
|
})
|
|
196
|
+
|
|
197
|
+
describe('transport hardening (F11)', () => {
|
|
198
|
+
test('throws a SERVER_ERROR-coded error on a non-retryable non-2xx (e.g. 500) without crashing on HTML body', async () => {
|
|
199
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(httpError(500)))
|
|
200
|
+
|
|
201
|
+
const { graphql } = await import('./client.ts')
|
|
202
|
+
await expect(
|
|
203
|
+
graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
|
|
204
|
+
).rejects.toMatchObject({ code: 'SERVER_ERROR' })
|
|
205
|
+
// Must surface a real message, not a raw SyntaxError about "<"
|
|
206
|
+
await expect(
|
|
207
|
+
graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
|
|
208
|
+
).rejects.toThrow(/500/)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('throws SERVER_ERROR (no crash) when a 200 body is not JSON', async () => {
|
|
212
|
+
vi.stubGlobal(
|
|
213
|
+
'fetch',
|
|
214
|
+
vi.fn().mockResolvedValue({
|
|
215
|
+
ok: true,
|
|
216
|
+
status: 200,
|
|
217
|
+
headers: { get: () => 'text/html' },
|
|
218
|
+
json: () => Promise.reject(new SyntaxError('Unexpected token <')),
|
|
219
|
+
text: () => Promise.resolve('<html>proxy</html>'),
|
|
220
|
+
}),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
const { graphql } = await import('./client.ts')
|
|
224
|
+
await expect(graphql('query { whoami { id } }')).rejects.toMatchObject({
|
|
225
|
+
code: 'SERVER_ERROR',
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('retries on a transient 429 then succeeds', async () => {
|
|
230
|
+
const mockFetch = vi
|
|
231
|
+
.fn()
|
|
232
|
+
.mockResolvedValueOnce(httpError(429))
|
|
233
|
+
.mockResolvedValueOnce(ok({ data: { whoami: { id: '1' } } }))
|
|
234
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
235
|
+
|
|
236
|
+
const { graphql } = await import('./client.ts')
|
|
237
|
+
const result = await graphql('query { whoami { id } }', undefined, {
|
|
238
|
+
retryDelayMs: 0,
|
|
239
|
+
})
|
|
240
|
+
expect(result).toEqual({ whoami: { id: '1' } })
|
|
241
|
+
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('retries on 503 then gives up with a SERVER_ERROR-coded error', async () => {
|
|
245
|
+
const mockFetch = vi.fn().mockResolvedValue(httpError(503))
|
|
246
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
247
|
+
|
|
248
|
+
const { graphql } = await import('./client.ts')
|
|
249
|
+
await expect(
|
|
250
|
+
graphql('query { whoami { id } }', undefined, {
|
|
251
|
+
retries: 2,
|
|
252
|
+
retryDelayMs: 0,
|
|
253
|
+
}),
|
|
254
|
+
).rejects.toMatchObject({ code: 'SERVER_ERROR' })
|
|
255
|
+
// initial attempt + 2 retries = 3 calls
|
|
256
|
+
expect(mockFetch).toHaveBeenCalledTimes(3)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('does NOT retry on a non-transient 400', async () => {
|
|
260
|
+
const mockFetch = vi.fn().mockResolvedValue(httpError(400))
|
|
261
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
262
|
+
|
|
263
|
+
const { graphql } = await import('./client.ts')
|
|
264
|
+
await expect(
|
|
265
|
+
graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
|
|
266
|
+
).rejects.toMatchObject({ code: 'SERVER_ERROR' })
|
|
267
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('aborts and throws NETWORK_ERROR on timeout', async () => {
|
|
271
|
+
const mockFetch = vi.fn().mockImplementation((_url, init) => {
|
|
272
|
+
return new Promise((_resolve, reject) => {
|
|
273
|
+
const signal = (init as { signal?: AbortSignal }).signal
|
|
274
|
+
signal?.addEventListener('abort', () => {
|
|
275
|
+
reject(
|
|
276
|
+
Object.assign(new Error('The operation was aborted'), {
|
|
277
|
+
name: 'AbortError',
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
284
|
+
|
|
285
|
+
const { graphql } = await import('./client.ts')
|
|
286
|
+
await expect(
|
|
287
|
+
graphql('query { whoami { id } }', undefined, {
|
|
288
|
+
timeoutMs: 10,
|
|
289
|
+
retries: 0,
|
|
290
|
+
retryDelayMs: 0,
|
|
291
|
+
}),
|
|
292
|
+
).rejects.toMatchObject({ code: 'NETWORK_ERROR' })
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('passes a timeout signal to fetch', async () => {
|
|
296
|
+
const mockFetch = vi
|
|
297
|
+
.fn()
|
|
298
|
+
.mockResolvedValue(ok({ data: { whoami: { id: '1' } } }))
|
|
299
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
300
|
+
|
|
301
|
+
const { graphql } = await import('./client.ts')
|
|
302
|
+
await graphql('query { whoami { id } }')
|
|
303
|
+
|
|
304
|
+
const init = mockFetch.mock.calls[0]![1]
|
|
305
|
+
expect(init.signal).toBeInstanceOf(AbortSignal)
|
|
306
|
+
})
|
|
307
|
+
})
|
|
195
308
|
})
|
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
|
}
|