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:
2026-08-16 11:14:01 +02:00
co-authored by Claude Sonnet 5
parent 30350d80f4
commit 3f379ca2ac
123 changed files with 65 additions and 104 deletions
+42
View File
@@ -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}`);
}
}
+50
View File
@@ -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}`);
}
+111
View File
@@ -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;
}
+59
View File
@@ -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);
}
+73
View File
@@ -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}}`,
});
}
+28
View File
@@ -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;
}
}
+87
View File
@@ -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);
+50
View File
@@ -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;
});
+43
View File
@@ -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");
}
+48
View File
@@ -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
}
}
+23
View File
@@ -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]);
}
+9
View File
@@ -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");
});
+6
View File
@@ -0,0 +1,6 @@
export function slug(value: string): string {
return value
.replace(/[^A-Za-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
}
+35
View File
@@ -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);
});
+54
View File
@@ -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;
}
+9
View File
@@ -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;
}