fix: improve setup interactive UX

1. Mask API key input with * characters (raw mode)
2. Fetch and list available models from provider /models endpoint
3. Workspace prompt: fill path directly (default skip), not y/n
4. Add .gitkeep to workflows/ in init workspace scaffold
This commit is contained in:
2026-05-12 21:44:21 +08:00
parent ea16057803
commit 1e936cf04a
2 changed files with 111 additions and 22 deletions
@@ -320,6 +320,7 @@ export async function cmdInitWorkspace(
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
writeFile(join(rootPath, "workflows", ".gitkeep"), "", "utf8"),
writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"),
writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"),
]);
@@ -3,10 +3,18 @@ import { createInterface } from "node:readline/promises";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { printCliError, printCliLine } from "../../cli-output.js";
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
import { cmdSetup, printSetupSummary } from "./setup.js";
import type { SetupCliArgs } from "./types.js";
type OpenAiModelEntry = {
id: string;
};
type OpenAiModelsResponse = {
data: OpenAiModelEntry[];
};
function usageSetup(): string {
return [
"uncaged-workflow setup — configure workflow.yaml providers and default model",
@@ -139,6 +147,69 @@ async function promptLine(
return raw.trim();
}
/** Read a line with terminal echo disabled (for secrets). */
async function promptSecret(label: string): Promise<string> {
process.stdout.write(label);
return new Promise((resolve) => {
let buf = "";
const rawWasSet = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf8");
const onData = (ch: string) => {
const c = ch.toString();
if (c === "\n" || c === "\r" || c === "\u0004") {
if (process.stdin.isTTY) {
process.stdin.setRawMode(rawWasSet);
}
process.stdin.pause();
process.stdin.removeListener("data", onData);
process.stdout.write("\n");
resolve(buf.trim());
return;
}
if (c === "\u007F" || c === "\b") {
buf = buf.slice(0, -1);
return;
}
if (c === "\u0003") {
process.exit(130);
}
buf += c;
process.stdout.write("*");
};
process.stdin.on("data", onData);
});
}
/** Fetch available models from an OpenAI-compatible /models endpoint. */
async function fetchAvailableModels(
baseUrl: string,
apiKey: string,
): Promise<string[]> {
const url = baseUrl.replace(/\/+$/, "") + "/models";
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
return [];
}
const body = (await res.json()) as OpenAiModelsResponse;
if (!Array.isArray(body.data)) {
return [];
}
return body.data.map((m) => m.id).sort();
} catch {
return [];
}
}
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
const rl = createInterface({ input, output });
try {
@@ -158,34 +229,51 @@ async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>>
if (baseUrl === "") {
return err("base URL must not be empty");
}
// Note: readline does not support masked input; API key is visible during entry.
// Acceptable for a local dev CLI — not a production-facing prompt.
const apiKey = await promptLine(rl, "API key for this provider: ");
// Close readline before raw-mode secret prompt, reopen after.
rl.close();
const apiKey = await promptSecret("API key for this provider: ");
if (apiKey === "") {
return err("API key must not be empty");
}
const defaultModel = await promptLine(
rl,
`Default model — format: ${provider}/<model-name>\n (e.g. ${provider}/gpt-4o, ${provider}/qwen-plus): `,
);
const rl2 = createInterface({ input, output });
// Try to list available models from the provider.
printCliLine("\nFetching available models...");
const models = await fetchAvailableModels(baseUrl, apiKey);
let modelPrompt: string;
if (models.length > 0) {
const display = models.slice(0, 20);
printCliLine(`Available models (${models.length} total):`);
for (const m of display) {
printCliLine(` ${m}`);
}
if (models.length > 20) {
printCliLine(` ... and ${models.length - 20} more`);
}
modelPrompt = `\nDefault model — format: ${provider}/<model-name>: `;
} else {
printCliWarn("Could not fetch models (API may not support /models endpoint).");
modelPrompt = `Default model — format: ${provider}/<model-name>\n (e.g. ${provider}/gpt-4o, ${provider}/qwen-plus): `;
}
const defaultModel = await promptLine(rl2, modelPrompt);
if (defaultModel === "") {
rl2.close();
return err("default model must not be empty");
}
const yn = await promptLine(
rl,
"\nCreate a workflow workspace in the current directory? (y/n): ",
const wsPath = await promptLine(
rl2,
"\nWorkflow workspace path (default: ./workflows, leave empty to skip): ",
);
const lower = yn.toLowerCase();
rl2.close();
let initWorkspaceName: string | null = null;
if (lower === "y" || lower === "yes") {
const name = await promptLine(rl, "Workspace directory name: ");
if (name === "") {
return err("workspace name must not be empty");
}
initWorkspaceName = name;
} else if (lower !== "n" && lower !== "no" && lower !== "") {
return err('expected "y" or "n" for workspace init prompt');
if (wsPath !== "") {
initWorkspaceName = wsPath;
}
return ok({
provider,
baseUrl,
@@ -193,8 +281,8 @@ async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>>
defaultModel,
initWorkspaceName,
});
} finally {
rl.close();
} catch (e) {
return err(e instanceof Error ? e.message : String(e));
}
}