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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user