@archlast/forge-cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/serve.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { startServer, type AppHost, type RunningServer } from "@archlast/forge-runtime/server";
2
+ import { watch } from "node:fs";
3
+ import { basename } from "node:path";
4
+ import { fail, loadDashboard } from "./common";
5
+
6
+ export interface ServeOptions {
7
+ forgeDir: string;
8
+ dev: boolean;
9
+ env: Record<string, string | undefined>;
10
+ /** Watch this directory and call `onChange` with changed files (dev). */
11
+ watchDir?: string;
12
+ /** Prepares for a reload (codegen, installs); return false to skip it. */
13
+ onChange?: (files: string[]) => Promise<boolean>;
14
+ /** A frontend served next to the API (framework mode). */
15
+ app?: AppHost;
16
+ }
17
+
18
+ /** Runs the server in this process: SIGTERM/SIGINT stop gracefully, SIGHUP reloads the function runtime. */
19
+ export async function serve(opts: ServeOptions): Promise<RunningServer> {
20
+ const server = await startServer({ forgeDir: opts.forgeDir, dev: opts.dev, env: opts.env, dashboard: await loadDashboard(), app: opts.app }).catch((e) => fail(e.message));
21
+ let stopping = false;
22
+ const stop = async (signal: string) => {
23
+ if (stopping) process.exit(1); // second signal: don't wait
24
+ stopping = true;
25
+ console.log(`\n${signal}: draining connections…`);
26
+ await server.stop();
27
+ process.exit(0);
28
+ };
29
+ process.on("SIGTERM", () => void stop("SIGTERM"));
30
+ process.on("SIGINT", () => void stop("SIGINT"));
31
+ process.on("SIGHUP", () => void server.reload().then(() => console.log("✓ function runtime reloaded (SIGHUP)"), (e) => console.error(`✗ ${e.message}`)));
32
+
33
+ if (opts.watchDir && opts.onChange) {
34
+ const changed = new Set<string>();
35
+ let timer: Timer | undefined;
36
+ let running = Promise.resolve();
37
+ const apply = async () => {
38
+ const files = [...changed];
39
+ changed.clear();
40
+ try {
41
+ if (!(await opts.onChange!(files))) return;
42
+ await server.reload();
43
+ console.log(`✓ function runtime restarted (${files.map((f) => basename(f)).slice(0, 3).join(", ")}${files.length > 3 ? ", …" : ""})`);
44
+ } catch (e) {
45
+ console.error(`✗ ${(e as Error).message}`);
46
+ }
47
+ };
48
+ watch(opts.watchDir, { recursive: true }, (_event, file) => {
49
+ if (!file || /(^|\/)(node_modules|\.|data\/)/.test(file)) return;
50
+ if (!/\.(ts|js)$/.test(file) && !["package.json", "bun.lock"].includes(basename(file))) return;
51
+ changed.add(file);
52
+ clearTimeout(timer);
53
+ timer = setTimeout(() => (running = running.then(apply)), 150);
54
+ });
55
+ }
56
+ return server;
57
+ }
package/src/stack.ts ADDED
@@ -0,0 +1,106 @@
1
+ // The production stack: `up` builds the app image and starts everything in compose.yaml, `down` stops it.
2
+ import { generateSecrets, renderEnv, type StackService } from "create-archlast-forge/stack";
3
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { fail } from "./common";
6
+ import { dockerAvailable, engineImage, ensureEngineImage } from "./docker";
7
+ import { banner, c, ok, task, warn } from "./ui";
8
+
9
+ const DOCKER = process.env.DOCKER ?? "docker";
10
+
11
+ const composeFile = (root: string) => join(root, "compose.yaml");
12
+
13
+ function requireStack(root: string) {
14
+ if (!dockerAvailable()) fail("Docker isn't available. Install it and make sure it is running; the stack runs in containers.");
15
+ if (!existsSync(composeFile(root)))
16
+ fail(`No compose.yaml in ${root}. Projects made with \`archlast-forge init\` have one; for an older project, create the stack with:\n bun create archlast-forge <new-folder> --mode backend\nand copy compose.yaml, Dockerfile and .dockerignore across.`);
17
+ }
18
+
19
+ /** Runs `docker compose` for the project without blocking the spinner. */
20
+ async function compose(root: string, args: string[], opts: { inherit?: boolean } = {}) {
21
+ const proc = Bun.spawn([DOCKER, "compose", "--project-directory", root, "-f", composeFile(root), ...args], {
22
+ env: { ...process.env, ENGINE_IMAGE: process.env.ENGINE_IMAGE ?? engineImage() },
23
+ stdin: "ignore",
24
+ stdout: opts.inherit ? "inherit" : "pipe",
25
+ stderr: opts.inherit ? "inherit" : "pipe",
26
+ });
27
+ const [out, err, code] = await Promise.all([proc.stdout ? new Response(proc.stdout).text() : "", proc.stderr ? new Response(proc.stderr).text() : "", proc.exited]);
28
+ return { ok: code === 0, out: out.trim(), err: err.trim() };
29
+ }
30
+
31
+ /** Creates .env with fresh secrets if there isn't one. It is never overwritten: it holds the keys to the data. */
32
+ export function ensureEnv(root: string, services: StackService[]): boolean {
33
+ const file = join(root, ".env");
34
+ if (existsSync(file)) return false;
35
+ writeFileSync(file, renderEnv(services, generateSecrets()));
36
+ chmodSync(file, 0o600);
37
+ return true;
38
+ }
39
+
40
+ const rawEnvValue = (root: string, key: string) => new RegExp(`^${key}=(.*)$`, "m").exec(existsSync(join(root, ".env")) ? readFileSync(join(root, ".env"), "utf8") : "")?.[1]?.trim();
41
+ /** A value from .env, with `${OTHER}` filled in from the same file the way compose does (BETTER_AUTH_URL=https://${DOMAIN}). */
42
+ const envValue = (root: string, key: string) => rawEnvValue(root, key)?.replace(/\$\{(\w+)\}/g, (_, other) => rawEnvValue(root, other) ?? "");
43
+
44
+ /** The running app container of the project's compose stack, for commands that have to run inside it. */
45
+ export function appContainer(root: string): string | undefined {
46
+ if (!existsSync(composeFile(root)) || !dockerAvailable()) return undefined;
47
+ const p = Bun.spawnSync([DOCKER, "compose", "--project-directory", root, "-f", composeFile(root), "ps", "-q", "app"], { stdout: "pipe", stderr: "ignore", stdin: "ignore", env: { ...process.env, ENGINE_IMAGE: process.env.ENGINE_IMAGE ?? engineImage() } });
48
+ return p.exitCode === 0 ? p.stdout.toString().trim().split("\n")[0] || undefined : undefined;
49
+ }
50
+
51
+ /** Builds and starts the stack, and waits until the app reports healthy. */
52
+ export async function up(root: string, services: StackService[], opts: { rebuild?: boolean } = {}) {
53
+ requireStack(root);
54
+ if (ensureEnv(root, services)) ok("Created .env with fresh secrets (keep it out of git; it holds the keys to your data)");
55
+ ensureEngineImage();
56
+
57
+ await task("Building the app image", async () => {
58
+ const r = await compose(root, ["build", ...(opts.rebuild ? ["--no-cache"] : [])]);
59
+ if (!r.ok) throw new Error(r.err || r.out);
60
+ }).catch((e: Error) => fail(`The app image didn't build. The end of the output:\n\n${e.message.split("\n").slice(-15).join("\n")}`));
61
+
62
+ let upResult: { ok: boolean; err: string } | undefined;
63
+ await task(`Starting ${services.length ? `the app and ${services.filter((s) => s !== "caddy").join(", ") || "its proxy"}` : "the app"}`, async () => {
64
+ upResult = await compose(root, ["up", "-d", "--no-build", "--wait", "--wait-timeout", "240"]);
65
+ if (!upResult.ok) throw new Error(upResult.err);
66
+ }).catch(async () => {
67
+ console.error(`\n${upResult?.err.split("\n").slice(-6).join("\n")}\n`);
68
+ const logs = await compose(root, ["logs", "--tail", "30", "app"]);
69
+ console.error(c.dim(logs.out || logs.err));
70
+ fail("The stack didn't become healthy. The app's last log lines are above; `archlast-forge logs` follows them.");
71
+ });
72
+
73
+ const url = envValue(root, "BETTER_AUTH_URL") ?? "http://localhost:3000";
74
+ banner(`${c.green("●")} ${c.bold("up")}`, [
75
+ ["App", url],
76
+ ["Dashboard", `${url}/_dashboard`],
77
+ ["Admin key", "ADMIN_KEY in .env"],
78
+ ["Logs", "archlast-forge logs"],
79
+ ["Stop", "archlast-forge down"],
80
+ ]);
81
+ if (envValue(root, "ALLOW_INSECURE_HTTP") === "1" && !services.includes("caddy")) warn("Serving plain http, which is fine for trying it out. Put it behind https (add Caddy, or your own proxy) before real users arrive.");
82
+ }
83
+
84
+ export async function down(root: string, opts: { volumes?: boolean } = {}) {
85
+ requireStack(root);
86
+ const r = await task(opts.volumes ? "Stopping the stack and deleting its data" : "Stopping the stack", () => compose(root, ["down", ...(opts.volumes ? ["--volumes"] : [])]));
87
+ if (!r.ok) fail(r.err);
88
+ if (!opts.volumes) console.log(c.dim(" Data stays in Docker volumes. `archlast-forge down --volumes` deletes it."));
89
+ }
90
+
91
+ /** True when the project's stack has containers (running or not). */
92
+ export async function hasStack(root: string): Promise<boolean> {
93
+ if (!existsSync(composeFile(root)) || !dockerAvailable()) return false;
94
+ const r = await compose(root, ["ps", "-a", "-q"]);
95
+ return r.ok && r.out.length > 0;
96
+ }
97
+
98
+ export async function logs(root: string, service?: string) {
99
+ requireStack(root);
100
+ await compose(root, ["logs", "--follow", "--tail", "100", ...(service ? [service] : [])], { inherit: true });
101
+ }
102
+
103
+ export async function status(root: string) {
104
+ requireStack(root);
105
+ await compose(root, ["ps", "-a"], { inherit: true });
106
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Terminal output: colors when there's a terminal to show them, and a spinner that becomes plain lines in logs and CI.
2
+
3
+ const tty = Boolean(process.stdout.isTTY);
4
+ const color = (tty && !process.env.NO_COLOR && process.env.TERM !== "dumb") || Boolean(process.env.FORCE_COLOR);
5
+ const wrap = (open: number, close: number) => (s: string) => (color ? `\x1b[${open}m${s}\x1b[${close}m` : s);
6
+
7
+ export const c = { bold: wrap(1, 22), dim: wrap(2, 22), red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39), cyan: wrap(36, 39), magenta: wrap(35, 39) };
8
+
9
+ export const ok = (message: string) => console.log(`${c.green("✓")} ${message}`);
10
+ export const warn = (message: string) => console.log(`${c.yellow("!")} ${message}`);
11
+ export const step = (message: string) => console.log(`${c.cyan("→")} ${message}`);
12
+
13
+ /** A heading and aligned rows, e.g. the addresses a server answers on. */
14
+ export function banner(title: string, rows: [label: string, value: string][]) {
15
+ const width = Math.max(...rows.map(([l]) => l.length));
16
+ console.log(`\n ${title}\n`);
17
+ for (const [label, value] of rows) console.log(` ${c.dim("➜")} ${c.bold(label.padEnd(width))} ${value}`);
18
+ console.log();
19
+ }
20
+
21
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
22
+
23
+ /**
24
+ * Shows `message` with a spinner while `work` runs, then a ✓ or ✗ line. In a log (no terminal) it prints the message once.
25
+ * `work` gets a function to change the message.
26
+ */
27
+ export async function task<T>(message: string, work: (update: (message: string) => void) => Promise<T>): Promise<T> {
28
+ const began = performance.now();
29
+ let text = message;
30
+ let frame = 0;
31
+ const draw = () => process.stdout.write(`\r\x1b[2K${c.cyan(FRAMES[frame++ % FRAMES.length]!)} ${text}`);
32
+ const timer = tty ? setInterval(draw, 80) : undefined;
33
+ if (tty) draw();
34
+ else console.log(`→ ${message}`);
35
+ const took = () => c.dim(` ${((performance.now() - began) / 1000).toFixed(1)}s`);
36
+ try {
37
+ const result = await work((m) => (text = m));
38
+ if (timer) clearInterval(timer);
39
+ if (tty) process.stdout.write(`\r\x1b[2K`);
40
+ console.log(`${c.green("✓")} ${text}${took()}`);
41
+ return result;
42
+ } catch (e) {
43
+ if (timer) clearInterval(timer);
44
+ if (tty) process.stdout.write(`\r\x1b[2K`);
45
+ console.log(`${c.red("✗")} ${text}`);
46
+ throw e;
47
+ }
48
+ }