feat: release 2.7.0-rc3
This commit is contained in:
+28
-156
@@ -1,167 +1,28 @@
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { basename } from "node:path";
|
||||
import { defineCommand } from "citty";
|
||||
import { ctx } from "../lib/context";
|
||||
import { addAppToCompose, type AppOptions } from "../lib/scaffold";
|
||||
import { readCompose, resolveComposeFile } from "../lib/yaml";
|
||||
import {
|
||||
addAppToCompose,
|
||||
checkedAppPath,
|
||||
detectAppPaths,
|
||||
type AppOptions,
|
||||
type Source,
|
||||
} from "../lib/scaffold";
|
||||
import {
|
||||
listDockerfileTemplates,
|
||||
templateIds,
|
||||
} from "../lib/scaffold-templates";
|
||||
resolveScaffoldInput,
|
||||
terminalPrompt,
|
||||
type AppInput,
|
||||
type ScaffoldPrompt,
|
||||
} from "../lib/scaffold-prompts";
|
||||
|
||||
export type ScaffoldPrompt = (
|
||||
question: string,
|
||||
defaultValue?: string,
|
||||
) => Promise<string>;
|
||||
|
||||
export const terminalPrompt: ScaffoldPrompt = async (
|
||||
question,
|
||||
defaultValue,
|
||||
) => {
|
||||
if (!stdin.isTTY)
|
||||
throw new Error(`${question}: supply flags or use an interactive terminal`);
|
||||
const readline = createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
const result = (
|
||||
await readline.question(
|
||||
`${question}${defaultValue ? ` [${defaultValue}]` : ""}: `,
|
||||
)
|
||||
).trim();
|
||||
return result || defaultValue || "";
|
||||
} finally {
|
||||
readline.close();
|
||||
}
|
||||
};
|
||||
|
||||
export type AppInput = Partial<Omit<AppOptions, "source">> & {
|
||||
source?: "image" | "auto" | "template";
|
||||
image?: string;
|
||||
template?: string;
|
||||
nonInteractive?: boolean;
|
||||
};
|
||||
|
||||
async function answer(
|
||||
value: string | undefined,
|
||||
question: string,
|
||||
fallback: string | undefined,
|
||||
prompt: ScaffoldPrompt,
|
||||
nonInteractive: boolean,
|
||||
): Promise<string> {
|
||||
if (value !== undefined) return value;
|
||||
if (nonInteractive) {
|
||||
if (fallback !== undefined) return fallback;
|
||||
throw new Error(`${question} is required in non-interactive mode`);
|
||||
}
|
||||
return prompt(question, fallback);
|
||||
}
|
||||
export { terminalPrompt, type AppInput, type ScaffoldPrompt };
|
||||
|
||||
export async function resolveAppInput(
|
||||
root: string,
|
||||
input: AppInput,
|
||||
prompt: ScaffoldPrompt = terminalPrompt,
|
||||
composeFile?: string,
|
||||
): Promise<AppOptions> {
|
||||
const detected = await detectAppPaths(root);
|
||||
let path: string;
|
||||
if (input.path !== undefined) path = input.path;
|
||||
else if (detected.length === 0) path = ".";
|
||||
else if (detected.length === 1) path = detected[0]!;
|
||||
else {
|
||||
if (input.nonInteractive)
|
||||
throw new Error(
|
||||
`Multiple app roots found (${detected.join(", ")}); specify --path`,
|
||||
);
|
||||
path = await prompt(`App path (${detected.join(", ")})`);
|
||||
if (!detected.includes(path))
|
||||
throw new Error(
|
||||
`Select one of the detected app roots: ${detected.join(", ")}`,
|
||||
);
|
||||
}
|
||||
path = await checkedAppPath(root, path);
|
||||
const suggestedName =
|
||||
basename(path === "." ? root : path)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "app";
|
||||
const name = await answer(
|
||||
input.name,
|
||||
"Service name",
|
||||
/^[a-z]/.test(suggestedName) ? suggestedName : `app-${suggestedName}`,
|
||||
prompt,
|
||||
!!input.nonInteractive,
|
||||
);
|
||||
const defaultSource = detected.length === 0 ? "image" : "auto";
|
||||
const selectedSource = await answer(
|
||||
input.source,
|
||||
"Source (image/auto/template)",
|
||||
defaultSource,
|
||||
prompt,
|
||||
!!input.nonInteractive,
|
||||
);
|
||||
let source: Source;
|
||||
if (selectedSource === "image")
|
||||
source = {
|
||||
kind: "image",
|
||||
image: await answer(
|
||||
input.image,
|
||||
"Image",
|
||||
"nginx:stable",
|
||||
prompt,
|
||||
!!input.nonInteractive,
|
||||
),
|
||||
};
|
||||
else if (selectedSource === "auto") source = { kind: "auto" };
|
||||
else if (selectedSource === "template") {
|
||||
const choices = listDockerfileTemplates()
|
||||
.map(({ id, label }) => `${id} (${label})`)
|
||||
.join(", ");
|
||||
const template = await answer(
|
||||
input.template,
|
||||
`Dockerfile template (${choices})`,
|
||||
undefined,
|
||||
prompt,
|
||||
!!input.nonInteractive,
|
||||
);
|
||||
if (!templateIds.includes(template as (typeof templateIds)[number]))
|
||||
throw new Error(`Unknown Dockerfile template: ${template}`);
|
||||
source = {
|
||||
kind: "template",
|
||||
template: template as (typeof templateIds)[number],
|
||||
};
|
||||
} else throw new Error("Source must be image, auto, or template");
|
||||
const postgres = input.nonInteractive
|
||||
? input.postgres
|
||||
: await prompt(
|
||||
"Postgres claim (name or user/database; blank for none)",
|
||||
input.postgres,
|
||||
);
|
||||
const s3 = input.nonInteractive
|
||||
? input.s3
|
||||
: await prompt("S3 claim (name or key/bucket; blank for none)", input.s3);
|
||||
const cpu = input.nonInteractive
|
||||
? input.cpu
|
||||
: await prompt("CPU limit in cores (blank for default)", input.cpu);
|
||||
const memory = input.nonInteractive
|
||||
? input.memory
|
||||
: await prompt("Memory limit (blank for default)", input.memory);
|
||||
const replicasAnswer = input.nonInteractive
|
||||
? input.replicas?.toString()
|
||||
: await prompt("Replicas (blank for default)", input.replicas?.toString());
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
source,
|
||||
...(postgres ? { postgres } : {}),
|
||||
...(s3 ? { s3 } : {}),
|
||||
...(cpu ? { cpu } : {}),
|
||||
...(memory ? { memory } : {}),
|
||||
...(replicasAnswer ? { replicas: Number(replicasAnswer) } : {}),
|
||||
};
|
||||
const file = composeFile ?? (await resolveComposeFile(root));
|
||||
const existingServices = file
|
||||
? Object.keys((await readCompose(file)).services ?? {})
|
||||
: [];
|
||||
return (await resolveScaffoldInput(root, input, prompt, { existingServices }))
|
||||
.app;
|
||||
}
|
||||
|
||||
export const appArgs = {
|
||||
@@ -178,6 +39,10 @@ export const appArgs = {
|
||||
type: "string" as const,
|
||||
description: "Image reference when source=image",
|
||||
},
|
||||
cnb: {
|
||||
type: "string" as const,
|
||||
description: "Optional CNB URI when source=auto",
|
||||
},
|
||||
template: {
|
||||
type: "string" as const,
|
||||
description: "Dockerfile template ID when source=template",
|
||||
@@ -205,6 +70,7 @@ export function appInputFromArgs(args: Record<string, unknown>): AppInput {
|
||||
"source",
|
||||
"image",
|
||||
"template",
|
||||
"cnb",
|
||||
"postgres",
|
||||
"s3",
|
||||
"cpu",
|
||||
@@ -229,8 +95,14 @@ export const add = defineCommand({
|
||||
args: appArgs,
|
||||
async run({ args }) {
|
||||
const { cwd, path } = ctx();
|
||||
const app = await resolveAppInput(cwd, appInputFromArgs(args));
|
||||
const composePath = await addAppToCompose(cwd, app, await path());
|
||||
const file = await path();
|
||||
const app = await resolveAppInput(
|
||||
cwd,
|
||||
appInputFromArgs(args),
|
||||
terminalPrompt,
|
||||
file,
|
||||
);
|
||||
const composePath = await addAppToCompose(cwd, app, file);
|
||||
console.log(`Added ${app.name} to ${composePath}`);
|
||||
},
|
||||
}),
|
||||
|
||||
+111
-52
@@ -1,6 +1,6 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import Enquirer from "enquirer";
|
||||
import { stdin } from "node:process";
|
||||
import { apiRequest } from "../lib/api";
|
||||
import {
|
||||
readSession,
|
||||
@@ -11,50 +11,108 @@ import {
|
||||
|
||||
type LoginResponse = KuberSession;
|
||||
|
||||
async function promptUsername(): Promise<string> {
|
||||
const readline = createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
return (await readline.question("Username: ")).trim();
|
||||
} finally {
|
||||
readline.close();
|
||||
}
|
||||
type Credentials = { username: string; password: string };
|
||||
type AuthChoice = {
|
||||
name: string;
|
||||
input: string;
|
||||
value: string;
|
||||
initial?: string;
|
||||
cursor?: number;
|
||||
};
|
||||
export interface LoginPrompt {
|
||||
choices: AuthChoice[];
|
||||
values: Credentials;
|
||||
state: { submitted: boolean; cancelled: boolean; closed: boolean };
|
||||
initialize(): Promise<void>;
|
||||
render(): Promise<void>;
|
||||
submit(): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
run(): Promise<LoginResponse>;
|
||||
}
|
||||
|
||||
async function promptPassword(): Promise<string> {
|
||||
if (!stdin.isTTY || !stdin.setRawMode) {
|
||||
throw new Error("Password input requires an interactive terminal");
|
||||
// Enquirer's declarations omit its runtime BasicAuth factory.
|
||||
const BasicAuth = (
|
||||
Enquirer as typeof Enquirer & {
|
||||
BasicAuth: {
|
||||
create(
|
||||
authenticate: (values: Credentials) => Promise<LoginResponse | false>,
|
||||
): new (options: Record<string, unknown>) => LoginPrompt;
|
||||
};
|
||||
}
|
||||
).BasicAuth;
|
||||
|
||||
stdout.write("Password: ");
|
||||
stdin.setRawMode(true);
|
||||
stdin.resume();
|
||||
return new Promise((resolve, reject) => {
|
||||
let password = "";
|
||||
const cleanup = () => {
|
||||
stdin.off("data", onData);
|
||||
stdin.setRawMode(false);
|
||||
stdin.pause();
|
||||
stdout.write("\n");
|
||||
};
|
||||
const onData = (chunk: Buffer) => {
|
||||
const value = chunk.toString("utf8");
|
||||
if (value === "\u0003") {
|
||||
cleanup();
|
||||
reject(new Error("Login cancelled"));
|
||||
return;
|
||||
/** Actual BasicAuth prompt; injectable authentication keeps tests offline. */
|
||||
export function createLoginPrompt(
|
||||
username = "",
|
||||
persistent = false,
|
||||
authenticate: typeof loginUser = loginUser,
|
||||
options: { show?: boolean; stdout?: NodeJS.WriteStream } = {},
|
||||
): LoginPrompt {
|
||||
let failure: unknown;
|
||||
let authentication: Promise<LoginResponse | false> | undefined;
|
||||
const Auth = BasicAuth.create((values) => {
|
||||
// Repeated Enter presses must never issue a second login or session write.
|
||||
authentication ??= (async () => {
|
||||
try {
|
||||
const name = values.username.trim();
|
||||
if (!name) throw new Error("Username is required");
|
||||
return await authenticate(name, values.password, persistent);
|
||||
} catch (error) {
|
||||
// BasicAuth doesn't forward rejected authenticate callbacks to run().
|
||||
failure = error;
|
||||
return false as const;
|
||||
}
|
||||
if (value === "\r" || value === "\n") {
|
||||
cleanup();
|
||||
resolve(password);
|
||||
return;
|
||||
})();
|
||||
return authentication;
|
||||
});
|
||||
class KuberBasicAuth extends Auth {
|
||||
private submitting = false;
|
||||
|
||||
override async initialize() {
|
||||
await super.initialize();
|
||||
const choice = this.choices.find((item) => item.name === "username");
|
||||
if (choice) {
|
||||
choice.input = choice.value = username;
|
||||
choice.cursor = username.length;
|
||||
}
|
||||
if (value === "\u007f" || value === "\b") {
|
||||
password = password.slice(0, -1);
|
||||
return;
|
||||
await this.render();
|
||||
}
|
||||
|
||||
override async submit() {
|
||||
if (this.submitting || this.state.closed) return;
|
||||
this.submitting = true;
|
||||
await super.submit();
|
||||
}
|
||||
|
||||
override async render() {
|
||||
if (this.state.submitted) {
|
||||
// FormPrompt skips choice.format on submission and cancellation.
|
||||
const password = this.choices.find(
|
||||
(choice) => choice.name === "password",
|
||||
);
|
||||
if (password) password.input = password.value = password.initial = "";
|
||||
this.values.password = "";
|
||||
}
|
||||
password += value;
|
||||
};
|
||||
stdin.on("data", onData);
|
||||
await super.render();
|
||||
}
|
||||
|
||||
override async run(): Promise<LoginResponse> {
|
||||
let session: LoginResponse;
|
||||
try {
|
||||
session = await super.run();
|
||||
} catch {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
return new KuberBasicAuth({
|
||||
name: "login",
|
||||
message: "Log in to kuber.astrxl.dev",
|
||||
initial: { username },
|
||||
showPassword: false,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,14 +134,22 @@ export async function loginUser(
|
||||
}
|
||||
|
||||
/** Share the CLI's credential prompt with onboarding without exposing passwords. */
|
||||
export async function interactiveLogin(): Promise<LoginResponse> {
|
||||
if (!stdin.isTTY)
|
||||
export async function interactiveLogin(
|
||||
username = "",
|
||||
persistent = false,
|
||||
dependencies: {
|
||||
isTTY?: boolean;
|
||||
prompt?: typeof createLoginPrompt;
|
||||
} = {},
|
||||
): Promise<LoginResponse> {
|
||||
if (!(dependencies.isTTY ?? stdin.isTTY))
|
||||
throw new Error(
|
||||
"Login requires an interactive terminal; run kuber login first",
|
||||
);
|
||||
const username = await promptUsername();
|
||||
if (!username) throw new Error("Username is required");
|
||||
const session = await loginUser(username, await promptPassword(), false);
|
||||
const session = await (dependencies.prompt ?? createLoginPrompt)(
|
||||
username.trim(),
|
||||
persistent,
|
||||
).run();
|
||||
console.log(`Logged in as ${session.user.username}`);
|
||||
return session;
|
||||
}
|
||||
@@ -100,14 +166,7 @@ export const login = defineCommand({
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const username = String(args._[0] ?? "").trim() || (await promptUsername());
|
||||
if (!username) throw new Error("Username is required");
|
||||
const session = await loginUser(
|
||||
username,
|
||||
await promptPassword(),
|
||||
Boolean(args.persist),
|
||||
);
|
||||
console.log(`Logged in as ${session.user.username}`);
|
||||
await interactiveLogin(String(args._[0] ?? ""), Boolean(args.persist));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+47
-13
@@ -8,11 +8,11 @@ import { createCompose, projectName } from "../lib/scaffold";
|
||||
import {
|
||||
appArgs,
|
||||
appInputFromArgs,
|
||||
resolveAppInput,
|
||||
terminalPrompt,
|
||||
type AppInput,
|
||||
type ScaffoldPrompt,
|
||||
} from "./add";
|
||||
import { resolveScaffoldInput } from "../lib/scaffold-prompts";
|
||||
import { resolveComposeFile } from "../lib/yaml";
|
||||
import { basename } from "node:path";
|
||||
import { join } from "node:path";
|
||||
@@ -62,13 +62,13 @@ export async function initializeProject(
|
||||
const defaultProject = projectName(
|
||||
config.projectConfigured ? config.project : basename(root),
|
||||
);
|
||||
const project = projectName(
|
||||
input.project ??
|
||||
(input.nonInteractive
|
||||
? defaultProject
|
||||
: await prompt("Project name", defaultProject)),
|
||||
);
|
||||
const app = await resolveAppInput(root, input, prompt);
|
||||
const resolved = await resolveScaffoldInput(root, input, prompt, {
|
||||
defaultProject,
|
||||
project:
|
||||
input.project ?? (config.projectConfigured ? config.project : undefined),
|
||||
});
|
||||
const project = resolved.project!;
|
||||
const app = resolved.app;
|
||||
const login = dependencies.login ?? interactiveLogin;
|
||||
let session = await (dependencies.session ?? readSession)();
|
||||
if (!session) session = await login();
|
||||
@@ -90,11 +90,45 @@ export async function initializeProject(
|
||||
);
|
||||
// The server grant is idempotent. A GET cannot establish ownership of it:
|
||||
// another client may grant the same fingerprint before our POST.
|
||||
if (!alreadyTrusted)
|
||||
await request(trustPath, {
|
||||
method: "POST",
|
||||
json: { fingerprint: identity.fingerprint },
|
||||
});
|
||||
if (!alreadyTrusted) {
|
||||
try {
|
||||
await request(trustPath, {
|
||||
method: "POST",
|
||||
json: { fingerprint: identity.fingerprint },
|
||||
});
|
||||
} catch (error) {
|
||||
// A 401 is an explicit authentication denial; let the normal login
|
||||
// retry run without probing using the rejected session.
|
||||
if (error instanceof KuberApiError && error.status === 401)
|
||||
throw error;
|
||||
|
||||
// Client errors (other than auth denial) are definite rejection.
|
||||
const definitelyRejected =
|
||||
error instanceof KuberApiError &&
|
||||
error.status >= 400 &&
|
||||
error.status < 500;
|
||||
if (definitelyRejected) throw error;
|
||||
|
||||
let registration: boolean | undefined;
|
||||
try {
|
||||
const current = await request<WorkspaceTrustResponse>(trustPath);
|
||||
if (Array.isArray(current?.fingerprints))
|
||||
registration = current.fingerprints.includes(
|
||||
identity.fingerprint,
|
||||
);
|
||||
} catch {
|
||||
// The POST may have committed despite its failed response.
|
||||
}
|
||||
|
||||
if (registration !== false) {
|
||||
remoteRegistered = true;
|
||||
throw new Error(
|
||||
"Could not confirm whether server registration succeeded. Generated project files were kept; check kuber trust status and run kuber trust to repair local trust if needed.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
remoteRegistered = true;
|
||||
try {
|
||||
await (dependencies.persistTrust ?? updateTrust)((records) => [
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ function cloneCommand<T extends object>(command: T): T {
|
||||
export const main = defineCommand({
|
||||
meta: {
|
||||
name: "kuber",
|
||||
version: "2.7.0-rc2",
|
||||
version: "2.7.0-rc3",
|
||||
description: "Docker Compose -> K8s translation layer",
|
||||
},
|
||||
args: {
|
||||
|
||||
Reference in New Issue
Block a user