@sqaoss/flowy 1.2.2 → 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.
@@ -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
- json: () =>
39
- Promise.resolve({
40
- errors: [
41
- {
42
- message: 'Search query must be at least 3 characters',
43
- extensions: { code: 'VALIDATION_ERROR' },
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
- json: () =>
64
- Promise.resolve({
65
- errors: [
66
- {
67
- message: 'Something broke',
68
- extensions: { code: 'UNKNOWN_CODE' },
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.fn().mockResolvedValue({
107
- json: () =>
108
- Promise.resolve({
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 network error', async () => {
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(graphql('query { whoami { id } }')).rejects.toThrow(
126
- 'fetch failed',
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
- json: () =>
135
- Promise.resolve({
136
- errors: [
137
- {
138
- message: 'Active subscription required.',
139
- extensions: { code: 'SUBSCRIPTION_REQUIRED' },
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
- json: () =>
157
- Promise.resolve({
158
- errors: [
159
- {
160
- message: 'Subscription has expired.',
161
- extensions: { code: 'SUBSCRIPTION_EXPIRED' },
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
- json: () =>
179
- Promise.resolve({
180
- errors: [
181
- {
182
- message: 'Subscription is suspended.',
183
- extensions: { code: 'SUBSCRIPTION_SUSPENDED' },
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
  })
@@ -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 res = await fetch(apiUrl, {
18
- method: 'POST',
19
- headers,
20
- body: JSON.stringify({ query, variables }),
21
- })
63
+ const body = JSON.stringify({ query, variables })
22
64
 
23
- const json = (await res.json()) as {
24
- data?: T
25
- errors?: Array<{ message: string; extensions?: { code?: string } }>
26
- }
65
+ let lastError: CodedError | undefined
27
66
 
28
- if (json.errors?.length) {
29
- const error = json.errors[0]
30
- const code = error?.extensions?.code
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
- if (code === 'SUBSCRIPTION_REQUIRED') {
33
- throw new Error(
34
- 'An active subscription is required. Run `flowy billing checkout` to subscribe.',
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
- if (code === 'SUBSCRIPTION_EXPIRED') {
39
- throw new Error(
40
- 'Your subscription has expired. Run `flowy billing checkout` to renew.',
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 (code === 'SUBSCRIPTION_SUSPENDED') {
45
- throw new Error(
46
- 'Your subscription is suspended. Please contact support to resolve this.',
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
- const err = new Error(error?.message) as Error & { code?: string }
51
- if (code) err.code = code
52
- throw err
151
+ return json.data as T
53
152
  }
54
153
 
55
- return json.data as T
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 file content when path exists', async () => {
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('returns value as-is when path does not exist', async () => {
43
+ test('reads stdin when --description-file is "-"', async () => {
25
44
  const { resolveDescription } = await import('./description.ts')
26
- const result = await resolveDescription('Just a plain description')
27
- expect(result).toBe('Just a plain description')
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
  })
@@ -1,8 +1,63 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
 
3
- export async function resolveDescription(value: string): Promise<string> {
4
- if (existsSync(value)) {
5
- return readFileSync(value, 'utf-8')
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
- return value
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
  }