Flatten the repo: move everything out of app/ to the root
Now that the CLI and the Next.js app are one package, nesting it inside app/ served no purpose - the repo root itself becomes the published npm package. Merges app/.gitignore and app/README.md into the root versions, drops the now-duplicate app/LICENSE, and updates path references (README, docs/ARCHITECTURE.md, docs/CONFIG_REFERENCE.md, package.json's repository.directory) that assumed the app/ nesting. Also fixes a real bug this surfaced: the in-app docs viewer resolved docs/ relative to process.cwd(), which only worked by accident when the CLI happened to be invoked from app/'s parent directory. A first attempt at fixing it with import.meta.dirname broke instead, for the same cross-module-graph reason config-path resolution already documented - Next compiles Route Handlers through a separate module graph that doesn't preserve source-relative import.meta paths. Fixed by exposing the app root via TRIGGERSHELL_APP_ROOT (set once in server.ts, where import.meta *does* resolve correctly), the same pattern already used for TRIGGERSHELL_CONFIG_PATH. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import fs from "node:fs";
|
||||
import { ConfigError, loadConfig } from "../../lib/config/load";
|
||||
import { isPortFree } from "../lib/network";
|
||||
import { resolveConfigPath } from "../lib/paths";
|
||||
|
||||
export interface DoctorOptions {
|
||||
config?: string;
|
||||
}
|
||||
|
||||
export async function doctorCommand(opts: DoctorOptions): Promise<void> {
|
||||
const rows: [string, string][] = [];
|
||||
|
||||
rows.push(["Node.js", process.version]);
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const exists = fs.existsSync(configPath);
|
||||
rows.push(["Config path", `${configPath} ${exists ? "(exists)" : "(not found)"}`]);
|
||||
|
||||
if (exists) {
|
||||
try {
|
||||
const { config } = loadConfig(configPath);
|
||||
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)`,
|
||||
]);
|
||||
rows.push(["Scripts configured", String(config.scripts.length)]);
|
||||
rows.push(["Auth enabled", String(config.auth.enabled)]);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
rows.push(["Config", error.message]);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const labelWidth = Math.max(...rows.map(([label]) => label.length));
|
||||
for (const [label, value] of rows) {
|
||||
console.log(`${label.padEnd(labelWidth)} ${value}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveAppRoot } from "../lib/paths";
|
||||
|
||||
const DEFAULT_CONFIG_NAME = "triggershell.yml";
|
||||
|
||||
export interface InitOptions {
|
||||
port: number;
|
||||
auth: boolean;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (fs.existsSync(configPath) && !opts.force) {
|
||||
console.error(`${configPath} already exists. Use --force to overwrite.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const templatePath = path.join(resolveAppRoot(), "templates", DEFAULT_CONFIG_NAME);
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
const rendered = template
|
||||
.replace("__PORT__", String(opts.port))
|
||||
.replace("__AUTH_ENABLED__", opts.auth ? "true" : "false");
|
||||
fs.writeFileSync(configPath, rendered);
|
||||
console.log(`Created ${configPath}`);
|
||||
|
||||
const envPath = path.join(targetDir, ".env");
|
||||
if (opts.auth) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
console.log(
|
||||
`${envPath} already exists - make sure it sets TRIGGERSHELL_SESSION_SECRET (>= 32 chars).`,
|
||||
);
|
||||
} else {
|
||||
const sessionSecret = crypto.randomBytes(32).toString("hex");
|
||||
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(` triggershell users add <username> --config ${configPath}`);
|
||||
}
|
||||
|
||||
console.log("\nStart the app with:");
|
||||
console.log(` triggershell start --config ${configPath}`);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execa } from "execa";
|
||||
import { resolveConfigPath, resolveInvokedBinPath } from "../lib/paths";
|
||||
import {
|
||||
isRoot,
|
||||
renderUnit,
|
||||
SERVICE_NAME,
|
||||
systemUnitPath,
|
||||
userUnitPath,
|
||||
} from "../lib/systemd";
|
||||
|
||||
export interface ServiceInstallOptions {
|
||||
config?: string;
|
||||
port?: number;
|
||||
host?: string;
|
||||
system?: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceScopeOptions {
|
||||
system?: boolean;
|
||||
}
|
||||
|
||||
function scopeOf(opts: ServiceScopeOptions): "user" | "system" {
|
||||
return opts.system ? "system" : "user";
|
||||
}
|
||||
|
||||
export async function serviceInstallCommand(opts: ServiceInstallOptions): Promise<void> {
|
||||
const scope = scopeOf(opts);
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const unit = renderUnit({
|
||||
execPath: process.execPath,
|
||||
binPath: resolveInvokedBinPath(),
|
||||
configPath,
|
||||
configDir: path.dirname(configPath),
|
||||
port: opts.port,
|
||||
host: opts.host,
|
||||
scope,
|
||||
});
|
||||
|
||||
if (scope === "user") {
|
||||
const unitPath = userUnitPath();
|
||||
fs.mkdirSync(path.dirname(unitPath), { recursive: true });
|
||||
fs.writeFileSync(unitPath, unit);
|
||||
await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
|
||||
|
||||
console.log(`Installed ${unitPath}`);
|
||||
console.log("\nReview it, then start the service with:");
|
||||
console.log(` systemctl --user enable --now ${SERVICE_NAME}`);
|
||||
console.log("\nTail logs with:");
|
||||
console.log(` journalctl --user -u ${SERVICE_NAME} -f`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRoot()) {
|
||||
const unitPath = systemUnitPath();
|
||||
fs.writeFileSync(unitPath, unit);
|
||||
await execa("systemctl", ["daemon-reload"], { reject: false });
|
||||
|
||||
console.log(`Installed ${unitPath}`);
|
||||
console.log("\nReview it, then start the service with:");
|
||||
console.log(` systemctl enable --now ${SERVICE_NAME}`);
|
||||
console.log("\nTail logs with:");
|
||||
console.log(` journalctl -u ${SERVICE_NAME} -f`);
|
||||
return;
|
||||
}
|
||||
|
||||
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("\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> {
|
||||
const scope = scopeOf(opts);
|
||||
|
||||
if (scope === "user") {
|
||||
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 });
|
||||
console.log(`Removed ${unitPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRoot()) {
|
||||
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 });
|
||||
console.log(`Removed ${unitPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Not running as root. Remove the system service manually with:");
|
||||
console.log(` sudo systemctl disable --now ${SERVICE_NAME}`);
|
||||
console.log(` sudo rm ${systemUnitPath()}`);
|
||||
console.log(" sudo systemctl daemon-reload");
|
||||
}
|
||||
|
||||
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 });
|
||||
process.exitCode = result.exitCode ?? 1;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { ConfigError, loadConfig } from "../../lib/config/load";
|
||||
import { loadDotenv } from "../lib/env-file";
|
||||
import { isPortFree, openBrowser, waitUntilReady } from "../lib/network";
|
||||
import { resolveAppRoot, resolveConfigPath } from "../lib/paths";
|
||||
|
||||
export interface StartOptions {
|
||||
config?: string;
|
||||
port?: number;
|
||||
host?: string;
|
||||
noBrowser?: boolean;
|
||||
}
|
||||
|
||||
export async function startCommand(opts: StartOptions): Promise<void> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
loadDotenv(path.join(path.dirname(configPath), ".env"));
|
||||
|
||||
let loaded;
|
||||
try {
|
||||
loaded = loadConfig(configPath);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
console.error(`Config error: ${error.message}`);
|
||||
for (const issue of error.issues) console.error(` - ${issue}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const effectiveHost = opts.host ?? loaded.config.server.host;
|
||||
const effectivePort = opts.port ?? loaded.config.server.port;
|
||||
|
||||
if (!(await isPortFree(effectiveHost, effectivePort))) {
|
||||
console.error(
|
||||
`Port ${effectivePort} on ${effectiveHost} is already in use. Pass --port to use a different one.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
process.env.TRIGGERSHELL_CONFIG_PATH = configPath;
|
||||
process.env.PORT = String(effectivePort);
|
||||
process.env.HOST = effectiveHost;
|
||||
// Next's generated types mark NODE_ENV readonly; this is the one legitimate place that sets it
|
||||
// (the CLI IS what decides production mode) before importing server.ts.
|
||||
(process.env as { NODE_ENV: string }).NODE_ENV = "production";
|
||||
|
||||
const url = `http://${effectiveHost}:${effectivePort}`;
|
||||
if (!opts.noBrowser) {
|
||||
void waitUntilReady(`${url}/api/healthz`, 45_000).then((ready) => {
|
||||
if (ready) openBrowser(url);
|
||||
});
|
||||
}
|
||||
|
||||
const serverEntry = pathToFileURL(path.join(resolveAppRoot(), "server.ts")).href;
|
||||
await import(serverEntry);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { password as promptPassword } from "@inquirer/prompts";
|
||||
import { stringify } from "yaml";
|
||||
import { hashPassword } from "../../lib/auth/password";
|
||||
import { upsertEnvVar } from "../lib/env-file";
|
||||
import { resolveConfigPath } from "../lib/paths";
|
||||
import { slug } from "../lib/slug";
|
||||
|
||||
export interface UsersOptions {
|
||||
config?: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
function printSnippet(heading: string, entry: Record<string, unknown>): void {
|
||||
console.log(`\n${heading}\n`);
|
||||
console.log(stringify([entry]));
|
||||
}
|
||||
|
||||
async function promptNewPassword(): Promise<string> {
|
||||
for (;;) {
|
||||
const first = await promptPassword({ message: "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> {
|
||||
const password = await promptNewPassword();
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
if (opts.inline) {
|
||||
printSnippet("Add this under `auth.users:` in your config file:", { username, passwordHash });
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const envPath = path.join(path.dirname(configPath), ".env");
|
||||
const varName = `TRIGGERSHELL_USER_${slug(username)}_PASSWORD_HASH`;
|
||||
upsertEnvVar(envPath, varName, passwordHash);
|
||||
|
||||
console.log(`Stored ${varName} in ${envPath}`);
|
||||
printSnippet("Add this under `auth.users:` in your config file:", {
|
||||
username,
|
||||
passwordHash: `\${${varName}}`,
|
||||
});
|
||||
}
|
||||
|
||||
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")}`;
|
||||
|
||||
console.log("\nSave this token now - it will not be shown again:");
|
||||
console.log(` ${token}`);
|
||||
console.log(`Use it as: Authorization: Bearer ${token}`);
|
||||
|
||||
if (opts.inline) {
|
||||
printSnippet("Add this under `auth.tokens:` in your config file:", { name, tokenHash });
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const envPath = path.join(path.dirname(configPath), ".env");
|
||||
const varName = `TRIGGERSHELL_TOKEN_${slug(name)}_HASH`;
|
||||
upsertEnvVar(envPath, varName, tokenHash);
|
||||
|
||||
console.log(`Stored ${varName} in ${envPath}`);
|
||||
printSnippet("Add this under `auth.tokens:` in your config file:", {
|
||||
name,
|
||||
tokenHash: `\${${varName}}`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import path from "node:path";
|
||||
import { ConfigError, loadConfig } from "../../lib/config/load";
|
||||
import { loadDotenv } from "../lib/env-file";
|
||||
import { resolveConfigPath } from "../lib/paths";
|
||||
|
||||
export interface ValidateOptions {
|
||||
config?: string;
|
||||
}
|
||||
|
||||
export async function validateCommand(opts: ValidateOptions): Promise<void> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
loadDotenv(path.join(path.dirname(configPath), ".env"));
|
||||
|
||||
try {
|
||||
const { config } = loadConfig(configPath);
|
||||
console.log(`OK - ${configPath}`);
|
||||
console.log(` ${config.scripts.length} script(s) configured`);
|
||||
console.log(` auth.enabled: ${config.auth.enabled}`);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
console.error(`Config error: ${error.message}`);
|
||||
for (const issue of error.issues) console.error(` - ${issue}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Command } from "commander";
|
||||
import { doctorCommand } from "./commands/doctor";
|
||||
import { initCommand } from "./commands/init";
|
||||
import { serviceInstallCommand, serviceStatusCommand, serviceUninstallCommand } from "./commands/service";
|
||||
import { startCommand } from "./commands/start";
|
||||
import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
|
||||
import { validateCommand } from "./commands/validate";
|
||||
import { getVersion } from "./version";
|
||||
|
||||
const program = new Command("triggershell")
|
||||
.version(getVersion())
|
||||
.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("--no-auth", "Disable built-in login for the web app.")
|
||||
.option("--force", "Overwrite an existing config file.", false)
|
||||
.action(initCommand);
|
||||
|
||||
program
|
||||
.command("validate")
|
||||
.description("Validate a config file against the full schema.")
|
||||
.option("-c, --config <path>", "Path to the config file.")
|
||||
.action(validateCommand);
|
||||
|
||||
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("--host <host>", "Override the host from the config file.")
|
||||
.option("--no-browser", "Don't open a browser automatically.")
|
||||
.action(startCommand);
|
||||
|
||||
program
|
||||
.command("doctor")
|
||||
.description("Print diagnostic info about your environment and config.")
|
||||
.option("-c, --config <path>", "Path to the config file.")
|
||||
.action(doctorCommand);
|
||||
|
||||
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)
|
||||
.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)
|
||||
.action(usersAddTokenCommand);
|
||||
|
||||
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("--host <host>", "Override the host from the config file.")
|
||||
.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)
|
||||
.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)
|
||||
.action(serviceStatusCommand);
|
||||
|
||||
if (process.argv.length <= 2) {
|
||||
program.outputHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await program.parseAsync(process.argv);
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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");
|
||||
}
|
||||
|
||||
test("upsertEnvVar appends a new key", () => {
|
||||
const envPath = tmpEnvPath();
|
||||
upsertEnvVar(envPath, "FOO", "bar");
|
||||
assert.equal(fs.readFileSync(envPath, "utf-8"), "FOO=bar\n");
|
||||
});
|
||||
|
||||
test("upsertEnvVar replaces an existing key without duplicating the line", () => {
|
||||
const envPath = tmpEnvPath();
|
||||
upsertEnvVar(envPath, "FOO", "first");
|
||||
upsertEnvVar(envPath, "FOO", "second");
|
||||
const lines = fs.readFileSync(envPath, "utf-8").trim().split("\n");
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(lines[0], "FOO=second");
|
||||
});
|
||||
|
||||
test("upsertEnvVar preserves other existing keys", () => {
|
||||
const envPath = tmpEnvPath();
|
||||
upsertEnvVar(envPath, "FOO", "1");
|
||||
upsertEnvVar(envPath, "BAR", "2");
|
||||
const content = fs.readFileSync(envPath, "utf-8");
|
||||
assert.match(content, /FOO=1/);
|
||||
assert.match(content, /BAR=2/);
|
||||
});
|
||||
|
||||
test("loadDotenv sets process.env without overriding an already-set var", () => {
|
||||
const envPath = tmpEnvPath();
|
||||
upsertEnvVar(envPath, "TRIGGERSHELL_TEST_ALREADY_SET", "from-file");
|
||||
upsertEnvVar(envPath, "TRIGGERSHELL_TEST_NEW", "from-file");
|
||||
process.env.TRIGGERSHELL_TEST_ALREADY_SET = "from-shell";
|
||||
delete process.env.TRIGGERSHELL_TEST_NEW;
|
||||
|
||||
loadDotenv(envPath);
|
||||
|
||||
assert.equal(process.env.TRIGGERSHELL_TEST_ALREADY_SET, "from-shell");
|
||||
assert.equal(process.env.TRIGGERSHELL_TEST_NEW, "from-file");
|
||||
|
||||
delete process.env.TRIGGERSHELL_TEST_ALREADY_SET;
|
||||
delete process.env.TRIGGERSHELL_TEST_NEW;
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
interface EnvEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function parseEnvLines(content: string): EnvEntry[] {
|
||||
const entries: EnvEntry[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
entries.push({ key: trimmed.slice(0, eq), value: trimmed.slice(eq + 1) });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 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"))) {
|
||||
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 {
|
||||
const lines = fs.existsSync(envPath)
|
||||
? fs.readFileSync(envPath, "utf-8").split("\n")
|
||||
: [];
|
||||
const prefix = `${key}=`;
|
||||
const index = lines.findIndex((line) => line.startsWith(prefix));
|
||||
const newLine = `${key}=${value}`;
|
||||
if (index >= 0) {
|
||||
lines[index] = newLine;
|
||||
} else {
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
lines.push(newLine);
|
||||
}
|
||||
fs.writeFileSync(envPath, lines.join("\n") + "\n");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import net from "node:net";
|
||||
|
||||
export function isPortFree(host: string, port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect({ host, port, timeout: 500 });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitUntilReady(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
intervalMs = 400,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(1500) });
|
||||
if (response.status === 200) return true;
|
||||
} catch {
|
||||
// not ready yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function openBrowser(url: string): void {
|
||||
const command =
|
||||
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();
|
||||
} catch {
|
||||
// best-effort - not fatal if no browser opener is available
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
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)), "..", "..", "..");
|
||||
}
|
||||
|
||||
export function resolveConfigPath(configArg?: string): string {
|
||||
return path.resolve(process.cwd(), configArg ?? DEFAULT_CONFIG_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to the script that was actually invoked (`node <this>`), with any symlink
|
||||
* (as created by a global npm/pnpm install or `npm link`) resolved away. Used to build a
|
||||
* `systemd` `ExecStart` line that keeps working regardless of how the CLI was installed.
|
||||
*/
|
||||
export function resolveInvokedBinPath(): string {
|
||||
return fs.realpathSync(process.argv[1]);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { slug } from "./slug";
|
||||
|
||||
test("slug", () => {
|
||||
assert.equal(slug("ci-bot"), "CI_BOT");
|
||||
assert.equal(slug("Admin User"), "ADMIN_USER");
|
||||
assert.equal(slug("__weird--name__"), "WEIRD_NAME");
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export function slug(value: string): string {
|
||||
return value
|
||||
.replace(/[^A-Za-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
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",
|
||||
configPath: "/home/user/project/triggershell.yml",
|
||||
configDir: "/home/user/project",
|
||||
port: 8080,
|
||||
host: "0.0.0.0",
|
||||
scope: "user",
|
||||
});
|
||||
|
||||
assert.match(
|
||||
unit,
|
||||
/ExecStart=\/usr\/bin\/node .*triggershell start --config \/home\/user\/project\/triggershell\.yml --no-browser --port 8080 --host 0\.0\.0\.0/,
|
||||
);
|
||||
assert.match(unit, /WorkingDirectory=\/home\/user\/project/);
|
||||
assert.match(unit, /WantedBy=default\.target/);
|
||||
});
|
||||
|
||||
test("renderUnit uses multi-user.target for the system scope", () => {
|
||||
const unit = renderUnit({
|
||||
execPath: "/usr/bin/node",
|
||||
binPath: "/usr/lib/node_modules/triggershell/bin/triggershell.js",
|
||||
configPath: "/etc/triggershell/triggershell.yml",
|
||||
configDir: "/etc/triggershell",
|
||||
scope: "system",
|
||||
});
|
||||
|
||||
assert.match(unit, /WantedBy=multi-user\.target/);
|
||||
assert.match(unit, /ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const SERVICE_NAME = "triggershell";
|
||||
|
||||
export interface UnitOptions {
|
||||
execPath: string;
|
||||
binPath: string;
|
||||
configPath: string;
|
||||
configDir: string;
|
||||
port?: number;
|
||||
host?: string;
|
||||
scope: "user" | "system";
|
||||
}
|
||||
|
||||
export function renderUnit(opts: UnitOptions): string {
|
||||
const args = ["start", "--config", opts.configPath, "--no-browser"];
|
||||
if (opts.port !== undefined) args.push("--port", String(opts.port));
|
||||
if (opts.host !== undefined) args.push("--host", opts.host);
|
||||
|
||||
const execStart = [opts.execPath, opts.binPath, ...args]
|
||||
.map((part) => (part.includes(" ") ? `"${part}"` : part))
|
||||
.join(" ");
|
||||
|
||||
const wantedBy = opts.scope === "user" ? "default.target" : "multi-user.target";
|
||||
|
||||
return `[Unit]
|
||||
Description=TriggerShell - self-hosted script runner
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${execStart}
|
||||
WorkingDirectory=${opts.configDir}
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=${wantedBy}
|
||||
`;
|
||||
}
|
||||
|
||||
export function userUnitPath(): string {
|
||||
return path.join(os.homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`);
|
||||
}
|
||||
|
||||
export function systemUnitPath(): string {
|
||||
return path.join("/etc", "systemd", "system", `${SERVICE_NAME}.service`);
|
||||
}
|
||||
|
||||
export function isRoot(): boolean {
|
||||
return process.getuid?.() === 0;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
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 };
|
||||
return pkg.version;
|
||||
}
|
||||
Reference in New Issue
Block a user