|
|
|
@@ -1,4 +1,5 @@
|
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
|
|
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
|
|
|
import { runCommand } from "./command";
|
|
|
|
|
import { repoRoot, rootPath } from "./config";
|
|
|
|
|
import { k3sGuardShellLines, defaultNativeKubeconfig } from "./k3s-target-guard";
|
|
|
|
@@ -13,6 +14,20 @@ const defaultPrewarmImages = [
|
|
|
|
|
"postgres:16-alpine",
|
|
|
|
|
"rancher/mirrored-library-busybox:1.36.1",
|
|
|
|
|
];
|
|
|
|
|
const defaultWorktreeCopyFile = ".worktreecopy";
|
|
|
|
|
const defaultWorktreeFromRef = "origin/master";
|
|
|
|
|
const maxWorktreeCopyBytes = 1024 * 1024;
|
|
|
|
|
const refusedWorktreeCopyPrefixes = [
|
|
|
|
|
".git/",
|
|
|
|
|
".worktree/",
|
|
|
|
|
"node_modules/",
|
|
|
|
|
"dist/",
|
|
|
|
|
"build/",
|
|
|
|
|
"coverage/",
|
|
|
|
|
".state/",
|
|
|
|
|
"tmp/",
|
|
|
|
|
".tmp/",
|
|
|
|
|
];
|
|
|
|
|
const foundationRequiredKinds = new Set([
|
|
|
|
|
"Namespace/unidesk-dev",
|
|
|
|
|
"Secret/unidesk-dev-runtime-secrets",
|
|
|
|
@@ -71,6 +86,29 @@ interface PrewarmImagesOptions {
|
|
|
|
|
dryRun: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface WorktreeAddOptions {
|
|
|
|
|
targetPath: string;
|
|
|
|
|
branch: string | null;
|
|
|
|
|
fromRef: string;
|
|
|
|
|
copyLocal: boolean;
|
|
|
|
|
overwriteLocal: boolean;
|
|
|
|
|
dryRun: boolean;
|
|
|
|
|
json: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface WorktreeCopyPattern {
|
|
|
|
|
raw: string;
|
|
|
|
|
pattern: string;
|
|
|
|
|
include: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface WorktreeCopyRow {
|
|
|
|
|
path: string;
|
|
|
|
|
status: "copied" | "skipped" | "refused";
|
|
|
|
|
reason: string;
|
|
|
|
|
bytes: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isHelpArg(arg: string | undefined): boolean {
|
|
|
|
|
return arg === "help" || arg === "--help" || arg === "-h";
|
|
|
|
|
}
|
|
|
|
@@ -115,10 +153,10 @@ function parsePrewarmImagesOptions(args: string[]): PrewarmImagesOptions {
|
|
|
|
|
};
|
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
|
|
|
const arg = args[index];
|
|
|
|
|
if (arg === "--provider-id") {
|
|
|
|
|
if (arg === "--provider-id" || arg === "--target") {
|
|
|
|
|
const value = args[index + 1];
|
|
|
|
|
if (value === undefined || value.length === 0) throw new Error("--provider-id requires a value");
|
|
|
|
|
rejectUnsafeToken(value, "--provider-id");
|
|
|
|
|
if (value === undefined || value.length === 0) throw new Error(`${arg} requires a value`);
|
|
|
|
|
rejectUnsafeToken(value, arg);
|
|
|
|
|
options.providerId = value;
|
|
|
|
|
index += 1;
|
|
|
|
|
} else if (arg === "--image") {
|
|
|
|
@@ -148,6 +186,53 @@ function parsePrewarmImagesOptions(args: string[]): PrewarmImagesOptions {
|
|
|
|
|
return options;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseWorktreeAddOptions(args: string[]): WorktreeAddOptions {
|
|
|
|
|
const positional: string[] = [];
|
|
|
|
|
const options: WorktreeAddOptions = {
|
|
|
|
|
targetPath: "",
|
|
|
|
|
branch: null,
|
|
|
|
|
fromRef: defaultWorktreeFromRef,
|
|
|
|
|
copyLocal: true,
|
|
|
|
|
overwriteLocal: false,
|
|
|
|
|
dryRun: false,
|
|
|
|
|
json: false,
|
|
|
|
|
};
|
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
|
|
|
const arg = args[index];
|
|
|
|
|
if (arg === "--branch" || arg === "-b") {
|
|
|
|
|
const value = args[index + 1];
|
|
|
|
|
if (value === undefined || value.length === 0) throw new Error("--branch requires a branch name");
|
|
|
|
|
rejectUnsafeToken(value, "--branch");
|
|
|
|
|
options.branch = value;
|
|
|
|
|
index += 1;
|
|
|
|
|
} else if (arg === "--from") {
|
|
|
|
|
const value = args[index + 1];
|
|
|
|
|
if (value === undefined || value.length === 0) throw new Error("--from requires a git ref");
|
|
|
|
|
rejectUnsafeToken(value, "--from");
|
|
|
|
|
options.fromRef = value;
|
|
|
|
|
index += 1;
|
|
|
|
|
} else if (arg === "--copy-local") {
|
|
|
|
|
options.copyLocal = true;
|
|
|
|
|
} else if (arg === "--no-copy-local") {
|
|
|
|
|
options.copyLocal = false;
|
|
|
|
|
} else if (arg === "--overwrite-local") {
|
|
|
|
|
options.overwriteLocal = true;
|
|
|
|
|
} else if (arg === "--dry-run") {
|
|
|
|
|
options.dryRun = true;
|
|
|
|
|
} else if (arg === "--json") {
|
|
|
|
|
options.json = true;
|
|
|
|
|
} else if (!isHelpArg(arg)) {
|
|
|
|
|
if (arg.startsWith("-")) throw new Error(`unknown dev-env worktree add option: ${arg}`);
|
|
|
|
|
positional.push(arg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (positional.length !== 1) throw new Error("dev-env worktree add usage: bun scripts/cli.ts dev-env worktree add .worktree/<task> --branch <branch> [--from origin/master]");
|
|
|
|
|
options.targetPath = positional[0] ?? "";
|
|
|
|
|
rejectUnsafeToken(options.targetPath, "worktree path");
|
|
|
|
|
if (options.overwriteLocal) options.copyLocal = true;
|
|
|
|
|
return options;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scalarAfter(text: string, key: string): string | null {
|
|
|
|
|
const match = text.match(new RegExp(`^\\s*${key}:\\s*"?([^"\\n#]+)"?\\s*(?:#.*)?$`, "mu"));
|
|
|
|
|
return match?.[1]?.trim() ?? null;
|
|
|
|
@@ -216,6 +301,174 @@ function validateDatabaseUrl(url: string): { ok: boolean; url: string; reason: s
|
|
|
|
|
return { ok: true, url, reason: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizedRelativePath(path: string): string {
|
|
|
|
|
return path.split(sep).join("/");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveWorktreeTarget(targetPath: string): { absolutePath: string; displayPath: string } {
|
|
|
|
|
const absolutePath = isAbsolute(targetPath) ? resolve(targetPath) : resolve(repoRoot, targetPath);
|
|
|
|
|
const repoRelative = relative(repoRoot, absolutePath);
|
|
|
|
|
if (repoRelative.length === 0) throw new Error("worktree path must not be the repository root");
|
|
|
|
|
if (repoRelative.startsWith("..") || isAbsolute(repoRelative)) throw new Error("worktree path must stay under the UniDesk repository root");
|
|
|
|
|
const displayPath = normalizedRelativePath(repoRelative);
|
|
|
|
|
if (displayPath === ".git" || displayPath.startsWith(".git/")) throw new Error("worktree path must not be inside .git");
|
|
|
|
|
if (displayPath.split("/").some((part) => part.length === 0 || part === "." || part === "..")) throw new Error("worktree path must be normalized");
|
|
|
|
|
return { absolutePath, displayPath };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseWorktreeCopyPatterns(text: string): WorktreeCopyPattern[] {
|
|
|
|
|
return text.split(/\r?\n/u)
|
|
|
|
|
.map((line) => line.trim())
|
|
|
|
|
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
|
|
|
|
.map((line) => {
|
|
|
|
|
const include = !line.startsWith("!");
|
|
|
|
|
const pattern = include ? line : line.slice(1);
|
|
|
|
|
if (pattern.length === 0 || pattern.startsWith("/") || pattern.includes("\\") || pattern.split("/").some((part) => part === "..")) {
|
|
|
|
|
throw new Error(`${defaultWorktreeCopyFile} contains unsupported pattern: ${line}`);
|
|
|
|
|
}
|
|
|
|
|
return { raw: line, pattern, include };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeRegExp(value: string): string {
|
|
|
|
|
return value.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function globToRegExp(pattern: string): RegExp {
|
|
|
|
|
let source = "^";
|
|
|
|
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
|
|
|
const char = pattern[index] ?? "";
|
|
|
|
|
const next = pattern[index + 1] ?? "";
|
|
|
|
|
if (char === "*" && next === "*") {
|
|
|
|
|
source += ".*";
|
|
|
|
|
index += 1;
|
|
|
|
|
} else if (char === "*") {
|
|
|
|
|
source += "[^/]*";
|
|
|
|
|
} else if (char === "?") {
|
|
|
|
|
source += "[^/]";
|
|
|
|
|
} else {
|
|
|
|
|
source += escapeRegExp(char);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return new RegExp(`${source}$`, "u");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function patternPrefix(pattern: string): string {
|
|
|
|
|
const wildcardIndex = pattern.search(/[*?]/u);
|
|
|
|
|
const prefix = wildcardIndex < 0 ? pattern : pattern.slice(0, wildcardIndex);
|
|
|
|
|
const slashIndex = prefix.lastIndexOf("/");
|
|
|
|
|
return slashIndex < 0 ? "" : prefix.slice(0, slashIndex);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function walkFiles(root: string, relativeRoot: string, out: string[]): void {
|
|
|
|
|
if (!existsSync(root)) return;
|
|
|
|
|
const stat = statSync(root);
|
|
|
|
|
if (stat.isFile()) {
|
|
|
|
|
out.push(relativeRoot);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!stat.isDirectory()) return;
|
|
|
|
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
|
|
|
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
|
|
|
|
const childRelative = relativeRoot.length === 0 ? entry.name : `${relativeRoot}/${entry.name}`;
|
|
|
|
|
const childPath = resolve(root, entry.name);
|
|
|
|
|
if (entry.isDirectory()) walkFiles(childPath, childRelative, out);
|
|
|
|
|
else if (entry.isFile()) out.push(childRelative);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function candidateWorktreeCopyFiles(patterns: WorktreeCopyPattern[]): Set<string> {
|
|
|
|
|
const candidates = new Set<string>();
|
|
|
|
|
for (const pattern of patterns.filter((entry) => entry.include)) {
|
|
|
|
|
if (!pattern.pattern.includes("*") && !pattern.pattern.includes("?")) {
|
|
|
|
|
candidates.add(pattern.pattern);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const prefix = patternPrefix(pattern.pattern);
|
|
|
|
|
const files: string[] = [];
|
|
|
|
|
walkFiles(resolve(repoRoot, prefix), prefix, files);
|
|
|
|
|
const matcher = globToRegExp(pattern.pattern);
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
if (matcher.test(file)) candidates.add(file);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return candidates;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function includedByPatterns(path: string, patterns: WorktreeCopyPattern[]): boolean {
|
|
|
|
|
let included = false;
|
|
|
|
|
for (const pattern of patterns) {
|
|
|
|
|
if (globToRegExp(pattern.pattern).test(path)) included = pattern.include;
|
|
|
|
|
}
|
|
|
|
|
return included;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function copyRefusalReason(path: string, bytes: number): string | null {
|
|
|
|
|
const withSlash = path.endsWith("/") ? path : `${path}/`;
|
|
|
|
|
if (refusedWorktreeCopyPrefixes.some((prefix) => path === prefix.slice(0, -1) || withSlash.startsWith(prefix))) return "refused-runtime-or-build-path";
|
|
|
|
|
if (path.toLowerCase().includes("dump")) return "refused-dump-path";
|
|
|
|
|
if (bytes > maxWorktreeCopyBytes) return `refused-large-file>${maxWorktreeCopyBytes}`;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function copyWorktreeLocalFiles(targetAbsolutePath: string, overwriteLocal: boolean, dryRun = false): { configPath: string; patterns: string[]; rows: WorktreeCopyRow[] } {
|
|
|
|
|
const configPath = resolve(repoRoot, defaultWorktreeCopyFile);
|
|
|
|
|
if (!existsSync(configPath)) {
|
|
|
|
|
return {
|
|
|
|
|
configPath: defaultWorktreeCopyFile,
|
|
|
|
|
patterns: [],
|
|
|
|
|
rows: [{ path: defaultWorktreeCopyFile, status: "skipped", reason: "copy-config-missing", bytes: null }],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const patterns = parseWorktreeCopyPatterns(readFileSync(configPath, "utf8"));
|
|
|
|
|
const rows: WorktreeCopyRow[] = [];
|
|
|
|
|
for (const candidate of Array.from(candidateWorktreeCopyFiles(patterns)).sort()) {
|
|
|
|
|
if (!includedByPatterns(candidate, patterns)) continue;
|
|
|
|
|
const sourcePath = resolve(repoRoot, candidate);
|
|
|
|
|
const sourceRelative = normalizedRelativePath(relative(repoRoot, sourcePath));
|
|
|
|
|
if (sourceRelative !== candidate || sourceRelative.startsWith("..")) {
|
|
|
|
|
rows.push({ path: candidate, status: "refused", reason: "refused-outside-repo", bytes: null });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!existsSync(sourcePath)) {
|
|
|
|
|
rows.push({ path: candidate, status: "skipped", reason: "source-missing", bytes: null });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const stat = statSync(sourcePath);
|
|
|
|
|
if (!stat.isFile()) {
|
|
|
|
|
rows.push({ path: candidate, status: "skipped", reason: "not-a-file", bytes: null });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const refusalReason = copyRefusalReason(candidate, stat.size);
|
|
|
|
|
if (refusalReason !== null) {
|
|
|
|
|
rows.push({ path: candidate, status: "refused", reason: refusalReason, bytes: stat.size });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const targetPath = resolve(targetAbsolutePath, candidate);
|
|
|
|
|
const targetRelative = normalizedRelativePath(relative(targetAbsolutePath, targetPath));
|
|
|
|
|
if (targetRelative !== candidate || targetRelative.startsWith("..")) {
|
|
|
|
|
rows.push({ path: candidate, status: "refused", reason: "refused-target-outside-worktree", bytes: stat.size });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (existsSync(targetPath) && !overwriteLocal) {
|
|
|
|
|
rows.push({ path: candidate, status: "skipped", reason: "target-exists", bytes: stat.size });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (dryRun) {
|
|
|
|
|
rows.push({ path: candidate, status: "skipped", reason: existsSync(targetPath) ? "would-overwrite" : "would-copy", bytes: stat.size });
|
|
|
|
|
} else {
|
|
|
|
|
mkdirSync(dirname(targetPath), { recursive: true, mode: 0o700 });
|
|
|
|
|
copyFileSync(sourcePath, targetPath);
|
|
|
|
|
rows.push({ path: candidate, status: "copied", reason: overwriteLocal ? "overwritten" : "created", bytes: stat.size });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
configPath: defaultWorktreeCopyFile,
|
|
|
|
|
patterns: patterns.map((pattern) => pattern.raw),
|
|
|
|
|
rows,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function kubectlDryRun(manifestPath: string): unknown {
|
|
|
|
|
const kubeconfig = defaultNativeKubeconfig;
|
|
|
|
|
const guardScript = k3sGuardShellLines(kubeconfig).join("\n");
|
|
|
|
@@ -321,13 +574,98 @@ function prewarmImagesScript(options: PrewarmImagesOptions): string {
|
|
|
|
|
].join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function worktreeAddCommand(options: WorktreeAddOptions, targetAbsolutePath: string): string[] {
|
|
|
|
|
const command = ["git", "worktree", "add"];
|
|
|
|
|
if (options.branch !== null) command.push("-b", options.branch);
|
|
|
|
|
command.push(targetAbsolutePath, options.fromRef);
|
|
|
|
|
return command;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runWorktreeAdd(options: WorktreeAddOptions): unknown {
|
|
|
|
|
const target = resolveWorktreeTarget(options.targetPath);
|
|
|
|
|
if (existsSync(target.absolutePath) && !options.dryRun) throw new Error(`worktree target already exists: ${target.displayPath}`);
|
|
|
|
|
const command = worktreeAddCommand(options, target.absolutePath);
|
|
|
|
|
const result = {
|
|
|
|
|
ok: true,
|
|
|
|
|
dryRun: options.dryRun,
|
|
|
|
|
path: target.displayPath,
|
|
|
|
|
branch: options.branch,
|
|
|
|
|
fromRef: options.fromRef,
|
|
|
|
|
command,
|
|
|
|
|
copyLocal: options.copyLocal
|
|
|
|
|
? copyWorktreeLocalFiles(target.absolutePath, options.overwriteLocal, true)
|
|
|
|
|
: { configPath: defaultWorktreeCopyFile, patterns: [], rows: [{ path: defaultWorktreeCopyFile, status: "skipped" as const, reason: "copy-local-disabled", bytes: null }] },
|
|
|
|
|
};
|
|
|
|
|
if (!options.dryRun) {
|
|
|
|
|
const git = runCommand(command, repoRoot, { timeoutMs: 120_000 });
|
|
|
|
|
if (git.exitCode !== 0) {
|
|
|
|
|
throw new Error(`git worktree add failed: ${JSON.stringify({
|
|
|
|
|
command: git.command,
|
|
|
|
|
exitCode: git.exitCode,
|
|
|
|
|
signal: git.signal,
|
|
|
|
|
timedOut: git.timedOut,
|
|
|
|
|
stdoutTail: git.stdout.slice(-2000),
|
|
|
|
|
stderrTail: git.stderr.slice(-2000),
|
|
|
|
|
})}`);
|
|
|
|
|
}
|
|
|
|
|
if (options.copyLocal) result.copyLocal = copyWorktreeLocalFiles(target.absolutePath, options.overwriteLocal);
|
|
|
|
|
}
|
|
|
|
|
if (options.json) return result;
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
command: "dev-env worktree add",
|
|
|
|
|
contentType: "text/plain",
|
|
|
|
|
renderedText: renderWorktreeAdd(result),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderWorktreeAdd(result: {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
dryRun: boolean;
|
|
|
|
|
path: string;
|
|
|
|
|
branch: string | null;
|
|
|
|
|
fromRef: string;
|
|
|
|
|
command: string[];
|
|
|
|
|
copyLocal: { configPath: string; patterns: string[]; rows: WorktreeCopyRow[] };
|
|
|
|
|
}): string {
|
|
|
|
|
const rows = result.copyLocal.rows;
|
|
|
|
|
const counts = {
|
|
|
|
|
copied: rows.filter((row) => row.status === "copied").length,
|
|
|
|
|
skipped: rows.filter((row) => row.status === "skipped").length,
|
|
|
|
|
refused: rows.filter((row) => row.status === "refused").length,
|
|
|
|
|
};
|
|
|
|
|
const pathWidth = Math.min(60, Math.max(4, ...rows.map((row) => row.path.length)));
|
|
|
|
|
const statusWidth = Math.max(6, ...rows.map((row) => row.status.length));
|
|
|
|
|
const reasonWidth = Math.min(38, Math.max(6, ...rows.map((row) => row.reason.length)));
|
|
|
|
|
const truncate = (value: string, width: number): string => value.length <= width ? value : `${value.slice(0, Math.max(0, width - 1))}~`;
|
|
|
|
|
const lines = [
|
|
|
|
|
`worktree ${result.dryRun ? "dry-run" : "created"}: ${result.path}`,
|
|
|
|
|
`from: ${result.fromRef}${result.branch === null ? "" : ` branch: ${result.branch}`}`,
|
|
|
|
|
`copy config: ${result.copyLocal.configPath} copied=${counts.copied} skipped=${counts.skipped} refused=${counts.refused}`,
|
|
|
|
|
"",
|
|
|
|
|
`${"STATUS".padEnd(statusWidth)} ${"PATH".padEnd(pathWidth)} ${"BYTES".padStart(8)} REASON`,
|
|
|
|
|
];
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
lines.push([
|
|
|
|
|
row.status.toUpperCase().padEnd(statusWidth),
|
|
|
|
|
truncate(row.path, pathWidth).padEnd(pathWidth),
|
|
|
|
|
(row.bytes === null ? "-" : String(row.bytes)).padStart(8),
|
|
|
|
|
truncate(row.reason, reasonWidth),
|
|
|
|
|
].join(" "));
|
|
|
|
|
}
|
|
|
|
|
lines.push("");
|
|
|
|
|
lines.push(`drill down: bun scripts/cli.ts dev-env worktree add ${result.path} --from ${result.fromRef}${result.branch === null ? "" : ` --branch ${result.branch}`} --json`);
|
|
|
|
|
return lines.join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function devEnvHelp(): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
command: "dev-env",
|
|
|
|
|
usage: [
|
|
|
|
|
"bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]",
|
|
|
|
|
"bun scripts/cli.ts dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]",
|
|
|
|
|
"bun scripts/cli.ts dev-env prewarm-images [--image image] [--target D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]",
|
|
|
|
|
"bun scripts/cli.ts dev-env worktree add .worktree/<task> --branch <branch> [--from origin/master] [--copy-local] [--overwrite-local] [--json]",
|
|
|
|
|
],
|
|
|
|
|
defaultManifest,
|
|
|
|
|
defaultPrewarmImages,
|
|
|
|
@@ -338,6 +676,7 @@ function devEnvHelp(): Record<string, unknown> {
|
|
|
|
|
"dev DATABASE_URL values must target postgres-dev/unidesk_dev and not production routes",
|
|
|
|
|
"--kubectl-dry-run optionally asks kubectl to client-dry-run the manifest without applying it",
|
|
|
|
|
"prewarm-images imports dev foundation images from Docker into native k3s containerd on D601",
|
|
|
|
|
"worktree add creates UniDesk task worktrees and copies local files allowed by .worktreecopy",
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
@@ -345,6 +684,12 @@ function devEnvHelp(): Record<string, unknown> {
|
|
|
|
|
export function runDevEnvCommand(args: string[]): unknown {
|
|
|
|
|
const action = args[0];
|
|
|
|
|
if (action === undefined || isHelpArg(action)) return devEnvHelp();
|
|
|
|
|
if (action === "worktree") {
|
|
|
|
|
const subaction = args[1];
|
|
|
|
|
if (subaction === undefined || isHelpArg(subaction)) return devEnvHelp();
|
|
|
|
|
if (subaction !== "add") throw new Error("dev-env worktree usage: bun scripts/cli.ts dev-env worktree add .worktree/<task> --branch <branch> [--from origin/master]");
|
|
|
|
|
return runWorktreeAdd(parseWorktreeAddOptions(args.slice(2)));
|
|
|
|
|
}
|
|
|
|
|
if (action === "prewarm-images") {
|
|
|
|
|
const options = parsePrewarmImagesOptions(args.slice(1));
|
|
|
|
|
const script = prewarmImagesScript(options);
|
|
|
|
@@ -373,7 +718,7 @@ export function runDevEnvCommand(args: string[]): unknown {
|
|
|
|
|
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (action !== "validate") throw new Error("dev-env usage: bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run] OR dev-env prewarm-images");
|
|
|
|
|
if (action !== "validate") throw new Error("dev-env usage: bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run] OR dev-env prewarm-images OR dev-env worktree add");
|
|
|
|
|
|
|
|
|
|
const options = parseValidateOptions(args.slice(1));
|
|
|
|
|
const manifestPath = rootPath(options.manifestPath);
|
|
|
|
|