@canonical/pragma-cli 0.27.1-experimental.0 → 0.30.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/README.md +67 -15
- package/dist/pragma +0 -0
- package/package.json +16 -17
- package/src/config/defaultConfig.test.ts +33 -0
- package/src/config/defaultConfig.ts +59 -0
- package/src/config/defaults.json +17 -0
- package/src/config/findProjectConfigPath.test.ts +53 -0
- package/src/config/findProjectConfigPath.ts +36 -0
- package/src/config/index.ts +17 -2
- package/src/config/mergeConfigFileUpdate.ts +58 -0
- package/src/config/parseConfigValues.ts +215 -0
- package/src/config/readConfig.test.ts +104 -13
- package/src/config/readConfig.ts +12 -117
- package/src/config/readConfigLayers.test.ts +107 -0
- package/src/config/readConfigLayers.ts +115 -0
- package/src/config/resolveGlobalConfigPath.ts +13 -0
- package/src/config/resolveWriteConfigPath.test.ts +53 -0
- package/src/config/resolveWriteConfigPath.ts +31 -0
- package/src/config/types.ts +88 -2
- package/src/config/writeConfig.ts +25 -43
- package/src/constants.ts +12 -1
- package/src/domains/block/commands/index.ts +2 -1
- package/src/domains/block/commands/list.ts +5 -90
- package/src/domains/block/commands/lookup.ts +8 -167
- package/src/domains/block/commands/sample.ts +55 -0
- package/src/domains/block/formatters/index.ts +2 -1
- package/src/domains/block/formatters/sample.ts +21 -0
- package/src/domains/block/index.ts +3 -3
- package/src/domains/block/mcp/specs.ts +36 -168
- package/src/domains/block/operations/index.ts +2 -1
- package/src/domains/block/operations/lookup.ts +9 -0
- package/src/domains/block/operations/sample.ts +41 -0
- package/src/domains/block/orchestration/blockEmptyError.test.ts +26 -0
- package/src/domains/block/orchestration/resolveBlockLookup.ts +11 -3
- package/src/domains/block/stories.test.ts +28 -0
- package/src/domains/block/stories.ts +254 -0
- package/src/domains/config/commands/channel.test.ts +14 -3
- package/src/domains/config/commands/channel.ts +11 -7
- package/src/domains/config/commands/configScope.ts +46 -0
- package/src/domains/config/commands/framework.test.ts +105 -0
- package/src/domains/config/commands/framework.ts +91 -0
- package/src/domains/config/commands/index.ts +2 -0
- package/src/domains/config/commands/show.ts +5 -45
- package/src/domains/config/commands/tier.test.ts +13 -2
- package/src/domains/config/commands/tier.ts +11 -7
- package/src/domains/config/commands/trace.test.ts +165 -0
- package/src/domains/config/commands/trace.ts +83 -0
- package/src/domains/config/formatters/channel.test.ts +20 -6
- package/src/domains/config/formatters/channel.ts +14 -11
- package/src/domains/config/formatters/framework.ts +37 -0
- package/src/domains/config/formatters/index.ts +2 -0
- package/src/domains/config/formatters/show.test.ts +27 -0
- package/src/domains/config/formatters/show.ts +27 -4
- package/src/domains/config/formatters/tier.test.ts +26 -10
- package/src/domains/config/formatters/tier.ts +19 -11
- package/src/domains/config/formatters/trace.ts +28 -0
- package/src/domains/config/index.ts +15 -2
- package/src/domains/config/mcp/specs.ts +33 -53
- package/src/domains/config/operations/index.ts +1 -0
- package/src/domains/config/operations/show.test.ts +18 -0
- package/src/domains/config/operations/show.ts +7 -1
- package/src/domains/config/operations/types.ts +7 -0
- package/src/domains/config/operations/validateFramework.ts +24 -0
- package/src/domains/config/stories.ts +42 -0
- package/src/domains/config/tasks/index.ts +3 -0
- package/src/domains/config/tasks/setChannel.ts +13 -22
- package/src/domains/config/tasks/setFramework.ts +21 -0
- package/src/domains/config/tasks/setTier.ts +10 -23
- package/src/domains/config/tasks/setTrace.ts +20 -0
- package/src/domains/config/tasks/writeConfigField.ts +41 -0
- package/src/domains/create/commands/component.ts +5 -5
- package/src/domains/doctor/operations/checks/checkConfigFile.ts +19 -5
- package/src/domains/doctor/operations/checks/checkKeStore.ts +5 -5
- package/src/domains/doctor/operations/checks/checkMcpCommands.ts +130 -0
- package/src/domains/doctor/operations/checks/checkMcpConfigured.ts +1 -1
- package/src/domains/doctor/operations/checks/checkPackageRefs.ts +30 -48
- package/src/domains/doctor/operations/checks/checkSkillsSymlinked.ts +1 -1
- package/src/domains/doctor/operations/checks/index.ts +1 -0
- package/src/domains/doctor/operations/checks.test.ts +162 -8
- package/src/domains/doctor/operations/runChecks.test.ts +85 -4
- package/src/domains/doctor/operations/runChecks.ts +50 -12
- package/src/domains/graph/helpers/resolveUri.test.ts +50 -0
- package/src/domains/graph/helpers/resolveUri.ts +28 -21
- package/src/domains/graphql/commands/build.test.ts +196 -0
- package/src/domains/graphql/commands/build.ts +117 -0
- package/src/domains/graphql/commands/check.test.ts +141 -0
- package/src/domains/graphql/commands/check.ts +81 -0
- package/src/domains/graphql/commands/index.ts +5 -0
- package/src/domains/graphql/commands/serve.test.ts +40 -0
- package/src/domains/graphql/commands/serve.ts +131 -0
- package/src/domains/graphql/constants.ts +10 -0
- package/src/domains/graphql/formatters/index.ts +3 -0
- package/src/domains/graphql/formatters/report.ts +78 -0
- package/src/domains/graphql/helpers/gatherSources.test.ts +92 -0
- package/src/domains/graphql/helpers/gatherSources.ts +58 -0
- package/src/domains/graphql/helpers/index.ts +6 -0
- package/src/domains/graphql/helpers/parsePrefixes.test.ts +44 -0
- package/src/domains/graphql/helpers/parsePrefixes.ts +38 -0
- package/src/domains/graphql/helpers/resolveSourceFiles.test.ts +56 -0
- package/src/domains/graphql/helpers/resolveSourceFiles.ts +45 -0
- package/src/domains/graphql/helpers/routeGraphql.test.ts +43 -0
- package/src/domains/graphql/helpers/routeGraphql.ts +28 -0
- package/src/domains/graphql/index.ts +24 -0
- package/src/domains/graphql/operations/compileSchema.test.ts +95 -0
- package/src/domains/graphql/operations/compileSchema.ts +107 -0
- package/src/domains/graphql/operations/createSchemaServer.test.ts +50 -0
- package/src/domains/graphql/operations/createSchemaServer.ts +75 -0
- package/src/domains/graphql/operations/index.ts +12 -0
- package/src/domains/graphql/types.ts +29 -0
- package/src/domains/info/operations/collectInfo.test.ts +16 -5
- package/src/domains/info/operations/collectInfo.ts +16 -19
- package/src/domains/llm/capabilities.test.ts +91 -0
- package/src/domains/llm/commands/capabilities.ts +28 -0
- package/src/domains/llm/commands/index.ts +1 -0
- package/src/domains/llm/data/commandReference.ts +90 -18
- package/src/domains/llm/data/decisionTrees.ts +15 -13
- package/src/domains/llm/data/index.ts +2 -0
- package/src/domains/llm/data/toolCatalog.ts +190 -0
- package/src/domains/llm/formatters/capabilities.ts +50 -0
- package/src/domains/llm/formatters/index.ts +1 -0
- package/src/domains/llm/index.ts +3 -2
- package/src/domains/llm/mcp/index.ts +1 -1
- package/src/domains/llm/mcp/specs.ts +59 -52
- package/src/domains/llm/types.ts +40 -0
- package/src/domains/modifier/commands/index.ts +2 -1
- package/src/domains/modifier/commands/list.ts +5 -38
- package/src/domains/modifier/commands/lookup.ts +8 -79
- package/src/domains/modifier/commands/sample.ts +53 -0
- package/src/domains/modifier/formatters/index.ts +2 -1
- package/src/domains/modifier/formatters/sample.ts +15 -0
- package/src/domains/modifier/index.ts +7 -3
- package/src/domains/modifier/mcp/specs.ts +38 -70
- package/src/domains/modifier/operations/index.ts +2 -1
- package/src/domains/modifier/operations/lookup.ts +9 -0
- package/src/domains/modifier/operations/sample.ts +32 -0
- package/src/domains/modifier/orchestration/modifierEmptyError.test.ts +26 -0
- package/src/domains/modifier/orchestration/modifierEmptyError.ts +7 -2
- package/src/domains/modifier/orchestration/resolveModifierLookup.ts +15 -2
- package/src/domains/modifier/stories.test.ts +20 -0
- package/src/domains/modifier/stories.ts +77 -0
- package/src/domains/ontology/commands/list.ts +5 -58
- package/src/domains/ontology/commands/show.ts +6 -58
- package/src/domains/ontology/formatters/show.ts +7 -1
- package/src/domains/ontology/mcp/specs.ts +7 -84
- package/src/domains/ontology/stories.test.ts +36 -0
- package/src/domains/ontology/stories.ts +108 -0
- package/src/domains/refs/operations/index.ts +1 -1
- package/src/domains/refs/operations/paths.ts +29 -1
- package/src/domains/refs/operations/updateRefs.test.ts +6 -3
- package/src/domains/refs/operations/updateRefs.ts +2 -36
- package/src/domains/setup/commands/skills.test.ts +6 -2
- package/src/domains/setup/commands/skills.ts +6 -2
- package/src/domains/setup/helpers/runSetupTask.test.ts +148 -150
- package/src/domains/setup/helpers/runSetupTask.ts +62 -70
- package/src/domains/setup/operations/setupMcp.test.ts +6 -1
- package/src/domains/shared/bootStore.ts +92 -50
- package/src/domains/shared/compactUri.test.ts +11 -0
- package/src/domains/shared/compactUri.ts +10 -5
- package/src/domains/shared/context.ts +2 -2
- package/src/domains/shared/contracts.ts +12 -0
- package/src/domains/shared/createInteractivePromptSession.test.ts +439 -0
- package/src/domains/shared/createInteractivePromptSession.ts +236 -0
- package/src/domains/shared/emptyErrorRecovery.test.ts +109 -0
- package/src/domains/shared/loaders/bundledLoader.test.ts +248 -0
- package/src/domains/shared/loaders/bundledLoader.ts +206 -0
- package/src/domains/shared/loaders/gitLoader.ts +31 -0
- package/src/domains/shared/loaders/index.ts +9 -0
- package/src/domains/shared/loaders/localLoader.ts +87 -0
- package/src/domains/shared/loaders/readPackageDir.ts +147 -0
- package/src/domains/shared/loaders/storyAssetsPlugin.test.ts +84 -0
- package/src/domains/shared/loaders/storyAssetsPlugin.ts +46 -0
- package/src/domains/shared/lookupMany.test.ts +120 -0
- package/src/domains/shared/lookupMany.ts +43 -3
- package/src/domains/shared/mergeAndParseRefs.test.ts +89 -0
- package/src/domains/shared/mergeAndParseRefs.ts +44 -0
- package/src/domains/shared/packages.ts +14 -120
- package/src/domains/shared/pickRandom.ts +22 -0
- package/src/domains/shared/resolveConfiguredGraphs.test.ts +27 -0
- package/src/domains/shared/resolveConfiguredGraphs.ts +41 -0
- package/src/domains/shared/runtime.ts +7 -63
- package/src/domains/shared/sampleFormatters.ts +47 -0
- package/src/domains/shared/semanticPackage.ts +112 -0
- package/src/domains/shared/stories/compileLookupCommand.test.ts +145 -0
- package/src/domains/shared/stories/compileLookupCommand.ts +100 -0
- package/src/domains/shared/stories/compileLookupTool.test.ts +105 -0
- package/src/domains/shared/stories/compileLookupTool.ts +96 -0
- package/src/domains/shared/stories/compileReadCommand.test.ts +121 -0
- package/src/domains/shared/stories/compileReadCommand.ts +48 -0
- package/src/domains/shared/stories/compileReadTool.test.ts +64 -0
- package/src/domains/shared/stories/compileReadTool.ts +38 -0
- package/src/domains/shared/stories/condense.test.ts +18 -0
- package/src/domains/shared/stories/condense.ts +15 -0
- package/src/domains/shared/stories/index.ts +20 -0
- package/src/domains/shared/stories/normalizeNames.test.ts +20 -0
- package/src/domains/shared/stories/normalizeNames.ts +22 -0
- package/src/domains/shared/stories/pack/applyPackFilters.test.ts +102 -0
- package/src/domains/shared/stories/pack/applyPackFilters.ts +76 -0
- package/src/domains/shared/stories/pack/buildLookupQuery.test.ts +51 -0
- package/src/domains/shared/stories/pack/buildLookupQuery.ts +80 -0
- package/src/domains/shared/stories/pack/collectPackStories.test.ts +108 -0
- package/src/domains/shared/stories/pack/collectPackStories.ts +76 -0
- package/src/domains/shared/stories/pack/compilePackCommands.ts +40 -0
- package/src/domains/shared/stories/pack/compilePackStories.test.ts +146 -0
- package/src/domains/shared/stories/pack/compilePackStories.ts +253 -0
- package/src/domains/shared/stories/pack/compilePackToolSpecs.ts +43 -0
- package/src/domains/shared/stories/pack/index.ts +28 -0
- package/src/domains/shared/stories/pack/runSelectQuery.ts +31 -0
- package/src/domains/shared/stories/pack/standardParity.test.ts +308 -0
- package/src/domains/shared/stories/pack/types.ts +104 -0
- package/src/domains/shared/stories/pack/validateStoryPackDefinition.test.ts +240 -0
- package/src/domains/shared/stories/pack/validateStoryPackDefinition.ts +408 -0
- package/src/domains/shared/stories/projectCliParameters.test.ts +59 -0
- package/src/domains/shared/stories/projectCliParameters.ts +57 -0
- package/src/domains/shared/stories/projectToolParams.test.ts +50 -0
- package/src/domains/shared/stories/projectToolParams.ts +32 -0
- package/src/domains/shared/stories/requirePragmaContext.test.ts +23 -0
- package/src/domains/shared/stories/requirePragmaContext.ts +23 -0
- package/src/domains/shared/stories/resolveLookupDetailed.test.ts +57 -0
- package/src/domains/shared/stories/resolveLookupDetailed.ts +27 -0
- package/src/domains/shared/stories/types.ts +171 -0
- package/src/domains/shared/suggestions/detectCrossDomain.test.ts +43 -0
- package/src/domains/shared/suggestions/detectCrossDomain.ts +116 -0
- package/src/domains/shared/suggestions/expandGlob.test.ts +92 -0
- package/src/domains/shared/suggestions/expandGlob.ts +52 -0
- package/src/domains/shared/suggestions/expandLookupQueries.ts +57 -0
- package/src/domains/shared/suggestions/index.ts +7 -0
- package/src/domains/shared/suggestions/listDomainNames.ts +61 -0
- package/src/domains/shared/suggestions/suggestNames.test.ts +70 -0
- package/src/domains/shared/suggestions/suggestNames.ts +130 -0
- package/src/domains/shared/types/index.ts +1 -0
- package/src/domains/shared/types/runtime.ts +4 -1
- package/src/domains/shared/types/sample.ts +17 -0
- package/src/domains/skill/commands/index.ts +3 -1
- package/src/domains/skill/commands/list.test.ts +6 -2
- package/src/domains/skill/commands/list.ts +5 -63
- package/src/domains/skill/commands/lookup.test.ts +118 -0
- package/src/domains/skill/commands/lookup.ts +14 -0
- package/src/domains/skill/formatters/index.ts +1 -0
- package/src/domains/skill/formatters/lookup.test.ts +46 -0
- package/src/domains/skill/formatters/lookup.ts +57 -0
- package/src/domains/skill/helpers/resolveSkillSources.ts +48 -16
- package/src/domains/skill/index.ts +7 -3
- package/src/domains/skill/mcp/specs.ts +10 -29
- package/src/domains/skill/operations/discover.test.ts +61 -30
- package/src/domains/skill/operations/discover.ts +28 -37
- package/src/domains/skill/operations/index.ts +2 -1
- package/src/domains/skill/operations/list.test.ts +15 -7
- package/src/domains/skill/operations/lookup.test.ts +91 -0
- package/src/domains/skill/operations/lookup.ts +70 -0
- package/src/domains/skill/stories.test.ts +43 -0
- package/src/domains/skill/stories.ts +103 -0
- package/src/domains/skill/types.ts +8 -0
- package/src/domains/standard/commands/index.ts +2 -1
- package/src/domains/standard/commands/list.ts +5 -84
- package/src/domains/standard/commands/lookup.ts +8 -93
- package/src/domains/standard/commands/sample.ts +57 -0
- package/src/domains/standard/formatters/index.ts +2 -1
- package/src/domains/standard/formatters/sample.ts +15 -0
- package/src/domains/standard/index.ts +8 -1
- package/src/domains/standard/mcp/specs.ts +44 -152
- package/src/domains/standard/operations/index.ts +2 -1
- package/src/domains/standard/operations/lookup.ts +9 -0
- package/src/domains/standard/operations/sample.ts +35 -0
- package/src/domains/standard/orchestration/resolveStandardLookup.ts +15 -2
- package/src/domains/standard/orchestration/standardEmptyError.test.ts +33 -0
- package/src/domains/standard/orchestration/standardEmptyError.ts +16 -6
- package/src/domains/standard/stories.test.ts +24 -0
- package/src/domains/standard/stories.ts +151 -0
- package/src/domains/tier/commands/list.ts +5 -55
- package/src/domains/tier/mcp/specs.ts +5 -31
- package/src/domains/tier/stories.test.ts +22 -0
- package/src/domains/tier/stories.ts +52 -0
- package/src/domains/token/commands/index.ts +2 -1
- package/src/domains/token/commands/sample.ts +53 -0
- package/src/domains/token/formatters/index.ts +2 -1
- package/src/domains/token/formatters/sample.ts +16 -0
- package/src/domains/token/index.ts +8 -1
- package/src/domains/token/mcp/specs.ts +52 -2
- package/src/domains/token/operations/index.ts +2 -1
- package/src/domains/token/operations/lookup.ts +9 -0
- package/src/domains/token/operations/sample.ts +32 -0
- package/src/domains/token/orchestration/resolveTokenLookup.ts +15 -2
- package/src/domains/token/orchestration/tokenEmptyError.test.ts +33 -0
- package/src/domains/token/orchestration/tokenEmptyError.ts +5 -2
- package/src/domains/trace/commands/clear.ts +71 -0
- package/src/domains/trace/commands/index.ts +5 -0
- package/src/domains/trace/commands/sessions.ts +32 -0
- package/src/domains/trace/commands/show.ts +145 -0
- package/src/domains/trace/formatters/index.ts +9 -0
- package/src/domains/trace/formatters/sessions.ts +80 -0
- package/src/domains/trace/formatters/show.ts +122 -0
- package/src/domains/trace/index.ts +17 -0
- package/src/domains/trace/operations/index.ts +8 -0
- package/src/domains/trace/operations/listSessions.test.ts +55 -0
- package/src/domains/trace/operations/listSessions.ts +52 -0
- package/src/domains/trace/operations/readTraceLog.test.ts +100 -0
- package/src/domains/trace/operations/readTraceLog.ts +104 -0
- package/src/domains/trace/tracePlugin.test.ts +171 -0
- package/src/domains/trace/tracePlugin.ts +159 -0
- package/src/domains/trace/types.ts +65 -0
- package/src/error/PragmaError.ts +15 -2
- package/src/error/types.ts +15 -1
- package/src/mcp/error/serializeErrorPayload.ts +7 -0
- package/src/mcp/error/types.ts +8 -0
- package/src/mcp/registerResources.test.ts +405 -75
- package/src/mcp/registerResources.ts +5 -299
- package/src/mcp/resources/buildGraphIndex.ts +167 -0
- package/src/mcp/resources/buildResourceList.test.ts +97 -0
- package/src/mcp/resources/buildResourceList.ts +176 -0
- package/src/mcp/resources/classifyEntity.test.ts +40 -0
- package/src/mcp/resources/classifyEntity.ts +27 -0
- package/src/mcp/resources/constants.ts +105 -0
- package/src/mcp/resources/displayPredicates.ts +62 -0
- package/src/mcp/resources/pickFirstValue.test.ts +29 -0
- package/src/mcp/resources/pickFirstValue.ts +26 -0
- package/src/mcp/resources/pickPrimaryType.test.ts +41 -0
- package/src/mcp/resources/pickPrimaryType.ts +26 -0
- package/src/mcp/resources/rankUriCompletions.test.ts +52 -0
- package/src/mcp/resources/rankUriCompletions.ts +64 -0
- package/src/mcp/resources/readEntity.ts +169 -0
- package/src/mcp/resources/registerResources.ts +126 -0
- package/src/mcp/resources/types.ts +128 -0
- package/src/mcp/tools/index.ts +9 -0
- package/src/mcp/tools/registerTools.test.ts +84 -12
- package/src/mcp/types.ts +4 -0
- package/src/mcp/utils/wrapTool.test.ts +1 -0
- package/src/pipeline/collectCommands.ts +15 -2
- package/src/pipeline/renderError.ts +15 -0
- package/src/pipeline/resolveCommandKind.ts +8 -1
- package/src/pipeline/runCli.ts +51 -6
- package/src/testing/createTestMcpClient.ts +5 -1
- package/src/testing/graphqlFixtures.ts +72 -0
- package/src/testing/index.ts +17 -0
- package/src/testing/integration/__snapshots__/executor-golden.test.ts.snap +1207 -0
- package/src/testing/integration/cross-binary-byte-equality.test.ts +129 -0
- package/src/testing/integration/executor-execution.test.ts +101 -0
- package/src/testing/integration/executor-golden.test.ts +145 -0
- package/src/testing/integration/mcp-surface.test.ts +11 -5
- package/src/testing/integration/parity.test.ts +17 -12
- package/src/testing/regression/0001-skill-discovery-granularity.test.ts +71 -0
- package/src/testing/setupXdgIsolation.ts +15 -0
- package/src/testing/standardParityFixtures.ts +92 -0
- package/src/testing/storyFixtures.ts +63 -0
- package/src/pipeline/runInteractiveCommand.ts +0 -198
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import type { Effect, PromptQuestion } from "@canonical/task";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const { createInterfaceMock } = vi.hoisted(() => ({
|
|
5
|
+
createInterfaceMock: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("node:readline", () => ({ createInterface: createInterfaceMock }));
|
|
9
|
+
|
|
10
|
+
const { default: createInteractivePromptSession } = await import(
|
|
11
|
+
"./createInteractivePromptSession.js"
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const prompt = (question: PromptQuestion): Effect & { _tag: "Prompt" } => ({
|
|
15
|
+
_tag: "Prompt",
|
|
16
|
+
question,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A fake persistent readline interface honouring the real contract: one
|
|
21
|
+
* interface answers many questions from a queue, `close()` emits 'close'
|
|
22
|
+
* synchronously, and an exhausted queue simulates EOF by closing.
|
|
23
|
+
*/
|
|
24
|
+
const stubReadline = (lines: string[]) => {
|
|
25
|
+
const closeListeners: Array<() => void> = [];
|
|
26
|
+
const sigintListeners: Array<() => void> = [];
|
|
27
|
+
const queries: string[] = [];
|
|
28
|
+
const iface = {
|
|
29
|
+
on: (event: string, handler: () => void) => {
|
|
30
|
+
if (event === "close") closeListeners.push(handler);
|
|
31
|
+
if (event === "SIGINT") sigintListeners.push(handler);
|
|
32
|
+
},
|
|
33
|
+
question: (query: string, cb: (answer: string) => void) => {
|
|
34
|
+
queries.push(query);
|
|
35
|
+
const next = lines.shift();
|
|
36
|
+
if (next === undefined) {
|
|
37
|
+
for (const handler of closeListeners) handler();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
cb(next);
|
|
41
|
+
},
|
|
42
|
+
close: () => {
|
|
43
|
+
for (const handler of closeListeners) handler();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
createInterfaceMock.mockReturnValue(iface);
|
|
47
|
+
return {
|
|
48
|
+
queries,
|
|
49
|
+
sigint: () => {
|
|
50
|
+
for (const handler of sigintListeners) handler();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** A stub whose question never answers — for interrupt-while-pending tests. */
|
|
56
|
+
const stubHangingReadline = () => {
|
|
57
|
+
const closeListeners: Array<() => void> = [];
|
|
58
|
+
const sigintListeners: Array<() => void> = [];
|
|
59
|
+
createInterfaceMock.mockReturnValue({
|
|
60
|
+
on: (event: string, handler: () => void) => {
|
|
61
|
+
if (event === "close") closeListeners.push(handler);
|
|
62
|
+
if (event === "SIGINT") sigintListeners.push(handler);
|
|
63
|
+
},
|
|
64
|
+
question: () => {},
|
|
65
|
+
close: () => {
|
|
66
|
+
for (const handler of closeListeners) handler();
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
sigint: () => {
|
|
71
|
+
for (const handler of sigintListeners) handler();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("createInteractivePromptSession", () => {
|
|
77
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
|
78
|
+
let writes: string[];
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
vi.clearAllMocks();
|
|
82
|
+
writes = [];
|
|
83
|
+
stderrSpy = vi
|
|
84
|
+
.spyOn(process.stderr, "write")
|
|
85
|
+
.mockImplementation((chunk) => {
|
|
86
|
+
writes.push(String(chunk));
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
stderrSpy.mockRestore();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// --- text --------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
it("reads a text answer and shows the default hint", async () => {
|
|
98
|
+
const { queries } = stubReadline(["Widget"]);
|
|
99
|
+
const session = createInteractivePromptSession();
|
|
100
|
+
|
|
101
|
+
const answer = await session.answerPrompt(
|
|
102
|
+
prompt({ type: "text", name: "n", message: "Name?", default: "Btn" }),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(answer).toBe("Widget");
|
|
106
|
+
expect(queries[0]).toBe("Name? (Btn): ");
|
|
107
|
+
session.dispose();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("takes the text default on empty input", async () => {
|
|
111
|
+
stubReadline([""]);
|
|
112
|
+
const session = createInteractivePromptSession();
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
await session.answerPrompt(
|
|
116
|
+
prompt({ type: "text", name: "n", message: "Name?", default: "Btn" }),
|
|
117
|
+
),
|
|
118
|
+
).toBe("Btn");
|
|
119
|
+
session.dispose();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("re-asks a text prompt while empty with no default", async () => {
|
|
123
|
+
const { queries } = stubReadline(["", "", "Widget"]);
|
|
124
|
+
const session = createInteractivePromptSession();
|
|
125
|
+
|
|
126
|
+
expect(
|
|
127
|
+
await session.answerPrompt(
|
|
128
|
+
prompt({ type: "text", name: "n", message: "Name?" }),
|
|
129
|
+
),
|
|
130
|
+
).toBe("Widget");
|
|
131
|
+
expect(queries).toHaveLength(3);
|
|
132
|
+
session.dispose();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("re-asks on a validate failure, printing its message", async () => {
|
|
136
|
+
stubReadline(["bad", "good"]);
|
|
137
|
+
const session = createInteractivePromptSession();
|
|
138
|
+
|
|
139
|
+
const answer = await session.answerPrompt(
|
|
140
|
+
prompt({
|
|
141
|
+
type: "text",
|
|
142
|
+
name: "n",
|
|
143
|
+
message: "Name?",
|
|
144
|
+
validate: (value) => value === "good" || "must be good",
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(answer).toBe("good");
|
|
149
|
+
expect(writes).toContain("must be good\n");
|
|
150
|
+
session.dispose();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("re-asks on a boolean-false validate verdict with a generic message", async () => {
|
|
154
|
+
stubReadline(["bad", "good"]);
|
|
155
|
+
const session = createInteractivePromptSession();
|
|
156
|
+
|
|
157
|
+
const answer = await session.answerPrompt(
|
|
158
|
+
prompt({
|
|
159
|
+
type: "text",
|
|
160
|
+
name: "n",
|
|
161
|
+
message: "Name?",
|
|
162
|
+
validate: (value) => value === "good",
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(answer).toBe("good");
|
|
167
|
+
expect(writes).toContain("Invalid value.\n");
|
|
168
|
+
session.dispose();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// --- confirm -------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
it("parses yes/no answers and hints from the default", async () => {
|
|
174
|
+
const { queries } = stubReadline(["yes", "N"]);
|
|
175
|
+
const session = createInteractivePromptSession();
|
|
176
|
+
|
|
177
|
+
expect(
|
|
178
|
+
await session.answerPrompt(
|
|
179
|
+
prompt({ type: "confirm", name: "a", message: "A?", default: true }),
|
|
180
|
+
),
|
|
181
|
+
).toBe(true);
|
|
182
|
+
expect(
|
|
183
|
+
await session.answerPrompt(
|
|
184
|
+
prompt({ type: "confirm", name: "b", message: "B?", default: false }),
|
|
185
|
+
),
|
|
186
|
+
).toBe(false);
|
|
187
|
+
expect(queries).toEqual(["A? [Y/n] ", "B? [y/N] "]);
|
|
188
|
+
session.dispose();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("takes the confirm default on empty input", async () => {
|
|
192
|
+
stubReadline([""]);
|
|
193
|
+
const session = createInteractivePromptSession();
|
|
194
|
+
|
|
195
|
+
expect(
|
|
196
|
+
await session.answerPrompt(
|
|
197
|
+
prompt({ type: "confirm", name: "a", message: "A?", default: false }),
|
|
198
|
+
),
|
|
199
|
+
).toBe(false);
|
|
200
|
+
session.dispose();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("re-asks a confirm with no default until it gets y or n", async () => {
|
|
204
|
+
const { queries } = stubReadline(["", "maybe", "n"]);
|
|
205
|
+
const session = createInteractivePromptSession();
|
|
206
|
+
|
|
207
|
+
expect(
|
|
208
|
+
await session.answerPrompt(
|
|
209
|
+
prompt({ type: "confirm", name: "a", message: "A?" }),
|
|
210
|
+
),
|
|
211
|
+
).toBe(false);
|
|
212
|
+
expect(queries).toEqual(["A? [y/n] ", "A? [y/n] ", "A? [y/n] "]);
|
|
213
|
+
session.dispose();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// --- select ----------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
const shellSelect: PromptQuestion = {
|
|
219
|
+
type: "select",
|
|
220
|
+
name: "shell",
|
|
221
|
+
message: "Shell?",
|
|
222
|
+
choices: [
|
|
223
|
+
{ label: "Bash", value: "bash" },
|
|
224
|
+
{ label: "Zsh", value: "zsh" },
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
it("renders the menu and accepts a number, a value, or a label", async () => {
|
|
229
|
+
stubReadline(["2", "bash", "Zsh"]);
|
|
230
|
+
const session = createInteractivePromptSession();
|
|
231
|
+
|
|
232
|
+
expect(await session.answerPrompt(prompt(shellSelect))).toBe("zsh");
|
|
233
|
+
expect(await session.answerPrompt(prompt(shellSelect))).toBe("bash");
|
|
234
|
+
expect(await session.answerPrompt(prompt(shellSelect))).toBe("zsh");
|
|
235
|
+
expect(writes).toContain("Shell?\n");
|
|
236
|
+
expect(writes).toContain(" 1) Bash\n");
|
|
237
|
+
expect(writes).toContain(" 2) Zsh\n");
|
|
238
|
+
session.dispose();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("takes the select default on empty input, re-asks without one", async () => {
|
|
242
|
+
const { queries } = stubReadline(["", "", "1"]);
|
|
243
|
+
const session = createInteractivePromptSession();
|
|
244
|
+
|
|
245
|
+
expect(
|
|
246
|
+
await session.answerPrompt(prompt({ ...shellSelect, default: "zsh" })),
|
|
247
|
+
).toBe("zsh");
|
|
248
|
+
expect(await session.answerPrompt(prompt(shellSelect))).toBe("bash");
|
|
249
|
+
expect(queries).toHaveLength(3);
|
|
250
|
+
session.dispose();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("re-asks on an unmatched select answer", async () => {
|
|
254
|
+
const { queries } = stubReadline(["9", "fish", "2"]);
|
|
255
|
+
const session = createInteractivePromptSession();
|
|
256
|
+
|
|
257
|
+
expect(await session.answerPrompt(prompt(shellSelect))).toBe("zsh");
|
|
258
|
+
expect(queries).toHaveLength(3);
|
|
259
|
+
session.dispose();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("resolves a choiceless select to its default without prompting", async () => {
|
|
263
|
+
stubReadline([]);
|
|
264
|
+
const session = createInteractivePromptSession();
|
|
265
|
+
|
|
266
|
+
expect(
|
|
267
|
+
await session.answerPrompt(
|
|
268
|
+
prompt({ ...shellSelect, choices: [], default: "zsh" }),
|
|
269
|
+
),
|
|
270
|
+
).toBe("zsh");
|
|
271
|
+
expect(createInterfaceMock).not.toHaveBeenCalled();
|
|
272
|
+
session.dispose();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// --- multiselect -------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
const featureMulti: PromptQuestion = {
|
|
278
|
+
type: "multiselect",
|
|
279
|
+
name: "features",
|
|
280
|
+
message: "Features?",
|
|
281
|
+
choices: [
|
|
282
|
+
{ label: "A", value: "a" },
|
|
283
|
+
{ label: "B", value: "b" },
|
|
284
|
+
{ label: "C", value: "c" },
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
it("accepts comma-separated numbers, values, and labels", async () => {
|
|
289
|
+
stubReadline(["1, b ,C"]);
|
|
290
|
+
const session = createInteractivePromptSession();
|
|
291
|
+
|
|
292
|
+
expect(await session.answerPrompt(prompt(featureMulti))).toEqual([
|
|
293
|
+
"a",
|
|
294
|
+
"b",
|
|
295
|
+
"c",
|
|
296
|
+
]);
|
|
297
|
+
session.dispose();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("takes the multiselect default on empty input, re-asks on all-invalid", async () => {
|
|
301
|
+
const { queries } = stubReadline(["", "x,y", "2"]);
|
|
302
|
+
const session = createInteractivePromptSession();
|
|
303
|
+
|
|
304
|
+
expect(
|
|
305
|
+
await session.answerPrompt(prompt({ ...featureMulti, default: ["c"] })),
|
|
306
|
+
).toEqual(["c"]);
|
|
307
|
+
expect(await session.answerPrompt(prompt(featureMulti))).toEqual(["b"]);
|
|
308
|
+
expect(queries).toHaveLength(3);
|
|
309
|
+
session.dispose();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("resolves a choiceless multiselect to its default or none", async () => {
|
|
313
|
+
stubReadline([]);
|
|
314
|
+
const session = createInteractivePromptSession();
|
|
315
|
+
|
|
316
|
+
expect(
|
|
317
|
+
await session.answerPrompt(prompt({ ...featureMulti, choices: [] })),
|
|
318
|
+
).toEqual([]);
|
|
319
|
+
session.dispose();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("accepts a comma-separated single-select answer by its first valid token", async () => {
|
|
323
|
+
stubReadline(["nope,2,1"]);
|
|
324
|
+
const session = createInteractivePromptSession();
|
|
325
|
+
|
|
326
|
+
expect(await session.answerPrompt(prompt(shellSelect))).toBe("zsh");
|
|
327
|
+
session.dispose();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("resolves a choiceless select with no default to an empty string", async () => {
|
|
331
|
+
stubReadline([]);
|
|
332
|
+
const session = createInteractivePromptSession();
|
|
333
|
+
|
|
334
|
+
expect(
|
|
335
|
+
await session.answerPrompt(prompt({ ...shellSelect, choices: [] })),
|
|
336
|
+
).toBe("");
|
|
337
|
+
expect(createInterfaceMock).not.toHaveBeenCalled();
|
|
338
|
+
session.dispose();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// --- interrupt -----------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
it("Ctrl-C rejects the pending prompt and marks the session interrupted", async () => {
|
|
344
|
+
const { sigint } = stubHangingReadline();
|
|
345
|
+
const session = createInteractivePromptSession();
|
|
346
|
+
|
|
347
|
+
const pending = session.answerPrompt(
|
|
348
|
+
prompt({ type: "confirm", name: "a", message: "A?", default: true }),
|
|
349
|
+
);
|
|
350
|
+
sigint();
|
|
351
|
+
|
|
352
|
+
await expect(pending).rejects.toThrow("Prompt interrupted");
|
|
353
|
+
expect(session.wasInterrupted()).toBe(true);
|
|
354
|
+
await expect(
|
|
355
|
+
session.answerPrompt(
|
|
356
|
+
prompt({ type: "text", name: "b", message: "B?", default: "d" }),
|
|
357
|
+
),
|
|
358
|
+
).rejects.toThrow("Prompt interrupted");
|
|
359
|
+
session.dispose();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// --- lifecycle -----------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
it("spans one readline interface across many questions", async () => {
|
|
365
|
+
stubReadline(["n", "Widget"]);
|
|
366
|
+
const session = createInteractivePromptSession();
|
|
367
|
+
|
|
368
|
+
await session.answerPrompt(
|
|
369
|
+
prompt({ type: "confirm", name: "a", message: "A?", default: true }),
|
|
370
|
+
);
|
|
371
|
+
await session.answerPrompt(
|
|
372
|
+
prompt({ type: "text", name: "b", message: "B?" }),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
expect(createInterfaceMock).toHaveBeenCalledTimes(1);
|
|
376
|
+
session.dispose();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("falls back to defaults for every prompt after EOF", async () => {
|
|
380
|
+
// One answer, then the stream ends mid-question.
|
|
381
|
+
stubReadline(["n"]);
|
|
382
|
+
const session = createInteractivePromptSession();
|
|
383
|
+
|
|
384
|
+
expect(
|
|
385
|
+
await session.answerPrompt(
|
|
386
|
+
prompt({ type: "confirm", name: "a", message: "A?", default: true }),
|
|
387
|
+
),
|
|
388
|
+
).toBe(false);
|
|
389
|
+
expect(
|
|
390
|
+
await session.answerPrompt(
|
|
391
|
+
prompt({ type: "text", name: "b", message: "B?", default: "d" }),
|
|
392
|
+
),
|
|
393
|
+
).toBe("d");
|
|
394
|
+
expect(
|
|
395
|
+
await session.answerPrompt(
|
|
396
|
+
prompt({ type: "confirm", name: "c", message: "C?" }),
|
|
397
|
+
),
|
|
398
|
+
).toBe(true);
|
|
399
|
+
expect(await session.answerPrompt(prompt(shellSelect))).toBe("bash");
|
|
400
|
+
expect(await session.answerPrompt(prompt(featureMulti))).toEqual([]);
|
|
401
|
+
session.dispose();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("never opens readline when stdin already ended", async () => {
|
|
405
|
+
const endedSpy = vi
|
|
406
|
+
.spyOn(process.stdin, "readableEnded", "get")
|
|
407
|
+
.mockReturnValue(true);
|
|
408
|
+
try {
|
|
409
|
+
const session = createInteractivePromptSession();
|
|
410
|
+
expect(
|
|
411
|
+
await session.answerPrompt(
|
|
412
|
+
prompt({ type: "text", name: "n", message: "N?", default: "d" }),
|
|
413
|
+
),
|
|
414
|
+
).toBe("d");
|
|
415
|
+
expect(createInterfaceMock).not.toHaveBeenCalled();
|
|
416
|
+
session.dispose();
|
|
417
|
+
} finally {
|
|
418
|
+
endedSpy.mockRestore();
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("dispose closes the interface once and is safe to repeat", async () => {
|
|
423
|
+
stubReadline(["x"]);
|
|
424
|
+
const session = createInteractivePromptSession();
|
|
425
|
+
await session.answerPrompt(
|
|
426
|
+
prompt({ type: "text", name: "n", message: "N?" }),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
session.dispose();
|
|
430
|
+
session.dispose();
|
|
431
|
+
|
|
432
|
+
// After dispose (which closed the interface), prompts resolve to defaults.
|
|
433
|
+
expect(
|
|
434
|
+
await session.answerPrompt(
|
|
435
|
+
prompt({ type: "text", name: "n", message: "N?", default: "d" }),
|
|
436
|
+
),
|
|
437
|
+
).toBe("d");
|
|
438
|
+
});
|
|
439
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { createInterface, type Interface } from "node:readline";
|
|
2
|
+
import type { Effect, PromptQuestion } from "@canonical/task";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An interactive prompt session: the `promptHandler` seam implementation a
|
|
6
|
+
* terminal front-end injects into the shared executor, plus its lifecycle.
|
|
7
|
+
* Create one per command run and dispose it when the run ends.
|
|
8
|
+
*/
|
|
9
|
+
export interface InteractivePromptSession {
|
|
10
|
+
/** Resolve a `Prompt` effect by asking the user over readline. */
|
|
11
|
+
readonly answerPrompt: (
|
|
12
|
+
effect: Effect & { _tag: "Prompt" },
|
|
13
|
+
) => Promise<unknown>;
|
|
14
|
+
/** Whether the user interrupted a prompt (Ctrl-C). */
|
|
15
|
+
readonly wasInterrupted: () => boolean;
|
|
16
|
+
/** Release the underlying readline interface. Safe to call twice. */
|
|
17
|
+
readonly dispose: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Thrown when the user interrupts a prompt — the run must abort, not proceed. */
|
|
21
|
+
export class PromptInterruptedError extends Error {
|
|
22
|
+
constructor() {
|
|
23
|
+
super("Prompt interrupted");
|
|
24
|
+
this.name = "PromptInterruptedError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Render a choice list to stderr as a numbered menu. */
|
|
29
|
+
function renderChoices(
|
|
30
|
+
choices: ReadonlyArray<{ label: string; value: string }>,
|
|
31
|
+
): void {
|
|
32
|
+
for (const [index, choice] of choices.entries()) {
|
|
33
|
+
process.stderr.write(` ${index + 1}) ${choice.label}\n`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve one answer token against a choice list: a 1-based number, an exact
|
|
39
|
+
* value, or an exact label.
|
|
40
|
+
*/
|
|
41
|
+
function matchChoice(
|
|
42
|
+
choices: ReadonlyArray<{ label: string; value: string }>,
|
|
43
|
+
token: string,
|
|
44
|
+
): string | undefined {
|
|
45
|
+
const index = Number(token);
|
|
46
|
+
if (Number.isInteger(index) && index >= 1 && index <= choices.length) {
|
|
47
|
+
return choices[index - 1]?.value;
|
|
48
|
+
}
|
|
49
|
+
const matched = choices.find(
|
|
50
|
+
(choice) => choice.value === token || choice.label === token,
|
|
51
|
+
);
|
|
52
|
+
return matched?.value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create an interactive prompt session over stdin/stderr.
|
|
57
|
+
*
|
|
58
|
+
* One readline interface spans the whole session, so answers piped in a
|
|
59
|
+
* single chunk (`printf "n\nWidget\n" | pragma …`) survive across questions —
|
|
60
|
+
* closing a per-question interface would discard its buffered lines. Prompts
|
|
61
|
+
* render on stderr, keeping stdout clean for piping. On EOF (a closed or
|
|
62
|
+
* non-interactive stdin) every prompt resolves to its default instead of
|
|
63
|
+
* hanging.
|
|
64
|
+
*
|
|
65
|
+
* The unified dialect, per prompt type:
|
|
66
|
+
* - text: shows the default in parentheses; empty input takes the default or
|
|
67
|
+
* re-asks when there is none; a `validate` failure re-asks with its message.
|
|
68
|
+
* - confirm: `[Y/n]`/`[y/N]`; empty input takes the default, re-asks when the
|
|
69
|
+
* default is undefined.
|
|
70
|
+
* - select/multiselect: numbered menu; an answer token is a number, a value,
|
|
71
|
+
* or a label (multiselect: comma-separated); empty input takes the default
|
|
72
|
+
* or re-asks when there is none.
|
|
73
|
+
*
|
|
74
|
+
* @returns The session: the `promptHandler` seam function plus `dispose`.
|
|
75
|
+
* @note Impure — reads from stdin and writes prompts to stderr.
|
|
76
|
+
*/
|
|
77
|
+
export default function createInteractivePromptSession(): InteractivePromptSession {
|
|
78
|
+
let rl: Interface | undefined;
|
|
79
|
+
let ended = process.stdin.readableEnded === true;
|
|
80
|
+
let interrupted = false;
|
|
81
|
+
let pending: ((value: string) => void) | undefined;
|
|
82
|
+
|
|
83
|
+
const open = (): Interface => {
|
|
84
|
+
if (rl === undefined) {
|
|
85
|
+
rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
86
|
+
// Ctrl-C is an abort, not an EOF: proceeding with defaults would run the
|
|
87
|
+
// very command the user is cancelling. Mark, then close.
|
|
88
|
+
rl.on("SIGINT", () => {
|
|
89
|
+
interrupted = true;
|
|
90
|
+
rl?.close();
|
|
91
|
+
});
|
|
92
|
+
// EOF with a question outstanding: resolve it to "" so the caller falls
|
|
93
|
+
// back to defaults; every later ask short-circuits on `ended`.
|
|
94
|
+
rl.on("close", () => {
|
|
95
|
+
ended = true;
|
|
96
|
+
pending?.("");
|
|
97
|
+
pending = undefined;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return rl;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const ask = async (query: string): Promise<string> => {
|
|
104
|
+
if (interrupted) {
|
|
105
|
+
throw new PromptInterruptedError();
|
|
106
|
+
}
|
|
107
|
+
if (ended) {
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
const answer = await new Promise<string>((resolve) => {
|
|
111
|
+
pending = resolve;
|
|
112
|
+
open().question(query, (value) => {
|
|
113
|
+
pending = undefined;
|
|
114
|
+
resolve(value.trim());
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
if (interrupted) {
|
|
118
|
+
throw new PromptInterruptedError();
|
|
119
|
+
}
|
|
120
|
+
return answer;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const askText = async (
|
|
124
|
+
question: PromptQuestion & { type: "text" },
|
|
125
|
+
): Promise<string> => {
|
|
126
|
+
const hint = question.default === undefined ? "" : ` (${question.default})`;
|
|
127
|
+
for (;;) {
|
|
128
|
+
const answer = await ask(`${question.message}${hint}: `);
|
|
129
|
+
if (answer === "") {
|
|
130
|
+
if (question.default !== undefined) return question.default;
|
|
131
|
+
if (ended) return "";
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const verdict = question.validate?.(answer) ?? true;
|
|
135
|
+
if (verdict === true) return answer;
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
`${typeof verdict === "string" ? verdict : "Invalid value."}\n`,
|
|
138
|
+
);
|
|
139
|
+
if (ended) return question.default ?? "";
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const askConfirm = async (
|
|
144
|
+
question: PromptQuestion & { type: "confirm" },
|
|
145
|
+
): Promise<boolean> => {
|
|
146
|
+
const hint =
|
|
147
|
+
question.default === true
|
|
148
|
+
? "Y/n"
|
|
149
|
+
: question.default === false
|
|
150
|
+
? "y/N"
|
|
151
|
+
: "y/n";
|
|
152
|
+
for (;;) {
|
|
153
|
+
const answer = (
|
|
154
|
+
await ask(`${question.message} [${hint}] `)
|
|
155
|
+
).toLowerCase();
|
|
156
|
+
if (answer === "" && (question.default !== undefined || ended)) {
|
|
157
|
+
return question.default ?? true;
|
|
158
|
+
}
|
|
159
|
+
if (answer === "y" || answer === "yes") return true;
|
|
160
|
+
if (answer === "n" || answer === "no") return false;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const askSelect = async (
|
|
165
|
+
question: PromptQuestion & { type: "select" },
|
|
166
|
+
): Promise<string> => {
|
|
167
|
+
if (question.choices.length === 0) {
|
|
168
|
+
return question.default ?? "";
|
|
169
|
+
}
|
|
170
|
+
process.stderr.write(`${question.message}\n`);
|
|
171
|
+
renderChoices(question.choices);
|
|
172
|
+
const hint = question.default === undefined ? "" : ` (${question.default})`;
|
|
173
|
+
for (;;) {
|
|
174
|
+
const answer = await ask(`Choose a value${hint}: `);
|
|
175
|
+
if (answer === "") {
|
|
176
|
+
if (question.default !== undefined) return question.default;
|
|
177
|
+
if (ended) return question.choices[0]?.value ?? "";
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// Tolerate a comma-separated answer at a single select: first valid token.
|
|
181
|
+
const value = answer
|
|
182
|
+
.split(",")
|
|
183
|
+
.map((token) => matchChoice(question.choices, token.trim()))
|
|
184
|
+
.find((match) => match !== undefined);
|
|
185
|
+
if (value !== undefined) return value;
|
|
186
|
+
if (ended) return question.default ?? question.choices[0]?.value ?? "";
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const askMultiselect = async (
|
|
191
|
+
question: PromptQuestion & { type: "multiselect" },
|
|
192
|
+
): Promise<string[]> => {
|
|
193
|
+
if (question.choices.length === 0) {
|
|
194
|
+
return question.default ?? [];
|
|
195
|
+
}
|
|
196
|
+
process.stderr.write(`${question.message}\n`);
|
|
197
|
+
renderChoices(question.choices);
|
|
198
|
+
const hint =
|
|
199
|
+
question.default === undefined ? "" : ` (${question.default.join(",")})`;
|
|
200
|
+
for (;;) {
|
|
201
|
+
const answer = await ask(`Choose one or more values${hint}: `);
|
|
202
|
+
if (answer === "") {
|
|
203
|
+
if (question.default !== undefined) return question.default;
|
|
204
|
+
if (ended) return [];
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const picked = answer
|
|
208
|
+
.split(",")
|
|
209
|
+
.map((token) => matchChoice(question.choices, token.trim()))
|
|
210
|
+
.filter((value): value is string => value !== undefined);
|
|
211
|
+
if (picked.length > 0) return picked;
|
|
212
|
+
if (ended) return question.default ?? [];
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
answerPrompt: (effect) => {
|
|
218
|
+
const question = effect.question;
|
|
219
|
+
switch (question.type) {
|
|
220
|
+
case "confirm":
|
|
221
|
+
return askConfirm(question);
|
|
222
|
+
case "select":
|
|
223
|
+
return askSelect(question);
|
|
224
|
+
case "multiselect":
|
|
225
|
+
return askMultiselect(question);
|
|
226
|
+
default:
|
|
227
|
+
return askText(question);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
wasInterrupted: () => interrupted,
|
|
231
|
+
dispose: () => {
|
|
232
|
+
rl?.close();
|
|
233
|
+
rl = undefined;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|