@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/engine.ts ADDED
@@ -0,0 +1,134 @@
1
+ // Runs inside the engine container. The host's forge/ is mounted read-only at SRC; the engine keeps a copy at APP
2
+ // (its own filesystem), installs its dependencies there for Linux, and serves it.
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative } from "node:path";
5
+ import type { AppHost } from "@archlast/forge-runtime/server";
6
+ import { loadFramework } from "./app";
7
+ import { fail, generate } from "./common";
8
+ import { loadProjectConfig } from "./project";
9
+ import { serve } from "./serve";
10
+
11
+ export const SRC = process.env.ARCHLAST_SRC ?? "/src/forge";
12
+ export const APP = process.env.ARCHLAST_APP ?? "/app/forge";
13
+ const ENGINE_PACKAGES = new URL("../..", import.meta.url).pathname.replace(/\/$/, "");
14
+ const ENGINE_RUNTIME = join(ENGINE_PACKAGES, "runtime");
15
+ /** The project around forge/: where a full-stack app's app/, public/ and package.json are (mounted or copied in at SRC's parent), and where they run from. */
16
+ const PROJECT_SRC = dirname(SRC);
17
+ const PROJECT_APP = dirname(APP);
18
+
19
+ const skipped = (rel: string) => rel.split("/").some((seg) => seg === "node_modules" || seg === "data" || (seg.startsWith(".") && seg !== "."));
20
+
21
+ /** Copies the project into the engine's filesystem; removed files are removed too. */
22
+ export function sync(files?: string[]) {
23
+ mkdirSync(APP, { recursive: true });
24
+ if (!files) {
25
+ cpSync(SRC, APP, { recursive: true, force: true, filter: (src) => !skipped(relative(SRC, src)) });
26
+ return;
27
+ }
28
+ for (const rel of files) {
29
+ if (skipped(rel)) continue;
30
+ const from = join(SRC, rel);
31
+ const to = join(APP, rel);
32
+ if (existsSync(from)) {
33
+ mkdirSync(dirname(to), { recursive: true });
34
+ cpSync(from, to, { force: true });
35
+ } else rmSync(to, { force: true });
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Installs forge/package.json's dependencies inside the container (native packages build for Linux).
41
+ * @archlast/forge-* and workspace: entries are left out: the engine provides the runtime itself.
42
+ */
43
+ export function installDeps() {
44
+ const pkgFile = join(SRC, "package.json");
45
+ const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, "utf8")) : {};
46
+ const keep = (deps: Record<string, string> = {}) =>
47
+ Object.fromEntries(Object.entries(deps).filter(([name, spec]) => !name.startsWith("@archlast/forge-") && !String(spec).startsWith("workspace:")));
48
+ const manifest = { name: "forge-app", private: true, type: "module", dependencies: keep(pkg.dependencies), trustedDependencies: pkg.trustedDependencies };
49
+ writeFileSync(join(APP, "package.json"), JSON.stringify(manifest, null, 2));
50
+ const args = ["install", ...(pkg.trustedDependencies ? [] : ["--ignore-scripts"])];
51
+ console.log(`→ installing server dependencies: bun ${args.join(" ")}`);
52
+ let proc = Bun.spawnSync([process.execPath, ...args], { cwd: APP, stdout: "inherit", stderr: "inherit" });
53
+ const lock = ["bun.lock", "bun.lockb"].map((f) => join(APP, f)).find(existsSync);
54
+ if (proc.exitCode !== 0 && lock) {
55
+ // The lockfile came from the host, where forge/package.json also listed @archlast/forge-* packages (possibly from a local path) that this copy leaves out.
56
+ console.log("! the copied lockfile didn't fit; installing again without it");
57
+ rmSync(lock);
58
+ proc = Bun.spawnSync([process.execPath, ...args], { cwd: APP, stdout: "inherit", stderr: "inherit" });
59
+ }
60
+ if (proc.exitCode !== 0) throw new Error(`bun install failed (exit ${proc.exitCode}). Fix forge/package.json; native packages may need "trustedDependencies".`);
61
+ const link = join(APP, "node_modules", "@archlast", "forge-runtime");
62
+ mkdirSync(dirname(link), { recursive: true });
63
+ rmSync(link, { recursive: true, force: true });
64
+ symlinkSync(ENGINE_RUNTIME, link);
65
+ }
66
+
67
+ /** Packages the engine provides itself, so an app and the engine always share one copy of each. */
68
+ const provided = (name: string) => name.startsWith("@archlast/forge-") || name === "react" || name === "react-dom";
69
+
70
+ /**
71
+ * A full-stack app: copy app/ and public/ in, install the dependencies the engine doesn't provide, link the ones it does
72
+ * (react among them: one copy, or hooks break), and build the browser and server bundles.
73
+ */
74
+ async function prepareApp() {
75
+ for (const f of ["app", "public", "tsconfig.json", "archlast-forge.json"]) if (existsSync(join(PROJECT_SRC, f))) cpSync(join(PROJECT_SRC, f), join(PROJECT_APP, f), { recursive: true, force: true });
76
+ const pkgFile = join(PROJECT_SRC, "package.json");
77
+ const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, "utf8")) : {};
78
+ const dependencies = Object.fromEntries(Object.entries<string>(pkg.dependencies ?? {}).filter(([name, spec]) => !provided(name) && !String(spec).startsWith("workspace:")));
79
+ writeFileSync(join(PROJECT_APP, "package.json"), JSON.stringify({ name: "app", private: true, type: "module", dependencies, trustedDependencies: pkg.trustedDependencies }, null, 2));
80
+ if (Object.keys(dependencies).length) {
81
+ console.log(`→ installing the app's dependencies: bun install (${Object.keys(dependencies).join(", ")})`);
82
+ const proc = Bun.spawnSync([process.execPath, "install", ...(pkg.trustedDependencies ? [] : ["--ignore-scripts"])], { cwd: PROJECT_APP, stdout: "inherit", stderr: "inherit" });
83
+ if (proc.exitCode !== 0) throw new Error(`bun install failed for the app (exit ${proc.exitCode}). Fix package.json.`);
84
+ }
85
+ const link = (target: string, name: string) => {
86
+ const at = join(PROJECT_APP, "node_modules", name);
87
+ mkdirSync(dirname(at), { recursive: true });
88
+ rmSync(at, { recursive: true, force: true });
89
+ symlinkSync(target, at);
90
+ };
91
+ for (const name of ["framework", "react", "client", "runtime"]) link(join(ENGINE_PACKAGES, name), `@archlast/forge-${name}`);
92
+ for (const name of ["react", "react-dom"]) link(dirname(realpathSync(Bun.resolveSync(`${name}/package.json`, join(ENGINE_PACKAGES, "framework")))), name);
93
+
94
+ await generate(APP);
95
+ const { buildApp } = await loadFramework(PROJECT_APP, "build");
96
+ const { manifest } = await buildApp({ root: PROJECT_APP, dev: false }).catch((e: Error) => fail(e.message));
97
+ console.log(`✓ built the app (${Object.keys(manifest.routes).length} routes)`);
98
+ }
99
+
100
+ /** `engine prepare`: used when building an app image, so the container starts without installing or building. */
101
+ export async function prepare() {
102
+ if (!existsSync(join(SRC, "schema.ts"))) fail(`No schema.ts in ${SRC}`);
103
+ sync();
104
+ installDeps();
105
+ if (loadProjectConfig(SRC)?.mode === "framework") await prepareApp();
106
+ }
107
+
108
+ /** `engine serve`: the container's main process. */
109
+ export async function engineServe(dev: boolean) {
110
+ if (!existsSync(join(SRC, "schema.ts"))) fail(`No schema.ts in ${SRC}. Mount your forge/ directory there.`);
111
+ sync();
112
+ if (!existsSync(join(APP, "node_modules", "@archlast", "forge-runtime"))) installDeps();
113
+ const env = { ...process.env, DATA_DIR: process.env.DATA_DIR ?? "/data" };
114
+
115
+ let app: AppHost | undefined;
116
+ if (loadProjectConfig(SRC)?.mode === "framework") {
117
+ if (dev) fail("A full-stack app runs on your machine in development: `archlast-forge dev` (it uses Docker only for services).");
118
+ if (!existsSync(join(PROJECT_APP, ".forge", "build", "latest.json"))) await prepareApp();
119
+ app = await (await loadFramework(PROJECT_APP, "server")).createApp({ root: PROJECT_APP, dev: false });
120
+ }
121
+ const server = await serve({
122
+ forgeDir: APP,
123
+ dev,
124
+ env,
125
+ watchDir: dev ? SRC : undefined,
126
+ app,
127
+ onChange: async (files) => {
128
+ sync(files);
129
+ if (files.some((f) => f.endsWith("package.json"))) installDeps();
130
+ return true;
131
+ },
132
+ });
133
+ console.log(`✓ archlast-forge engine on ${server.url} (${dev ? "development" : "production"}${app ? ", with the app" : ""})`);
134
+ }
package/src/index.ts ADDED
@@ -0,0 +1,493 @@
1
+ #!/usr/bin/env bun
2
+ import { ops } from "@archlast/forge-runtime/server";
3
+ import { watch } from "node:fs";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { parseArgs, type ParseArgsConfig } from "node:util";
6
+ import { buildFramework, loadFramework } from "./app";
7
+ import { devSecrets, fail, findForgeDir, generate, trustProxyOrigins, VERSION } from "./common";
8
+ import { ensureDeps, findUp } from "./deps";
9
+ import * as dockerEngine from "./docker";
10
+ import { engineServe, prepare } from "./engine";
11
+ import { loadProjectConfig, projectRoot, type ProjectConfig } from "./project";
12
+ import { serve } from "./serve";
13
+ import * as stack from "./stack";
14
+ import { banner, c, ok, step, warn } from "./ui";
15
+
16
+ // `init` has its own options and prompts: hand everything after it to the scaffolder.
17
+ if (Bun.argv[2] === "init") {
18
+ await (await import("create-archlast-forge")).main(Bun.argv.slice(3));
19
+ process.exit(0);
20
+ }
21
+
22
+ const USAGE = `${c.bold("archlast-forge")} ${VERSION}
23
+
24
+ ${c.bold("Start")}
25
+ init [dir] create a project: a full-stack app or a backend, with the services you pick
26
+ --mode <framework|backend> --services <postgres,redis,s3,caddy | none> --yes --no-install --no-git
27
+
28
+ ${c.bold("Develop")}
29
+ dev run with live reload: a full-stack app on this machine, a backend's engine in Docker
30
+ --port <n> port to listen on (default 3000)
31
+ --with <list> services to start: postgres, redis, s3 (default: the ones in archlast-forge.json)
32
+ --no-docker run a backend's engine in this process
33
+ --run "<cmd>" also run a command alongside, e.g. a separate frontend
34
+ codegen write forge/_generated/ and check what your frontend imports
35
+
36
+ ${c.bold("Ship")}
37
+ up build the image and start the stack in compose.yaml: the app and its services
38
+ down [--volumes] stop the stack (--volumes also deletes its data)
39
+ logs [service] follow the logs
40
+ status show what is running
41
+ build [--tag <image>] build the app image (--no-docker: only the production bundles of a full-stack app)
42
+ start run in production: --no-docker here, or --image <tag> in Docker
43
+
44
+ ${c.bold("Data")}
45
+ migrate sync tables and indexes; report documents that don't match the schema
46
+ --run-migrations [name…] run data migrations (resumable) --status list them
47
+ export <file> all documents as JSON Lines
48
+ import <file> restore an export (--replace clears the tables first)
49
+ backup <file> SQLite database copy (use pg_dump for Postgres)
50
+
51
+ ${c.bold("Config")}
52
+ env list | env get <NAME> | env set <NAME> <value> | env rm <NAME>
53
+ admin add <email> [--role owner|admin|viewer] [--password <pw>] | admin list | admin remove <email>
54
+ dashboard print the dashboard URL
55
+
56
+ ${c.bold("Engine")}
57
+ engine build build the engine image from this repository
58
+ stop stop a single engine container (older projects)
59
+
60
+ Options: --dir <forge dir> --port <n> --no-docker`;
61
+
62
+ /** A mistyped option is a one-line message, not a stack trace. */
63
+ function parseCommandLine<T extends ParseArgsConfig>(config: T) {
64
+ try {
65
+ return parseArgs(config);
66
+ } catch (e) {
67
+ return fail(`${(e as Error).message.replace(/\.$/, "")}. Run \`archlast-forge --help\` to see the options.`);
68
+ }
69
+ }
70
+
71
+ const { positionals, values } = parseCommandLine({
72
+ args: Bun.argv.slice(2),
73
+ allowPositionals: true,
74
+ options: {
75
+ dir: { type: "string" },
76
+ port: { type: "string" },
77
+ "no-docker": { type: "boolean" },
78
+ docker: { type: "boolean" },
79
+ with: { type: "string" },
80
+ tag: { type: "string" },
81
+ image: { type: "string" },
82
+ "run-migrations": { type: "boolean" },
83
+ status: { type: "boolean" },
84
+ replace: { type: "boolean" },
85
+ role: { type: "string" },
86
+ password: { type: "string" },
87
+ "env-file": { type: "string" },
88
+ run: { type: "string" },
89
+ volumes: { type: "boolean" },
90
+ rebuild: { type: "boolean" },
91
+ dev: { type: "boolean" },
92
+ help: { type: "boolean", short: "h" },
93
+ version: { type: "boolean", short: "v" },
94
+ },
95
+ });
96
+ if (values.version) {
97
+ console.log(VERSION);
98
+ process.exit(0);
99
+ }
100
+ const [command, ...rest] = positionals;
101
+ const port = Number(values.port ?? process.env.PORT ?? 3000);
102
+ const inContainer = process.env.ARCHLAST_ENGINE === "1";
103
+
104
+ /** Docker unless told otherwise, or unavailable (with a hint), or already inside the engine. */
105
+ function useDocker() {
106
+ if (values["no-docker"] || inContainer || process.env.ARCHLAST_NO_DOCKER === "1") return false;
107
+ if (dockerEngine.dockerAvailable()) return true;
108
+ if (values.docker) fail("Docker isn't available. Install it, or pass --no-docker to run in this process.");
109
+ console.log("! Docker isn't available; running in this process (--no-docker)");
110
+ return false;
111
+ }
112
+
113
+ function devEnv(dir: string) {
114
+ const env = devSecrets({ ...process.env }, join(process.env.DATA_DIR ?? join(dir, "data")));
115
+ env.DATA_DIR ??= join(dir, "data");
116
+ // The app's own address is trusted, and a proxy in front of it (code-server's port forwarding) is worked out from that one.
117
+ env.TRUSTED_ORIGINS ||= `http://localhost:${port}`;
118
+ env.BETTER_AUTH_URL = `http://localhost:${port}`; // a project's .env (made for the stack) must not point sessions at another address
119
+ trustProxyOrigins(env);
120
+ return env;
121
+ }
122
+
123
+ /** `--run "vite"`: a companion process for the frontend, stopped with the CLI. */
124
+ function companion() {
125
+ if (!values.run) return;
126
+ const proc = Bun.spawn(["sh", "-c", values.run], { stdout: "inherit", stderr: "inherit", stdin: "inherit" });
127
+ process.on("exit", () => proc.kill());
128
+ }
129
+
130
+ /** The services `dev` starts: --with, or the ones in archlast-forge.json (Caddy is only for production). */
131
+ function devServices(cfg?: ProjectConfig): dockerEngine.Service[] {
132
+ const wanted = values.with ? values.with.split(",").map((x) => x.trim()) : (cfg?.services ?? []);
133
+ return wanted.filter((x) => x !== "caddy" && x !== "none") as dockerEngine.Service[];
134
+ }
135
+
136
+ /** Regenerates types and installs dependencies when forge/ changes; false means nothing needs a reload. */
137
+ function forgeChanged(dir: string) {
138
+ return async (files: string[]) => {
139
+ const source = files.filter((f) => !f.includes("_generated"));
140
+ if (!source.length) return false; // our own codegen output
141
+ const installed = source.some((f) => ["package.json", "bun.lock"].includes(basename(f))) && (await ensureDeps(dir));
142
+ const code = source.some((f) => /\.(ts|js)$/.test(f));
143
+ if (!installed && !code) return false;
144
+ await generate(dir);
145
+ return true;
146
+ };
147
+ }
148
+
149
+ /** A full-stack app: the engine and the app in this process, services (if any) in Docker with ports on localhost. */
150
+ async function devFramework(dir: string, cfg: ProjectConfig) {
151
+ const began = performance.now();
152
+ const root = projectRoot(dir);
153
+ const env = devEnv(dir);
154
+ await ensureDeps(dir);
155
+ ok(`codegen: ${(await generate(dir)).length} modules`);
156
+
157
+ const services = devServices(cfg);
158
+ let where: string[] = [];
159
+ const noDocker = Boolean(values["no-docker"] || process.env.ARCHLAST_NO_DOCKER === "1");
160
+ if (services.length && noDocker) warn(`--no-docker: not starting ${services.join(", ")}; using SQLite. Set DATABASE_URL, REDIS_URL or S3_* yourself to use others.`);
161
+ else if (services.length) {
162
+ if (!dockerEngine.dockerAvailable()) fail(`This project uses ${services.join(", ")}, which run in Docker, and Docker isn't available.\nStart Docker, or run \`archlast-forge dev --no-docker\` to develop on SQLite.`);
163
+ step(`starting ${services.join(", ")}`);
164
+ const started = dockerEngine.startServices(dockerEngine.containerName(dir, true), services, { host: true });
165
+ Object.assign(env, started.env);
166
+ where = started.where;
167
+ process.on("exit", dockerEngine.stopStartedServices); // Ctrl-C ends the session, so it ends its services too
168
+ }
169
+
170
+ companion(); // --run "<cmd>"
171
+ const { createApp } = await loadFramework(root, "server");
172
+ const app = await createApp({ root, dev: true });
173
+ const server = await serve({ forgeDir: dir, dev: true, env: { ...env, PORT: String(port) }, app, watchDir: dir, onChange: forgeChanged(dir) });
174
+ banner(`${c.bold("archlast-forge")} ${VERSION} ${c.dim("·")} full-stack app ${c.dim(`· ready in ${Math.round(performance.now() - began)} ms`)}`, [
175
+ ["App", server.url],
176
+ ["Dashboard", `${server.url}/_dashboard`],
177
+ ["Admin key", env.ADMIN_KEY!],
178
+ ...where.map((w): [string, string] => ["Service", w]),
179
+ ]);
180
+ console.log(c.dim(" Save a file and the page reloads. Ctrl-C stops everything.\n"));
181
+ const lock = findUp(dir, "bun.lock");
182
+ if (lock && !lock.startsWith(dir)) watch(lock, () => void ensureDeps(dir).then(async (did) => void (did && (await server.reload()))));
183
+ }
184
+
185
+ async function dev() {
186
+ const dir = findForgeDir(values.dir);
187
+ const cfg = loadProjectConfig(dir);
188
+ if (cfg?.mode === "framework") return devFramework(dir, cfg);
189
+
190
+ const env = devEnv(dir);
191
+ companion();
192
+ await ensureDeps(dir); // host install: editor types; the engine installs its own copy
193
+ ok(`codegen: ${(await generate(dir)).length} modules`);
194
+
195
+ if (useDocker()) {
196
+ const name = dockerEngine.runEngine({
197
+ forgeDir: dir,
198
+ port,
199
+ dev: true,
200
+ env: { BETTER_AUTH_SECRET: env.BETTER_AUTH_SECRET!, ADMIN_KEY: env.ADMIN_KEY!, BETTER_AUTH_URL: `http://localhost:${port}`, TRUSTED_ORIGINS: env.TRUSTED_ORIGINS ?? "" },
201
+ services: devServices(cfg),
202
+ });
203
+ banner(`${c.bold("archlast-forge")} ${VERSION} ${c.dim("·")} backend ${c.dim("· engine in Docker")}`, [
204
+ ["API", `http://localhost:${port}`],
205
+ ["Dashboard", `http://localhost:${port}/_dashboard`],
206
+ ["Admin key", env.ADMIN_KEY!],
207
+ ["Container", name],
208
+ ]);
209
+ // The engine watches the mounted source; the host only keeps codegen current.
210
+ watchSource(dir, async (files) => {
211
+ if (files.some((f) => ["package.json", "bun.lock"].includes(basename(f)))) await ensureDeps(dir);
212
+ await generate(dir);
213
+ });
214
+ await dockerEngine.attach(name, true);
215
+ console.log(`${name} stopped`);
216
+ process.exit(0); // the container is gone; don't linger on the file watcher
217
+ }
218
+
219
+ const skipped = devServices(cfg);
220
+ if (skipped.length) warn(`Not starting ${skipped.join(", ")} without Docker; using SQLite. Set DATABASE_URL, REDIS_URL or S3_* yourself to use others.`);
221
+ const server = await serve({ forgeDir: dir, dev: true, env: { ...env, PORT: String(port) }, watchDir: dir, onChange: forgeChanged(dir) });
222
+ banner(`${c.bold("archlast-forge")} ${VERSION} ${c.dim("·")} backend ${c.dim("· in this process")}`, [
223
+ ["API", server.url],
224
+ ["Dashboard", `${server.url}/_dashboard`],
225
+ ["Admin key", env.ADMIN_KEY!],
226
+ ]);
227
+ const lock = findUp(dir, "bun.lock");
228
+ if (lock && !lock.startsWith(dir)) watch(lock, () => void ensureDeps(dir).then(async (did) => void (did && (await server.reload()))));
229
+ }
230
+
231
+ function watchSource(dir: string, onChange: (files: string[]) => Promise<void>) {
232
+ let timer: Timer | undefined;
233
+ const changed = new Set<string>();
234
+ watch(dir, { recursive: true }, (_e, file) => {
235
+ if (!file || /(^|\/)(_generated|node_modules|data|\.)/.test(file)) return;
236
+ changed.add(file);
237
+ clearTimeout(timer);
238
+ timer = setTimeout(() => {
239
+ const files = [...changed];
240
+ changed.clear();
241
+ onChange(files).catch((e) => console.error(`✗ ${e.message}`));
242
+ }, 150);
243
+ });
244
+ }
245
+
246
+ /** Where data and config commands run: inside the running engine container, or in this process. */
247
+ function engineTarget(dir: string) {
248
+ if (!useDocker()) return null;
249
+ // The app of a compose stack, then a single engine container.
250
+ return stack.appContainer(projectRoot(dir)) ?? dockerEngine.runningContainer(dir) ?? null;
251
+ }
252
+
253
+ /**
254
+ * Runs a data command in the running server (imports reach its subscribers), or here when no server answers.
255
+ * Reaching the server needs ADMIN_KEY (the environment, or the dev secrets).
256
+ */
257
+ async function data(cmd: string, args: Record<string, unknown> = {}): Promise<any> {
258
+ const dir = findForgeDir(values.dir);
259
+ const key = process.env.ADMIN_KEY ?? devEnv(dir).ADMIN_KEY;
260
+ const res = await fetch(`http://localhost:${port}/_cli`, {
261
+ method: "POST",
262
+ headers: { "x-admin-key": key!, "content-type": "application/json" },
263
+ body: JSON.stringify({ cmd, args }),
264
+ }).catch(() => null);
265
+ if (res && res.status !== 404) {
266
+ const body = (await res.json().catch(() => ({}))) as { result?: unknown; message?: string };
267
+ if (res.status === 401) fail(`The server on port ${port} rejected the admin key; set ADMIN_KEY to the one it runs with.`);
268
+ if (!res.ok) fail(body.message ?? `The server answered ${res.status}`);
269
+ return body.result;
270
+ }
271
+ const env = { ...process.env, DATA_DIR: process.env.DATA_DIR ?? join(dir, "data") };
272
+ try {
273
+ if (/^(env|admin)\./.test(cmd)) {
274
+ const db = await ops.openDb(env);
275
+ try {
276
+ return await ops.runCommand({ db }, cmd, args);
277
+ } finally {
278
+ await db.close();
279
+ }
280
+ }
281
+ const p = await ops.openProject(dir, env);
282
+ try {
283
+ return await ops.runCommand(p, cmd, args);
284
+ } finally {
285
+ await p.close();
286
+ }
287
+ } catch (e) {
288
+ return fail((e as Error).message);
289
+ }
290
+ }
291
+
292
+ /** Runs this command inside the engine container if one is up for the project. */
293
+ function forwarded(dir: string, args: string[]) {
294
+ const target = engineTarget(dir);
295
+ if (!target) return false;
296
+ const r = dockerEngine.execInEngine(target, args);
297
+ if (!r.ok) process.exit(1);
298
+ return true;
299
+ }
300
+
301
+ /** Applies config changes to a running server: SIGHUP the container, or ask a local server to reload. */
302
+ async function reloadRunning(dir: string) {
303
+ const target = engineTarget(dir);
304
+ if (target) {
305
+ dockerEngine.docker(["kill", "--signal", "HUP", target]);
306
+ return console.log("✓ engine reloaded");
307
+ }
308
+ const key = process.env.ADMIN_KEY ?? devEnv(dir).ADMIN_KEY;
309
+ const res = await fetch(`http://localhost:${port}/_admin/reload`, { method: "POST", headers: { "x-admin-key": key!, "x-forge-admin": "1" } }).catch(() => null);
310
+ console.log(res?.ok ? "✓ running server reloaded" : " (no running server picked it up; it applies on next start)");
311
+ }
312
+
313
+ const commands: Record<string, () => Promise<unknown>> = {
314
+ dev,
315
+ async codegen() {
316
+ const dir = findForgeDir(values.dir);
317
+ await ensureDeps(dir).catch((e) => fail(e.message));
318
+ const modules = await generate(dir).catch((e) => fail(e.message));
319
+ console.log(`✓ wrote ${join(dir, "_generated")} (${modules.length} modules)`);
320
+ },
321
+
322
+ async build() {
323
+ const dir = findForgeDir(values.dir);
324
+ const cfg = loadProjectConfig(dir);
325
+ await generate(dir);
326
+ if (!useDocker()) {
327
+ if (cfg?.mode !== "framework") return console.log("Nothing to build without Docker: a backend runs straight from forge/ (`archlast-forge start --no-docker`).");
328
+ await buildFramework(projectRoot(dir));
329
+ return console.log(`\nRun it here with: archlast-forge start --no-docker`);
330
+ }
331
+ const tag = values.tag ?? `${dockerEngine.projectName(dir)}:latest`;
332
+ dockerEngine.buildAppImage(dir, tag);
333
+ ok(`built ${tag}`);
334
+ console.log(` run the whole stack: archlast-forge up\n or just this image: archlast-forge start --image ${tag} (or docker run -p 3000:3000 -v data:/data --env-file .env ${tag})`);
335
+ },
336
+ async up() {
337
+ const dir = findForgeDir(values.dir);
338
+ const cfg = loadProjectConfig(dir) ?? fail("This folder has no archlast-forge.json. Create a project with `archlast-forge init`, or run a single image with `archlast-forge build` and `start --image`.");
339
+ await ensureDeps(dir).catch((e) => fail(e.message));
340
+ await generate(dir).catch((e) => fail(e.message));
341
+ await stack.up(projectRoot(dir), cfg.services, { rebuild: values.rebuild });
342
+ },
343
+ async down() {
344
+ await stack.down(projectRoot(findForgeDir(values.dir)), { volumes: values.volumes });
345
+ },
346
+ async start() {
347
+ const dir = findForgeDir(values.dir);
348
+ const cfg = loadProjectConfig(dir);
349
+ if (useDocker()) {
350
+ // A project with a stack starts as a stack; --image runs one image on its own.
351
+ if (cfg && !values.image) return commands.up!();
352
+ const image = values.image ?? fail("Pass --image <tag> (from `archlast-forge build`), or --no-docker to run in this process.");
353
+ const name = dockerEngine.runEngine({
354
+ forgeDir: dir,
355
+ port,
356
+ dev: false,
357
+ env: {},
358
+ image,
359
+ envFile: values["env-file"],
360
+ services: values.with ? (values.with.split(",") as dockerEngine.Service[]) : [],
361
+ });
362
+ return ok(`${name} running ${image} on port ${port} (restarts automatically; \`archlast-forge logs\` to follow)`);
363
+ }
364
+ let app;
365
+ if (cfg?.mode === "framework") {
366
+ const root = projectRoot(dir);
367
+ app = await (await loadFramework(root, "server")).createApp({ root, dev: false }).catch((e: Error) => fail(e.message.includes("No production build") ? `${e.message}` : e.message));
368
+ }
369
+ // Next to forge/, where `dev` keeps it, unless DATA_DIR says otherwise.
370
+ const server = await serve({ forgeDir: dir, dev: false, env: { ...process.env, DATA_DIR: process.env.DATA_DIR ?? join(dir, "data"), PORT: String(port) }, app });
371
+ ok(`archlast-forge on ${server.url}${app ? " (app and API)" : ""}`);
372
+ },
373
+ async stop() {
374
+ const dir = findForgeDir(values.dir);
375
+ if (await stack.hasStack(projectRoot(dir))) return stack.down(projectRoot(dir));
376
+ for (const name of [dockerEngine.containerName(dir), dockerEngine.containerName(dir, true)])
377
+ if (dockerEngine.isRunning(name)) dockerEngine.docker(["stop", "-t", "15", name], { inherit: true });
378
+ },
379
+ async logs() {
380
+ const dir = findForgeDir(values.dir);
381
+ if (await stack.hasStack(projectRoot(dir))) return stack.logs(projectRoot(dir), rest[0]);
382
+ const name = dockerEngine.runningContainer(dir) ?? fail("Nothing is running for this project (`archlast-forge up` starts the stack)");
383
+ await dockerEngine.attach(name, false);
384
+ },
385
+ async status() {
386
+ const dir = findForgeDir(values.dir);
387
+ if (await stack.hasStack(projectRoot(dir))) return stack.status(projectRoot(dir));
388
+ const name = dockerEngine.containerName(dir);
389
+ const r = dockerEngine.docker(["ps", "-a", "--filter", `name=^${name}`, "--format", "{{.Names}}\t{{.Status}}\t{{.Ports}}"]);
390
+ console.log(r.out || `no containers for ${name}`);
391
+ },
392
+
393
+ async migrate() {
394
+ const dir = findForgeDir(values.dir);
395
+ if (forwarded(dir, ["migrate", ...rest, ...(values["run-migrations"] ? ["--run-migrations"] : []), ...(values.status ? ["--status"] : [])])) return;
396
+ if (values.status) {
397
+ for (const m of await data("migrate.status")) console.log(`${m.state.padEnd(8)} ${m.name} (${m.table}, ${m.processed} processed)${m.error ? ` ✗ ${m.error}` : ""}`);
398
+ return;
399
+ }
400
+ if (values["run-migrations"]) {
401
+ const ran: string[] = await data("migrate.run", { names: rest });
402
+ return console.log(ran.length ? `✓ ran ${ran.join(", ")}` : "✓ no pending migrations");
403
+ }
404
+ const { tables, invalid } = await data("migrate.check");
405
+ console.log(`✓ migrated ${tables} tables`);
406
+ for (const { id, error } of invalid.slice(0, 50)) console.error(` ✗ ${id}: ${error}`);
407
+ if (invalid.length) fail(`${invalid.length} documents don't match the schema; write a migration and run \`archlast-forge migrate --run-migrations\``);
408
+ },
409
+ async export() {
410
+ const file = rest[0] ?? fail("Usage: archlast-forge export <file>");
411
+ const dir = findForgeDir(values.dir);
412
+ const target = engineTarget(dir);
413
+ if (target) {
414
+ if (!dockerEngine.execInEngine(target, ["export", "/tmp/export.jsonl"]).ok) process.exit(1);
415
+ if (!dockerEngine.copyFrom(target, "/tmp/export.jsonl", resolve(file))) fail("docker cp failed");
416
+ return console.log(`✓ copied to ${file}`);
417
+ }
418
+ console.log(`✓ exported ${await data("export", { file: resolve(file) })} documents to ${file}`);
419
+ },
420
+ async import() {
421
+ const file = rest[0] ?? fail("Usage: archlast-forge import <file> [--replace]");
422
+ const dir = findForgeDir(values.dir);
423
+ const target = engineTarget(dir);
424
+ if (target) {
425
+ if (!dockerEngine.copyTo(target, resolve(file), "/tmp/import.jsonl")) fail("docker cp failed");
426
+ if (!dockerEngine.execInEngine(target, ["import", "/tmp/import.jsonl", ...(values.replace ? ["--replace"] : [])]).ok) process.exit(1);
427
+ return;
428
+ }
429
+ console.log(`✓ imported ${await data("import", { file: resolve(file), replace: values.replace })} documents`);
430
+ },
431
+ async backup() {
432
+ const file = rest[0] ?? fail("Usage: archlast-forge backup <file>");
433
+ const dir = findForgeDir(values.dir);
434
+ const target = engineTarget(dir);
435
+ if (target) {
436
+ if (!dockerEngine.execInEngine(target, ["backup", "/tmp/backup.db"]).ok) process.exit(1);
437
+ if (!dockerEngine.copyFrom(target, "/tmp/backup.db", resolve(file))) fail("docker cp failed");
438
+ return console.log(`✓ copied to ${file}`);
439
+ }
440
+ await data("backup", { file: resolve(file) });
441
+ console.log(`✓ backed up to ${file}`);
442
+ },
443
+
444
+ async env() {
445
+ const [action, name, value] = rest;
446
+ const dir = findForgeDir(values.dir);
447
+ if (forwarded(dir, ["env", ...rest])) {
448
+ if (action === "set" || action === "rm") await reloadRunning(dir);
449
+ return;
450
+ }
451
+ if (action === "list" || !action) for (const e of await data("env.list")) console.log(`${e.name}=${e.value}`);
452
+ else if (action === "get") console.log((await data("env.list")).find((e: { name: string }) => e.name === name)?.value ?? fail(`${name} isn't set`));
453
+ else if (action === "set" && name && value !== undefined) await data("env.set", { name, value });
454
+ else if (action === "rm" && name) await data("env.rm", { name });
455
+ else fail("Usage: archlast-forge env list | get <NAME> | set <NAME> <value> | rm <NAME>");
456
+ if (action === "set" || action === "rm") {
457
+ console.log(`✓ ${action === "set" ? "set" : "removed"} ${name}`);
458
+ if (!inContainer) await reloadRunning(dir);
459
+ }
460
+ },
461
+ async admin() {
462
+ const [action, email] = rest;
463
+ const dir = findForgeDir(values.dir);
464
+ const args = ["admin", ...rest, ...(values.role ? ["--role", values.role] : []), ...(values.password ? ["--password", values.password] : [])];
465
+ if (forwarded(dir, args)) return;
466
+ if (action === "list" || !action) for (const a of await data("admin.list")) console.log(`${a.role.padEnd(6)} ${a.email}`);
467
+ else if (action === "add" && email) {
468
+ const password = values.password ?? Buffer.from(crypto.getRandomValues(new Uint8Array(15))).toString("base64url");
469
+ await data("admin.add", { email, password, role: values.role ?? "admin" });
470
+ console.log(`✓ ${email} (${values.role ?? "admin"})${values.password ? "" : ` password: ${password}`}`);
471
+ } else if (action === "remove" && email) {
472
+ await data("admin.remove", { email });
473
+ console.log(`✓ removed ${email}`);
474
+ } else fail("Usage: archlast-forge admin add <email> [--role owner|admin|viewer] [--password <pw>] | admin list | admin remove <email>");
475
+ },
476
+ async dashboard() {
477
+ console.log(`http://localhost:${port}/_dashboard`);
478
+ },
479
+
480
+ async engine() {
481
+ const [action] = rest;
482
+ if (action === "build") return dockerEngine.buildEngine(values.tag);
483
+ if (action === "serve") return engineServe(Boolean(values.dev));
484
+ if (action === "prepare") return prepare();
485
+ fail("Usage: archlast-forge engine build [--tag <image>]");
486
+ },
487
+ };
488
+
489
+ const run = values.help || !command ? undefined : commands[command];
490
+ if (!run) {
491
+ console.log(USAGE);
492
+ if (command && !values.help) process.exit(1);
493
+ } else await run().catch((e) => fail((e as Error).message));
package/src/project.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { parseProjectConfig, type ProjectConfig } from "create-archlast-forge/stack";
4
+
5
+ export type { ProjectConfig };
6
+
7
+ /** The directory that holds forge/ (and app/, compose.yaml, ...). */
8
+ export const projectRoot = (forgeDir: string) => (["forge", "convex"].includes(basename(forgeDir)) ? dirname(forgeDir) : forgeDir);
9
+
10
+ /** archlast-forge.json, or undefined for a project from before it existed (those behave as a backend with no services). */
11
+ export function loadProjectConfig(forgeDir: string): ProjectConfig | undefined {
12
+ const file = join(projectRoot(forgeDir), "archlast-forge.json");
13
+ if (!existsSync(file)) return undefined;
14
+ try {
15
+ return parseProjectConfig(JSON.parse(readFileSync(file, "utf8")), file);
16
+ } catch (e) {
17
+ throw new Error(e instanceof SyntaxError ? `${file} isn't valid JSON: ${e.message}` : (e as Error).message);
18
+ }
19
+ }