x43.fast 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.
@@ -0,0 +1,75 @@
1
+ // A capsule project on disk: its source files, its x43.json, and what `init`/`pull` write.
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { z } from "zod";
6
+ import { isAppName } from "./schemas.js";
7
+ /** A message for the person: printed as is, without a stack trace. */
8
+ export class Fail extends Error {
9
+ }
10
+ /** cli/ (from src/ and from dist/ alike): templates, types and AGENTS.md live there. */
11
+ export const CLI = fileURLToPath(new URL("..", import.meta.url));
12
+ export const SIDES = ["server", "client", "shared"];
13
+ /** The vendored type project (scripts/vendor-types.mjs): the runtime it came from and the checker's options. */
14
+ export const TYPES = join(CLI, "types");
15
+ export const runtime = z
16
+ .object({ runtime: z.string(), commit: z.string(), compilerOptions: z.record(z.string(), z.unknown()), paths: z.record(z.string(), z.string()) })
17
+ .parse(JSON.parse(readFileSync(join(TYPES, "runtime.json"), "utf8")));
18
+ /** A tsconfig with the hosted checker's options, resolving `cloudapps/*` and `preact*` from `types`. */
19
+ export function tsconfig(types, include) {
20
+ const paths = Object.fromEntries(Object.entries(runtime.paths).map(([specifier, file]) => [specifier, [`${types}/${file}`]]));
21
+ return { compilerOptions: { ...runtime.compilerOptions, paths }, include: [...include, `${types}/dom.d.ts`] };
22
+ }
23
+ /** Every file under `root` as `{ "a/b.ts": text }` (posix paths relative to `root`, sorted); {} if it's missing. */
24
+ function readTree(root) {
25
+ if (!existsSync(root))
26
+ return {};
27
+ const paths = readdirSync(root, { recursive: true, withFileTypes: true })
28
+ .filter((entry) => entry.isFile())
29
+ .map((entry) => relative(root, join(entry.parentPath, entry.name)).split(sep).join("/"));
30
+ return Object.fromEntries(paths.sort().map((path) => [path, readFileSync(join(root, path), "utf8")]));
31
+ }
32
+ /** The capsule's source files in `dir`: every .ts/.tsx under server/, client/ and shared/. */
33
+ export function collectFiles(dir) {
34
+ return Object.fromEntries(SIDES.flatMap((side) => Object.entries(readTree(join(dir, side))).map(([path, text]) => [`${side}/${path}`, text]))
35
+ .filter(([path]) => /\.tsx?$/.test(path)));
36
+ }
37
+ const Project = z.object({ name: z.string().refine(isAppName, "not a valid app name") });
38
+ /** The app name saved in `dir`/x43.json, if there is one. */
39
+ export function savedName(dir) {
40
+ const path = join(dir, "x43.json");
41
+ if (!existsSync(path))
42
+ return undefined;
43
+ const project = Project.safeParse(JSON.parse(readFileSync(path, "utf8")));
44
+ if (!project.success)
45
+ throw new Fail(`${path}: expected { "name": "<app name>" }`);
46
+ return project.data.name;
47
+ }
48
+ const projectJson = (name) => `${JSON.stringify({ name }, null, 2)}\n`;
49
+ export const saveName = (dir, name) => writeFileSync(join(dir, "x43.json"), projectJson(name));
50
+ export const TEMPLATES = ["todo", "blank"];
51
+ export const template = (name) => readTree(join(CLI, "templates", name));
52
+ /**
53
+ * Writes a project: the app's files plus what editors and agents need (tsconfig.json, the vendored
54
+ * types/, AGENTS.md, CLAUDE.md), and x43.json if a name is given. Refuses to overwrite any file.
55
+ */
56
+ export function writeProject(dir, app, name) {
57
+ const files = {
58
+ ...app,
59
+ "tsconfig.json": `${JSON.stringify(tsconfig("./types", [...SIDES]), null, 2)}\n`,
60
+ ...Object.fromEntries(Object.entries(readTree(TYPES)).map(([path, text]) => [`types/${path}`, text])),
61
+ "AGENTS.md": readFileSync(join(CLI, "AGENTS.md"), "utf8"),
62
+ "CLAUDE.md": "@AGENTS.md\n",
63
+ ...(name ? { "x43.json": projectJson(name) } : {}),
64
+ };
65
+ const existing = Object.keys(files).filter((path) => existsSync(join(dir, path)));
66
+ if (existing.length) {
67
+ const more = existing.length > 3 ? ` and ${existing.length - 3} more` : "";
68
+ throw new Fail(`Won't overwrite existing files in ${dir}: ${existing.slice(0, 3).join(", ")}${more}.`);
69
+ }
70
+ for (const [path, text] of Object.entries(files)) {
71
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
72
+ writeFileSync(join(dir, path), text);
73
+ }
74
+ return Object.keys(files);
75
+ }
@@ -0,0 +1,104 @@
1
+ // The Host's publish API shapes and the app name rule, copied from mvp/src/shared/api.ts (cli/ is
2
+ // standalone). Keep them in sync with that file.
3
+ import { z } from "zod";
4
+ /** An app name: one hostname label of at most 32 characters. */
5
+ const NAME = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
6
+ // Names that would look like the site's own hosts (`api-app.x43.fast`, …).
7
+ const RESERVED = new Set(["api", "www", "mail", "admin"]);
8
+ export const isAppName = (name) => NAME.test(name) && !RESERVED.has(name);
9
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
10
+ // The largest multiple of 36 that fits in a byte: bytes at or past it are dropped, so `% 36` favours no letter.
11
+ const UNBIASED = 256 - (256 % ALPHABET.length);
12
+ /** A fresh app name: 6 random characters from [a-z0-9] (lowercase: DNS is case-insensitive). */
13
+ export function randomName(length = 6) {
14
+ let name = "";
15
+ while (name.length < length) {
16
+ for (const byte of crypto.getRandomValues(new Uint8Array(length))) {
17
+ if (byte < UNBIASED && name.length < length)
18
+ name += ALPHABET[byte % ALPHABET.length];
19
+ }
20
+ }
21
+ return name;
22
+ }
23
+ /** An app's source files: `{ [path]: source }`. */
24
+ export const Files = z.record(z.string(), z.string());
25
+ /** The body of a publish, and the answer to `GET /api/apps/:name/files`. */
26
+ export const FilesBody = z.object({ files: Files });
27
+ export const Published = z.discriminatedUnion("ok", [
28
+ // `warning`: the Host couldn't prerender `/`, so it is served empty until its script runs.
29
+ z.object({ ok: z.literal(true), hash: z.string(), url: z.string(), warning: z.string().optional() }),
30
+ z.object({ ok: z.literal(false), errors: z.array(z.string()) }),
31
+ ]);
32
+ export const Called = z.discriminatedUnion("ok", [
33
+ z.object({ ok: z.literal(true), value: z.unknown() }),
34
+ z.object({ ok: z.literal(false), error: z.string() }),
35
+ ]);
36
+ /** `POST /api/apps/:name/sandbox`: each call's result; the writes were rolled back. */
37
+ export const Sandboxed = z.object({ results: z.array(Called) });
38
+ /** `GET /api/apps/:name/errors`: the live version's runtime errors, most recently seen first. */
39
+ export const Errors = z.object({
40
+ errors: z.array(z.object({
41
+ source: z.enum(["server", "browser"]),
42
+ where: z.string(),
43
+ name: z.string(),
44
+ message: z.string(),
45
+ stack: z.string(),
46
+ count: z.number(),
47
+ firstSeen: z.number(),
48
+ lastSeen: z.number(),
49
+ })),
50
+ });
51
+ /**
52
+ * `runtime`: the runtime build the live version was built with (null: nothing published). `expiresAt`: when
53
+ * its plan deletes it (ms; on an idle expiry, if it stays idle; null: never or nothing published).
54
+ */
55
+ export const AppStatus = z.object({ name: z.string(), url: z.string(), hash: z.string().nullable(), runtime: z.string().nullable(), expiresAt: z.number().nullable().default(null) });
56
+ /** `GET /api/version`: the Host's runtime build. */
57
+ export const HostVersion = z.object({ runtime: z.string() });
58
+ /** Any refusal outside those results (401, 403, 404, 405, 413, a malformed body). */
59
+ export const Refused = z.object({ ok: z.literal(false), error: z.string() });
60
+ /** An account; `plan` is guest, free, pro or admin. */
61
+ export const User = z.object({ id: z.string(), login: z.string(), name: z.string().nullable(), plan: z.string() });
62
+ /** `GET /api/me`: the account, its plan's limits (the fields shown here) and its apps. */
63
+ export const Me = z.object({
64
+ user: User,
65
+ limits: z.object({
66
+ label: z.string(),
67
+ apps: z.number().nullable(),
68
+ visitors: z.number(),
69
+ bytes: z.number(),
70
+ versions: z.number(),
71
+ cronMinutes: z.number().nullable(),
72
+ expiry: z.object({ after: z.enum(["created", "idle"]), days: z.number() }).nullable(),
73
+ webBuilder: z.boolean(),
74
+ // Pro: outbound fetch, secrets, HTTP endpoints (absent from Hosts before them).
75
+ fetch: z.boolean().default(false),
76
+ // Pro: uploads and custom domains (absent from Hosts before them).
77
+ uploadBytes: z.number().default(0),
78
+ domains: z.number().default(0),
79
+ }),
80
+ apps: z.array(z.object({ name: z.string(), url: z.string(), title: z.string().nullable(), version: z.number().nullable(), created: z.number() })),
81
+ });
82
+ /** `POST /api/guest` (no token): a guest account (no sign-in), its token, and the claim code and link that keep its apps. */
83
+ export const Guest = z.object({ token: z.string(), claim: z.string(), claimUrl: z.string(), user: User });
84
+ /** `POST /api/claim`: the guest's apps now yours, and those over your plan's app limit (still deleted on the guest's schedule). */
85
+ export const Claimed = z.object({ claimed: z.array(z.string()), left: z.array(z.string()) });
86
+ /** `POST /api/device`: open `url` (it shows `code`), then poll `POST /api/device/token` with `device`. */
87
+ export const DeviceStart = z.object({ code: z.string(), device: z.string(), url: z.string(), interval: z.number(), expiresIn: z.number() });
88
+ /** Still waiting, or approved: the token, handed out once. */
89
+ export const DevicePoll = z.discriminatedUnion("status", [
90
+ z.object({ status: z.literal("pending") }),
91
+ z.object({ status: z.literal("approved"), token: z.string(), user: User }),
92
+ ]);
93
+ export const Ok = z.object({ ok: z.literal(true) });
94
+ /** `GET /api/apps/:name/secrets`: names only, never values. */
95
+ export const Secrets = z.object({ secrets: z.array(z.object({ name: z.string(), updated: z.number() })) });
96
+ /** A custom domain (`/api/domains`): served once `active`; `records`: what to add at the domain's DNS provider. */
97
+ export const Domain = z.object({
98
+ hostname: z.string(),
99
+ app: z.string(),
100
+ status: z.enum(["pending", "active"]),
101
+ records: z.array(z.object({ type: z.enum(["CNAME", "TXT"]), name: z.string(), value: z.string() })),
102
+ errors: z.array(z.string()),
103
+ });
104
+ export const Domains = z.object({ domains: z.array(Domain) });
package/dist/x43.js ADDED
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ // x43: create, check and deploy Cloud apps (capsules) through the Host's publish API.
3
+ import { spawn } from "node:child_process";
4
+ import { relative, resolve } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { DEFAULT_API, findLogin, loadLogin, loginOrGuest, parseCalls, removeLogin, request, saveLogin, trimApi } from "./api.js";
7
+ import { importErrors, typeErrors } from "./check.js";
8
+ import { collectFiles, Fail, runtime, savedName, saveName, template, TEMPLATES, writeProject } from "./project.js";
9
+ import { AppStatus, Called, Claimed, DevicePoll, DeviceStart, Domain, Domains, Errors, FilesBody, HostVersion, isAppName, Me, Ok, Published, randomName, Sandboxed, Secrets } from "./schemas.js";
10
+ const HELP = `x43: build live web apps on Cloud apps (${DEFAULT_API.replace(/\/api$/, "")})
11
+
12
+ x43 init [dir] [--template todo|blank] write a new capsule project (default: todo, in .)
13
+ x43 check type-check the capsule here and apply the Host's import rules
14
+ x43 deploy [--name <name>] [--no-check] check, then publish; the first deploy picks a name (x43.json).
15
+ Not logged in: deploys as a guest (apps deleted after 7 days)
16
+ x43 login [--api <url>] sign in: open the link it prints, approve the code, done
17
+ x43 login --token [--api <url>] save a token instead (read from stdin or a hidden prompt)
18
+ x43 claim after \`x43 login\`: move this machine's guest apps to your account
19
+ x43 whoami your account, plan and its limits
20
+ x43 apps your apps
21
+ x43 logout revoke this machine's token and forget it
22
+ x43 status [--name <name>] name, URL, live version and when it is deleted
23
+ x43 call <query|mutation> <handler> [json args] [… more calls] [--commit] [--name <name>]
24
+ test handlers on the live app (as the user "builder-agent") in a
25
+ sandbox: later calls see earlier writes, then all are rolled back;
26
+ --commit saves the writes (only for seed data you were asked for)
27
+ x43 errors [--name <name>] the live version's runtime errors (server and browser), newest first
28
+ x43 secret set <NAME> [--name <name>] set a secret (value from stdin or a hidden prompt; Pro): ctx.secrets.NAME
29
+ x43 secret list | rm <NAME> the app's secrets' names (never values); delete one
30
+ x43 pull <name> [dir] download an app's live source into a new project
31
+ x43 open [--name <name>] print (and open) the app's URL
32
+ x43 domain add <hostname> [--name <name>] serve the app on your own domain (Pro); prints the DNS records to add
33
+ x43 domain status your domains: pending or active, and their DNS records
34
+ x43 domain remove <hostname> stop serving an app on that domain
35
+
36
+ Env: X43_API, X43_TOKEN override the saved login. Agents: see AGENTS.md.`;
37
+ const { values, positionals } = (() => {
38
+ try {
39
+ return parseArgs({
40
+ allowPositionals: true,
41
+ options: {
42
+ api: { type: "string" },
43
+ token: { type: "boolean" },
44
+ template: { type: "string" },
45
+ name: { type: "string" },
46
+ "no-check": { type: "boolean" },
47
+ commit: { type: "boolean" },
48
+ help: { type: "boolean", short: "h" },
49
+ },
50
+ });
51
+ }
52
+ catch (e) {
53
+ console.error(`x43: ${e instanceof Error ? e.message : String(e)}\n\n${HELP}`);
54
+ process.exit(2);
55
+ }
56
+ })();
57
+ const [command, ...args] = positionals;
58
+ const here = process.cwd();
59
+ const commands = {
60
+ async login() {
61
+ const api = trimApi(values.api ?? process.env.X43_API ?? DEFAULT_API);
62
+ const token = values.token ? await readSecret(`Token for ${api}: `) : await deviceLogin(api);
63
+ if (!token)
64
+ throw new Fail("No token given.");
65
+ const { runtime: host } = await request({ api, token }, "/version", HostVersion);
66
+ const { user, limits } = await request({ api, token }, "/me", Me);
67
+ // A guest's claim outlives its token, for `x43 claim`.
68
+ const before = findLogin().login;
69
+ const unclaimed = before?.api === api ? ("guest" in before ? before.guest : before.unclaimed) : undefined;
70
+ console.log(`Logged in to ${api} as ${user.login} (${limits.label}; saved in ${saveLogin({ api, token, unclaimed })}).`);
71
+ if (unclaimed)
72
+ console.log("Run `x43 claim` to keep your guest apps on this account.");
73
+ warnRuntime(host);
74
+ },
75
+ async whoami() {
76
+ const login = loadLogin();
77
+ const { user, limits: l, apps } = await request(login, "/me", Me);
78
+ const expiry = !l.expiry ? "never expire" : l.expiry.after === "idle" ? `are deleted after ${l.expiry.days} idle days` : `are deleted ${l.expiry.days} days after their first deploy`;
79
+ console.log(`${user.login}${user.name ? ` (${user.name})` : ""} on ${login.api}`);
80
+ console.log(`plan ${l.label}: ${apps.length}${l.apps === null ? "" : ` of ${l.apps}`} apps; per app ${l.visitors} visitors at once, ${Math.round(l.bytes / 1_048_576)} MiB of data, ${l.versions} versions kept, ${l.cronMinutes === null ? "no crons" : `crons every ${l.cronMinutes} min at most`}${l.fetch ? "; fetch, secrets and HTTP endpoints" : ""}${l.uploadBytes ? `; ${l.uploadBytes / 1024 ** 3} GiB of uploads` : ""}${l.domains ? `; ${l.domains} custom domain${l.domains > 1 ? "s" : ""}` : ""}; apps ${expiry}`);
81
+ if ("guest" in login)
82
+ console.log("You're a guest (no login). To keep your apps: `x43 login`, then `x43 claim`.");
83
+ },
84
+ async apps() {
85
+ const { apps, limits } = await request(loadLogin(), "/me", Me);
86
+ if (!apps.length)
87
+ return console.log("No apps yet: `x43 init` then `x43 deploy`.");
88
+ // Only an expiry from creation is known here; `x43 status` has the exact one.
89
+ const deleted = (created) => (limits.expiry?.after === "created" ? ` deleted ${expiry(created + limits.expiry.days * DAY_MS)}` : "");
90
+ for (const a of apps)
91
+ console.log(`${a.name.padEnd(12)} ${(a.version === null ? "-" : `v${a.version}`).padEnd(5)} ${a.url}${a.title ? ` ${a.title}` : ""}${deleted(a.created)}`);
92
+ },
93
+ async logout() {
94
+ const login = loadLogin();
95
+ // The internal token can't be revoked (the Host says so); the file goes either way.
96
+ const revoked = await request(login, "/logout", Ok, {}).then(() => true, () => false);
97
+ const path = removeLogin();
98
+ console.log(`${revoked ? "Revoked the token" : "Couldn't revoke the token"}${path ? ` and removed ${path}` : ""}.`);
99
+ if ("guest" in login)
100
+ console.log("That was a guest login: its apps can't be claimed any more and are deleted on schedule.");
101
+ },
102
+ async init() {
103
+ const dir = resolve(args[0] ?? ".");
104
+ const name = values.template ?? "todo";
105
+ const chosen = TEMPLATES.find((t) => t === name);
106
+ if (!chosen)
107
+ throw new Fail(`Unknown template "${name}" (choose ${TEMPLATES.join(" or ")}).`);
108
+ const written = writeProject(dir, template(chosen));
109
+ const cd = relative(here, dir);
110
+ console.log(`Created a ${chosen} capsule in ${cd || "."} (${written.length} files). Next:${cd ? ` cd ${cd} &&` : ""} x43 check && x43 deploy`);
111
+ },
112
+ async check() {
113
+ const files = capsule();
114
+ const errors = await checkErrors(files);
115
+ if (errors.length)
116
+ failWith(errors, `check found ${errors.length} error${errors.length > 1 ? "s" : ""}.`);
117
+ console.log(`check: ok (${Object.keys(files).length} files)`);
118
+ },
119
+ async deploy() {
120
+ const files = capsule();
121
+ if (!values["no-check"]) {
122
+ const errors = await checkErrors(files);
123
+ if (errors.length)
124
+ failWith(errors, `check found ${errors.length} error${errors.length > 1 ? "s" : ""}; fix them (or deploy with --no-check).`);
125
+ }
126
+ const login = await loginOrGuest(); // no login yet: a guest account, saved like one
127
+ const saved = savedName(here);
128
+ const name = saved || values.name ? appName() : await freshName(login);
129
+ const result = await request(login, `/apps/${name}/publish`, Published, { files });
130
+ if (!result.ok)
131
+ failWith(result.errors, "The Host refused the files.");
132
+ if (!saved)
133
+ saveName(here, name);
134
+ console.log(`Deployed ${name} (${result.hash.slice(0, 12)}): ${result.url}`);
135
+ if (result.warning)
136
+ console.error(`warning: ${result.warning}`);
137
+ if ("guest" in login) {
138
+ const { expiresAt } = await appStatus(login, name);
139
+ console.log(`\nThis is a guest app (no login): it is deleted ${expiresAt === null ? "after 7 days" : expiry(expiresAt)}.`);
140
+ console.log(`To keep it: \`x43 login\`, then \`x43 claim\` (or open ${login.guest.url} signed in).`);
141
+ }
142
+ },
143
+ async status() {
144
+ const status = await appStatus(loadLogin(), appName());
145
+ console.log(`name ${status.name}\nurl ${status.url}`);
146
+ console.log(status.hash ? `version ${status.hash.slice(0, 12)} (runtime ${status.runtime})` : "version not published");
147
+ if (status.hash)
148
+ console.log(`expires ${status.expiresAt === null ? "never" : expiry(status.expiresAt)}`);
149
+ if (status.runtime)
150
+ warnRuntime(status.runtime);
151
+ },
152
+ async claim() {
153
+ const login = loadLogin();
154
+ if ("guest" in login)
155
+ throw new Fail("You're a guest: `x43 login` first, then `x43 claim` moves your guest apps to that account.");
156
+ if (!login.unclaimed)
157
+ throw new Fail("No guest apps to claim on this machine (a guest's `x43 deploy` saves its claim).");
158
+ const { claimed, left } = await request(login, "/claim", Claimed, { code: login.unclaimed.code });
159
+ saveLogin({ api: login.api, token: login.token });
160
+ console.log(claimed.length ? `Claimed ${claimed.join(", ")}: now yours, on your plan's limits.` : "No apps were moved.");
161
+ if (left.length)
162
+ console.log(`Not claimed (over your plan's app limit; deleted on the guest schedule): ${left.join(", ")}.`);
163
+ },
164
+ async call() {
165
+ const calls = parseCalls(args);
166
+ const login = loadLogin();
167
+ const app = appName();
168
+ const results = values.commit ? [] : (await request(login, `/apps/${app}/sandbox`, Sandboxed, { calls })).results;
169
+ for (const c of values.commit ? calls : [])
170
+ results.push(await request(login, `/apps/${app}/call`, Called, c)); // in order
171
+ for (const [i, result] of results.entries()) {
172
+ const { kind, name } = calls[i];
173
+ if (calls.length > 1)
174
+ console.log(`# ${kind} ${name}`);
175
+ console.log(result.ok ? JSON.stringify(result.value ?? null, null, 2) : `failed: ${result.error}`);
176
+ }
177
+ console.error(values.commit ? "(committed: visitors see these writes)" : "(sandbox: rolled back, nothing was saved; --commit saves)");
178
+ const failed = results.filter((r) => !r.ok).length;
179
+ if (failed)
180
+ throw new Fail(`${failed} of ${calls.length} call${calls.length > 1 ? "s" : ""} failed.`);
181
+ },
182
+ async errors() {
183
+ const { errors } = await request(loadLogin(), `/apps/${appName()}/errors`, Errors);
184
+ if (!errors.length)
185
+ return console.log("No runtime errors in the live version.");
186
+ for (const e of errors) {
187
+ const ago = Math.max(0, Math.round((Date.now() - e.lastSeen) / 60_000));
188
+ console.log(`${e.source} · ${e.where} · ${e.name}: ${e.message} (×${e.count}, last ${ago ? `${ago} min ago` : "just now"})`);
189
+ if (e.stack)
190
+ console.log(` at ${e.stack}`);
191
+ }
192
+ },
193
+ async secret() {
194
+ const [action, secret] = args;
195
+ const login = loadLogin();
196
+ const base = `/apps/${appName()}/secrets`;
197
+ if (action === "list" && !secret) {
198
+ const { secrets } = await request(login, base, Secrets);
199
+ if (!secrets.length)
200
+ return console.log("No secrets. Set one with `x43 secret set NAME`.");
201
+ for (const s of secrets)
202
+ console.log(s.name);
203
+ return;
204
+ }
205
+ if (!secret || (action !== "set" && action !== "rm"))
206
+ throw new Fail("Usage: x43 secret set <NAME> | x43 secret list | x43 secret rm <NAME>");
207
+ const path = `${base}/${encodeURIComponent(secret)}`;
208
+ if (action === "rm") {
209
+ await request(login, path, Ok, undefined, "DELETE");
210
+ return console.log(`Deleted ${secret}.`);
211
+ }
212
+ const value = await readSecret(`Value for ${secret} (hidden): `);
213
+ if (!value)
214
+ throw new Fail("No value given.");
215
+ await request(login, path, Ok, { value }, "PUT");
216
+ console.log(`Set ${secret}: mutations, crons and HTTP handlers read it as ctx.secrets.${secret}.`);
217
+ },
218
+ async pull() {
219
+ const [name, dir = name] = args;
220
+ if (!name || !isAppName(name))
221
+ throw new Fail("Usage: x43 pull <name> [dir]");
222
+ const { files } = await request(loadLogin(), `/apps/${name}/files`, FilesBody);
223
+ if (!Object.keys(files).length)
224
+ throw new Fail(`${name} has nothing published.`);
225
+ writeProject(resolve(dir), files, name);
226
+ console.log(`Pulled ${name} into ${dir} (${Object.keys(files).join(", ")}).`);
227
+ },
228
+ async open() {
229
+ const { url, hash } = await appStatus(loadLogin(), appName());
230
+ console.log(url);
231
+ if (!hash)
232
+ console.error("(nothing published yet)");
233
+ openUrl(url);
234
+ },
235
+ async domain() {
236
+ const [action, hostname] = args;
237
+ const login = loadLogin();
238
+ if (action === "add" && hostname) {
239
+ const domain = await request(login, "/domains", Domain, { hostname, app: appName() });
240
+ console.log(`Added ${domain.hostname} for ${domain.app}. Add these DNS records at your domain's DNS provider:`);
241
+ printDomain(domain);
242
+ return console.log("Then run `x43 domain status` until it says active (usually a few minutes after the records are in).");
243
+ }
244
+ if (action === "status") {
245
+ const { domains } = await request(login, "/domains", Domains);
246
+ if (!domains.length)
247
+ return console.log("No custom domains: `x43 domain add <hostname>` (Pro).");
248
+ for (const d of domains) {
249
+ console.log(`${d.hostname} → ${d.app}: ${d.status === "active" ? `active, https://${d.hostname}` : "pending"}`);
250
+ if (d.status === "pending")
251
+ printDomain(d);
252
+ }
253
+ return;
254
+ }
255
+ if (action === "remove" && hostname) {
256
+ await request(login, `/domains/${encodeURIComponent(hostname)}`, Ok, undefined, "DELETE");
257
+ return console.log(`Removed ${hostname}; you can delete its DNS records.`);
258
+ }
259
+ throw new Fail("Usage: x43 domain add <hostname> [--name <name>] | x43 domain status | x43 domain remove <hostname>");
260
+ },
261
+ };
262
+ function printDomain(domain) {
263
+ for (const r of domain.records)
264
+ console.log(` ${r.type.padEnd(6)} ${r.name} ${r.value}`);
265
+ for (const e of domain.errors)
266
+ console.log(` (Cloudflare: ${e})`);
267
+ }
268
+ /** The capsule's files in the current directory; fails if there is none. */
269
+ function capsule() {
270
+ const files = collectFiles(here);
271
+ if (!Object.keys(files).length)
272
+ throw new Fail(`No capsule in ${here} (no server/ or client/ files). Run \`x43 init\` first.`);
273
+ return files;
274
+ }
275
+ const checkErrors = async (files) => [...(await importErrors(files)), ...typeErrors(here)];
276
+ function failWith(errors, summary) {
277
+ for (const error of errors)
278
+ console.log(error);
279
+ throw new Fail(summary);
280
+ }
281
+ /** --name, else x43.json's name. */
282
+ function appName() {
283
+ const name = values.name ?? savedName(here);
284
+ if (!name)
285
+ throw new Fail("No app here: run `x43 deploy` first, or pass --name <name>.");
286
+ if (!isAppName(name))
287
+ throw new Fail(`"${name}" isn't a valid app name (a-z, 0-9 and dashes, at most 32).`);
288
+ return name;
289
+ }
290
+ const appStatus = (login, name) => request(login, `/apps/${name}`, AppStatus);
291
+ /** A random name with nothing published under it (the API takes any name, so don't replace someone's app). */
292
+ async function freshName(login) {
293
+ for (let i = 0; i < 5; i++) {
294
+ const name = randomName();
295
+ if ((await appStatus(login, name)).hash === null)
296
+ return name;
297
+ }
298
+ throw new Fail("Couldn't find a free app name; pass --name <name>.");
299
+ }
300
+ const DAY_MS = 86_400_000;
301
+ /** A deletion time: `on 2026-11-20 (in 7 days)`. */
302
+ const expiry = (at) => `on ${new Date(at).toISOString().slice(0, 10)} (in ${Math.max(0, Math.ceil((at - Date.now()) / DAY_MS))} days)`;
303
+ function warnRuntime(host) {
304
+ if (host === runtime.runtime)
305
+ return;
306
+ console.error(`warning: the Host's runtime is ${host}, this CLI's types are from ${runtime.runtime}; \`x43 check\` may disagree with the Host until the CLI is updated.`);
307
+ }
308
+ /** The device login: the person approves the printed code in the browser, signed in; returns the new token. */
309
+ async function deviceLogin(api) {
310
+ const start = await request({ api }, "/device", DeviceStart, {});
311
+ console.log(`Open ${start.url}\nand approve the code ${start.code} (it expires in ${Math.round(start.expiresIn / 60)} minutes).`);
312
+ openUrl(start.url);
313
+ const deadline = Date.now() + start.expiresIn * 1000;
314
+ while (Date.now() < deadline) {
315
+ await new Promise((resolve) => setTimeout(resolve, start.interval * 1000));
316
+ const polled = await request({ api }, "/device/token", DevicePoll, { device: start.device });
317
+ if (polled.status === "approved")
318
+ return polled.token;
319
+ }
320
+ throw new Fail("The code expired before it was approved; run `x43 login` again.");
321
+ }
322
+ /** Opens a URL in the browser when a person is at the terminal. */
323
+ function openUrl(url) {
324
+ if (!process.stdout.isTTY)
325
+ return;
326
+ const [cmd, ...rest] = process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", '""'] : ["xdg-open"];
327
+ spawn(cmd, [...rest, url], { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
328
+ }
329
+ /** A token or secret: all of stdin when piped, else a prompt that doesn't echo. */
330
+ async function readSecret(prompt) {
331
+ const { stdin } = process;
332
+ let text = "";
333
+ if (!stdin.isTTY) {
334
+ for await (const chunk of stdin)
335
+ text += String(chunk);
336
+ return text.trim();
337
+ }
338
+ process.stderr.write(prompt);
339
+ stdin.setRawMode(true);
340
+ try {
341
+ for await (const chunk of stdin) {
342
+ for (const char of String(chunk)) {
343
+ if (char === "\r" || char === "\n")
344
+ return text.trim();
345
+ if (char === "\u0003")
346
+ throw new Fail("Cancelled.");
347
+ text = char === "\u007f" || char === "\b" ? text.slice(0, -1) : text + char;
348
+ }
349
+ }
350
+ return text.trim();
351
+ }
352
+ finally {
353
+ stdin.setRawMode(false);
354
+ process.stderr.write("\n");
355
+ }
356
+ }
357
+ const isCommand = (name) => name !== undefined && Object.hasOwn(commands, name);
358
+ if (values.help || !command) {
359
+ console.log(HELP);
360
+ }
361
+ else if (!isCommand(command)) {
362
+ console.error(`x43: unknown command "${command}"\n\n${HELP}`);
363
+ process.exitCode = 2;
364
+ }
365
+ else {
366
+ try {
367
+ await commands[command]();
368
+ }
369
+ catch (e) {
370
+ // A Fail is meant for the person; anything else is a bug, still shown without a stack.
371
+ console.error(`x43: ${e instanceof Error ? e.message : String(e)}`);
372
+ process.exitCode = 1;
373
+ }
374
+ }
package/llms.txt ADDED
@@ -0,0 +1,27 @@
1
+ # x43
2
+
3
+ > x43 (Cloud apps) hosts small live web apps ("capsules"): a TypeScript server (schema, queries, mutations and crons over a per-app database) and a Preact + Tailwind client with a tiny router, deployed with the `x43` CLI to https://<name>-app.x43.fast.
4
+
5
+ The full guide for coding agents is AGENTS.md (copied into every project by `x43 init`).
6
+
7
+ ## Guide
8
+
9
+ - [The loop](AGENTS.md#the-loop): init → write files → `x43 check` until clean → `x43 deploy` (no account needed: a guest app, deleted after 7 days unless the person runs `x43 login` then `x43 claim`) → `x43 call` (sandboxed) and `x43 errors` to test → share the URL
10
+ - [App layout](AGENTS.md#app-layout): server/index.ts, client/index.tsx, shared/*.ts
11
+ - [Imports](AGENTS.md#imports): what each directory may import
12
+ - [Server](AGENTS.md#server-cloudappsserver): schema and validators (nested data), indexes, ctx.db queries (range, order, paginate), ctx.auth, crons, meta (title, description, icon, fonts)
13
+ - [Client](AGENTS.md#client-cloudappsclient): createClient, useQuery, useMutation, usePaginatedQuery, Router/Route/Link, useTitle, Preact, Tailwind
14
+ - [Landing pages](AGENTS.md#landing-pages): sections, meta and fonts, a private waitlist form, no invented claims, what `/` shows before its script runs
15
+ - [Example](AGENTS.md#example-a-complete-app): a complete app
16
+ - [Pro: other sites, secrets, webhooks](AGENTS.md#pro-other-sites-secrets-webhooks): fetch allowlist, ctx.secrets and x43 secret, HTTP endpoints at /_http/…
17
+ - [Testing with x43 call and x43 errors](AGENTS.md#testing-with-x43-call-and-x43-errors)
18
+ - [What the platform can't do](AGENTS.md#what-the-platform-cant-do)
19
+ - [Limits](AGENTS.md#limits): per plan (Guest, Free, Pro); `x43 whoami` prints yours
20
+ - [Common mistakes](AGENTS.md#common-mistakes)
21
+ - [CLI reference](AGENTS.md#cli-reference)
22
+
23
+ ## Optional
24
+
25
+ - [README.md](README.md): installing the CLI, deploying as a guest, login (`x43 login`: approve a code on x43.fast) and `x43 claim`, commands, types
26
+ - [types/cloudapps/runtime/server.d.ts](types/cloudapps/runtime/server.d.ts): exact server API types
27
+ - [types/cloudapps/runtime/client.d.ts](types/cloudapps/runtime/client.d.ts): exact client API types
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "x43.fast",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Create, check and deploy live web apps on x43.fast from any coding agent",
5
+ "license": "UNLICENSED",
6
+ "homepage": "https://x43.fast",
7
+ "keywords": ["x43", "deploy", "agents", "cli", "cloudflare", "web apps"],
8
+ "type": "module",
9
+ "bin": { "x43": "dist/x43.js" },
10
+ "engines": { "node": ">=22" },
11
+ "files": ["dist", "types", "templates", "skills", "AGENTS.md", "CHATS.md", "llms.txt"],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.build.json && chmod +x dist/x43.js",
14
+ "typecheck": "tsc -p tsconfig.json",
15
+ "test": "node --test 'test/*.test.ts'",
16
+ "vendor-types": "node scripts/vendor-types.mjs"
17
+ },
18
+ "dependencies": {
19
+ "es-module-lexer": "3.0.4",
20
+ "sucrase": "3.35.1",
21
+ "typescript": "7.0.2",
22
+ "zod": "^4.6.7"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^26.6.6"
26
+ }
27
+ }
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: x43
3
+ description: Build, check and deploy a small live web app (site, tool, tracker, poll, guestbook, game…) on x43 / Cloud apps (x43.fast) with the x43 CLI. Use when the user asks to make, build, create, deploy, publish or change an app, site or tool "on x43", "on cloud apps" or "on x43.fast", or works in a directory with an x43.json.
4
+ ---
5
+
6
+ # x43: live apps on x43.fast
7
+
8
+ An x43 app (capsule) is `server/index.ts` (schema, queries, mutations) + `client/index.tsx` (a Preact `App`, Tailwind classes) + optional `shared/*.ts`. The Host builds and runs it at `https://<name>-app.x43.fast`. Read the project's `AGENTS.md` (`x43 init` and `x43 pull` write it) before writing code: it has the exact rules, the API and an example.
9
+
10
+ ## Loop
11
+
12
+ 1. No account is needed to start: without a login, `x43 deploy` deploys as a guest, and guest apps are deleted 7 days after their first deploy. Tell the user so; to keep the app, they run `x43 login` themselves (it prints a link and code they approve on x43.fast; the token must never pass through you or a command line), then `x43 claim`. If a command says "refused the token", ask them to run `x43 login`. `x43 whoami` shows the account and its plan's limits.
13
+ 2. New app: `x43 init <dir> && cd <dir>`. Existing app: `cd` into it (it has `x43.json`), or `x43 pull <name> <dir>`.
14
+ 3. Write or edit `server/index.ts`, `client/index.tsx` (and `shared/` if needed). Keep it to one screen and only what was asked.
15
+ 4. `x43 check`; fix every `path:line:col TSxxxx message`; repeat until `check: ok`.
16
+ 5. `x43 deploy`; if the Host refuses (`path:line:col message`), fix and deploy again.
17
+ 6. Test on the live app in one sandboxed sequence (rolled back, visitors see nothing): `x43 call mutation <name> '<json args>' query <name>`; confirm the results. Then `x43 errors`. Fix → check → deploy → call until right. `--commit` only for seed data the user asked for.
18
+ 7. Give the user the URL from `x43 deploy` (or `x43 open`) and one short paragraph on what the app does.
19
+
20
+ ## Rules that bite (details in AGENTS.md)
21
+
22
+ - server imports only `cloudapps/server` + relative; client only `preact`, `preact/hooks`, `cloudapps/client` + relative, plus `import type app from "../server"`; shared only relative. No npm packages, React or localStorage for data.
23
+ - Growing lists go in their own table (`v.id` + index), not `v.array`; read with an index and `take`/`paginate`/`count`, not by collecting a table. Time-based work (daily resets, expiry) is a cron. Args are one JSON object (`{}` for none) and untrusted: validate and `throw new Error("…")`.
24
+ - Preact: `class`, `onInput`, hooks from `preact/hooks`; `useQuery` is `undefined` while loading; catch mutation rejections.
25
+ - Don't use `--no-check` to get past type errors.
26
+ - `/` is rendered to HTML at deploy (queries loading): browser globals (`window`, `document`, `localStorage`) only in `useEffect` and handlers, or `x43 deploy` warns and `/` is empty until its script runs.
27
+ - Pro only: `fetch: ["api.example.com"]` in `capsule()` lets mutations, crons and HTTP handlers (not queries) call those hosts over https; `ctx.secrets.NAME` holds API keys the user sets with `x43 secret set NAME` (ask them to run it; never put a key in code or pass it through you); `http: { "POST /hook": (ctx, req) => ({ status, body }) }` serves webhooks at `<app URL>/_http/hook`. Other plans: the deploy is refused; tell the user.
28
+ - APIs (AI, weather, email, payments) only through Pro `fetch` with the user's key; otherwise say so and build the closest honest version. No scripts from other sites (fonts: `meta.fonts`); images from other sites only over https and not on the Guest plan. A deploy over the plan's limits (apps, cron interval) is refused: tell the user. Data is shared by default; private (filtered by `ctx.auth.userId`) only when clearly personal; show only actions the server allows.
29
+ - Uploads only on Pro: `const { url } = await upload(file)` from `cloudapps/client` (images or PDF, ≤ 10 MiB; store the `url`), `ctx.files.delete(url)` in a mutation. Custom domains (Pro): `x43 domain add <hostname>`, only when asked.
30
+
31
+ ## Landing pages
32
+
33
+ Stacked full-width Tailwind sections (hero with one call to action, features, FAQ, footer), mobile first. `meta`: real title, one-line description, emoji `icon`, `fonts: ["Inter", "Playfair Display"]` (used as `font-[Inter]`, `font-[Playfair_Display]`). A waitlist/contact form is a validating mutation that stores the entry with no query returning it; the owner reads entries in the Data tab. No invented testimonials, logos, numbers or quotes. Keep the text static in components so it is in the HTML of `/`.