Run prettier across the repo, exclude the lockfile from it
Prettier had never been run in --check mode here before, so this had drifted across most files (markdown tables, long option() chains, line wrapping). Purely formatting, no logic changes - needed so a CI format:check gate can actually pass. Adds .prettierignore for pnpm-lock.yaml specifically: prettier's YAML formatter rewrites every quoted key (single -> double quotes) producing an ~8700-line diff of pure noise on a file pnpm itself owns the formatting of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,10 @@ export async function doctorCommand(opts: DoctorOptions): Promise<void> {
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const exists = fs.existsSync(configPath);
|
||||
rows.push(["Config path", `${configPath} ${exists ? "(exists)" : "(not found)"}`]);
|
||||
rows.push([
|
||||
"Config path",
|
||||
`${configPath} ${exists ? "(exists)" : "(not found)"}`,
|
||||
]);
|
||||
|
||||
if (exists) {
|
||||
try {
|
||||
@@ -22,7 +25,9 @@ export async function doctorCommand(opts: DoctorOptions): Promise<void> {
|
||||
const portFree = await isPortFree(config.server.host, config.server.port);
|
||||
rows.push([
|
||||
"Port available",
|
||||
portFree ? "yes" : `no (${config.server.host}:${config.server.port} in use)`,
|
||||
portFree
|
||||
? "yes"
|
||||
: `no (${config.server.host}:${config.server.port} in use)`,
|
||||
]);
|
||||
rows.push(["Scripts configured", String(config.scripts.length)]);
|
||||
rows.push(["Auth enabled", String(config.auth.enabled)]);
|
||||
|
||||
@@ -11,7 +11,10 @@ export interface InitOptions {
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export async function initCommand(targetPath: string | undefined, opts: InitOptions): Promise<void> {
|
||||
export async function initCommand(
|
||||
targetPath: string | undefined,
|
||||
opts: InitOptions,
|
||||
): Promise<void> {
|
||||
const targetDir = path.resolve(process.cwd(), targetPath ?? ".");
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
const configPath = path.join(targetDir, DEFAULT_CONFIG_NAME);
|
||||
@@ -22,7 +25,11 @@ export async function initCommand(targetPath: string | undefined, opts: InitOpti
|
||||
return;
|
||||
}
|
||||
|
||||
const templatePath = path.join(resolveAppRoot(), "templates", DEFAULT_CONFIG_NAME);
|
||||
const templatePath = path.join(
|
||||
resolveAppRoot(),
|
||||
"templates",
|
||||
DEFAULT_CONFIG_NAME,
|
||||
);
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
const rendered = template
|
||||
.replace("__PORT__", String(opts.port))
|
||||
@@ -38,10 +45,15 @@ export async function initCommand(targetPath: string | undefined, opts: InitOpti
|
||||
);
|
||||
} else {
|
||||
const sessionSecret = crypto.randomBytes(32).toString("hex");
|
||||
fs.writeFileSync(envPath, `TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`);
|
||||
fs.writeFileSync(
|
||||
envPath,
|
||||
`TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`,
|
||||
);
|
||||
console.log(`Created ${envPath} (keep this out of version control)`);
|
||||
}
|
||||
console.log("\nAuth is enabled but no users are configured yet. Add one with:");
|
||||
console.log(
|
||||
"\nAuth is enabled but no users are configured yet. Add one with:",
|
||||
);
|
||||
console.log(` triggershell users add <username> --config ${configPath}`);
|
||||
}
|
||||
|
||||
|
||||
+21
-6
@@ -33,7 +33,10 @@ function exitCodeFor(status: RunStatus): number {
|
||||
return status === "succeeded" ? 0 : 1;
|
||||
}
|
||||
|
||||
export async function runCommand(scriptId: string, opts: RunOptions): Promise<void> {
|
||||
export async function runCommand(
|
||||
scriptId: string,
|
||||
opts: RunOptions,
|
||||
): Promise<void> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
loadDotenv(path.join(path.dirname(configPath), ".env"));
|
||||
|
||||
@@ -161,7 +164,10 @@ async function runLocal(
|
||||
if (message.runId !== runId) return;
|
||||
if (message.type === "output") {
|
||||
process.stdout.write(message.chunk);
|
||||
} else if (message.type === "status" && !NON_TERMINAL.includes(message.status)) {
|
||||
} else if (
|
||||
message.type === "status" &&
|
||||
!NON_TERMINAL.includes(message.status)
|
||||
) {
|
||||
cleanup();
|
||||
resolve(message.status);
|
||||
}
|
||||
@@ -187,7 +193,9 @@ async function runRemote(
|
||||
token: string | undefined,
|
||||
wait: boolean,
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const response = await fetch(`${url}/api/scripts/${scriptId}/runs`, {
|
||||
@@ -197,7 +205,9 @@ async function runRemote(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}) as Record<string, unknown>);
|
||||
const body = await response
|
||||
.json()
|
||||
.catch(() => ({}) as Record<string, unknown>);
|
||||
if (response.status === 401) {
|
||||
console.error(
|
||||
"Unauthorized - pass --token or set TRIGGERSHELL_API_TOKEN (see `triggershell users add-token`).",
|
||||
@@ -210,7 +220,9 @@ async function runRemote(
|
||||
console.error(` ${field}: ${issues.join(", ")}`);
|
||||
}
|
||||
} else {
|
||||
console.error((body.error as string) ?? `Request failed (${response.status})`);
|
||||
console.error(
|
||||
(body.error as string) ?? `Request failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -252,7 +264,10 @@ async function runRemote(
|
||||
if (message.runId !== runId) return;
|
||||
if (message.type === "output") {
|
||||
process.stdout.write(message.chunk);
|
||||
} else if (message.type === "status" && !NON_TERMINAL.includes(message.status)) {
|
||||
} else if (
|
||||
message.type === "status" &&
|
||||
!NON_TERMINAL.includes(message.status)
|
||||
) {
|
||||
cleanup();
|
||||
resolve(message.status);
|
||||
} else if (message.type === "error") {
|
||||
|
||||
@@ -52,7 +52,9 @@ export async function scriptsShowCommand(
|
||||
if (!script) {
|
||||
console.error(`No script '${scriptId}' configured.`);
|
||||
if (scripts.length > 0) {
|
||||
console.error(`Available scripts: ${scripts.map((s) => s.id).join(", ")}`);
|
||||
console.error(
|
||||
`Available scripts: ${scripts.map((s) => s.id).join(", ")}`,
|
||||
);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -60,7 +62,9 @@ export async function scriptsShowCommand(
|
||||
|
||||
console.log(script.name);
|
||||
if (script.description) console.log(script.description);
|
||||
console.log(`\ncommand: ${script.command} ${script.args.join(" ")}`.trimEnd());
|
||||
console.log(
|
||||
`\ncommand: ${script.command} ${script.args.join(" ")}`.trimEnd(),
|
||||
);
|
||||
|
||||
if (script.variables.length === 0) {
|
||||
console.log("\nThis script takes no parameters.");
|
||||
|
||||
@@ -26,7 +26,9 @@ function scopeOf(opts: ServiceScopeOptions): "user" | "system" {
|
||||
return opts.system ? "system" : "user";
|
||||
}
|
||||
|
||||
export async function serviceInstallCommand(opts: ServiceInstallOptions): Promise<void> {
|
||||
export async function serviceInstallCommand(
|
||||
opts: ServiceInstallOptions,
|
||||
): Promise<void> {
|
||||
const scope = scopeOf(opts);
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const unit = renderUnit({
|
||||
@@ -68,18 +70,24 @@ export async function serviceInstallCommand(opts: ServiceInstallOptions): Promis
|
||||
|
||||
const scratchPath = path.join(os.tmpdir(), `${SERVICE_NAME}.service`);
|
||||
fs.writeFileSync(scratchPath, unit);
|
||||
console.log(`Not running as root - wrote the unit file to ${scratchPath} instead.`);
|
||||
console.log(
|
||||
`Not running as root - wrote the unit file to ${scratchPath} instead.`,
|
||||
);
|
||||
console.log("\nReview it, then run:");
|
||||
console.log(` sudo install -m 644 ${scratchPath} ${systemUnitPath()}`);
|
||||
console.log(" sudo systemctl daemon-reload");
|
||||
console.log(` sudo systemctl enable --now ${SERVICE_NAME}`);
|
||||
}
|
||||
|
||||
export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promise<void> {
|
||||
export async function serviceUninstallCommand(
|
||||
opts: ServiceScopeOptions,
|
||||
): Promise<void> {
|
||||
const scope = scopeOf(opts);
|
||||
|
||||
if (scope === "user") {
|
||||
await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], { reject: false });
|
||||
await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], {
|
||||
reject: false,
|
||||
});
|
||||
const unitPath = userUnitPath();
|
||||
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
|
||||
await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
|
||||
@@ -88,7 +96,9 @@ export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promis
|
||||
}
|
||||
|
||||
if (isRoot()) {
|
||||
await execa("systemctl", ["disable", "--now", SERVICE_NAME], { reject: false });
|
||||
await execa("systemctl", ["disable", "--now", SERVICE_NAME], {
|
||||
reject: false,
|
||||
});
|
||||
const unitPath = systemUnitPath();
|
||||
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
|
||||
await execa("systemctl", ["daemon-reload"], { reject: false });
|
||||
@@ -102,10 +112,17 @@ export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promis
|
||||
console.log(" sudo systemctl daemon-reload");
|
||||
}
|
||||
|
||||
export async function serviceStatusCommand(opts: ServiceScopeOptions): Promise<void> {
|
||||
export async function serviceStatusCommand(
|
||||
opts: ServiceScopeOptions,
|
||||
): Promise<void> {
|
||||
const scope = scopeOf(opts);
|
||||
const args =
|
||||
scope === "user" ? ["--user", "status", SERVICE_NAME] : ["status", SERVICE_NAME];
|
||||
const result = await execa("systemctl", args, { stdio: "inherit", reject: false });
|
||||
scope === "user"
|
||||
? ["--user", "status", SERVICE_NAME]
|
||||
: ["status", SERVICE_NAME];
|
||||
const result = await execa("systemctl", args, {
|
||||
stdio: "inherit",
|
||||
reject: false,
|
||||
});
|
||||
process.exitCode = result.exitCode ?? 1;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export async function startCommand(opts: StartOptions): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const serverEntry = pathToFileURL(path.join(resolveAppRoot(), "server.ts")).href;
|
||||
const serverEntry = pathToFileURL(
|
||||
path.join(resolveAppRoot(), "server.ts"),
|
||||
).href;
|
||||
await import(serverEntry);
|
||||
}
|
||||
|
||||
@@ -20,18 +20,27 @@ function printSnippet(heading: string, entry: Record<string, unknown>): void {
|
||||
async function promptNewPassword(): Promise<string> {
|
||||
for (;;) {
|
||||
const first = await promptPassword({ message: "Password", mask: true });
|
||||
const second = await promptPassword({ message: "Confirm password", mask: true });
|
||||
const second = await promptPassword({
|
||||
message: "Confirm password",
|
||||
mask: true,
|
||||
});
|
||||
if (first === second) return first;
|
||||
console.error("Passwords did not match, try again.\n");
|
||||
}
|
||||
}
|
||||
|
||||
export async function usersAddCommand(username: string, opts: UsersOptions): Promise<void> {
|
||||
export async function usersAddCommand(
|
||||
username: string,
|
||||
opts: UsersOptions,
|
||||
): Promise<void> {
|
||||
const password = await promptNewPassword();
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
if (opts.inline) {
|
||||
printSnippet("Add this under `auth.users:` in your config file:", { username, passwordHash });
|
||||
printSnippet("Add this under `auth.users:` in your config file:", {
|
||||
username,
|
||||
passwordHash,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,7 +56,10 @@ export async function usersAddCommand(username: string, opts: UsersOptions): Pro
|
||||
});
|
||||
}
|
||||
|
||||
export async function usersAddTokenCommand(name: string, opts: UsersOptions): Promise<void> {
|
||||
export async function usersAddTokenCommand(
|
||||
name: string,
|
||||
opts: UsersOptions,
|
||||
): Promise<void> {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const tokenHash = `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`;
|
||||
|
||||
@@ -56,7 +68,10 @@ export async function usersAddTokenCommand(name: string, opts: UsersOptions): Pr
|
||||
console.log(`Use it as: Authorization: Bearer ${token}`);
|
||||
|
||||
if (opts.inline) {
|
||||
printSnippet("Add this under `auth.tokens:` in your config file:", { name, tokenHash });
|
||||
printSnippet("Add this under `auth.tokens:` in your config file:", {
|
||||
name,
|
||||
tokenHash,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+74
-18
@@ -3,7 +3,11 @@ import { doctorCommand } from "./commands/doctor";
|
||||
import { initCommand } from "./commands/init";
|
||||
import { runCommand } from "./commands/run";
|
||||
import { scriptsListCommand, scriptsShowCommand } from "./commands/scripts";
|
||||
import { serviceInstallCommand, serviceStatusCommand, serviceUninstallCommand } from "./commands/service";
|
||||
import {
|
||||
serviceInstallCommand,
|
||||
serviceStatusCommand,
|
||||
serviceUninstallCommand,
|
||||
} from "./commands/service";
|
||||
import { startCommand } from "./commands/start";
|
||||
import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
|
||||
import { validateCommand } from "./commands/validate";
|
||||
@@ -15,12 +19,19 @@ function collect(value: string, previous: string[]): string[] {
|
||||
|
||||
const program = new Command("triggershell")
|
||||
.version(getVersion())
|
||||
.description("Launch the TriggerShell web app: run your configured shell scripts from a browser.");
|
||||
.description(
|
||||
"Launch the TriggerShell web app: run your configured shell scripts from a browser.",
|
||||
);
|
||||
|
||||
program
|
||||
.command("init [path]")
|
||||
.description("Scaffold a new triggershell.yml (and .env, if auth is enabled)")
|
||||
.option("--port <port>", "Port the web app will listen on.", (v) => Number(v), 4173)
|
||||
.option(
|
||||
"--port <port>",
|
||||
"Port the web app will listen on.",
|
||||
(v) => Number(v),
|
||||
4173,
|
||||
)
|
||||
.option("--no-auth", "Disable built-in login for the web app.")
|
||||
.option("--force", "Overwrite an existing config file.", false)
|
||||
.action(initCommand);
|
||||
@@ -35,7 +46,9 @@ program
|
||||
.command("start")
|
||||
.description("Run the web app in production mode.")
|
||||
.option("-c, --config <path>", "Path to the config file.")
|
||||
.option("--port <port>", "Override the port from the config file.", (v) => Number(v))
|
||||
.option("--port <port>", "Override the port from the config file.", (v) =>
|
||||
Number(v),
|
||||
)
|
||||
.option("--host <host>", "Override the host from the config file.")
|
||||
.option("--no-browser", "Don't open a browser automatically.")
|
||||
.action(startCommand);
|
||||
@@ -46,7 +59,9 @@ program
|
||||
.option("-c, --config <path>", "Path to the config file.")
|
||||
.action(doctorCommand);
|
||||
|
||||
const scripts = program.command("scripts").description("List and inspect configured scripts.");
|
||||
const scripts = program
|
||||
.command("scripts")
|
||||
.description("List and inspect configured scripts.");
|
||||
|
||||
scripts
|
||||
.command("list")
|
||||
@@ -71,57 +86,98 @@ program
|
||||
[],
|
||||
)
|
||||
.option("--host <host>", "Override the host from the config file.")
|
||||
.option("--port <port>", "Override the port from the config file.", (v) => Number(v))
|
||||
.option("--port <port>", "Override the port from the config file.", (v) =>
|
||||
Number(v),
|
||||
)
|
||||
.option(
|
||||
"--token <token>",
|
||||
"API token for an already-running server (or set TRIGGERSHELL_API_TOKEN).",
|
||||
)
|
||||
.option("--local", "Always run in this process, even if the web server is reachable.", false)
|
||||
.option(
|
||||
"--local",
|
||||
"Always run in this process, even if the web server is reachable.",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--remote",
|
||||
"Require a reachable web server; don't fall back to running locally.",
|
||||
false,
|
||||
)
|
||||
.option("--no-wait", "Print the run ID and exit immediately instead of streaming output.")
|
||||
.option(
|
||||
"--no-wait",
|
||||
"Print the run ID and exit immediately instead of streaming output.",
|
||||
)
|
||||
.action(runCommand);
|
||||
|
||||
const users = program.command("users").description("Manage auth users and API tokens defined in your config file.");
|
||||
const users = program
|
||||
.command("users")
|
||||
.description("Manage auth users and API tokens defined in your config file.");
|
||||
|
||||
users
|
||||
.command("add <username>")
|
||||
.description("Hash a password with argon2id and wire it up for auth.users.")
|
||||
.option("-c, --config <path>", "Path to the config file (used to locate .env).")
|
||||
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false)
|
||||
.option(
|
||||
"-c, --config <path>",
|
||||
"Path to the config file (used to locate .env).",
|
||||
)
|
||||
.option(
|
||||
"--inline",
|
||||
"Print the raw hash to paste into the config instead of storing it in .env.",
|
||||
false,
|
||||
)
|
||||
.action(usersAddCommand);
|
||||
|
||||
users
|
||||
.command("add-token <name>")
|
||||
.description("Generate an API token and wire its hash up for auth.tokens.")
|
||||
.option("-c, --config <path>", "Path to the config file (used to locate .env).")
|
||||
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false)
|
||||
.option(
|
||||
"-c, --config <path>",
|
||||
"Path to the config file (used to locate .env).",
|
||||
)
|
||||
.option(
|
||||
"--inline",
|
||||
"Print the raw hash to paste into the config instead of storing it in .env.",
|
||||
false,
|
||||
)
|
||||
.action(usersAddTokenCommand);
|
||||
|
||||
const service = program.command("service").description("Manage the systemd service (Linux only).");
|
||||
const service = program
|
||||
.command("service")
|
||||
.description("Manage the systemd service (Linux only).");
|
||||
|
||||
service
|
||||
.command("install")
|
||||
.description("Install a systemd unit that runs `triggershell start`.")
|
||||
.option("-c, --config <path>", "Path to the config file.")
|
||||
.option("--port <port>", "Override the port from the config file.", (v) => Number(v))
|
||||
.option("--port <port>", "Override the port from the config file.", (v) =>
|
||||
Number(v),
|
||||
)
|
||||
.option("--host <host>", "Override the host from the config file.")
|
||||
.option("--system", "Install a system-wide unit instead of a per-user one.", false)
|
||||
.option(
|
||||
"--system",
|
||||
"Install a system-wide unit instead of a per-user one.",
|
||||
false,
|
||||
)
|
||||
.action(serviceInstallCommand);
|
||||
|
||||
service
|
||||
.command("uninstall")
|
||||
.description("Stop, disable, and remove the systemd unit.")
|
||||
.option("--system", "Target the system-wide unit instead of the per-user one.", false)
|
||||
.option(
|
||||
"--system",
|
||||
"Target the system-wide unit instead of the per-user one.",
|
||||
false,
|
||||
)
|
||||
.action(serviceUninstallCommand);
|
||||
|
||||
service
|
||||
.command("status")
|
||||
.description("Show the systemd unit's status.")
|
||||
.option("--system", "Target the system-wide unit instead of the per-user one.", false)
|
||||
.option(
|
||||
"--system",
|
||||
"Target the system-wide unit instead of the per-user one.",
|
||||
false,
|
||||
)
|
||||
.action(serviceStatusCommand);
|
||||
|
||||
if (process.argv.length <= 2) {
|
||||
|
||||
@@ -6,7 +6,10 @@ import { test } from "node:test";
|
||||
import { loadDotenv, upsertEnvVar } from "./env-file";
|
||||
|
||||
function tmpEnvPath(): string {
|
||||
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "triggershell-env-")), ".env");
|
||||
return path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "triggershell-env-")),
|
||||
".env",
|
||||
);
|
||||
}
|
||||
|
||||
test("upsertEnvVar appends a new key", () => {
|
||||
|
||||
@@ -20,13 +20,19 @@ function parseEnvLines(content: string): EnvEntry[] {
|
||||
/** Loads a `.env` file into `process.env`, without overriding vars already set. */
|
||||
export function loadDotenv(envPath: string): void {
|
||||
if (!fs.existsSync(envPath)) return;
|
||||
for (const { key, value } of parseEnvLines(fs.readFileSync(envPath, "utf-8"))) {
|
||||
for (const { key, value } of parseEnvLines(
|
||||
fs.readFileSync(envPath, "utf-8"),
|
||||
)) {
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets `key=value` in a `.env` file, replacing an existing line for that key rather than duplicating it. */
|
||||
export function upsertEnvVar(envPath: string, key: string, value: string): void {
|
||||
export function upsertEnvVar(
|
||||
envPath: string,
|
||||
key: string,
|
||||
value: string,
|
||||
): void {
|
||||
const lines = fs.existsSync(envPath)
|
||||
? fs.readFileSync(envPath, "utf-8").split("\n")
|
||||
: [];
|
||||
|
||||
+10
-2
@@ -51,10 +51,18 @@ export async function isServerReachable(url: string): Promise<boolean> {
|
||||
|
||||
export function openBrowser(url: string): void {
|
||||
const command =
|
||||
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||
process.platform === "darwin"
|
||||
? "open"
|
||||
: process.platform === "win32"
|
||||
? "start"
|
||||
: "xdg-open";
|
||||
const args = process.platform === "win32" ? ["", url] : [url];
|
||||
try {
|
||||
spawn(command, args, { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref();
|
||||
spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
shell: process.platform === "win32",
|
||||
}).unref();
|
||||
} catch {
|
||||
// best-effort - not fatal if no browser opener is available
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ const DEFAULT_CONFIG_NAME = "triggershell.yml";
|
||||
|
||||
/** Root of the installed `triggershell` package - one level up from `src/cli/lib`. */
|
||||
export function resolveAppRoot(): string {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
return path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveConfigPath(configArg?: string): string {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { renderUnit } from "./systemd";
|
||||
test("renderUnit builds an absolute-path ExecStart with the given args", () => {
|
||||
const unit = renderUnit({
|
||||
execPath: "/usr/bin/node",
|
||||
binPath: "/home/user/.local/share/pnpm/global/5/node_modules/.bin/triggershell",
|
||||
binPath:
|
||||
"/home/user/.local/share/pnpm/global/5/node_modules/.bin/triggershell",
|
||||
configPath: "/home/user/project/triggershell.yml",
|
||||
configDir: "/home/user/project",
|
||||
port: 8080,
|
||||
@@ -31,5 +32,8 @@ test("renderUnit uses multi-user.target for the system scope", () => {
|
||||
});
|
||||
|
||||
assert.match(unit, /WantedBy=multi-user\.target/);
|
||||
assert.match(unit, /ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m);
|
||||
assert.match(
|
||||
unit,
|
||||
/ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,8 @@ export function renderUnit(opts: UnitOptions): string {
|
||||
.map((part) => (part.includes(" ") ? `"${part}"` : part))
|
||||
.join(" ");
|
||||
|
||||
const wantedBy = opts.scope === "user" ? "default.target" : "multi-user.target";
|
||||
const wantedBy =
|
||||
opts.scope === "user" ? "default.target" : "multi-user.target";
|
||||
|
||||
return `[Unit]
|
||||
Description=TriggerShell - self-hosted script runner
|
||||
@@ -42,7 +43,13 @@ WantedBy=${wantedBy}
|
||||
}
|
||||
|
||||
export function userUnitPath(): string {
|
||||
return path.join(os.homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`);
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
".config",
|
||||
"systemd",
|
||||
"user",
|
||||
`${SERVICE_NAME}.service`,
|
||||
);
|
||||
}
|
||||
|
||||
export function systemUnitPath(): string {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { test } from "node:test";
|
||||
import type { VariableConfig } from "../../lib/config/schema";
|
||||
import { coerceVariables, parseVarFlags } from "./variables";
|
||||
|
||||
function stringVar(name: string, overrides: Partial<VariableConfig> = {}): VariableConfig {
|
||||
function stringVar(
|
||||
name: string,
|
||||
overrides: Partial<VariableConfig> = {},
|
||||
): VariableConfig {
|
||||
return {
|
||||
type: "string",
|
||||
name,
|
||||
@@ -62,18 +65,32 @@ test("parseVarFlags rejects a flag with no '='", () => {
|
||||
});
|
||||
|
||||
test("coerceVariables coerces booleans and numbers, passes strings through", () => {
|
||||
const variables = [stringVar("environment"), boolVar("dryRun"), numberVar("replicas")];
|
||||
const variables = [
|
||||
stringVar("environment"),
|
||||
boolVar("dryRun"),
|
||||
numberVar("replicas"),
|
||||
];
|
||||
const values = coerceVariables(variables, {
|
||||
environment: ["staging"],
|
||||
dryRun: ["true"],
|
||||
replicas: ["3"],
|
||||
});
|
||||
assert.deepEqual(values, { environment: "staging", dryRun: true, replicas: 3 });
|
||||
assert.deepEqual(values, {
|
||||
environment: "staging",
|
||||
dryRun: true,
|
||||
replicas: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("coerceVariables rejects an invalid boolean/number", () => {
|
||||
assert.throws(() => coerceVariables([boolVar("dryRun")], { dryRun: ["yes"] }), /must be 'true' or 'false'/);
|
||||
assert.throws(() => coerceVariables([numberVar("replicas")], { replicas: ["abc"] }), /not a valid number/);
|
||||
assert.throws(
|
||||
() => coerceVariables([boolVar("dryRun")], { dryRun: ["yes"] }),
|
||||
/must be 'true' or 'false'/,
|
||||
);
|
||||
assert.throws(
|
||||
() => coerceVariables([numberVar("replicas")], { replicas: ["abc"] }),
|
||||
/not a valid number/,
|
||||
);
|
||||
});
|
||||
|
||||
test("coerceVariables collects a multiselect variable's repeats into an array", () => {
|
||||
@@ -85,7 +102,8 @@ test("coerceVariables collects a multiselect variable's repeats into an array",
|
||||
|
||||
test("coerceVariables rejects a non-multiselect variable given more than once", () => {
|
||||
assert.throws(
|
||||
() => coerceVariables([stringVar("environment")], { environment: ["a", "b"] }),
|
||||
() =>
|
||||
coerceVariables([stringVar("environment")], { environment: ["a", "b"] }),
|
||||
/given 2 times/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@ export function parseVarFlags(pairs: string[]): Record<string, string[]> {
|
||||
for (const pair of pairs) {
|
||||
const eq = pair.indexOf("=");
|
||||
if (eq === -1) {
|
||||
throw new Error(`--var ${pair} is missing '=' - expected --var name=value`);
|
||||
throw new Error(
|
||||
`--var ${pair} is missing '=' - expected --var name=value`,
|
||||
);
|
||||
}
|
||||
const name = pair.slice(0, eq);
|
||||
const value = pair.slice(eq + 1);
|
||||
@@ -29,7 +31,9 @@ export function coerceVariables(
|
||||
|
||||
for (const name of Object.keys(grouped)) {
|
||||
if (!known.has(name)) {
|
||||
throw new Error(`--var ${name}=... does not match any variable on this script`);
|
||||
throw new Error(
|
||||
`--var ${name}=... does not match any variable on this script`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +56,16 @@ export function coerceVariables(
|
||||
if (variable.type === "boolean") {
|
||||
if (value === "true") values[variable.name] = true;
|
||||
else if (value === "false") values[variable.name] = false;
|
||||
else throw new Error(`--var ${variable.name}=${value} must be 'true' or 'false'`);
|
||||
else
|
||||
throw new Error(
|
||||
`--var ${variable.name}=${value} must be 'true' or 'false'`,
|
||||
);
|
||||
} else if (variable.type === "number") {
|
||||
const n = Number(value);
|
||||
if (Number.isNaN(n)) {
|
||||
throw new Error(`--var ${variable.name}=${value} is not a valid number`);
|
||||
throw new Error(
|
||||
`--var ${variable.name}=${value} is not a valid number`,
|
||||
);
|
||||
}
|
||||
values[variable.name] = n;
|
||||
} else {
|
||||
|
||||
+3
-1
@@ -4,6 +4,8 @@ import { resolveAppRoot } from "./lib/paths";
|
||||
|
||||
export function getVersion(): string {
|
||||
const pkgPath = path.join(resolveAppRoot(), "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version: string };
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
|
||||
version: string;
|
||||
};
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user