@archlast/forge-cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +6 -2
- package/package.json +39 -4
- package/src/app.ts +43 -0
- package/src/codegen.ts +67 -0
- package/src/common.ts +56 -0
- package/src/deps.ts +33 -0
- package/src/docker.ts +256 -0
- package/src/engine.ts +134 -0
- package/src/index.ts +476 -0
- package/src/project.ts +19 -0
- package/src/serve.ts +57 -0
- package/src/stack.ts +97 -0
- package/src/ui.ts +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 algochad
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @archlast/forge-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The archlast-forge command line: dev, up, build, data and config commands.
|
|
4
|
+
|
|
5
|
+
Part of [archlast-forge](https://github.com/algochad/archlast-forge), a self-hosted reactive backend and full-stack framework for TypeScript on Bun. Start with `bun create archlast-forge`; the documentation is in the repository.
|
|
6
|
+
|
|
7
|
+
MIT licensed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@archlast/forge-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The archlast-forge command line: dev, up, build, data and config commands.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/algochad/archlast-forge.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/algochad/archlast-forge#readme",
|
|
12
|
+
"bugs": "https://github.com/algochad/archlast-forge/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"archlast-forge",
|
|
15
|
+
"backend",
|
|
16
|
+
"realtime",
|
|
17
|
+
"bun",
|
|
18
|
+
"convex"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/codegen.ts"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"archlast-forge": "./src/index.ts"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@archlast/forge-dashboard": "^0.1.0",
|
|
29
|
+
"@archlast/forge-runtime": "^0.1.0",
|
|
30
|
+
"create-archlast-forge": "^0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"bun": ">=1.4.3"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Framework mode, from the CLI's side: find the framework package in the project, build the app, and describe the result.
|
|
2
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fail } from "./common";
|
|
5
|
+
import { c, task } from "./ui";
|
|
6
|
+
|
|
7
|
+
type Server = typeof import("@archlast/forge-framework/server");
|
|
8
|
+
type Build = typeof import("@archlast/forge-framework/build");
|
|
9
|
+
type Parts = { server: Server; build: Build };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Loads @archlast/forge-framework/<part> from the project. Inside the engine image the project's copy is a link to the engine's own,
|
|
13
|
+
* and a checkout of this repository finds it next to the CLI.
|
|
14
|
+
*/
|
|
15
|
+
export async function loadFramework<P extends keyof Parts>(root: string, part: P): Promise<Parts[P]> {
|
|
16
|
+
const spec = `@archlast/forge-framework/${part}`;
|
|
17
|
+
const sibling = new URL(`../../framework/src/${part === "server" ? "server.tsx" : "build.ts"}`, import.meta.url).pathname;
|
|
18
|
+
for (const from of [() => Bun.resolveSync(spec, root), () => sibling]) {
|
|
19
|
+
try {
|
|
20
|
+
const path = from();
|
|
21
|
+
if (existsSync(path)) return (await import(path)) as Parts[P];
|
|
22
|
+
} catch {
|
|
23
|
+
// try the next place
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return fail(`This is a full-stack project, which needs @archlast/forge-framework. Install it with:\n bun add @archlast/forge-framework`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const kb = (bytes: number) => `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} kB`;
|
|
30
|
+
|
|
31
|
+
/** Builds the production bundles into <root>/.forge/build and prints what the browser will download. */
|
|
32
|
+
export async function buildFramework(root: string) {
|
|
33
|
+
const { buildApp } = await loadFramework(root, "build");
|
|
34
|
+
const { manifest, dir } = await task("Building the app", () => buildApp({ root, dev: false })).catch((e: Error) => fail(e.message));
|
|
35
|
+
const client = join(dir, "client");
|
|
36
|
+
const size = (file: string, ext = "") => (existsSync(join(client, file + ext)) ? statSync(join(client, file + ext)).size : 0);
|
|
37
|
+
const chunks = readdirSync(client).filter((f) => f.endsWith(".js"));
|
|
38
|
+
const total = chunks.reduce((n, f) => n + size(f), 0);
|
|
39
|
+
const first = [manifest.entry, ...manifest.css];
|
|
40
|
+
console.log(` ${c.dim("first load")} ${first.map((f) => `${f.replace(/-[a-z0-9]+(\.\w+)$/, "$1")} ${kb(size(f))} ${c.dim(`(${kb(size(f, ".br") || size(f, ".gz") || size(f))} compressed)`)}`).join(", ")}`);
|
|
41
|
+
console.log(` ${c.dim("all code ")} ${chunks.length} files, ${kb(total)}; each page loads only the routes it shows`);
|
|
42
|
+
return { manifest, dir };
|
|
43
|
+
}
|
package/src/codegen.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { listModules } from "@archlast/forge-runtime/server";
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const HEADER = "// Generated by `archlast-forge codegen`. Do not edit.\n";
|
|
6
|
+
|
|
7
|
+
type Tree = { [key: string]: Tree | string };
|
|
8
|
+
|
|
9
|
+
const render = (tree: Tree, visibility: string, indent = " "): string =>
|
|
10
|
+
Object.entries(tree)
|
|
11
|
+
.map(
|
|
12
|
+
([k, v]) =>
|
|
13
|
+
`${indent}${JSON.stringify(k)}: ${typeof v === "string" ? `ModuleRefs<typeof ${v}, "${visibility}">` : `{\n${render(v, visibility, indent + " ")}${indent}}`};\n`,
|
|
14
|
+
)
|
|
15
|
+
.join("");
|
|
16
|
+
|
|
17
|
+
/** Writes forge/_generated/{api,dataModel}.ts. Types are derived from the modules, so nothing is hand-written. */
|
|
18
|
+
export async function codegen(forgeDir: string) {
|
|
19
|
+
const modules = listModules(forgeDir);
|
|
20
|
+
const tree: Tree = {};
|
|
21
|
+
const imports = modules.map((m, i) => {
|
|
22
|
+
const id = `m${i}_${m.name.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
23
|
+
const path = m.name.split("/");
|
|
24
|
+
let node = tree;
|
|
25
|
+
for (const seg of path.slice(0, -1)) node = (node[seg] ??= {}) as Tree;
|
|
26
|
+
node[path.at(-1)!] = id;
|
|
27
|
+
return `import type * as ${id} from "../${m.name}";\n`;
|
|
28
|
+
});
|
|
29
|
+
const api =
|
|
30
|
+
HEADER +
|
|
31
|
+
`import { anyApi, type ModuleRefs } from "@archlast/forge-runtime";\n` +
|
|
32
|
+
`import type {} from "./dataModel";\n` +
|
|
33
|
+
imports.join("") +
|
|
34
|
+
`\n/** Public functions, callable from clients. */\nexport const api: {\n${render(tree, "public")}} = anyApi;\n` +
|
|
35
|
+
`\n/** Internal functions: callable only from other functions, the scheduler and crons. */\nexport const internal: {\n${render(tree, "internal")}} = anyApi;\n`;
|
|
36
|
+
const dataModel =
|
|
37
|
+
HEADER +
|
|
38
|
+
`import type schema from "../schema";\n\n` +
|
|
39
|
+
`declare module "@archlast/forge-runtime" {\n interface Register {\n schema: typeof schema;\n }\n}\n\n` +
|
|
40
|
+
`export type { Doc, Id, TableNames } from "@archlast/forge-runtime";\n`;
|
|
41
|
+
const out = join(forgeDir, "_generated");
|
|
42
|
+
mkdirSync(out, { recursive: true });
|
|
43
|
+
// Skip unchanged files so watchers don't loop.
|
|
44
|
+
for (const [file, content] of [["api.ts", api], ["dataModel.ts", dataModel]] as const) {
|
|
45
|
+
const path = join(out, file);
|
|
46
|
+
if (!existsSync(path) || (await Bun.file(path).text()) !== content) await Bun.write(path, content);
|
|
47
|
+
}
|
|
48
|
+
return modules.map((m) => m.name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Frontend code (src/ in an app with its own bundler, app/ in framework mode) may import only forge/_generated, or server-only code would reach the browser bundle. */
|
|
52
|
+
export async function checkFrontendImports(forgeDir: string) {
|
|
53
|
+
const generated = join(forgeDir, "_generated");
|
|
54
|
+
const violations: string[] = [];
|
|
55
|
+
for (const dir of ["src", "app"].map((d) => join(dirname(forgeDir), d)).filter(existsSync)) {
|
|
56
|
+
for (const file of new Bun.Glob("**/*.{ts,tsx,js,jsx,mts}").scanSync({ cwd: dir, absolute: true })) {
|
|
57
|
+
const loader = file.endsWith("x") ? "tsx" : "ts";
|
|
58
|
+
for (const imp of new Bun.Transpiler({ loader }).scanImports(await Bun.file(file).text())) {
|
|
59
|
+
if (!imp.path.startsWith(".")) continue;
|
|
60
|
+
const target = resolve(dirname(file), imp.path);
|
|
61
|
+
const inside = (d: string) => target === d || target.startsWith(d + "/");
|
|
62
|
+
if (inside(forgeDir) && !inside(generated)) violations.push(`${relative(process.cwd(), file)} imports "${imp.path}"`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return violations;
|
|
67
|
+
}
|
package/src/common.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { checkFrontendImports, codegen } from "./codegen";
|
|
4
|
+
import { c } from "./ui";
|
|
5
|
+
|
|
6
|
+
export function fail(message: string): never {
|
|
7
|
+
console.error(`${c.red("✗")} ${message}`);
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The folder holding schema.ts and your functions: `forge/`, or the current directory if it has schema.ts. A project still using the old `convex/` name keeps working, with a nudge to rename it. */
|
|
12
|
+
export function findForgeDir(dir?: string) {
|
|
13
|
+
let picked = dir;
|
|
14
|
+
if (!picked) {
|
|
15
|
+
if (existsSync("forge/schema.ts")) picked = "forge";
|
|
16
|
+
else if (existsSync("schema.ts")) picked = ".";
|
|
17
|
+
else if (existsSync("convex/schema.ts")) {
|
|
18
|
+
console.warn("! Using convex/, the old name for this folder. Rename it to forge/ (and update imports of ../convex/_generated).");
|
|
19
|
+
picked = "convex";
|
|
20
|
+
} else picked = "forge";
|
|
21
|
+
}
|
|
22
|
+
const abs = resolve(picked);
|
|
23
|
+
if (!existsSync(join(abs, "schema.ts"))) fail(`No schema.ts in ${abs}. Pass --dir <path to forge/>.`);
|
|
24
|
+
return abs;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Dev-only secrets, persisted so sessions and the dashboard key survive restarts. */
|
|
28
|
+
export function devSecrets(env: Record<string, string | undefined>, dir: string) {
|
|
29
|
+
const file = join(dir, ".dev-secrets.json");
|
|
30
|
+
const saved: Record<string, string> = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
31
|
+
for (const key of ["BETTER_AUTH_SECRET", "ADMIN_KEY"]) env[key] ||= saved[key] ??= Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("hex");
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
writeFileSync(file, JSON.stringify(saved, null, 2), { mode: 0o600 });
|
|
34
|
+
return env;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Behind code-server's port proxy (VSCODE_PROXY_URI=https://{{port}}.host/), browsers use the proxy origin, so trust it too. */
|
|
38
|
+
export function trustProxyOrigins(env: Record<string, string | undefined>) {
|
|
39
|
+
const uri = env.VSCODE_PROXY_URI;
|
|
40
|
+
if (!uri?.includes("{{port}}.")) return;
|
|
41
|
+
const ports = (env.TRUSTED_ORIGINS ?? "").split(",").map((o) => /^http:\/\/localhost:(\d+)$/.exec(o.trim())?.[1]);
|
|
42
|
+
const extra = ports.filter(Boolean).map((p) => new URL(uri.replace("{{port}}", p!)).origin);
|
|
43
|
+
env.TRUSTED_ORIGINS = [env.TRUSTED_ORIGINS, ...extra].filter(Boolean).join(",");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function generate(dir: string) {
|
|
47
|
+
const modules = await codegen(dir);
|
|
48
|
+
const violations = await checkFrontendImports(dir);
|
|
49
|
+
if (violations.length) throw new Error(`Frontend code must import only forge/_generated:\n ${violations.join("\n ")}`);
|
|
50
|
+
return modules;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const loadDashboard = async () => (await import("@archlast/forge-dashboard")).default;
|
|
54
|
+
|
|
55
|
+
/** The CLI's own version; the engine image is tagged with it. */
|
|
56
|
+
export const VERSION: string = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
package/src/deps.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function findUp(dir: string, name: string): string | undefined {
|
|
5
|
+
for (let d = dir; ; d = dirname(d)) {
|
|
6
|
+
if (existsSync(join(d, name))) return join(d, name);
|
|
7
|
+
if (dirname(d) === d) return undefined;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const depsHash = async (pkg: string, lock?: string) =>
|
|
12
|
+
String(Bun.hash((await Bun.file(pkg).text()) + (lock && existsSync(lock) ? await Bun.file(lock).text() : "")));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs `bun install` for forge/package.json when it or the lockfile changed since the last install.
|
|
16
|
+
* Lifecycle scripts are off unless the manifest lists `trustedDependencies` (then Bun runs only those).
|
|
17
|
+
*/
|
|
18
|
+
export async function ensureDeps(forgeDir: string): Promise<boolean> {
|
|
19
|
+
const pkg = join(forgeDir, "package.json");
|
|
20
|
+
if (!existsSync(pkg)) return false;
|
|
21
|
+
const lock = findUp(forgeDir, "bun.lock");
|
|
22
|
+
const stamp = join(forgeDir, "_generated", ".depshash");
|
|
23
|
+
if (existsSync(stamp) && (await Bun.file(stamp).text()) === (await depsHash(pkg, lock))) return false;
|
|
24
|
+
const manifest = await Bun.file(pkg).json();
|
|
25
|
+
const args = manifest.trustedDependencies ? ["install"] : ["install", "--ignore-scripts"];
|
|
26
|
+
console.log(`→ installing server dependencies: bun ${args.join(" ")} (${forgeDir})`);
|
|
27
|
+
const proc = Bun.spawnSync([process.execPath, ...args], { cwd: forgeDir, stdout: "inherit", stderr: "inherit" });
|
|
28
|
+
if (proc.exitCode !== 0)
|
|
29
|
+
throw new Error(`bun install failed in ${forgeDir} (exit ${proc.exitCode}). Fix forge/package.json; native packages may need "trustedDependencies".`);
|
|
30
|
+
mkdirSync(dirname(stamp), { recursive: true });
|
|
31
|
+
await Bun.write(stamp, await depsHash(pkg, findUp(forgeDir, "bun.lock")));
|
|
32
|
+
return true;
|
|
33
|
+
}
|
package/src/docker.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Host side of the Docker engine: the CLI starts, stops and talks to the engine container.
|
|
2
|
+
import { cpSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
5
|
+
import { fail, VERSION } from "./common";
|
|
6
|
+
import { projectRoot } from "./project";
|
|
7
|
+
|
|
8
|
+
const DOCKER = process.env.DOCKER ?? "docker";
|
|
9
|
+
const REPO_ROOT = new URL("../../..", import.meta.url).pathname.replace(/\/$/, "");
|
|
10
|
+
export const ENGINE_DOCKERFILE = join(REPO_ROOT, "docker", "engine.Dockerfile");
|
|
11
|
+
|
|
12
|
+
/** Released to GHCR by the release workflow; `engine build` tags local builds the same way. */
|
|
13
|
+
export const engineImage = () => process.env.ARCHLAST_ENGINE_IMAGE ?? `ghcr.io/algochad/archlast-forge-engine:${VERSION}`;
|
|
14
|
+
|
|
15
|
+
export function docker(args: string[], opts: { inherit?: boolean; input?: string } = {}) {
|
|
16
|
+
const proc = Bun.spawnSync([DOCKER, ...args], {
|
|
17
|
+
stdout: opts.inherit ? "inherit" : "pipe",
|
|
18
|
+
stderr: opts.inherit ? "inherit" : "pipe",
|
|
19
|
+
stdin: opts.input === undefined ? "ignore" : new TextEncoder().encode(opts.input),
|
|
20
|
+
});
|
|
21
|
+
return { ok: proc.exitCode === 0, out: proc.stdout?.toString().trim() ?? "", err: proc.stderr?.toString().trim() ?? "" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let available: boolean | undefined;
|
|
25
|
+
export const dockerAvailable = () => (available ??= Bun.spawnSync([DOCKER, "info"], { stdout: "ignore", stderr: "ignore" }).exitCode === 0);
|
|
26
|
+
|
|
27
|
+
/** Stable per-project name, from the directory holding forge/. */
|
|
28
|
+
export function projectName(forgeDir: string) {
|
|
29
|
+
const root = ["forge", "convex"].includes(basename(forgeDir)) ? dirname(forgeDir) : forgeDir;
|
|
30
|
+
const slug = basename(root).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "app";
|
|
31
|
+
return `${slug}-${Bun.hash(root).toString(16).slice(0, 6)}`;
|
|
32
|
+
}
|
|
33
|
+
/** Production and dev containers of a project have separate names, so one never replaces the other. */
|
|
34
|
+
export const containerName = (forgeDir: string, dev = false) => `archlast-forge-${projectName(forgeDir)}${dev ? "-dev" : ""}`;
|
|
35
|
+
|
|
36
|
+
/** The project's running engine container (production first), if any. */
|
|
37
|
+
export const runningContainer = (forgeDir: string) => [containerName(forgeDir), containerName(forgeDir, true)].find(isRunning);
|
|
38
|
+
|
|
39
|
+
export const isRunning = (name: string) => docker(["inspect", "-f", "{{.State.Running}}", name]).out === "true";
|
|
40
|
+
|
|
41
|
+
/** Builds the engine image from this repository. */
|
|
42
|
+
export function buildEngine(tag = engineImage()) {
|
|
43
|
+
if (!existsSync(ENGINE_DOCKERFILE)) fail(`No engine Dockerfile at ${ENGINE_DOCKERFILE}; set ARCHLAST_ENGINE_IMAGE to a published image instead.`);
|
|
44
|
+
console.log(`→ building ${tag}`);
|
|
45
|
+
if (!docker(["build", "-f", ENGINE_DOCKERFILE, "-t", tag, REPO_ROOT], { inherit: true }).ok) fail("Engine image build failed");
|
|
46
|
+
return tag;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ensureEngineImage() {
|
|
50
|
+
const image = engineImage();
|
|
51
|
+
if (docker(["image", "inspect", image]).ok) return image;
|
|
52
|
+
if (existsSync(ENGINE_DOCKERFILE)) return buildEngine(image);
|
|
53
|
+
console.log(`→ pulling ${image}`);
|
|
54
|
+
if (docker(["pull", image], { inherit: true }).ok) return image;
|
|
55
|
+
fail(`Engine image ${image} isn't available. Set ARCHLAST_ENGINE_IMAGE to one you can pull or build.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const SERVICES = {
|
|
59
|
+
postgres: {
|
|
60
|
+
image: "postgres:17-alpine",
|
|
61
|
+
env: { POSTGRES_USER: "forge", POSTGRES_PASSWORD: "forge", POSTGRES_DB: "forge" },
|
|
62
|
+
volume: "/var/lib/postgresql/data",
|
|
63
|
+
port: 5432,
|
|
64
|
+
provides: (address: string) => ({ DATABASE_URL: `postgres://forge:forge@${address}/forge` }),
|
|
65
|
+
},
|
|
66
|
+
redis: { image: "redis:7-alpine", env: {}, volume: "/data", port: 6379, provides: (address: string) => ({ REDIS_URL: `redis://${address}` }) },
|
|
67
|
+
s3: {
|
|
68
|
+
image: "rustfs/rustfs:latest",
|
|
69
|
+
env: { RUSTFS_ACCESS_KEY: "forgeadmin", RUSTFS_SECRET_KEY: "forgeadmin", RUSTFS_CORS_ALLOWED_ORIGINS: "*" },
|
|
70
|
+
volume: "/data",
|
|
71
|
+
cmd: ["/data"],
|
|
72
|
+
port: 9000,
|
|
73
|
+
// shortcut: with the engine in a container, one project per machine can publish 9000; set S3_ENDPOINT yourself for more.
|
|
74
|
+
publish: "9000:9000",
|
|
75
|
+
provides: (address: string, host: boolean) => ({
|
|
76
|
+
S3_ENDPOINT: `http://${address}`,
|
|
77
|
+
S3_PUBLIC_ENDPOINT: host ? `http://${address}` : process.env.S3_PUBLIC_ENDPOINT || "http://localhost:9000",
|
|
78
|
+
S3_BUCKET: "forge",
|
|
79
|
+
S3_ACCESS_KEY_ID: "forgeadmin",
|
|
80
|
+
S3_SECRET_ACCESS_KEY: "forgeadmin",
|
|
81
|
+
S3_REGION: "us-east-1",
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
} as const;
|
|
85
|
+
export type Service = keyof typeof SERVICES;
|
|
86
|
+
|
|
87
|
+
/** The host port Docker published for a container port. */
|
|
88
|
+
function publishedPort(container: string, port: number) {
|
|
89
|
+
const out = docker(["port", container, `${port}/tcp`]).out;
|
|
90
|
+
const found = /:(\d+)\s*$/m.exec(out);
|
|
91
|
+
if (!found) fail(`Couldn't find the published port of ${container} (docker port said: ${out || "nothing"})`);
|
|
92
|
+
return Number(found![1]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Blocks until a service answers, so the engine doesn't start against a database that is still initializing. */
|
|
96
|
+
function waitReady(container: string, service: Service) {
|
|
97
|
+
const probe = { postgres: ["pg_isready", "-U", "forge", "-d", "forge"], redis: ["redis-cli", "ping"], s3: undefined }[service];
|
|
98
|
+
if (!probe) return;
|
|
99
|
+
for (let i = 0; i < 120; i++) {
|
|
100
|
+
if (docker(["exec", container, ...probe]).ok) return;
|
|
101
|
+
Bun.sleepSync(500);
|
|
102
|
+
}
|
|
103
|
+
fail(`${service} didn't become ready in 60 s. See: docker logs ${container}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface StartedServices {
|
|
107
|
+
env: Record<string, string>;
|
|
108
|
+
/** For display: "postgres on localhost:54321". */
|
|
109
|
+
where: string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Service containers this process started, so `dev` can stop exactly those when it ends. */
|
|
113
|
+
const startedHere = new Set<string>();
|
|
114
|
+
|
|
115
|
+
/** Stops the service containers this process started. Their data stays in the named volumes. */
|
|
116
|
+
export function stopStartedServices() {
|
|
117
|
+
for (const container of startedHere) docker(["stop", "-t", "3", container]);
|
|
118
|
+
startedHere.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Starts service containers on the project's network and returns the env vars that point the engine at them.
|
|
123
|
+
* `host: true` is for an engine running on this machine: ports are published on 127.0.0.1 and the URLs say localhost.
|
|
124
|
+
* Otherwise the engine is a container on the same network and uses the containers' names.
|
|
125
|
+
*/
|
|
126
|
+
export function startServices(name: string, services: Service[], opts: { host?: boolean } = {}): StartedServices {
|
|
127
|
+
const out: StartedServices = { env: {}, where: [] };
|
|
128
|
+
if (!services.length) return out;
|
|
129
|
+
docker(["network", "create", name]);
|
|
130
|
+
for (const s of services) {
|
|
131
|
+
const def = SERVICES[s];
|
|
132
|
+
if (!def) fail(`Unknown service "${s}" (postgres, redis, s3)`);
|
|
133
|
+
const container = `${name}-${s}`;
|
|
134
|
+
if (!isRunning(container)) {
|
|
135
|
+
docker(["rm", "-f", container]);
|
|
136
|
+
const envArgs = Object.entries(def.env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
|
|
137
|
+
const cmd = "cmd" in def ? [...def.cmd] : [];
|
|
138
|
+
const publish = opts.host ? ["-p", `127.0.0.1::${def.port}`] : "publish" in def ? ["-p", def.publish] : [];
|
|
139
|
+
const run = docker(["run", "-d", "--name", container, "--network", name, "-v", `${container}:${def.volume}`, ...publish, ...envArgs, def.image, ...cmd]);
|
|
140
|
+
if (!run.ok) fail(`Couldn't start ${s}: ${run.err}`);
|
|
141
|
+
startedHere.add(container);
|
|
142
|
+
}
|
|
143
|
+
waitReady(container, s);
|
|
144
|
+
const address = opts.host ? `localhost:${publishedPort(container, def.port)}` : `${container}:${def.port}`;
|
|
145
|
+
Object.assign(out.env, (def.provides as (a: string, h: boolean) => Record<string, string>)(address, Boolean(opts.host)));
|
|
146
|
+
out.where.push(`${s} on ${address}`);
|
|
147
|
+
}
|
|
148
|
+
if (services.includes("s3")) {
|
|
149
|
+
// The bucket must exist before the first upload.
|
|
150
|
+
for (let i = 0; i < 30; i++) {
|
|
151
|
+
const mb = docker(["run", "--rm", "--network", name, "-e", "AWS_ACCESS_KEY_ID=forgeadmin", "-e", "AWS_SECRET_ACCESS_KEY=forgeadmin", "-e", "AWS_DEFAULT_REGION=us-east-1", "amazon/aws-cli", "--endpoint-url", `http://${name}-s3:9000`, "s3", "mb", "s3://forge"]);
|
|
152
|
+
if (mb.ok || /BucketAlready/.test(mb.err)) break;
|
|
153
|
+
Bun.sleepSync(1000);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface RunOptions {
|
|
160
|
+
forgeDir: string;
|
|
161
|
+
port: number;
|
|
162
|
+
dev: boolean;
|
|
163
|
+
env: Record<string, string>;
|
|
164
|
+
services?: Service[];
|
|
165
|
+
image?: string;
|
|
166
|
+
/** Defaults to forge/.env when it exists. */
|
|
167
|
+
envFile?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Runs the engine container. In dev, forge/ is mounted read-only and the engine watches it. */
|
|
171
|
+
export function runEngine(o: RunOptions) {
|
|
172
|
+
const name = containerName(o.forgeDir, o.dev);
|
|
173
|
+
const image = o.image ?? ensureEngineImage();
|
|
174
|
+
docker(["rm", "-f", name]);
|
|
175
|
+
const serviceEnv = startServices(name, o.services ?? []).env;
|
|
176
|
+
const env = { PORT: "3000", DATA_DIR: "/data", ...serviceEnv, ...o.env };
|
|
177
|
+
const envFile = o.envFile ?? join(o.forgeDir, ".env");
|
|
178
|
+
const args = [
|
|
179
|
+
"run",
|
|
180
|
+
"-d",
|
|
181
|
+
"--name",
|
|
182
|
+
name,
|
|
183
|
+
"-p",
|
|
184
|
+
`${o.port}:3000`,
|
|
185
|
+
"-v",
|
|
186
|
+
`${name}-data:/data`,
|
|
187
|
+
"--add-host",
|
|
188
|
+
"host.docker.internal:host-gateway",
|
|
189
|
+
...((o.services ?? []).length ? ["--network", name] : []),
|
|
190
|
+
...(existsSync(envFile) ? ["--env-file", envFile] : []),
|
|
191
|
+
...Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
|
|
192
|
+
...(o.dev ? ["-v", `${o.forgeDir}:/src/forge:ro`, "-v", `${name}-modules:/app/forge/node_modules`] : ["--restart", "unless-stopped"]),
|
|
193
|
+
image,
|
|
194
|
+
"engine",
|
|
195
|
+
"serve",
|
|
196
|
+
...(o.dev ? ["--dev"] : []),
|
|
197
|
+
];
|
|
198
|
+
const run = docker(args);
|
|
199
|
+
if (!run.ok) fail(`docker run failed: ${run.err}`);
|
|
200
|
+
return name;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Follows the container's logs; Ctrl-C stops the container (dev). */
|
|
204
|
+
export function attach(name: string, stopOnExit: boolean) {
|
|
205
|
+
const logs = Bun.spawn([DOCKER, "logs", "-f", "--since", "0s", name], { stdout: "inherit", stderr: "inherit" });
|
|
206
|
+
const stop = () => {
|
|
207
|
+
if (stopOnExit) {
|
|
208
|
+
console.log(`\nstopping ${name}…`);
|
|
209
|
+
docker(["stop", "-t", "15", name]); // SIGTERM: the engine drains connections first
|
|
210
|
+
stopStartedServices();
|
|
211
|
+
}
|
|
212
|
+
logs.kill();
|
|
213
|
+
process.exit(0);
|
|
214
|
+
};
|
|
215
|
+
process.on("SIGINT", stop);
|
|
216
|
+
process.on("SIGTERM", stop);
|
|
217
|
+
return logs.exited;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Builds a production image: the engine plus this project's code and dependencies.
|
|
222
|
+
* A project made by `archlast-forge init` has its own Dockerfile and builds from its root; older ones get a generated one around forge/.
|
|
223
|
+
*/
|
|
224
|
+
export function buildAppImage(forgeDir: string, tag: string) {
|
|
225
|
+
const image = ensureEngineImage();
|
|
226
|
+
const root = projectRoot(forgeDir);
|
|
227
|
+
if (existsSync(join(root, "Dockerfile")) && existsSync(join(root, "archlast-forge.json"))) {
|
|
228
|
+
console.log(`→ building ${tag} from ${image}`);
|
|
229
|
+
if (!docker(["build", "-t", tag, "-f", join(root, "Dockerfile"), "--build-arg", `ENGINE_IMAGE=${image}`, root], { inherit: true }).ok) fail("App image build failed");
|
|
230
|
+
return tag;
|
|
231
|
+
}
|
|
232
|
+
const ctx = mkdtempSync(join(tmpdir(), "afx-build-"));
|
|
233
|
+
try {
|
|
234
|
+
cpSync(forgeDir, join(ctx, "forge"), {
|
|
235
|
+
recursive: true,
|
|
236
|
+
filter: (src) => !relative(forgeDir, src).split("/").some((seg) => seg === "node_modules" || seg === "data" || seg === ".env" || seg === ".git"),
|
|
237
|
+
});
|
|
238
|
+
writeFileSync(
|
|
239
|
+
join(ctx, "Dockerfile"),
|
|
240
|
+
`FROM ${image}\nCOPY forge /src/forge\nRUN bun /engine/packages/cli/src/index.ts engine prepare\nENV NODE_ENV=production\n`,
|
|
241
|
+
);
|
|
242
|
+
console.log(`→ building ${tag} from ${image}`);
|
|
243
|
+
if (!docker(["build", "-t", tag, ctx], { inherit: true }).ok) fail("App image build failed");
|
|
244
|
+
} finally {
|
|
245
|
+
rmSync(ctx, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
return tag;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Runs a CLI command inside the running engine container. */
|
|
251
|
+
export function execInEngine(name: string, args: string[], opts: { inherit?: boolean } = { inherit: true }) {
|
|
252
|
+
return docker(["exec", name, "bun", "/engine/packages/cli/src/index.ts", ...args, "--dir", "/app/forge", "--no-docker"], opts);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const copyFrom = (name: string, path: string, to: string) => docker(["cp", `${name}:${path}`, to]).ok;
|
|
256
|
+
export const copyTo = (name: string, from: string, path: string) => docker(["cp", from, `${name}:${path}`]).ok;
|