planmode 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2080 -717
- package/dist/mcp.js +798 -262
- package/package.json +2 -1
- package/src/commands/context.ts +111 -0
- package/src/commands/doctor.ts +46 -14
- package/src/commands/init.ts +95 -47
- package/src/commands/install.ts +17 -2
- package/src/commands/interactive.ts +556 -0
- package/src/commands/login.ts +50 -23
- package/src/commands/publish.ts +15 -3
- package/src/commands/record.ts +32 -8
- package/src/commands/run.ts +6 -15
- package/src/commands/search.ts +89 -18
- package/src/commands/snapshot.ts +33 -9
- package/src/commands/test.ts +43 -13
- package/src/commands/update.ts +57 -15
- package/src/index.ts +11 -2
- package/src/lib/context.ts +265 -0
- package/src/lib/installer.ts +57 -29
- package/src/lib/prompts.ts +159 -0
- package/src/lib/publisher.ts +176 -144
- package/src/mcp.ts +146 -0
- package/src/types/index.ts +28 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse, stringify } from "yaml";
|
|
4
|
+
import type { ContextIndex, ContextRepoIndex, IndexedFile } from "../types/index.js";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
6
|
+
|
|
7
|
+
const CONTEXT_DIR = ".planmode";
|
|
8
|
+
const CONTEXT_FILE = "context.yaml";
|
|
9
|
+
|
|
10
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
11
|
+
".txt", ".md", ".markdown", ".pdf", ".rtf",
|
|
12
|
+
".doc", ".docx", ".csv", ".tsv", ".json",
|
|
13
|
+
".yaml", ".yml", ".xml", ".html", ".htm",
|
|
14
|
+
".rst", ".org", ".tex", ".log",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const IGNORED_DIRS = new Set([
|
|
18
|
+
"node_modules", ".git", "dist", "build", ".next",
|
|
19
|
+
"__pycache__", ".venv", "venv", ".tox",
|
|
20
|
+
"target", "out", ".cache", ".turbo",
|
|
21
|
+
"coverage", ".nyc_output",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function getContextPath(projectDir: string): string {
|
|
25
|
+
return path.join(projectDir, CONTEXT_DIR, CONTEXT_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emptyIndex(): ContextIndex {
|
|
29
|
+
return { version: 1, repos: [] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readContextIndex(projectDir: string = process.cwd()): ContextIndex {
|
|
33
|
+
const filePath = getContextPath(projectDir);
|
|
34
|
+
try {
|
|
35
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
36
|
+
const data = parse(raw) as ContextIndex;
|
|
37
|
+
return data ?? emptyIndex();
|
|
38
|
+
} catch {
|
|
39
|
+
return emptyIndex();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeContextIndex(index: ContextIndex, projectDir: string = process.cwd()): void {
|
|
44
|
+
const dirPath = path.join(projectDir, CONTEXT_DIR);
|
|
45
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
46
|
+
const filePath = getContextPath(projectDir);
|
|
47
|
+
fs.writeFileSync(filePath, stringify(index), "utf-8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function walkDirectory(dirPath: string): IndexedFile[] {
|
|
51
|
+
const files: IndexedFile[] = [];
|
|
52
|
+
|
|
53
|
+
function walk(currentPath: string): void {
|
|
54
|
+
let entries: fs.Dirent[];
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.name.startsWith(".") && IGNORED_DIRS.has(entry.name)) continue;
|
|
63
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
64
|
+
|
|
65
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
66
|
+
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
walk(fullPath);
|
|
69
|
+
} else if (entry.isFile()) {
|
|
70
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
71
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const stat = fs.statSync(fullPath);
|
|
75
|
+
const relativePath = path.relative(dirPath, fullPath);
|
|
76
|
+
files.push({
|
|
77
|
+
path: relativePath,
|
|
78
|
+
extension: ext,
|
|
79
|
+
size: stat.size,
|
|
80
|
+
modified_at: stat.mtime.toISOString(),
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
// Skip files we can't stat
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
walk(dirPath);
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function addContextRepo(
|
|
94
|
+
repoPath: string,
|
|
95
|
+
options: { name?: string; projectDir?: string } = {},
|
|
96
|
+
): ContextRepoIndex {
|
|
97
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
98
|
+
const absolutePath = path.resolve(projectDir, repoPath);
|
|
99
|
+
|
|
100
|
+
if (!fs.existsSync(absolutePath)) {
|
|
101
|
+
throw new Error(`Directory not found: ${repoPath}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!fs.statSync(absolutePath).isDirectory()) {
|
|
105
|
+
throw new Error(`Not a directory: ${repoPath}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const index = readContextIndex(projectDir);
|
|
109
|
+
|
|
110
|
+
// Store relative path if inside project, absolute otherwise
|
|
111
|
+
const relative = path.relative(projectDir, absolutePath);
|
|
112
|
+
const isInsideProject = !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
113
|
+
const storedPath = isInsideProject ? relative : absolutePath;
|
|
114
|
+
|
|
115
|
+
// Check if already added
|
|
116
|
+
const existing = index.repos.find(
|
|
117
|
+
(r) => r.repo.path === storedPath || r.repo.name === options.name,
|
|
118
|
+
);
|
|
119
|
+
if (existing) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Context repo already exists: ${existing.repo.name ?? existing.repo.path}. Use \`planmode context reindex\` to refresh.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
logger.info(`Scanning ${absolutePath}...`);
|
|
126
|
+
const files = walkDirectory(absolutePath);
|
|
127
|
+
|
|
128
|
+
const now = new Date().toISOString();
|
|
129
|
+
const repoIndex: ContextRepoIndex = {
|
|
130
|
+
repo: {
|
|
131
|
+
path: storedPath,
|
|
132
|
+
name: options.name,
|
|
133
|
+
added_at: now,
|
|
134
|
+
},
|
|
135
|
+
files,
|
|
136
|
+
indexed_at: now,
|
|
137
|
+
file_count: files.length,
|
|
138
|
+
total_size: files.reduce((sum, f) => sum + f.size, 0),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
index.repos.push(repoIndex);
|
|
142
|
+
writeContextIndex(index, projectDir);
|
|
143
|
+
|
|
144
|
+
logger.success(`Added "${options.name ?? storedPath}" — ${files.length} file(s), ${formatSize(repoIndex.total_size)}`);
|
|
145
|
+
|
|
146
|
+
// Log type breakdown
|
|
147
|
+
const breakdown = getTypeBreakdown(files);
|
|
148
|
+
if (breakdown.length > 0) {
|
|
149
|
+
logger.dim(` ${breakdown.join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return repoIndex;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function removeContextRepo(
|
|
156
|
+
pathOrName: string,
|
|
157
|
+
projectDir: string = process.cwd(),
|
|
158
|
+
): void {
|
|
159
|
+
const index = readContextIndex(projectDir);
|
|
160
|
+
|
|
161
|
+
const idx = index.repos.findIndex(
|
|
162
|
+
(r) => r.repo.path === pathOrName || r.repo.name === pathOrName,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (idx === -1) {
|
|
166
|
+
throw new Error(`Context repo not found: ${pathOrName}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const removed = index.repos[idx]!;
|
|
170
|
+
index.repos.splice(idx, 1);
|
|
171
|
+
writeContextIndex(index, projectDir);
|
|
172
|
+
|
|
173
|
+
logger.success(`Removed "${removed.repo.name ?? removed.repo.path}"`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function reindexContext(
|
|
177
|
+
pathOrName?: string,
|
|
178
|
+
projectDir: string = process.cwd(),
|
|
179
|
+
): void {
|
|
180
|
+
const index = readContextIndex(projectDir);
|
|
181
|
+
|
|
182
|
+
if (index.repos.length === 0) {
|
|
183
|
+
throw new Error("No context repos configured. Use `planmode context add <path>` first.");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const targets = pathOrName
|
|
187
|
+
? index.repos.filter(
|
|
188
|
+
(r) => r.repo.path === pathOrName || r.repo.name === pathOrName,
|
|
189
|
+
)
|
|
190
|
+
: index.repos;
|
|
191
|
+
|
|
192
|
+
if (pathOrName && targets.length === 0) {
|
|
193
|
+
throw new Error(`Context repo not found: ${pathOrName}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const repo of targets) {
|
|
197
|
+
const absolutePath = path.resolve(projectDir, repo.repo.path);
|
|
198
|
+
|
|
199
|
+
if (!fs.existsSync(absolutePath)) {
|
|
200
|
+
logger.warn(`Directory not found, skipping: ${repo.repo.path}`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
logger.info(`Re-scanning ${repo.repo.name ?? repo.repo.path}...`);
|
|
205
|
+
const files = walkDirectory(absolutePath);
|
|
206
|
+
|
|
207
|
+
repo.files = files;
|
|
208
|
+
repo.indexed_at = new Date().toISOString();
|
|
209
|
+
repo.file_count = files.length;
|
|
210
|
+
repo.total_size = files.reduce((sum, f) => sum + f.size, 0);
|
|
211
|
+
|
|
212
|
+
logger.success(`Reindexed "${repo.repo.name ?? repo.repo.path}" — ${files.length} file(s), ${formatSize(repo.total_size)}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
writeContextIndex(index, projectDir);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface ContextSummary {
|
|
219
|
+
totalRepos: number;
|
|
220
|
+
totalFiles: number;
|
|
221
|
+
totalSize: number;
|
|
222
|
+
repos: Array<{
|
|
223
|
+
name: string;
|
|
224
|
+
path: string;
|
|
225
|
+
fileCount: number;
|
|
226
|
+
totalSize: number;
|
|
227
|
+
typeBreakdown: string[];
|
|
228
|
+
indexedAt: string;
|
|
229
|
+
}>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function getContextSummary(projectDir: string = process.cwd()): ContextSummary {
|
|
233
|
+
const index = readContextIndex(projectDir);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
totalRepos: index.repos.length,
|
|
237
|
+
totalFiles: index.repos.reduce((sum, r) => sum + r.file_count, 0),
|
|
238
|
+
totalSize: index.repos.reduce((sum, r) => sum + r.total_size, 0),
|
|
239
|
+
repos: index.repos.map((r) => ({
|
|
240
|
+
name: r.repo.name ?? r.repo.path,
|
|
241
|
+
path: r.repo.path,
|
|
242
|
+
fileCount: r.file_count,
|
|
243
|
+
totalSize: r.total_size,
|
|
244
|
+
typeBreakdown: getTypeBreakdown(r.files),
|
|
245
|
+
indexedAt: r.indexed_at,
|
|
246
|
+
})),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getTypeBreakdown(files: IndexedFile[]): string[] {
|
|
251
|
+
const counts = new Map<string, number>();
|
|
252
|
+
for (const file of files) {
|
|
253
|
+
counts.set(file.extension, (counts.get(file.extension) ?? 0) + 1);
|
|
254
|
+
}
|
|
255
|
+
return Array.from(counts.entries())
|
|
256
|
+
.sort((a, b) => b[1] - a[1])
|
|
257
|
+
.map(([ext, count]) => `${ext}: ${count}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function formatSize(bytes: number): string {
|
|
261
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
262
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
263
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
264
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
265
|
+
}
|
package/src/lib/installer.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { parseManifest, readPackageContent } from "./manifest.js";
|
|
|
11
11
|
import { renderTemplate, collectVariableValues } from "./template.js";
|
|
12
12
|
import { logger } from "./logger.js";
|
|
13
13
|
import { trackDownload } from "./analytics.js";
|
|
14
|
+
import { isInteractive, promptForVariables, withSpinner } from "./prompts.js";
|
|
14
15
|
|
|
15
16
|
function getInstallDir(type: PackageType): string {
|
|
16
17
|
switch (type) {
|
|
@@ -37,6 +38,7 @@ export interface InstallOptions {
|
|
|
37
38
|
noInput?: boolean;
|
|
38
39
|
variables?: Record<string, string>;
|
|
39
40
|
projectDir?: string;
|
|
41
|
+
interactive?: boolean;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export async function installPackage(
|
|
@@ -44,6 +46,7 @@ export async function installPackage(
|
|
|
44
46
|
options: InstallOptions = {},
|
|
45
47
|
): Promise<void> {
|
|
46
48
|
const projectDir = options.projectDir ?? process.cwd();
|
|
49
|
+
const interactive = options.interactive ?? (isInteractive() && !options.noInput);
|
|
47
50
|
|
|
48
51
|
// Check lockfile first
|
|
49
52
|
const locked = getLockedVersion(packageName, projectDir);
|
|
@@ -53,44 +56,68 @@ export async function installPackage(
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
// Resolve version
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
content = await fetchFileAtTag(
|
|
59
|
+
const resolveAndFetch = async () => {
|
|
60
|
+
const { version, metadata } = await resolveVersion(packageName, options.version);
|
|
61
|
+
const versionMeta = await fetchVersionMetadata(packageName, version);
|
|
62
|
+
return { version, metadata, versionMeta };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const { version, metadata, versionMeta } = interactive
|
|
66
|
+
? await withSpinner(
|
|
67
|
+
`Resolving ${packageName}...`,
|
|
68
|
+
resolveAndFetch,
|
|
69
|
+
`Resolved ${packageName}`,
|
|
70
|
+
)
|
|
71
|
+
: await (async () => {
|
|
72
|
+
logger.info(`Resolving ${packageName}...`);
|
|
73
|
+
return resolveAndFetch();
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
// Fetch manifest and content
|
|
77
|
+
const fetchContent = async () => {
|
|
78
|
+
const basePath = versionMeta.source.path ? `${versionMeta.source.path}/` : "";
|
|
79
|
+
const manifestRaw = await fetchFileAtTag(
|
|
78
80
|
versionMeta.source.repository,
|
|
79
81
|
versionMeta.source.tag,
|
|
80
|
-
`${basePath}
|
|
82
|
+
`${basePath}planmode.yaml`,
|
|
81
83
|
);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
const manifest = parseManifest(manifestRaw);
|
|
85
|
+
|
|
86
|
+
let content: string;
|
|
87
|
+
if (manifest.content) {
|
|
88
|
+
content = manifest.content;
|
|
89
|
+
} else if (manifest.content_file) {
|
|
90
|
+
content = await fetchFileAtTag(
|
|
91
|
+
versionMeta.source.repository,
|
|
92
|
+
versionMeta.source.tag,
|
|
93
|
+
`${basePath}${manifest.content_file}`,
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error("Package has no content or content_file");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { manifest, content };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const { manifest, content: rawContent } = interactive
|
|
103
|
+
? await withSpinner(
|
|
104
|
+
`Fetching ${packageName}@${version}...`,
|
|
105
|
+
fetchContent,
|
|
106
|
+
`Fetched ${packageName}@${version}`,
|
|
107
|
+
)
|
|
108
|
+
: await (async () => {
|
|
109
|
+
logger.info(`Fetching ${packageName}@${version}...`);
|
|
110
|
+
return fetchContent();
|
|
111
|
+
})();
|
|
85
112
|
|
|
86
113
|
// Process variables if templated
|
|
114
|
+
let content = rawContent;
|
|
87
115
|
if (manifest.variables && Object.keys(manifest.variables).length > 0) {
|
|
88
116
|
const provided = options.variables ?? {};
|
|
89
|
-
if (
|
|
90
|
-
const values =
|
|
117
|
+
if (interactive) {
|
|
118
|
+
const values = await promptForVariables(manifest.variables, provided, false);
|
|
91
119
|
content = renderTemplate(content, values);
|
|
92
120
|
} else {
|
|
93
|
-
// Use defaults for non-provided values
|
|
94
121
|
const values = collectVariableValues(manifest.variables, provided);
|
|
95
122
|
content = renderTemplate(content, values);
|
|
96
123
|
}
|
|
@@ -170,6 +197,7 @@ export async function installPackage(
|
|
|
170
197
|
version: range === "*" ? undefined : range,
|
|
171
198
|
projectDir,
|
|
172
199
|
noInput: options.noInput,
|
|
200
|
+
interactive: options.interactive,
|
|
173
201
|
});
|
|
174
202
|
}
|
|
175
203
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import type { VariableDefinition } from "../types/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns true if the CLI is running in an interactive terminal.
|
|
6
|
+
* False when piped, in CI, or when --no-input is set.
|
|
7
|
+
*/
|
|
8
|
+
export function isInteractive(): boolean {
|
|
9
|
+
return Boolean(process.stdin.isTTY) && !process.env.CI;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wraps a clack prompt result — if the user cancels (Ctrl+C),
|
|
14
|
+
* prints a cancel message and exits cleanly.
|
|
15
|
+
*/
|
|
16
|
+
export function handleCancel<T>(value: T | symbol): T {
|
|
17
|
+
if (p.isCancel(value)) {
|
|
18
|
+
p.cancel("Cancelled.");
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
return value as T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Prompts for a single manifest variable using the appropriate clack widget.
|
|
26
|
+
*/
|
|
27
|
+
async function promptForVariable(
|
|
28
|
+
name: string,
|
|
29
|
+
def: VariableDefinition,
|
|
30
|
+
): Promise<string | number | boolean> {
|
|
31
|
+
switch (def.type) {
|
|
32
|
+
case "enum": {
|
|
33
|
+
const value = await p.select({
|
|
34
|
+
message: def.description || name,
|
|
35
|
+
options: (def.options ?? []).map((opt) => ({
|
|
36
|
+
value: opt,
|
|
37
|
+
label: opt,
|
|
38
|
+
})),
|
|
39
|
+
initialValue: def.default !== undefined ? String(def.default) : undefined,
|
|
40
|
+
});
|
|
41
|
+
return handleCancel(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
case "boolean": {
|
|
45
|
+
const value = await p.confirm({
|
|
46
|
+
message: def.description || name,
|
|
47
|
+
initialValue: def.default !== undefined ? Boolean(def.default) : false,
|
|
48
|
+
});
|
|
49
|
+
return handleCancel(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
case "number": {
|
|
53
|
+
const value = await p.text({
|
|
54
|
+
message: def.description || name,
|
|
55
|
+
placeholder: def.default !== undefined ? String(def.default) : undefined,
|
|
56
|
+
defaultValue: def.default !== undefined ? String(def.default) : undefined,
|
|
57
|
+
validate(input) {
|
|
58
|
+
if (isNaN(Number(input))) return "Must be a number";
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return Number(handleCancel(value));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
case "string":
|
|
65
|
+
default: {
|
|
66
|
+
const value = await p.text({
|
|
67
|
+
message: def.description || name,
|
|
68
|
+
placeholder: def.default !== undefined ? String(def.default) : undefined,
|
|
69
|
+
defaultValue: def.default !== undefined ? String(def.default) : undefined,
|
|
70
|
+
validate(input) {
|
|
71
|
+
if (def.required && !input) return `${name} is required`;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
return handleCancel(value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Collects all missing variable values interactively.
|
|
81
|
+
* Merges with already-provided values.
|
|
82
|
+
* Falls back to defaults or throws if not interactive.
|
|
83
|
+
*/
|
|
84
|
+
export async function promptForVariables(
|
|
85
|
+
variableDefs: Record<string, VariableDefinition>,
|
|
86
|
+
provided: Record<string, string>,
|
|
87
|
+
noInput: boolean = false,
|
|
88
|
+
): Promise<Record<string, string | number | boolean>> {
|
|
89
|
+
const values: Record<string, string | number | boolean> = {};
|
|
90
|
+
|
|
91
|
+
for (const [name, def] of Object.entries(variableDefs)) {
|
|
92
|
+
if (def.type === "resolved") continue;
|
|
93
|
+
|
|
94
|
+
if (provided[name] !== undefined) {
|
|
95
|
+
values[name] = coerceValue(provided[name]!, def);
|
|
96
|
+
} else if (def.default !== undefined) {
|
|
97
|
+
if (isInteractive() && !noInput) {
|
|
98
|
+
// In interactive mode, let user confirm/change defaults
|
|
99
|
+
values[name] = await promptForVariable(name, def);
|
|
100
|
+
} else {
|
|
101
|
+
values[name] = def.default;
|
|
102
|
+
}
|
|
103
|
+
} else if (def.required) {
|
|
104
|
+
if (isInteractive() && !noInput) {
|
|
105
|
+
values[name] = await promptForVariable(name, def);
|
|
106
|
+
} else {
|
|
107
|
+
throw new Error(`Missing required variable: ${name} -- ${def.description}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return values;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function coerceValue(
|
|
116
|
+
raw: string,
|
|
117
|
+
def: VariableDefinition,
|
|
118
|
+
): string | number | boolean {
|
|
119
|
+
switch (def.type) {
|
|
120
|
+
case "number":
|
|
121
|
+
return Number(raw);
|
|
122
|
+
case "boolean":
|
|
123
|
+
return raw === "true" || raw === "1" || raw === "yes";
|
|
124
|
+
case "enum":
|
|
125
|
+
if (def.options && !def.options.includes(raw)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Invalid value "${raw}" for enum variable. Options: ${def.options.join(", ")}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return raw;
|
|
131
|
+
default:
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Wraps an async operation with a clack spinner.
|
|
138
|
+
* Only shows spinner when interactive.
|
|
139
|
+
*/
|
|
140
|
+
export async function withSpinner<T>(
|
|
141
|
+
message: string,
|
|
142
|
+
fn: () => Promise<T>,
|
|
143
|
+
successMessage?: string,
|
|
144
|
+
): Promise<T> {
|
|
145
|
+
if (!isInteractive()) {
|
|
146
|
+
return fn();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const s = p.spinner();
|
|
150
|
+
s.start(message);
|
|
151
|
+
try {
|
|
152
|
+
const result = await fn();
|
|
153
|
+
s.stop(successMessage ?? message);
|
|
154
|
+
return result;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
s.stop(`Failed: ${message}`);
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|