create-archlast-forge 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/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
- # Temporary Holding Version
1
+ # create-archlast-forge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Create an archlast-forge project: a full-stack app or a backend, with the services you choose.
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": "create-archlast-forge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Create an archlast-forge project: a full-stack app or a backend, with the services you choose.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/algochad/archlast-forge.git",
9
+ "directory": "packages/create-archlast-forge"
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
+ "bin": {
22
+ "create-archlast-forge": "./src/index.ts"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "template"
27
+ ],
28
+ "dependencies": {
29
+ "@clack/prompts": "^1.8.1"
30
+ },
31
+ "exports": {
32
+ ".": "./src/index.ts",
33
+ "./stack": "./src/stack.ts"
34
+ },
35
+ "engines": {
36
+ "bun": ">=1.4.3"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,148 @@
1
+ import * as p from "@clack/prompts";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { basename, relative, resolve } from "node:path";
4
+ import type { Readable, Writable } from "node:stream";
5
+ import { scaffold } from "./scaffold";
6
+ import { defaultServices, nameOf, parseFlags, USAGE, type Flags } from "./options";
7
+ import { DEFAULT_SERVICES, MODES, SERVICES, type Mode, type StackService } from "./stack";
8
+
9
+ /** Where prompts read and write; tests pass their own streams. */
10
+ export interface Io {
11
+ input?: Readable;
12
+ output?: Writable;
13
+ }
14
+
15
+ export interface Answers {
16
+ dir: string;
17
+ mode: Mode;
18
+ services: StackService[];
19
+ install: boolean;
20
+ git: boolean;
21
+ }
22
+
23
+ /** The person pressed Ctrl-C at a prompt. */
24
+ export class Cancelled extends Error {}
25
+
26
+ const answer = <T>(value: T | symbol): T => {
27
+ if (p.isCancel(value)) throw new Cancelled("Cancelled");
28
+ return value as T;
29
+ };
30
+
31
+ const dirProblem = (dir: string) => (existsSync(resolve(dir)) && readdirSync(resolve(dir)).length ? `${dir} already has files in it. Pick an empty folder or a new name.` : undefined);
32
+
33
+ /** Asks for whatever the flags left out. With `interactive` false, missing answers take their defaults. */
34
+ export async function gather(flags: Flags, interactive: boolean, io: Io = {}): Promise<Answers> {
35
+ const ask = <T>(run: (o: Io) => Promise<T | symbol>): Promise<T> => run(io).then((v) => answer<T>(v));
36
+
37
+ let dir = flags.dir;
38
+ if (dir === undefined) {
39
+ if (!interactive) throw new Error("Which folder? Pass a directory: bun create archlast-forge my-app");
40
+ dir = await ask<string>((o) =>
41
+ p.text({ ...o, message: "Where should we create your project?", placeholder: "my-app", defaultValue: "my-app", validate: (v) => dirProblem(v || "my-app") }),
42
+ );
43
+ } else {
44
+ const problem = dirProblem(dir);
45
+ if (problem) throw new Error(problem);
46
+ }
47
+
48
+ let mode: Mode;
49
+ if (flags.mode) mode = flags.mode;
50
+ else {
51
+ mode = interactive
52
+ ? await ask<Mode>((o) =>
53
+ p.select<Mode>({
54
+ ...o,
55
+ message: "What are you building?",
56
+ initialValue: "framework",
57
+ options: (Object.keys(MODES) as Mode[]).map((m) => ({ value: m, label: MODES[m].label, hint: MODES[m].hint })),
58
+ }),
59
+ )
60
+ : "framework";
61
+ }
62
+
63
+ let services = flags.services;
64
+ if (!services) {
65
+ services = interactive
66
+ ? await ask<StackService[]>((o) =>
67
+ p.multiselect<StackService>({
68
+ ...o,
69
+ message: "Which services will production need? (space selects, enter confirms; none keeps SQLite, with no database container)",
70
+ required: false,
71
+ initialValues: DEFAULT_SERVICES[mode],
72
+ options: (Object.keys(SERVICES) as StackService[]).map((s) => ({ value: s, label: SERVICES[s].label, hint: SERVICES[s].hint })),
73
+ }),
74
+ )
75
+ : defaultServices(mode);
76
+ }
77
+
78
+ const confirm = async (given: boolean | undefined, message: string) => given ?? (interactive ? await ask<boolean>((o) => p.confirm({ ...o, message, initialValue: true })) : false);
79
+ const install = await confirm(flags.install, "Install dependencies with bun?");
80
+ const git = await confirm(flags.git, "Start a git repository?");
81
+ return { dir, mode, services, install, git };
82
+ }
83
+
84
+ const run = async (cmd: string[], cwd: string) => {
85
+ const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
86
+ const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
87
+ return { ok: code === 0, output: (err || out).trim() };
88
+ };
89
+
90
+ export function nextSteps(a: Answers, installed: boolean): string {
91
+ const rel = relative(process.cwd(), resolve(a.dir)) || ".";
92
+ const cd = rel.startsWith("..") ? resolve(a.dir) : rel; // a path that climbs out of here reads better in full
93
+ const lines = [cd !== "." ? `cd ${cd}` : undefined, installed ? undefined : "bun install", "bun run dev"].filter(Boolean) as string[];
94
+ const where = a.mode === "framework" ? "Your app: http://localhost:3000" : "Your backend: http://localhost:3000";
95
+ return `${lines.join("\n")}\n\n${where}\nDashboard: http://localhost:3000/_dashboard\n\nTo deploy: bun run up (builds the image and starts the stack in compose.yaml)`;
96
+ }
97
+
98
+ export async function main(argv: string[], io: Io = {}): Promise<void> {
99
+ let flags: Flags;
100
+ try {
101
+ flags = parseFlags(argv);
102
+ } catch (e) {
103
+ console.error(`✗ ${(e as Error).message}\n\n${USAGE}`);
104
+ process.exit(1);
105
+ }
106
+ if (flags.help) return console.log(USAGE);
107
+ const interactive = !flags.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI;
108
+
109
+ if (interactive) p.intro(" create archlast-forge ");
110
+ let answers: Answers;
111
+ try {
112
+ answers = await gather(flags, interactive, io);
113
+ } catch (e) {
114
+ if (e instanceof Cancelled) {
115
+ p.cancel("Cancelled. Nothing was created.");
116
+ process.exit(130);
117
+ }
118
+ console.error(`✗ ${(e as Error).message}`);
119
+ process.exit(1);
120
+ }
121
+
122
+ const target = (() => {
123
+ try {
124
+ return scaffold(answers);
125
+ } catch (e) {
126
+ console.error(`✗ ${(e as Error).message}`);
127
+ return process.exit(1);
128
+ }
129
+ })();
130
+ p.log.success(`Created ${basename(target)} (${MODES[answers.mode].label.toLowerCase()}${answers.services.length ? `, with ${answers.services.map((s) => SERVICES[s].label).join(", ")}` : ""})`);
131
+
132
+ let installed = false;
133
+ if (answers.install) {
134
+ const spin = p.spinner();
135
+ spin.start("Installing dependencies");
136
+ const r = await run(["bun", "install"], target);
137
+ installed = r.ok;
138
+ if (r.ok) spin.stop("Installed dependencies");
139
+ else spin.error(`bun install failed:\n${r.output.split("\n").slice(-8).join("\n")}\nRun it again once that is fixed.`);
140
+ }
141
+ if (answers.git && !existsSync(resolve(target, ".git"))) {
142
+ const r = await run(["git", "init", "-q"], target);
143
+ if (r.ok) p.log.success("Started a git repository");
144
+ else p.log.warn("Couldn't start a git repository (is git installed?)");
145
+ }
146
+ p.note(nextSteps(answers, installed), "Next");
147
+ p.outro("Happy building.");
148
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ // bun create archlast-forge my-app (or: bunx create-archlast-forge my-app, or: archlast-forge init)
3
+ export { main } from "./cli";
4
+ export { scaffold, VERSION, type ScaffoldOptions } from "./scaffold";
5
+ export * from "./options";
6
+ export * from "./stack";
7
+
8
+ if (import.meta.main) {
9
+ const { main } = await import("./cli");
10
+ await main(process.argv.slice(2));
11
+ }
package/src/options.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { basename, resolve } from "node:path";
2
+ import { parseArgs } from "node:util";
3
+ import { DEFAULT_SERVICES, MODES, parseServiceList, type Mode, type StackService } from "./stack";
4
+
5
+ export interface Flags {
6
+ dir?: string;
7
+ mode?: Mode;
8
+ services?: StackService[];
9
+ yes: boolean;
10
+ install?: boolean;
11
+ git?: boolean;
12
+ help: boolean;
13
+ }
14
+
15
+ export const USAGE = `Usage: bun create archlast-forge [directory] [options]
16
+
17
+ --mode <framework|backend> framework: a full-stack React app (server rendering, file routes, live queries)
18
+ backend: a backend container with its own database, cache and storage
19
+ --services <list> postgres, redis, s3, caddy (comma separated) or "none"
20
+ --install / --no-install run \`bun install\` afterwards
21
+ --git / --no-git start a git repository
22
+ -y, --yes take the default for everything not given, and ask nothing
23
+ -h, --help
24
+
25
+ Without options you are asked. Defaults: framework mode with postgres, redis, s3 and caddy;
26
+ backend mode with postgres, redis and s3.`;
27
+
28
+ export function parseFlags(argv: string[]): Flags {
29
+ const { values, positionals } = parseArgs({
30
+ args: argv,
31
+ allowPositionals: true,
32
+ options: {
33
+ mode: { type: "string", short: "m" },
34
+ template: { type: "string" },
35
+ services: { type: "string", short: "s" },
36
+ install: { type: "boolean" },
37
+ "no-install": { type: "boolean" },
38
+ git: { type: "boolean" },
39
+ "no-git": { type: "boolean" },
40
+ yes: { type: "boolean", short: "y" },
41
+ help: { type: "boolean", short: "h" },
42
+ },
43
+ });
44
+ const mode = values.mode ?? values.template;
45
+ if (mode !== undefined && !(mode in MODES)) throw new Error(`Unknown mode "${mode}". Choose framework or backend.`);
46
+ const pick = (on?: boolean, off?: boolean) => (off ? false : on ? true : undefined);
47
+ return {
48
+ dir: positionals[0],
49
+ mode: mode as Mode | undefined,
50
+ services: values.services === undefined ? undefined : parseServiceList(values.services),
51
+ yes: Boolean(values.yes),
52
+ install: pick(values.install, values["no-install"]),
53
+ git: pick(values.git, values["no-git"]),
54
+ help: Boolean(values.help),
55
+ };
56
+ }
57
+
58
+ /** A name safe for package.json and Docker Compose: lowercase letters, digits, `-` and `_`. */
59
+ export function nameOf(dir: string): string {
60
+ return basename(resolve(dir)).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "") || "app";
61
+ }
62
+
63
+ export const defaultServices = (mode: Mode, given?: StackService[]) => given ?? DEFAULT_SERVICES[mode];
@@ -0,0 +1,57 @@
1
+ // Writes a new project from the templates.
2
+ import { chmodSync, cpSync, existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { nameOf } from "./options";
5
+ import {
6
+ DOCKERIGNORE,
7
+ generateSecrets,
8
+ renderCaddyfile,
9
+ renderCompose,
10
+ renderDockerfile,
11
+ renderEnv,
12
+ type Mode,
13
+ type StackService,
14
+ } from "./stack";
15
+
16
+ const TEMPLATE = new URL("../template", import.meta.url).pathname;
17
+ export const VERSION: string = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
18
+
19
+ export interface ScaffoldOptions {
20
+ dir: string;
21
+ mode: Mode;
22
+ services: StackService[];
23
+ /** The archlast-forge version the project depends on. Defaults to this package's. */
24
+ version?: string;
25
+ }
26
+
27
+ /** Writes a new project into `dir` (which may exist but must be empty) and returns its absolute path. */
28
+ export function scaffold({ dir, mode, services, version = VERSION }: ScaffoldOptions): string {
29
+ const target = resolve(dir);
30
+ if (existsSync(target) && readdirSync(target).length) throw new Error(`${target} isn't empty`);
31
+ const name = nameOf(target);
32
+ for (const part of ["base", mode]) cpSync(join(TEMPLATE, part), target, { recursive: true });
33
+ renameSync(join(target, "gitignore"), join(target, ".gitignore")); // npm drops .gitignore files from packages
34
+ if (services.includes("s3")) cpSync(join(TEMPLATE, "extras", "files.ts"), join(target, "forge", "files.ts")); // something that uses the storage
35
+
36
+ const fill = (path: string) => {
37
+ for (const entry of readdirSync(path)) {
38
+ const p = join(path, entry);
39
+ if (statSync(p).isDirectory()) fill(p);
40
+ else writeFileSync(p, readFileSync(p, "utf8").replaceAll("__NAME__", name).replaceAll("__VERSION__", version));
41
+ }
42
+ };
43
+ fill(target);
44
+
45
+ const write = (file: string, text: string, secret = false) => {
46
+ writeFileSync(join(target, file), text);
47
+ if (secret) chmodSync(join(target, file), 0o600);
48
+ };
49
+ write("archlast-forge.json", JSON.stringify({ mode, services }, null, 2) + "\n");
50
+ write("Dockerfile", renderDockerfile(version));
51
+ write(".dockerignore", DOCKERIGNORE);
52
+ write("compose.yaml", renderCompose({ name, services, version }));
53
+ write(".env.example", renderEnv(services));
54
+ write(".env", renderEnv(services, generateSecrets()), true);
55
+ if (services.includes("caddy")) write("Caddyfile", renderCaddyfile(services));
56
+ return target;
57
+ }
package/src/stack.ts ADDED
@@ -0,0 +1,249 @@
1
+ // What a project says about how it is built and deployed: archlast-forge.json, and the files generated from it
2
+ // (compose.yaml, Dockerfile, .env, Caddyfile). Pure functions, so the CLI and the scaffolder share them.
3
+
4
+ export type Mode = "framework" | "backend";
5
+
6
+ export const MODES: Record<Mode, { label: string; hint: string }> = {
7
+ framework: { label: "Full-stack app", hint: "React with server rendering, file routes and live queries, served by the same container as your backend" },
8
+ backend: { label: "Backend only", hint: "A container with its database, cache and storage; use any frontend or none" },
9
+ };
10
+
11
+ export const SERVICES = {
12
+ postgres: { label: "PostgreSQL", hint: "shared database instead of a SQLite file" },
13
+ redis: { label: "Redis", hint: "reactivity across servers, cache and rate limits" },
14
+ s3: { label: "S3 storage (RustFS)", hint: "file uploads with presigned URLs" },
15
+ caddy: { label: "Caddy", hint: "HTTPS with automatic certificates in front of the app" },
16
+ } as const;
17
+ export type StackService = keyof typeof SERVICES;
18
+
19
+ /** Services a mode starts with: what a production deployment needs. An app gets HTTPS too; a backend is usually put behind a proxy of its own. */
20
+ export const DEFAULT_SERVICES: Record<Mode, StackService[]> = { framework: ["postgres", "redis", "s3", "caddy"], backend: ["postgres", "redis", "s3"] };
21
+
22
+ /** archlast-forge.json */
23
+ export interface ProjectConfig {
24
+ mode: Mode;
25
+ services: StackService[];
26
+ }
27
+
28
+ /** Validates archlast-forge.json with messages that say what to change. */
29
+ export function parseProjectConfig(json: unknown, source = "archlast-forge.json"): ProjectConfig {
30
+ const bad = (why: string): never => {
31
+ throw new Error(`${source}: ${why}`);
32
+ };
33
+ if (typeof json !== "object" || json === null || Array.isArray(json)) return bad("expected an object like { \"mode\": \"framework\", \"services\": [] }");
34
+ const { mode, services = [] } = json as { mode?: unknown; services?: unknown };
35
+ if (mode !== "framework" && mode !== "backend") return bad(`"mode" must be "framework" or "backend" (got ${JSON.stringify(mode)})`);
36
+ if (!Array.isArray(services)) return bad(`"services" must be a list such as ["postgres", "redis"]`);
37
+ for (const s of services) if (!(s in SERVICES)) return bad(`unknown service ${JSON.stringify(s)}; choose from ${Object.keys(SERVICES).join(", ")}`);
38
+ return { mode, services: [...new Set(services as StackService[])] };
39
+ }
40
+
41
+ export const parseServiceList = (text: string): StackService[] => {
42
+ const names = text.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s && s !== "none");
43
+ for (const n of names) if (!(n in SERVICES)) throw new Error(`Unknown service "${n}". Choose from ${Object.keys(SERVICES).join(", ")}, or "none".`);
44
+ return [...new Set(names as StackService[])];
45
+ };
46
+
47
+ const hex = (bytes: number) => Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("hex");
48
+
49
+ export interface Secrets {
50
+ BETTER_AUTH_SECRET: string;
51
+ ADMIN_KEY: string;
52
+ POSTGRES_PASSWORD: string;
53
+ S3_ACCESS_KEY: string;
54
+ S3_SECRET_KEY: string;
55
+ }
56
+
57
+ export const generateSecrets = (): Secrets => ({
58
+ BETTER_AUTH_SECRET: hex(32),
59
+ ADMIN_KEY: hex(24),
60
+ POSTGRES_PASSWORD: hex(16),
61
+ S3_ACCESS_KEY: `forge${hex(6)}`,
62
+ S3_SECRET_KEY: hex(20),
63
+ });
64
+
65
+ /** The compose file's `.env`. With no secrets it is `.env.example`: the same keys, empty. */
66
+ export function renderEnv(services: StackService[], secrets?: Secrets): string {
67
+ const has = (s: StackService) => services.includes(s);
68
+ const v = (k: keyof Secrets) => secrets?.[k] ?? "";
69
+ const lines = [
70
+ secrets ? "# Generated by archlast-forge. It holds secrets: keep it out of git." : "# Copy to .env and fill in, or let `archlast-forge up` generate one.",
71
+ "",
72
+ "# Signs sessions. Changing it signs everybody out.",
73
+ `BETTER_AUTH_SECRET=${v("BETTER_AUTH_SECRET")}`,
74
+ "# Opens the dashboard (/_dashboard) and the CLI's data commands.",
75
+ `ADMIN_KEY=${v("ADMIN_KEY")}`,
76
+ "",
77
+ // DOMAIN comes first: Compose fills ${DOMAIN} only from lines above the one that uses it.
78
+ ...(has("caddy")
79
+ ? ["# Your domain; point its DNS (and s3.<domain>) at this machine.", "DOMAIN=localhost", "", "# The address people use to reach the app.", "BETTER_AUTH_URL=https://${DOMAIN}"]
80
+ : ["# The address people use to reach the app.", "BETTER_AUTH_URL=http://localhost:3000", "# Plain http is only for trying things out; remove this once BETTER_AUTH_URL is https.", "ALLOW_INSECURE_HTTP=1", "PORT=3000"]),
81
+ ];
82
+ if (has("postgres")) lines.push("", "POSTGRES_PASSWORD=" + v("POSTGRES_PASSWORD"));
83
+ if (has("s3")) lines.push("", `S3_ACCESS_KEY=${v("S3_ACCESS_KEY")}`, `S3_SECRET_KEY=${v("S3_SECRET_KEY")}`, "# Where browsers upload to and download from.", has("caddy") ? "S3_PUBLIC_ENDPOINT=https://s3.${DOMAIN}" : "S3_PUBLIC_ENDPOINT=http://localhost:9000");
84
+ if (has("s3") && !has("caddy")) lines.push("# The port above is published on; change both together if 9000 is taken.", "S3_PORT=9000");
85
+ return lines.join("\n") + "\n";
86
+ }
87
+
88
+ export function renderCaddyfile(services: StackService[]): string {
89
+ return `# HTTPS for the app${services.includes("s3") ? " and its file storage" : ""}. Caddy gets and renews certificates by itself once DOMAIN points here.
90
+ {$DOMAIN:localhost} {
91
+ encode zstd gzip
92
+ reverse_proxy app:3000
93
+ }
94
+ ${services.includes("s3") ? `
95
+ s3.{$DOMAIN:localhost} {
96
+ reverse_proxy rustfs:9000
97
+ }
98
+ ` : ""}`;
99
+ }
100
+
101
+ /** The engine image the project builds on; `ENGINE_IMAGE` overrides it. */
102
+ export const engineImageFor = (version: string) => `ghcr.io/algochad/archlast-forge-engine:${version}`;
103
+
104
+ export const renderDockerfile = (version: string) => `# Your app on the archlast-forge engine: \`archlast-forge build\` and \`docker compose up --build\` both use this.
105
+ ARG ENGINE_IMAGE=${engineImageFor(version)}
106
+ FROM \${ENGINE_IMAGE}
107
+ COPY . /src
108
+ # Installs dependencies for Linux, generates the types and, in a full-stack app, builds the browser and server bundles.
109
+ RUN bun /engine/packages/cli/src/index.ts engine prepare
110
+ ENV NODE_ENV=production
111
+ `;
112
+
113
+ export const DOCKERIGNORE = `node_modules
114
+ .forge
115
+ .git
116
+ .env
117
+ .env.*
118
+ !.env.example
119
+ data
120
+ forge/data
121
+ forge/node_modules
122
+ dist
123
+ `;
124
+
125
+ /** compose.yaml: the app and the services it was asked to run, wired together. */
126
+ export function renderCompose({ name, services, version }: { name: string; services: StackService[]; version: string }): string {
127
+ const has = (s: StackService) => services.includes(s);
128
+ const proxied = has("caddy");
129
+ const env: string[] = [
130
+ "BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?missing: run `archlast-forge up`, which creates .env}",
131
+ "ADMIN_KEY: ${ADMIN_KEY:?missing: run `archlast-forge up`, which creates .env}",
132
+ "BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}",
133
+ "ALLOW_INSECURE_HTTP: ${ALLOW_INSECURE_HTTP:-}",
134
+ "DASHBOARD: ${DASHBOARD:-on}",
135
+ ];
136
+ if (proxied) env.push("TRUST_PROXY: \"1\"", "TRUSTED_ORIGINS: https://${DOMAIN}");
137
+ if (has("postgres")) env.push("DATABASE_URL: postgres://forge:${POSTGRES_PASSWORD}@postgres:5432/forge");
138
+ if (has("redis")) env.push("REDIS_URL: redis://redis:6379");
139
+ if (has("s3"))
140
+ env.push("S3_ENDPOINT: http://rustfs:9000", "S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT}", "S3_BUCKET: forge", "S3_REGION: us-east-1", "S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY}", "S3_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}");
141
+
142
+ // Quoted: the "missing" messages contain ": ", which YAML would take for a new key. Compose still expands ${...} inside quotes.
143
+ const quoted = (e: string) => ` ${e.slice(0, e.indexOf(": "))}: "${e.slice(e.indexOf(": ") + 2).replace(/^"|"$/g, "")}"`;
144
+
145
+ const deps: string[] = [];
146
+ if (has("postgres")) deps.push(" postgres:\n condition: service_healthy");
147
+ if (has("redis")) deps.push(" redis:\n condition: service_healthy");
148
+ if (has("s3")) deps.push(" rustfs-init:\n condition: service_completed_successfully");
149
+
150
+ const parts: string[] = [];
151
+ parts.push(`# The production stack for ${name}. \`archlast-forge up\` builds and starts it; \`archlast-forge down\` stops it.
152
+ # This file is yours: edit it, and archlast-forge will run what you wrote.
153
+ name: ${name}
154
+
155
+ services:
156
+ app:
157
+ build:
158
+ context: .
159
+ args:
160
+ ENGINE_IMAGE: \${ENGINE_IMAGE:-${engineImageFor(version)}}
161
+ image: ${name}:latest
162
+ restart: unless-stopped
163
+ ${proxied ? "" : ` ports:\n - "\${PORT:-3000}:3000"\n`} environment:
164
+ ${env.map(quoted).join("\n")}
165
+ volumes:
166
+ - app-data:/data${deps.length ? `\n depends_on:\n${deps.join("\n")}` : ""}`);
167
+
168
+ if (has("postgres"))
169
+ parts.push(` postgres:
170
+ image: postgres:17-alpine
171
+ restart: unless-stopped
172
+ environment:
173
+ POSTGRES_USER: forge
174
+ POSTGRES_PASSWORD: "\${POSTGRES_PASSWORD:?missing: run \`archlast-forge up\`, which creates .env}"
175
+ POSTGRES_DB: forge
176
+ volumes:
177
+ - postgres-data:/var/lib/postgresql/data
178
+ healthcheck:
179
+ test: ["CMD-SHELL", "pg_isready -U forge -d forge"]
180
+ interval: 3s
181
+ timeout: 3s
182
+ retries: 20`);
183
+
184
+ if (has("redis"))
185
+ parts.push(` redis:
186
+ image: redis:7-alpine
187
+ restart: unless-stopped
188
+ command: ["redis-server", "--appendonly", "yes"]
189
+ volumes:
190
+ - redis-data:/data
191
+ healthcheck:
192
+ test: ["CMD", "redis-cli", "ping"]
193
+ interval: 3s
194
+ timeout: 3s
195
+ retries: 20`);
196
+
197
+ if (has("s3"))
198
+ parts.push(` rustfs:
199
+ image: rustfs/rustfs:latest
200
+ restart: unless-stopped
201
+ command: ["/data"]
202
+ environment:
203
+ RUSTFS_ACCESS_KEY: "\${S3_ACCESS_KEY:?missing: run \`archlast-forge up\`, which creates .env}"
204
+ RUSTFS_SECRET_KEY: "\${S3_SECRET_KEY:?missing: run \`archlast-forge up\`, which creates .env}"
205
+ RUSTFS_CORS_ALLOWED_ORIGINS: "*"
206
+ ${proxied ? "" : ` ports:\n - "\${S3_PORT:-9000}:9000" # browsers upload and download here, through presigned URLs\n`} volumes:
207
+ - rustfs-data:/data
208
+
209
+ # Creates the bucket once, then exits.
210
+ rustfs-init:
211
+ image: amazon/aws-cli
212
+ depends_on:
213
+ - rustfs
214
+ restart: "no"
215
+ environment:
216
+ AWS_ACCESS_KEY_ID: \${S3_ACCESS_KEY}
217
+ AWS_SECRET_ACCESS_KEY: \${S3_SECRET_KEY}
218
+ AWS_DEFAULT_REGION: us-east-1
219
+ entrypoint:
220
+ - sh
221
+ - -c
222
+ - |
223
+ for i in $$(seq 1 60); do
224
+ out=$$(aws --endpoint-url http://rustfs:9000 s3 mb s3://forge 2>&1) && exit 0
225
+ echo "$$out" | grep -q BucketAlready && exit 0
226
+ sleep 1
227
+ done
228
+ echo "$$out"; exit 1`);
229
+
230
+ if (proxied)
231
+ parts.push(` caddy:
232
+ image: caddy:2-alpine
233
+ restart: unless-stopped
234
+ ports:
235
+ - "80:80"
236
+ - "443:443"
237
+ - "443:443/udp"
238
+ environment:
239
+ DOMAIN: \${DOMAIN:-localhost}
240
+ volumes:
241
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
242
+ - caddy-data:/data
243
+ - caddy-config:/config
244
+ depends_on:
245
+ - app`);
246
+
247
+ const volumes = ["app-data", ...(has("postgres") ? ["postgres-data"] : []), ...(has("redis") ? ["redis-data"] : []), ...(has("s3") ? ["rustfs-data"] : []), ...(proxied ? ["caddy-data", "caddy-config"] : [])];
248
+ return `${parts.join("\n\n")}\n\nvolumes:\n${volumes.map((v) => ` ${v}:`).join("\n")}\n`;
249
+ }
@@ -0,0 +1,39 @@
1
+ # __NAME__
2
+
3
+ A backend on [archlast-forge](https://github.com/algochad/archlast-forge): your schema and functions in `forge/`, running in a container next to its database, cache and storage.
4
+
5
+ ```bash
6
+ bun install
7
+ bun run dev # the engine in Docker, reloading as you edit forge/
8
+ bun run up # the production stack from compose.yaml
9
+ ```
10
+
11
+ ## Develop
12
+
13
+ `bun run dev` starts the engine with `forge/` mounted, and the services listed in `archlast-forge.json`. Edits restart only your functions; open connections stay up.
14
+
15
+ - Dashboard: http://localhost:3000/_dashboard (the admin key is printed on start)
16
+ - Try the example function: `curl localhost:3000/http/hello`
17
+
18
+ Your frontend, whatever it is, talks to this server:
19
+
20
+ ```ts
21
+ import { ArchlastForgeClient } from "@archlast/forge-client";
22
+ import { api } from "./forge/_generated/api";
23
+
24
+ const client = new ArchlastForgeClient({ url: "http://localhost:3000" });
25
+ const page = await client.query(api.messages.list, { paginationOpts: { numItems: 20, cursor: null } });
26
+ client.watch(api.messages.list, { paginationOpts: { numItems: 20, cursor: null } }, (update) => console.log(update)); // live
27
+ ```
28
+
29
+ React, Vue and Svelte bindings are `@archlast/forge-react`, `@archlast/forge-vue` and `@archlast/forge-svelte`. Add your frontend's origin to `TRUSTED_ORIGINS` so its cookies and requests are accepted.
30
+
31
+ ## Deploy
32
+
33
+ ```bash
34
+ bun run up # builds the image, starts everything, waits until it is healthy
35
+ bun run logs
36
+ bun run down # stops it; data stays in Docker volumes
37
+ ```
38
+
39
+ `compose.yaml` is the stack. It is yours to edit. Secrets live in `.env`, which `init` created with fresh keys and git ignores (`up` makes one only if it's missing); `.env.example` lists the keys. To put it on a server, copy the project there and run `bun run up` (or `docker compose up -d --build`).
@@ -0,0 +1,13 @@
1
+ import { httpAction, httpRouter } from "@archlast/forge-runtime";
2
+
3
+ // Plain HTTP routes, served under /http: this one answers GET /http/hello.
4
+ // Use them for webhooks and public APIs; everything else should be a query or mutation.
5
+ const http = httpRouter();
6
+
7
+ http.route({
8
+ method: "GET",
9
+ path: "/hello",
10
+ handler: httpAction(async () => Response.json({ message: "Hello from __NAME__" })),
11
+ });
12
+
13
+ export default http;
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "archlast-forge dev",
7
+ "codegen": "archlast-forge codegen",
8
+ "typecheck": "archlast-forge codegen && tsc -p forge --noEmit",
9
+ "up": "archlast-forge up",
10
+ "down": "archlast-forge down",
11
+ "logs": "archlast-forge logs"
12
+ },
13
+ "devDependencies": {
14
+ "@archlast/forge-cli": "^__VERSION__",
15
+ "typescript": "^7.0.2"
16
+ }
17
+ }
@@ -0,0 +1,22 @@
1
+ import { ForgeError, mutation, paginationOptsValidator, query, v } from "@archlast/forge-runtime";
2
+
3
+ /** Newest first, paginated: the client loads more as you scroll. */
4
+ export const list = query({
5
+ args: { paginationOpts: paginationOptsValidator },
6
+ handler: (ctx, { paginationOpts }) => ctx.db.query("messages").order("desc").paginate(paginationOpts),
7
+ });
8
+
9
+ export const search = query({
10
+ args: { text: v.string() },
11
+ handler: (ctx, { text }) => ctx.db.query("messages").withSearchIndex("search_body", (q) => q.search("body", text)).take(20),
12
+ });
13
+
14
+ export const send = mutation({
15
+ args: { body: v.string() },
16
+ handler: async (ctx, { body }) => {
17
+ const me = await ctx.auth.getUserIdentity();
18
+ if (!me) throw new ForgeError("Sign in to post");
19
+ if (!body.trim() || body.length > 2000) throw new ForgeError("Messages are 1 to 2000 characters");
20
+ return ctx.db.insert("messages", { body: body.trim(), authorId: me.userId, authorName: me.name });
21
+ },
22
+ });
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "__NAME__-forge",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": {
6
+ "@archlast/forge-runtime": "^__VERSION__"
7
+ },
8
+ "devDependencies": {
9
+ "@types/bun": "1.4.3"
10
+ }
11
+ }
@@ -0,0 +1,6 @@
1
+ import { defineSchema, defineTable, v } from "@archlast/forge-runtime";
2
+
3
+ // `users` and Better Auth's tables are built in.
4
+ export default defineSchema({
5
+ messages: defineTable({ body: v.string(), authorId: v.id("users"), authorName: v.string() }).searchIndex("search_body", { searchField: "body" }),
6
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023", "DOM"],
5
+ "types": ["bun"],
6
+ "module": "ESNext",
7
+ "moduleResolution": "bundler",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "verbatimModuleSyntax": true
12
+ },
13
+ "include": ["**/*.ts"],
14
+ "exclude": ["node_modules", "data"]
15
+ }
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ dist
3
+ .forge
4
+ .env
5
+ forge/data
6
+ data
7
+ forge/_generated/.depshash
@@ -0,0 +1,19 @@
1
+ import { ForgeError, mutation, query, v } from "@archlast/forge-runtime";
2
+
3
+ /**
4
+ * Step one of an upload: the browser PUTs the file to the returned `uploadUrl`, then saves `storageId`
5
+ * (with another mutation) wherever it belongs. In React, `useUploadFile(api.files.generateUploadUrl)` does both steps.
6
+ */
7
+ export const generateUploadUrl = mutation({
8
+ args: { contentType: v.optional(v.string()) },
9
+ handler: async (ctx, args) => {
10
+ if (!(await ctx.auth.getUserIdentity())) throw new ForgeError("Sign in to upload files");
11
+ return ctx.storage.generateUploadUrl(args);
12
+ },
13
+ });
14
+
15
+ /** A temporary link to a stored file, or null if it no longer exists. */
16
+ export const url = query({
17
+ args: { storageId: v.id("_storage") },
18
+ handler: (ctx, { storageId }) => ctx.storage.getUrl(storageId),
19
+ });
@@ -0,0 +1,37 @@
1
+ # __NAME__
2
+
3
+ A full-stack app on [archlast-forge](https://github.com/algochad/archlast-forge): server-rendered React pages, a typed backend and live queries, served by one container.
4
+
5
+ ```bash
6
+ bun install
7
+ bun run dev # http://localhost:3000, reloading as you edit
8
+ ```
9
+
10
+ `dev` starts the services in `archlast-forge.json` (PostgreSQL, Redis, S3 storage) in Docker. `bun run dev -- --no-docker` skips them and uses SQLite.
11
+
12
+ ## Where things are
13
+
14
+ ```
15
+ app/
16
+ root.tsx the HTML document, header and footer around every page
17
+ routes/ one file per page: index.tsx is /, chat.tsx is /chat, [id].tsx is /:id
18
+ styles.css
19
+ forge/ your backend: schema.ts and functions
20
+ public/ served as they are: /favicon.svg, /robots.txt
21
+ ```
22
+
23
+ - A route's `loader` runs on the server for the first load and in the browser after that. `ctx.query(api.x.y, args)` fetches data and keeps it live for `useQuery`.
24
+ - `useViewer()` is the signed-in user, already right in the server-rendered HTML.
25
+ - Server-only packages go in `forge/package.json`; pages may import only `forge/_generated`.
26
+ - The admin dashboard is at http://localhost:3000/_dashboard (the key is printed on start).
27
+
28
+ ## Deploy
29
+
30
+ ```bash
31
+ bun run up # builds the image, starts the stack from compose.yaml, waits until it is healthy
32
+ bun run down
33
+ ```
34
+
35
+ `compose.yaml` runs the app with PostgreSQL, Redis, S3 storage (RustFS) and Caddy, which serves HTTPS on ports 80 and 443. Before the first real deploy, open `.env` and set `DOMAIN` to your domain, and point its DNS and `s3.<domain>` at the machine. Caddy then gets the certificates itself.
36
+
37
+ `bun run build && bun run start` runs the production bundles on this machine without Docker. `compose.yaml` is yours to edit; secrets are in `.env`, which `init` created with fresh keys and git ignores (`up` makes one only if it's missing).
@@ -0,0 +1,10 @@
1
+ // Files the bundler understands. Add more here as you import them.
2
+ declare module "*.css";
3
+ declare module "*.svg" {
4
+ const src: string;
5
+ export default src;
6
+ }
7
+ declare module "*.png" {
8
+ const src: string;
9
+ export default src;
10
+ }
@@ -0,0 +1,88 @@
1
+ import { isRouteErrorResponse, Link, NavLink, Outlet, useNavigate, useRouteError, useViewer } from "@archlast/forge-framework";
2
+ import { useAuth } from "@archlast/forge-react";
3
+ import type { ReactNode } from "react";
4
+ import "./styles.css";
5
+
6
+ /** The HTML document around every page, including error pages. */
7
+ export function Layout({ children }: { children: ReactNode }) {
8
+ return (
9
+ <html lang="en">
10
+ <head>
11
+ <meta charSet="utf-8" />
12
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
13
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
14
+ </head>
15
+ <body>{children}</body>
16
+ </html>
17
+ );
18
+ }
19
+
20
+ /** The header and footer around a page, and around the error page, so there is always a way back. */
21
+ function Chrome({ children }: { children: ReactNode }) {
22
+ const viewer = useViewer();
23
+ const auth = useAuth();
24
+ const navigate = useNavigate();
25
+
26
+ async function signOut() {
27
+ await auth.signOut();
28
+ await navigate("/");
29
+ }
30
+
31
+ return (
32
+ <>
33
+ <header className="site-header">
34
+ <div className="container">
35
+ <Link to="/" className="brand">
36
+ <img src="/favicon.svg" alt="" />
37
+ __NAME__
38
+ </Link>
39
+ <nav className="site-nav" aria-label="Main">
40
+ <NavLink to="/" end>Home</NavLink>
41
+ <NavLink to="/chat">Chat</NavLink>
42
+ </nav>
43
+ {viewer ? (
44
+ <div className="who">
45
+ <span className="avatar" aria-hidden>{viewer.name.slice(0, 1).toUpperCase()}</span>
46
+ <span>{viewer.name}</span>
47
+ <button className="btn small" onClick={signOut}>Sign out</button>
48
+ </div>
49
+ ) : (
50
+ <Link className="btn small primary" to="/sign-in">Sign in</Link>
51
+ )}
52
+ </div>
53
+ </header>
54
+ <div className="page">{children}</div>
55
+ <footer className="site-footer">
56
+ <div className="container">Built with archlast-forge · edit <code>app/root.tsx</code> to change this page</div>
57
+ </footer>
58
+ </>
59
+ );
60
+ }
61
+
62
+ export default function Root() {
63
+ return (
64
+ <Chrome>
65
+ <Outlet />
66
+ </Chrome>
67
+ );
68
+ }
69
+
70
+ /** Shown for 404s and for errors thrown by loaders and components. */
71
+ export function ErrorBoundary() {
72
+ const error = useRouteError();
73
+ const status = isRouteErrorResponse(error) ? error.status : 500;
74
+ const message = status === 404 ? "That page doesn't exist." : isRouteErrorResponse(error) ? String(error.data || error.statusText) : error instanceof Error ? error.message : "Something went wrong.";
75
+ return (
76
+ <Chrome>
77
+ <main className="container problem">
78
+ <title>{status === 404 ? "Page not found" : "Something went wrong"}</title>
79
+ <p className="code">{status}</p>
80
+ <h1>{status === 404 ? "Page not found" : "Something went wrong"}</h1>
81
+ <p className="lead">{message}</p>
82
+ <p>
83
+ <Link className="btn primary" to="/">Go home</Link>
84
+ </p>
85
+ </main>
86
+ </Chrome>
87
+ );
88
+ }
@@ -0,0 +1,69 @@
1
+ import { redirect, useViewer, type LoaderArgs } from "@archlast/forge-framework";
2
+ import { ForgeError, useMutation, usePaginatedQuery } from "@archlast/forge-react";
3
+ import { useEffect, useRef, useState, type FormEvent } from "react";
4
+ import { api } from "../../forge/_generated/api";
5
+
6
+ const PAGE = 30;
7
+
8
+ // Runs on the server for the first load and in the browser for later visits. The messages it fetches are kept in the live cache,
9
+ // so the list below has its data on first render and then stays current.
10
+ export const loader = async ({ ctx }: LoaderArgs) => {
11
+ if (!(await ctx.viewer())) throw redirect("/sign-in");
12
+ await ctx.query(api.messages.list, { paginationOpts: { numItems: PAGE, cursor: null } });
13
+ return null;
14
+ };
15
+
16
+ export default function Chat() {
17
+ const viewer = useViewer();
18
+ const { results, status, loadMore } = usePaginatedQuery(api.messages.list, {}, { initialNumItems: PAGE });
19
+ const send = useMutation(api.messages.send);
20
+ const [body, setBody] = useState("");
21
+ const [error, setError] = useState<string>();
22
+ const end = useRef<HTMLLIElement>(null);
23
+ const messages = [...results].reverse(); // the query is newest first; a chat reads oldest first
24
+
25
+ useEffect(() => {
26
+ end.current?.scrollIntoView({ block: "end" }); // not returned: in current browsers it returns a Promise, which an effect must not
27
+ }, [messages.length]);
28
+
29
+ async function submit(e: FormEvent) {
30
+ e.preventDefault();
31
+ if (!body.trim()) return;
32
+ setError(undefined);
33
+ try {
34
+ await send({ body });
35
+ setBody("");
36
+ } catch (err) {
37
+ setError(err instanceof ForgeError || err instanceof Error ? err.message : "Couldn't send that");
38
+ }
39
+ }
40
+
41
+ return (
42
+ <main className="container">
43
+ <title>Chat</title>
44
+ <h1>Chat</h1>
45
+ <section className="card chat" aria-label="Messages">
46
+ <ul className="chat-log">
47
+ {status === "CanLoadMore" && (
48
+ <li className="more">
49
+ <button className="btn small" onClick={() => loadMore(PAGE)}>Load earlier messages</button>
50
+ </li>
51
+ )}
52
+ {messages.length === 0 && <li className="chat-empty">No messages yet. Say hello, then open this page in another window.</li>}
53
+ {messages.map((m) => (
54
+ <li key={m._id} className={`msg${m.authorId === viewer?.userId ? " mine" : ""}`}>
55
+ <small>{m.authorName}</small>
56
+ <p>{m.body}</p>
57
+ </li>
58
+ ))}
59
+ <li ref={end} aria-hidden />
60
+ </ul>
61
+ <form className="chat-form" onSubmit={submit}>
62
+ <input type="text" value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write a message" aria-label="Message" maxLength={2000} autoComplete="off" />
63
+ <button className="btn primary" disabled={!body.trim()}>Send</button>
64
+ </form>
65
+ {error && <p className="error" role="alert" style={{ padding: "0 1rem" }}>{error}</p>}
66
+ </section>
67
+ </main>
68
+ );
69
+ }
@@ -0,0 +1,37 @@
1
+ import { Link, useViewer } from "@archlast/forge-framework";
2
+
3
+ export default function Home() {
4
+ const viewer = useViewer();
5
+ return (
6
+ <main className="container hero">
7
+ <title>__NAME__</title>
8
+ <p className="eyebrow">archlast-forge</p>
9
+ <h1>Realtime. Full-stack. One deploy.</h1>
10
+ <p className="lead">
11
+ Server-rendered React, a typed backend and live queries in a single container. Edit <code>app/routes/index.tsx</code> and save: the page reloads by itself.
12
+ </p>
13
+ <div className="actions">
14
+ {viewer ? (
15
+ <Link className="btn primary" to="/chat">Open the chat</Link>
16
+ ) : (
17
+ <Link className="btn primary" to="/sign-in">Get started</Link>
18
+ )}
19
+ <a className="btn" href="https://github.com/algochad/archlast-forge">Read the docs</a>
20
+ </div>
21
+ <ul className="features">
22
+ <li className="card">
23
+ <h3>Rendered on the server</h3>
24
+ <p>Every page arrives as HTML with its data already in it, then the browser takes over without a flicker.</p>
25
+ </li>
26
+ <li className="card">
27
+ <h3>Live by default</h3>
28
+ <p>A query in a component stays current. Open the chat in two windows and watch both update.</p>
29
+ </li>
30
+ <li className="card">
31
+ <h3>One container</h3>
32
+ <p>Pages, API, auth, files and the dashboard ship together. <code>archlast-forge up</code> starts the lot.</p>
33
+ </li>
34
+ </ul>
35
+ </main>
36
+ );
37
+ }
@@ -0,0 +1,60 @@
1
+ import { redirect, useNavigate, type LoaderArgs } from "@archlast/forge-framework";
2
+ import { useAuth } from "@archlast/forge-react";
3
+ import { useState, type FormEvent } from "react";
4
+
5
+ export const loader = async ({ ctx }: LoaderArgs) => {
6
+ if (await ctx.viewer()) throw redirect("/chat");
7
+ return null;
8
+ };
9
+
10
+ export default function SignIn() {
11
+ const auth = useAuth();
12
+ const navigate = useNavigate();
13
+ const [creating, setCreating] = useState(false);
14
+ const [error, setError] = useState<string>();
15
+ const [busy, setBusy] = useState(false);
16
+
17
+ async function submit(e: FormEvent<HTMLFormElement>) {
18
+ e.preventDefault();
19
+ const form = new FormData(e.currentTarget);
20
+ const email = String(form.get("email"));
21
+ const password = String(form.get("password"));
22
+ setBusy(true);
23
+ setError(undefined);
24
+ const res = creating
25
+ ? await auth.signUp.email({ email, password, name: String(form.get("name") || email.split("@")[0]) })
26
+ : await auth.signIn.email({ email, password });
27
+ setBusy(false);
28
+ if (res.error) return setError(res.error.message ?? "That didn't work");
29
+ await navigate("/chat");
30
+ }
31
+
32
+ return (
33
+ <main className="narrow form-card card">
34
+ <title>{creating ? "Create account" : "Sign in"}</title>
35
+ <h1>{creating ? "Create your account" : "Welcome back"}</h1>
36
+ <form onSubmit={submit}>
37
+ {creating && (
38
+ <div className="field">
39
+ <label htmlFor="name">Name</label>
40
+ <input id="name" name="name" type="text" autoComplete="name" />
41
+ </div>
42
+ )}
43
+ <div className="field">
44
+ <label htmlFor="email">Email</label>
45
+ <input id="email" name="email" type="email" autoComplete="email" required />
46
+ </div>
47
+ <div className="field">
48
+ <label htmlFor="password">Password</label>
49
+ <input id="password" name="password" type="password" autoComplete={creating ? "new-password" : "current-password"} minLength={8} required />
50
+ </div>
51
+ {error && <p className="error" role="alert">{error}</p>}
52
+ <button className="btn primary" style={{ width: "100%" }} disabled={busy}>{busy ? "One moment…" : creating ? "Create account" : "Sign in"}</button>
53
+ </form>
54
+ <p className="switch">
55
+ {creating ? "Already have an account? " : "New here? "}
56
+ <button type="button" onClick={() => (setCreating(!creating), setError(undefined))}>{creating ? "Sign in" : "Create an account"}</button>
57
+ </p>
58
+ </main>
59
+ );
60
+ }
@@ -0,0 +1,96 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ --bg: #fafaf9;
4
+ --fg: #1c1917;
5
+ --muted: #78716c;
6
+ --line: #e7e5e4;
7
+ --card: #ffffff;
8
+ --accent: #4f46e5;
9
+ --accent-fg: #ffffff;
10
+ --accent-soft: #eef2ff;
11
+ --danger: #dc2626;
12
+ --radius: 14px;
13
+ --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
14
+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
15
+ }
16
+
17
+ @media (prefers-color-scheme: dark) {
18
+ :root {
19
+ --bg: #0c0a09;
20
+ --fg: #fafaf9;
21
+ --muted: #a8a29e;
22
+ --line: #292524;
23
+ --card: #1c1917;
24
+ --accent: #818cf8;
25
+ --accent-fg: #0c0a09;
26
+ --accent-soft: #1e1b4b;
27
+ --danger: #f87171;
28
+ }
29
+ }
30
+
31
+ * { box-sizing: border-box; }
32
+ html { -webkit-text-size-adjust: 100%; }
33
+ body { margin: 0; min-height: 100dvh; display: flex; flex-direction: column; background: var(--bg); color: var(--fg); font: 16px/1.6 var(--font); }
34
+ a { color: var(--accent); }
35
+ h1, h2, h3 { line-height: 1.15; letter-spacing: -0.02em; margin: 0 0 0.5em; }
36
+ h1 { font-size: clamp(2rem, 5vw, 3.25rem); }
37
+ code { font-family: var(--mono); font-size: 0.9em; background: var(--accent-soft); padding: 0.1em 0.4em; border-radius: 6px; }
38
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
39
+
40
+ .container { width: min(100% - 2rem, 64rem); margin-inline: auto; }
41
+ .narrow { width: min(100% - 2rem, 26rem); margin-inline: auto; }
42
+ .page { flex: 1; padding-block: 2.5rem; }
43
+
44
+ .site-header { border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(8px); position: sticky; top: 0; z-index: 10; }
45
+ .site-header .container { display: flex; align-items: center; gap: 1.25rem; min-height: 3.5rem; }
46
+ .brand { display: inline-flex; align-items: center; gap: 0.6rem; font-weight: 700; color: var(--fg); text-decoration: none; }
47
+ .brand img { width: 1.6rem; height: 1.6rem; }
48
+ .site-nav { display: flex; gap: 0.25rem; flex: 1; }
49
+ .site-nav a { color: var(--muted); text-decoration: none; padding: 0.35rem 0.7rem; border-radius: 8px; font-size: 0.95rem; }
50
+ .site-nav a:hover { color: var(--fg); background: var(--accent-soft); }
51
+ .site-nav a[aria-current="page"] { color: var(--fg); font-weight: 600; }
52
+ .who { display: flex; align-items: center; gap: 0.6rem; font-size: 0.9rem; color: var(--muted); }
53
+ .avatar { display: grid; place-items: center; width: 1.9rem; height: 1.9rem; border-radius: 50%; background: var(--accent); color: var(--accent-fg); font-weight: 700; font-size: 0.85rem; }
54
+
55
+ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; font: inherit; font-weight: 600; padding: 0.55rem 1.1rem; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--fg); cursor: pointer; text-decoration: none; transition: transform 0.05s, background 0.15s; }
56
+ .btn:hover { background: var(--accent-soft); }
57
+ .btn:active { transform: translateY(1px); }
58
+ .btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
59
+ .btn.primary:hover { filter: brightness(1.08); }
60
+ .btn.small { padding: 0.3rem 0.7rem; font-size: 0.85rem; }
61
+ .btn:disabled { opacity: 0.55; cursor: not-allowed; }
62
+
63
+ .hero { padding-block: 3rem 2rem; }
64
+ .eyebrow { text-transform: uppercase; letter-spacing: 0.12em; font-size: 0.78rem; font-weight: 700; color: var(--accent); margin: 0 0 1rem; }
65
+ .lead { font-size: 1.2rem; color: var(--muted); max-width: 42rem; }
66
+ .actions { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-block: 1.75rem 3rem; }
67
+ .features { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; list-style: none; padding: 0; margin: 0; }
68
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 1.25rem; }
69
+ .features h3 { font-size: 1.05rem; margin-bottom: 0.3rem; }
70
+ .features p { margin: 0; color: var(--muted); font-size: 0.95rem; }
71
+
72
+ .form-card { margin-top: 4vh; }
73
+ .form-card h1 { font-size: 1.6rem; }
74
+ .field { display: grid; gap: 0.3rem; margin-bottom: 1rem; }
75
+ .field label { font-size: 0.85rem; font-weight: 600; }
76
+ input[type="text"], input[type="email"], input[type="password"] { font: inherit; padding: 0.6rem 0.8rem; border-radius: 10px; border: 1px solid var(--line); background: var(--bg); color: inherit; width: 100%; }
77
+ .switch { margin: 1rem 0 0; text-align: center; color: var(--muted); font-size: 0.9rem; }
78
+ .switch button { all: unset; color: var(--accent); cursor: pointer; text-decoration: underline; }
79
+ .error { color: var(--danger); font-size: 0.9rem; margin: 0 0 1rem; }
80
+
81
+ .chat { display: flex; flex-direction: column; height: min(70dvh, 40rem); padding: 0; overflow: hidden; }
82
+ .chat-log { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.6rem; list-style: none; margin: 0; }
83
+ .chat-empty { margin: auto; color: var(--muted); text-align: center; }
84
+ .msg { max-width: 80%; padding: 0.55rem 0.85rem; border-radius: 14px; background: var(--accent-soft); align-self: flex-start; }
85
+ .msg.mine { align-self: flex-end; background: var(--accent); color: var(--accent-fg); }
86
+ .msg small { display: block; opacity: 0.7; font-size: 0.75rem; margin-bottom: 0.1rem; }
87
+ .msg p { margin: 0; overflow-wrap: anywhere; }
88
+ .chat-form { display: flex; gap: 0.6rem; padding: 0.75rem; border-top: 1px solid var(--line); }
89
+ .chat-form input { flex: 1; }
90
+ .more { text-align: center; }
91
+
92
+ .site-footer { border-top: 1px solid var(--line); color: var(--muted); font-size: 0.85rem; padding-block: 1.25rem; }
93
+ .problem { text-align: center; padding-block: 8vh; }
94
+ .problem .lead { margin-inline: auto; }
95
+ .problem .code { font-size: clamp(3rem, 12vw, 6rem); font-weight: 800; color: var(--accent); line-height: 1; margin: 0; }
96
+ .problem pre { text-align: left; background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 1rem; overflow: auto; font: 0.8rem/1.5 var(--mono); }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "archlast-forge dev",
7
+ "build": "archlast-forge build --no-docker",
8
+ "start": "archlast-forge start --no-docker",
9
+ "codegen": "archlast-forge codegen",
10
+ "typecheck": "archlast-forge codegen && tsc --noEmit",
11
+ "up": "archlast-forge up",
12
+ "down": "archlast-forge down"
13
+ },
14
+ "dependencies": {
15
+ "@archlast/forge-framework": "^__VERSION__",
16
+ "@archlast/forge-react": "^__VERSION__",
17
+ "react": "^19.3.1",
18
+ "react-dom": "^19.3.1"
19
+ },
20
+ "devDependencies": {
21
+ "@archlast/forge-cli": "^__VERSION__",
22
+ "@types/react": "^19.3.0",
23
+ "@types/react-dom": "^19.3.0",
24
+ "typescript": "^7.0.2"
25
+ }
26
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#4f46e5"/><path d="M20 44V20h10c6 0 10 3 10 8s-4 8-10 8h-4v8z" fill="#fff"/><circle cx="46" cy="44" r="4" fill="#a5b4fc"/></svg>
@@ -0,0 +1,2 @@
1
+ User-agent: *
2
+ Allow: /
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "verbatimModuleSyntax": true,
12
+ "types": []
13
+ },
14
+ "include": ["app"]
15
+ }