1import { assert, describe, it } from "@effect/vitest"
2import { Config, ConfigProvider, Effect, FileSystem, Layer, Option, Path, Ref, Result, Stdio } from "effect"
3import { Argument, CliError, Command, Flag, Param, Primitive, Prompt } from "effect/cli"
4import * as Lexer from "effect/cli/internal/lexer"
5import * as Parser from "effect/cli/internal/parser"
6import { ChildProcessSpawner } from "effect/process"
7import { TestConsole } from "effect/testing"
8import * as MockTerminal from "./services/MockTerminal.ts"
9
10const ConsoleLayer = TestConsole.layer
11const FileSystemLayer = FileSystem.layerNoop({})
12const PathLayer = Path.layer
13const TerminalLayer = MockTerminal.layer
14const StdioLayer = Stdio.layerTest({})
15const ChildProcessSpawnerLayer = Layer.succeed(
16 ChildProcessSpawner.ChildProcessSpawner,
17 ChildProcessSpawner.make(() => Effect.die("Not implemented"))
18)
19
20const TestLayer = Layer.mergeAll(
21 ConsoleLayer,
22 FileSystemLayer,
23 PathLayer,
24 TerminalLayer,
25 StdioLayer,
26 ChildProcessSpawnerLayer
27)
28
29describe("Param", () => {
30 it.effect("Never remains an always-failing flag sentinel", () =>
31 Effect.gen(function*() {
32 for (const sentinel of [Flag.Never, Param.Never(Param.flagKind)]) {
33 const missing = yield* Effect.flip(sentinel.parse({ flags: {}, arguments: [] }))
34 assert.instanceOf(missing, CliError.MissingOption)
35 const supplied = yield* Effect.flip(sentinel.parse({ flags: { __never__: ["value"] }, arguments: [] }))
36 assert.instanceOf(supplied, CliError.InvalidValue)
37 }
38 }).pipe(Effect.provide(TestLayer)))
39
40 it.effect("Never remains an always-failing argument sentinel", () =>
41 Effect.gen(function*() {
42 for (const sentinel of [Argument.Never, Param.Never(Param.argumentKind)]) {
43 const missing = yield* Effect.flip(sentinel.parse({ flags: {}, arguments: [] }))
44 assert.instanceOf(missing, CliError.MissingArgument)
45 const supplied = yield* Effect.flip(sentinel.parse({ flags: {}, arguments: ["value"] }))
46 assert.instanceOf(supplied, CliError.InvalidValue)
47 }
48 }).pipe(Effect.provide(TestLayer)))
49
50 describe("boolean", () => {
51 it.effect("returns MissingOption when a boolean flag is omitted", () =>
52 Effect.gen(function*() {
53 const error = yield* Effect.flip(
54 Flag.Boolean("verbose").parse({
55 flags: {},
56 arguments: []
57 })
58 )
59
60 assert.instanceOf(error, CliError.MissingOption)
61 }).pipe(Effect.provide(TestLayer)))
62
63 it.effect("uses the requested default when a boolean flag is omitted", () =>
64 Effect.gen(function*() {
65 const flag = Flag.Boolean("verbose").pipe(Flag.withDefault(true))
66
67 const [, value] = yield* flag.parse({
68 flags: {},
69 arguments: []
70 })
71
72 assert.isTrue(value)
73 }).pipe(Effect.provide(TestLayer)))
74
75 it.effect("restores switch behavior with a false default", () =>
76 Effect.gen(function*() {
77 const flag = Flag.Boolean("verbose").pipe(Flag.withDefault(false))
78
79 const [, value] = yield* flag.parse({
80 flags: {},
81 arguments: []
82 })
83
84 assert.isFalse(value)
85 }).pipe(Effect.provide(TestLayer)))
86 })
87
88 it.effect("recognizes the alternate flag declared by orElse", () =>
89 Effect.gen(function*() {
90 const command = Command.make("app", {
91 config: Flag.String("config").pipe(
92 Flag.orElse(() => Flag.String("config-url"))
93 )
94 })
95
96 const parsed = yield* Parser.parseArgs(Lexer.lex(["--config-url", "https://example.com"]), command)
97
98 assert.isUndefined(parsed.errors)
99 assert.deepStrictEqual(parsed.flags["config-url"], ["https://example.com"])
100 }).pipe(Effect.provide(TestLayer)))
101
102 it.effect("registers and parses the alternate flag declared by orElseResult", () =>
103 Effect.gen(function*() {
104 const flag = Flag.String("config").pipe(
105 Flag.orElseResult(() => Flag.String("config-url"))
106 )
107
108 assert.deepStrictEqual(Param.extractSingleParams(flag).map((param) => param.name), ["config", "config-url"])
109
110 const [, result] = yield* flag.parse({
111 flags: { "config-url": ["https://example.com"] },
112 arguments: []
113 })
114
115 assert.deepStrictEqual(result, Result.fail("https://example.com"))
116 }).pipe(Effect.provide(TestLayer)))
117
118 it("preserves __proto__ as an own makeSingle option", () => {
119 const value = { polluted: true }
120 const param = Param.makeSingle({
121 ["__proto__"]: value,
122 kind: Param.flagKind,
123 name: "name",
124 primitiveType: Primitive.String
125 } as any)
126
127 assert.isTrue(Param.isParam(param))
128 assert.isTrue(Object.hasOwn(param, "__proto__"))
129 assert.strictEqual((param as any)["__proto__"], value)
130 })
131
132 describe("optional", () => {
133 it.effect("returns none when an optional boolean flag is omitted", () =>
134 Effect.gen(function*() {
135 const flag = Flag.Boolean("verbose").pipe(Flag.optional)
136
137 const [, value] = yield* flag.parse({
138 flags: {},
139 arguments: []
140 })
141
142 assert.deepStrictEqual(value, Option.none())
143 }).pipe(Effect.provide(TestLayer)))
144
145 it.effect("returns some false when an optional boolean flag is explicitly disabled", () =>
146 Effect.gen(function*() {
147 const flag = Flag.Boolean("verbose").pipe(Flag.optional)
148
149 const [, value] = yield* flag.parse({
150 flags: { verbose: ["false"] },
151 arguments: []
152 })
153
154 assert.deepStrictEqual(value, Option.some(false))
155 }).pipe(Effect.provide(TestLayer)))
156
157 it.effect("supports alternative flags", () =>
158 Effect.gen(function*() {
159 const flag = Flag.String("config").pipe(
160 Flag.orElse(() => Flag.String("config-url")),
161 Flag.optional
162 )
163
164 const [, value] = yield* flag.parse({ flags: { config: ["config.json"] }, arguments: [] })
165
166 assert.deepStrictEqual(value, Option.some("config.json"))
167 }).pipe(Effect.provide(TestLayer)))
168 })
169
170 it.effect("uses the default when a required variadic argument is omitted", () =>
171 Effect.gen(function*() {
172 const argument = Argument.String("files").pipe(
173 Argument.atLeast(1),
174 Argument.withDefault(["README.md"])
175 )
176
177 const result = yield* argument.parse({ flags: {}, arguments: [] })
178
179 assert.deepStrictEqual(result, [[], ["README.md"]])
180 }).pipe(Effect.provide(TestLayer)))
181
182 describe("withFallbackPrompt", () => {
183 it.effect("prompts for missing flag values and preserves remaining args", () =>
184 Effect.gen(function*() {
185 const prompt = Prompt.String({ message: "Name" })
186 const flag = Flag.String("name").pipe(Flag.withFallbackPrompt(prompt))
187
188 yield* MockTerminal.inputText("Chandra")
189 yield* MockTerminal.inputKey("enter")
190
191 const [remaining, value] = yield* flag.parse({
192 flags: {},
193 arguments: ["tail"]
194 })
195
196 assert.strictEqual(value, "Chandra")
197 assert.deepStrictEqual(remaining, ["tail"])
198 }).pipe(Effect.provide(TestLayer)))
199
200 it.effect("does not prompt when flag value is provided", () =>
201 Effect.gen(function*() {
202 const prompt = Prompt.String({ message: "Name" })
203 const flag = Flag.String("name").pipe(Flag.withFallbackPrompt(prompt))
204
205 const [, value] = yield* flag.parse({
206 flags: { name: ["Ava"] },
207 arguments: []
208 })
209
210 assert.strictEqual(value, "Ava")
211 }).pipe(Effect.provide(TestLayer)))
212
213 it.effect("prompts for missing arguments", () =>
214 Effect.gen(function*() {
215 const prompt = Prompt.String({ message: "File" })
216 const argument = Argument.String("file").pipe(Argument.withFallbackPrompt(prompt))
217
218 yield* MockTerminal.inputText("notes.txt")
219 yield* MockTerminal.inputKey("enter")
220
221 const [remaining, value] = yield* argument.parse({
222 flags: {},
223 arguments: []
224 })
225
226 assert.strictEqual(value, "notes.txt")
227 assert.deepStrictEqual(remaining, [])
228 }).pipe(Effect.provide(TestLayer)))
229
230 it.effect("builds flag fallback prompts from effects lazily", () =>
231 Effect.gen(function*() {
232 const calls = yield* Ref.make(0)
233 const prompt = Effect.gen(function*() {
234 yield* Ref.update(calls, (n) => n + 1)
235 return Prompt.String({ message: "Name from effect" })
236 })
237
238 const flag = Flag.String("name").pipe(Flag.withFallbackPrompt(prompt))
239
240 const [, provided] = yield* flag.parse({
241 flags: { name: ["Ava"] },
242 arguments: []
243 })
244 assert.strictEqual(provided, "Ava")
245 assert.strictEqual(yield* Ref.get(calls), 0)
246
247 yield* MockTerminal.inputText("Mia")
248 yield* MockTerminal.inputKey("enter")
249
250 const [remaining, prompted] = yield* flag.parse({
251 flags: {},
252 arguments: ["tail"]
253 })
254
255 assert.strictEqual(prompted, "Mia")
256 assert.deepStrictEqual(remaining, ["tail"])
257 assert.strictEqual(yield* Ref.get(calls), 1)
258 }).pipe(Effect.provide(TestLayer)))
259
260 it.effect("builds argument fallback prompts from effects lazily", () =>
261 Effect.gen(function*() {
262 const calls = yield* Ref.make(0)
263 const prompt = Effect.gen(function*() {
264 yield* Ref.update(calls, (n) => n + 1)
265 return Prompt.String({ message: "File from effect" })
266 })
267
268 const argument = Argument.String("file").pipe(Argument.withFallbackPrompt(prompt))
269
270 const [providedRemaining, provided] = yield* argument.parse({
271 flags: {},
272 arguments: ["notes.txt", "tail"]
273 })
274 assert.strictEqual(provided, "notes.txt")
275 assert.deepStrictEqual(providedRemaining, ["tail"])
276 assert.strictEqual(yield* Ref.get(calls), 0)
277
278 yield* MockTerminal.inputText("draft.md")
279 yield* MockTerminal.inputKey("enter")
280
281 const [promptedRemaining, prompted] = yield* argument.parse({
282 flags: {},
283 arguments: []
284 })
285
286 assert.strictEqual(prompted, "draft.md")
287 assert.deepStrictEqual(promptedRemaining, [])
288 assert.strictEqual(yield* Ref.get(calls), 1)
289 }).pipe(Effect.provide(TestLayer)))
290
291 it.effect("propagates errors from effectful fallback prompt creation", () =>
292 Effect.gen(function*() {
293 const failure = new CliError.UserError({
294 cause: "failed to build prompt"
295 })
296 const prompt = Effect.fail(failure)
297
298 const flag = Flag.String("name").pipe(Flag.withFallbackPrompt(prompt))
299
300 const error = yield* Effect.flip(
301 flag.parse({
302 flags: {},
303 arguments: []
304 })
305 )
306
307 assert.strictEqual(error, failure)
308 }).pipe(Effect.provide(TestLayer)))
309
310 it.effect("prefers defaults over fallback prompts", () =>
311 Effect.gen(function*() {
312 const prompt = Prompt.String({ message: "Name" })
313 const flag = Flag.String("name").pipe(
314 Flag.withDefault("guest"),
315 Flag.withFallbackPrompt(prompt)
316 )
317
318 const [, value] = yield* flag.parse({
319 flags: {},
320 arguments: []
321 })
322
323 assert.strictEqual(value, "guest")
324 }).pipe(Effect.provide(TestLayer)))
325
326 it.effect("does not prompt for invalid flag values", () =>
327 Effect.gen(function*() {
328 const prompt = Prompt.String({ message: "Count" })
329 const flag = Flag.Int("count").pipe(Flag.withFallbackPrompt(prompt))
330
331 const error = yield* Effect.flip(
332 flag.parse({
333 flags: { count: ["nope"] },
334 arguments: []
335 })
336 )
337
338 assert.instanceOf(error, CliError.InvalidValue)
339 }).pipe(Effect.provide(TestLayer)))
340
341 it.effect("does not prompt for invalid argument values", () =>
342 Effect.gen(function*() {
343 const prompt = Prompt.String({ message: "Count" })
344 const argument = Argument.Int("count").pipe(Argument.withFallbackPrompt(prompt))
345
346 const error = yield* Effect.flip(
347 argument.parse({
348 flags: {},
349 arguments: ["nope"]
350 })
351 )
352
353 assert.instanceOf(error, CliError.InvalidValue)
354 }).pipe(Effect.provide(TestLayer)))
355
356 it.effect("prompts for missing boolean flags", () =>
357 Effect.gen(function*() {
358 const prompt = Prompt.Confirm({ message: "Verbose" })
359 const flag = Flag.Boolean("verbose").pipe(Flag.withFallbackPrompt(prompt))
360
361 yield* MockTerminal.inputKey("y")
362
363 const [, value] = yield* flag.parse({
364 flags: {},
365 arguments: []
366 })
367
368 assert.isTrue(value)
369 }).pipe(Effect.provide(TestLayer)))
370
371 it.effect("uses an explicitly disabled boolean before prompting", () =>
372 Effect.gen(function*() {
373 const prompt = Prompt.Confirm({ message: "Verbose" })
374 const flag = Flag.Boolean("verbose").pipe(Flag.withFallbackPrompt(prompt))
375
376 const [, value] = yield* flag.parse({
377 flags: { verbose: ["false"] },
378 arguments: []
379 })
380
381 assert.isFalse(value)
382 }).pipe(Effect.provide(TestLayer)))
383
384 it.effect("returns MissingOption when prompt is cancelled", () =>
385 Effect.gen(function*() {
386 const prompt = Prompt.String({ message: "Name" })
387 const flag = Flag.String("name").pipe(Flag.withFallbackPrompt(prompt))
388
389 yield* MockTerminal.inputKey("c", { ctrl: true })
390
391 const error = yield* Effect.flip(
392 flag.parse({
393 flags: {},
394 arguments: []
395 })
396 )
397
398 assert.instanceOf(error, CliError.MissingOption)
399 }).pipe(Effect.provide(TestLayer)))
400
401 it.effect("returns MissingArgument when argument prompt is cancelled", () =>
402 Effect.gen(function*() {
403 const prompt = Prompt.String({ message: "File" })
404 const argument = Argument.String("file").pipe(Argument.withFallbackPrompt(prompt))
405
406 yield* MockTerminal.inputKey("c", { ctrl: true })
407
408 const error = yield* Effect.flip(
409 argument.parse({
410 flags: {},
411 arguments: []
412 })
413 )
414
415 assert.instanceOf(error, CliError.MissingArgument)
416 }).pipe(Effect.provide(TestLayer)))
417 })
418
419 describe("withFallbackConfig", () => {
420 it.effect("uses ConfigProvider when a boolean flag is missing", () => {
421 const provider = ConfigProvider.fromEnv({
422 env: {
423 VERBOSE: "true"
424 }
425 })
426
427 return Effect.gen(function*() {
428 const flag = Flag.Boolean("verbose").pipe(
429 Flag.withFallbackConfig(Config.Boolean("VERBOSE"))
430 )
431
432 const [, value] = yield* flag.parse({
433 flags: {},
434 arguments: []
435 })
436
437 assert.isTrue(value)
438 }).pipe(
439 Effect.provideService(ConfigProvider.ConfigProvider, provider),
440 Effect.provide(TestLayer)
441 )
442 })
443
444 it.effect("uses an explicitly disabled boolean before reading config fallbacks", () => {
445 const provider = ConfigProvider.fromEnv({
446 env: {
447 VERBOSE: "true"
448 }
449 })
450
451 return Effect.gen(function*() {
452 const flag = Flag.Boolean("verbose").pipe(
453 Flag.withFallbackConfig(Config.Boolean("VERBOSE"))
454 )
455
456 const [, value] = yield* flag.parse({
457 flags: { verbose: ["false"] },
458 arguments: []
459 })
460
461 assert.isFalse(value)
462 }).pipe(
463 Effect.provideService(ConfigProvider.ConfigProvider, provider),
464 Effect.provide(TestLayer)
465 )
466 })
467
468 it.effect("uses ConfigProvider when a flag is missing", () => {
469 const provider = ConfigProvider.fromEnv({
470 env: {
471 NAME: "Ava"
472 }
473 })
474
475 return Effect.gen(function*() {
476 const flag = Flag.String("name").pipe(
477 Flag.withFallbackConfig(Config.String("NAME"))
478 )
479
480 const [, value] = yield* flag.parse({
481 flags: {},
482 arguments: []
483 })
484
485 assert.strictEqual(value, "Ava")
486 }).pipe(
487 Effect.provideService(ConfigProvider.ConfigProvider, provider),
488 Effect.provide(TestLayer)
489 )
490 })
491
492 it.effect("uses flag values before reading config fallbacks", () => {
493 const provider = ConfigProvider.fromEnv({
494 env: {
495 NAME: "Ava"
496 }
497 })
498
499 return Effect.gen(function*() {
500 const flag = Flag.String("name").pipe(
501 Flag.withFallbackConfig(Config.String("NAME"))
502 )
503
504 const [, value] = yield* flag.parse({
505 flags: { name: ["Maya"] },
506 arguments: []
507 })
508
509 assert.strictEqual(value, "Maya")
510 }).pipe(
511 Effect.provideService(ConfigProvider.ConfigProvider, provider),
512 Effect.provide(TestLayer)
513 )
514 })
515
516 it.effect("uses ConfigProvider when an argument is missing", () => {
517 const provider = ConfigProvider.fromEnv({
518 env: {
519 REPOSITORY: "repo"
520 }
521 })
522
523 return Effect.gen(function*() {
524 const argument = Argument.String("repository").pipe(
525 Argument.withFallbackConfig(Config.String("REPOSITORY"))
526 )
527
528 const [, value] = yield* argument.parse({
529 flags: {},
530 arguments: []
531 })
532
533 assert.strictEqual(value, "repo")
534 }).pipe(
535 Effect.provideService(ConfigProvider.ConfigProvider, provider),
536 Effect.provide(TestLayer)
537 )
538 })
539
540 it.effect("returns MissingOption when config is missing", () => {
541 const provider = ConfigProvider.fromEnv({ env: {} })
542
543 return Effect.gen(function*() {
544 const flag = Flag.String("name").pipe(
545 Flag.withFallbackConfig(Config.String("NAME"))
546 )
547
548 const error = yield* Effect.flip(
549 flag.parse({
550 flags: {},
551 arguments: []
552 })
553 )
554
555 assert.instanceOf(error, CliError.MissingOption)
556 }).pipe(
557 Effect.provideService(ConfigProvider.ConfigProvider, provider),
558 Effect.provide(TestLayer)
559 )
560 })
561
562 it.effect("returns InvalidValue when config fails to parse", () => {
563 const provider = ConfigProvider.fromEnv({
564 env: {
565 COUNT: "nope"
566 }
567 })
568
569 return Effect.gen(function*() {
570 const flag = Flag.Int("count").pipe(
571 Flag.withFallbackConfig(Config.Int("COUNT"))
572 )
573
574 const error = yield* Effect.flip(
575 flag.parse({
576 flags: {},
577 arguments: []
578 })
579 )
580
581 assert.instanceOf(error, CliError.InvalidValue)
582 }).pipe(
583 Effect.provideService(ConfigProvider.ConfigProvider, provider),
584 Effect.provide(TestLayer)
585 )
586 })
587 })
588})