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.
package/AGENTS.md ADDED
@@ -0,0 +1,227 @@
1
+ # Building apps on x43 (Cloud apps)
2
+
3
+ x43 hosts small, live web apps called capsules: a TypeScript server (schema, queries, mutations and scheduled crons over a per-app database) and a Preact client styled with Tailwind classes. You write the source files; `x43 deploy` sends them to the Host, which builds and runs them at a public URL, `https://<name>-app.x43.fast`. There is no build step, no npm install and no server to run: the Host builds the source, stores the data and keeps every open page live.
4
+
5
+ ## The loop
6
+
7
+ 1. `x43 init my-app && cd my-app` (or `--template blank`). It writes a working todo app, `tsconfig.json`, `types/` and this file.
8
+ 2. Write `server/index.ts` and `client/index.tsx` (and `shared/*.ts` if both sides need it).
9
+ 3. `x43 check` and fix every error it prints (`path:line:col TSxxxx message`). Repeat until it prints `check: ok`.
10
+ 4. `x43 deploy`. It checks again, publishes, and prints the live URL. The first deploy picks a random name and saves it in `x43.json`; later deploys update the same app. If the Host refuses the files (`path:line:col message`), fix them and deploy again: a refused deploy never replaces the live version. No login is needed: without one, the first deploy makes a guest account on this machine, and its apps are deleted 7 days after their first deploy (the output says when, and how to keep them). Tell the person that, and that they keep the app by running `x43 login` themselves; then `x43 claim`.
11
+ 5. Test the live app in one sandboxed sequence: `x43 call mutation add '{"text":"hello"}' query list`, and check the results (nothing is saved). Then `x43 errors` and fix what it lists. Fix, check, deploy and test again until it's right.
12
+ 6. Share the URL (`x43 open` prints it).
13
+
14
+ There is no local dev server: the deployed app is the preview, and a deploy takes about a second. `x43 status` shows the name, URL and live version; `x43 pull <name> [dir]` downloads an app's live source into a new project.
15
+
16
+ ## App layout
17
+
18
+ - `server/index.ts`: `export default capsule({ meta, schema, queries, mutations, crons, fetch, http })` (`meta`, `crons`, `fetch` and `http` optional; the last two are Pro)
19
+ - `client/index.tsx`: `export function App()`, a Preact component (mounted for you; no index.html, no render call)
20
+ - `shared/*.ts`: optional pure TypeScript used by both sides
21
+
22
+ Only `.ts`/`.tsx` files under `server/`, `client/` and `shared/` are deployed. `x43.json` holds the app name; `types/` and `tsconfig.json` are for editors and `tsc` (don't edit `types/`; `x43 check` uses its own copy and settings).
23
+
24
+ ## Imports
25
+
26
+ Anything else fails `x43 check` and the deploy.
27
+
28
+ - `server/` imports only `"cloudapps/server"` and relative `server/` or `shared/` files.
29
+ - `client/` imports only `"preact"`, `"preact/hooks"`, `"cloudapps/client"` and relative `client/` or `shared/` files, plus `import type app from "../server"`.
30
+ - `shared/` imports only relative `shared/` files.
31
+
32
+ No npm packages, no React, no `localStorage` for app data: data lives on the server. `fetch` only in server code, on Pro (see "Pro: other sites, secrets, webhooks"). Only whole-statement `import type …` is erased; `import { type X } from "../server"` still counts as a value import of `../server` and fails.
33
+
34
+ ## Server (`cloudapps/server`)
35
+
36
+ ```ts
37
+ import { capsule, table, v } from "cloudapps/server";
38
+ ```
39
+
40
+ - `schema`: `name: table({ field: v.string() | v.number() | v.boolean() | v.id("table") | v.userId() | v.literal("a") | v.union(v.literal("a"), v.literal("b")) | v.array(v.string()) | v.object({ n: v.number() }) | v.optional(...) }).index("by_a_b", ["a", "b"])`. Every document also has `_id: string`. Times as `Date.now()` numbers. Writes are validated against the whole shape, nested fields included. Indexes are optional and list top-level string, number, boolean or literal fields.
41
+ - `queries` / `mutations`: `name: async (ctx, args: { ... }) => value`. Args are one JSON object (use `{}` for none). Return JSON values.
42
+ - `ctx.db.get(table, id)` → doc or `null`
43
+ - `ctx.db.query(table)`, optionally `.withIndex("by_a_b", [aValue], { gte: from, lt: to })` (equality on the index's first fields, then one range `gt`/`gte` and/or `lt`/`lte` on the next field), optionally `.order("desc")`, then `.collect()`, `.first()`, `.take(n)`, `.paginate(args.paginationOpts)` → `{ page, cursor, isDone }` or `.count()` → a number, counted in SQLite. Results are ordered by the index's remaining fields, then insertion; without an index, by insertion (oldest first). Reads cost per row read: use an index with `take`/`paginate` rather than collecting a whole table to filter in JS, and `.count()` (with an index, e.g. votes per option) rather than collecting to count.
44
+ - Mutations only: `ctx.db.insert(table, doc)` → id; `ctx.db.patch(table, id, fields)` (top-level fields); `ctx.db.delete(table, id)`.
45
+ - `ctx.auth.userId`: the visitor's stable anonymous id (a per-app cookie; `"cron"` in crons). `ctx.log(...)` logs.
46
+ - Args are untrusted: coerce and check them (`String(args.text ?? "").trim()`, lengths, numbers) and `throw new Error("message")` to reject (the visitor sees the message; it isn't a runtime error, while any other throw, like a TypeError, is).
47
+ - A mutation is atomic: if it throws, none of its writes happen. Queries are live: every open page updates when data changes.
48
+ - Paginated query (for "load more" lists): `import { capsule, table, v, type PaginationOpts } from "cloudapps/server"`, then `feed: (ctx, args: { paginationOpts: PaginationOpts }) => ctx.db.query("posts").withIndex("by_at").order("desc").paginate(args.paginationOpts)`.
49
+ - `crons`: scheduled mutations, UTC. `crons: { cleanup: { every: "1h", run: async (ctx) => { ... } } }`; `every` is a number and `m`, `h` or `d`, no more often than the plan allows (Guest: no crons; Free: `"1h"`; Pro: `"1m"`); `at: "07:00"` only with `every: "1d"`. Runs fall on the hour, the quarter hour, midnight UTC (or `at`), …; a failing run is logged and its writes are dropped. Crons don't count as visits: on Free, an app nobody visits is still deleted after 60 days. Anything due at a time (a daily reset, expiring old items, closing a poll) is a cron, not code that waits for a visit.
50
+ - `meta: { title: "Habits", description: "One line for link previews", icon: "✅", fonts: ["Inter"] }`: the page's `<title>`, description and link preview (Open Graph); `icon` is an emoji favicon; `fonts`: up to 3 Google Fonts family names, used as `class="font-[Inter]"` (spaces as `_`: `font-[Playfair_Display]`).
51
+
52
+ The exact types are in `types/cloudapps/runtime/server.d.ts`.
53
+
54
+ ## Client (`cloudapps/client`)
55
+
56
+ ```tsx
57
+ import { createClient } from "cloudapps/client";
58
+ import { useState } from "preact/hooks";
59
+ import type app from "../server";
60
+ const { useQuery, useMutation, usePaginatedQuery } = createClient<typeof app>();
61
+ ```
62
+
63
+ - `const items = useQuery("list", {})` is the live result, `undefined` while loading.
64
+ - `const { results, status, loadMore } = usePaginatedQuery("feed", {}, { initialNumItems: 20 })` for a paginated query (its args without `paginationOpts`): live; `status` is `"LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted"`; show "Load more" when it's `"CanLoadMore"` and call `loadMore(20)`.
65
+ - Several screens: `import { Router, Route, Link, useRoute, useTitle, navigate } from "cloudapps/client"`. `<Router><Route path="/"><Home /></Route><Route path="/habits/:id"><Habit /></Route><Route><NotFound /></Route></Router>` renders the first matching route (a `Route` without `path` matches anything); `<Link href={`/habits/${h._id}`} class="…">` navigates without a reload; `const { path, params } = useRoute()` (`params.id`); `navigate("/")` after a mutation; `useTitle(habit.name)` sets the tab title. Every path serves the app, so links and reloads work.
66
+ - `const add = useMutation("add")`; `add({ text })` returns a Promise that rejects with the server's error message. Catch it and show it.
67
+ - Preact: `class` (not `className`), `onInput`, `e.currentTarget.value`, hooks from `"preact/hooks"`.
68
+ - Style with Tailwind utility classes only, written as literal strings in the source (no CSS files, no `<style>`; class names built at runtime from pieces aren't compiled). Make it look clean: a full-page layout (`min-h-screen`), readable spacing, clear buttons.
69
+ - `/` is rendered to HTML once per deploy, with every query still loading, so visitors, search engines and link previews see it before any script runs; then the page comes alive in the browser. Use `window`, `document` and `localStorage` only in `useEffect` and event handlers: otherwise `x43 deploy` prints a warning and `/` is empty until its script runs. Other paths render in the browser only.
70
+
71
+ ## Landing pages
72
+
73
+ For a landing, product or launch page:
74
+
75
+ - One `App` of stacked full-width sections (a hero with one clear call to action, features, how it works, FAQ, footer), mobile first, laid out with Tailwind (`mx-auto max-w-5xl px-6 py-24`, `grid gap-8 sm:grid-cols-3`).
76
+ - `meta`: a real title and one-line description (search results and link previews show them), an emoji icon, and `fonts` (one for headings, one for text).
77
+ - A waitlist or contact form is a mutation that validates and stores the entry (`email`, `message`, `at: Date.now()`), with no query returning the entries: the owner reads them in the builder's Data tab. Show a thank-you state after it succeeds and the mutation's error when it fails.
78
+ - Only facts the person gave you: no invented testimonials, customer logos, user counts, ratings, prices or press quotes. Leave them out or ask.
79
+ - Keep the page's text in the components, not behind a query, so it is in the HTML of `/` (check with `curl -s <url>`).
80
+
81
+ ## Example (a complete app)
82
+
83
+ ```ts
84
+ // server/index.ts
85
+ import { capsule, table, v } from "cloudapps/server";
86
+ export default capsule({
87
+ schema: { notes: table({ text: v.string(), author: v.userId(), at: v.number() }) },
88
+ queries: {
89
+ list: (ctx) => ctx.db.query("notes").order("desc").take(100),
90
+ },
91
+ mutations: {
92
+ add: async (ctx, args: { text: string }) => {
93
+ const text = String(args.text ?? "").trim();
94
+ if (!text || text.length > 500) throw new Error("Write 1-500 characters");
95
+ return ctx.db.insert("notes", { text, author: ctx.auth.userId, at: Date.now() });
96
+ },
97
+ },
98
+ });
99
+ ```
100
+
101
+ ```tsx
102
+ // client/index.tsx
103
+ import { createClient } from "cloudapps/client";
104
+ import { useState } from "preact/hooks";
105
+ import type app from "../server";
106
+ const { useQuery, useMutation } = createClient<typeof app>();
107
+ export function App() {
108
+ const notes = useQuery("list", {});
109
+ const add = useMutation("add");
110
+ const [text, setText] = useState("");
111
+ const [error, setError] = useState("");
112
+ return (
113
+ <main class="mx-auto min-h-screen max-w-lg p-6">
114
+ <form class="flex gap-2" onSubmit={(e) => { e.preventDefault(); add({ text }).then(() => { setText(""); setError(""); }, (err: Error) => setError(err.message)); }}>
115
+ <input class="flex-1 rounded border px-3 py-2" value={text} onInput={(e) => setText(e.currentTarget.value)} />
116
+ <button class="rounded bg-black px-4 py-2 text-white">Add</button>
117
+ </form>
118
+ {error && <p class="mt-2 text-red-600">{error}</p>}
119
+ {notes === undefined ? <p>Loading…</p> : <ul class="mt-4 space-y-2">{notes.map((n) => <li key={n._id}>{n.text}</li>)}</ul>}
120
+ </main>
121
+ );
122
+ }
123
+ ```
124
+
125
+ ## Pro: other sites, secrets, webhooks
126
+
127
+ On the Pro plan (`x43 whoami` says "fetch, secrets and HTTP endpoints"). On other plans a deploy that uses `fetch` or `http` is refused; tell the user.
128
+
129
+ - **Calling other sites**: list the hosts in `fetch` (exact names, at most 10); then mutations, crons and HTTP handlers may call the global `fetch` over https. Queries can't (their results are cached). At most 10 fetches per call, 5 s each, responses up to 5 MiB, redirects aren't followed (you get the 3xx). A fetch is a side effect: it happens even if the mutation throws later, and also in `x43 call`'s sandbox.
130
+ - **Secrets**: API keys never go in code. The user sets them: `x43 secret set OPENWEATHER_KEY` (reads the value from stdin or a hidden prompt; ask the user to run it, never pass a key through you), `x43 secret list` (names only), `x43 secret rm NAME`. Handlers read `ctx.secrets.NAME` (`string | undefined`) in mutations, crons and HTTP handlers, not queries. They belong to the app and survive deploys. A secret's value is replaced by `[secret NAME]` in errors; never return one from a handler.
131
+ - **HTTP endpoints** (webhooks): `http: { "POST /stripe": handler }` is served at `<app URL>/_http/stripe` and runs like a mutation as user `"http"`. `req` is `{ method, path, query, headers (lowercase), body (text, ≤ 1 MiB) }`; return `{ status?, headers?, body? }` (a string body as is, anything else as JSON; only content-type, cache-control, retry-after and location headers are kept). A throw answers 500 `{ error }`; over 120 requests a minute per app, 429. Check the sender's signature with `crypto.subtle` and a secret.
132
+
133
+ ```ts
134
+ // server/index.ts (Pro)
135
+ import { capsule, table, v } from "cloudapps/server";
136
+ export default capsule({
137
+ fetch: ["api.openweathermap.org"],
138
+ schema: { readings: table({ city: v.string(), temp: v.number(), at: v.number() }), events: table({ type: v.string(), at: v.number() }) },
139
+ queries: { latest: (ctx) => ctx.db.query("readings").order("desc").take(10) },
140
+ mutations: {
141
+ refresh: async (ctx, args: { city: string }) => {
142
+ const key = ctx.secrets.OPENWEATHER_KEY;
143
+ if (!key) throw new Error("Set the OPENWEATHER_KEY secret: x43 secret set OPENWEATHER_KEY");
144
+ const city = String(args.city ?? "").trim().slice(0, 80);
145
+ const res = await fetch(`https://api.openweathermap.org/data/2.5/weather?units=metric&q=${encodeURIComponent(city)}&appid=${key}`);
146
+ if (!res.ok) throw new Error(`Weather lookup failed (${res.status})`);
147
+ const data = (await res.json()) as { main?: { temp?: number } };
148
+ const temp = Number(data.main?.temp);
149
+ if (!Number.isFinite(temp)) throw new Error("No temperature in the answer");
150
+ return ctx.db.insert("readings", { city, temp, at: Date.now() });
151
+ },
152
+ },
153
+ http: {
154
+ "POST /hook": async (ctx, req) => {
155
+ const secret = ctx.secrets.HOOK_SECRET ?? "";
156
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
157
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(req.body)));
158
+ const hex = [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
159
+ if (!secret || req.headers["x-signature"] !== hex) return { status: 401, body: { error: "bad signature" } };
160
+ const event = JSON.parse(req.body) as { type?: unknown };
161
+ await ctx.db.insert("events", { type: String(event.type ?? "unknown"), at: Date.now() });
162
+ return { body: { ok: true } };
163
+ },
164
+ },
165
+ });
166
+ ```
167
+
168
+ ## Testing with `x43 call` and `x43 errors`
169
+
170
+ `x43 call <query|mutation> <handler> ['<json args>'] [<query|mutation> <handler> ['<json args>'] …]` runs handlers on the live app, in order, and prints each JSON result (or the error it threw; exit 1 if any failed). It runs as the fixed user `builder-agent`, so per-user queries see that user's data. It runs in a sandbox: each call sees the real data plus the earlier calls' writes, then every write is rolled back, so visitors never see test data. Run the main mutation with realistic data, then the main query, in one command. `--commit` saves the writes for real: use it only when the person asked for sample or seed data.
171
+
172
+ `x43 errors` lists the live version's runtime errors, newest first and counted: handlers that threw when visitors used the app (with the `file.js:line:col` they threw at; lines match your `.ts`/`.tsx` files) and uncaught errors from its pages in visitors' browsers. A new deploy starts an empty list. Check it after deploying and testing.
173
+
174
+ ## Limits
175
+
176
+ - They depend on the account's plan (`x43 whoami` prints them):
177
+
178
+ | | Guest | Free | Pro |
179
+ |---|---|---|---|
180
+ | apps | 3 | 5 | 25 |
181
+ | an app is deleted | 7 days after its first deploy | after 60 days without a visit, mutation or deploy | never |
182
+ | visitors at once, per app | 10 | 20 | 250 |
183
+ | data per app | 1 MiB, 10k rows | 10 MiB, 100k rows | 100 MiB, 1M rows |
184
+ | crons | none | every 1h at most | every 1m at most |
185
+ | versions kept | 5 | 5 | 30 |
186
+ | images from other https sites | no | yes | yes |
187
+ | fetch, secrets, HTTP endpoints | no | no | yes |
188
+ | uploads (images, PDFs) per app | no | no | 5 GiB |
189
+ | custom domains | no | no | 1 |
190
+
191
+ - Guest (no login) apps also carry a small top banner ("Unclaimed preview · deleted in N days · Report") and are hidden from search engines; a guest makes at most 3 new apps a day per network. Their server code runs in a smaller JavaScript engine: plain compute is slower, and there is no `TextEncoder`, `URL`, `Intl` or `structuredClone` (`crypto.randomUUID` and `crypto.getRandomValues` work).
192
+ - 5 s per handler; a deploy's files: at most 1 MiB of JSON. A visitor past the visitor cap sees "This app is full right now, retrying…" until a seat frees.
193
+ - Apps are public: anyone with the URL can use them, as an anonymous guest (one id per browser). There are no accounts or admins.
194
+
195
+ ## What the platform can't do
196
+
197
+ No file uploads except on Pro (below). Third-party APIs (weather, maps, AI, prices, email, SMS, payments) only on Pro, through `fetch` with the user's own key as a secret. Otherwise, or without a key, tell the person plainly and build the closest honest version (manual entry instead of live data); never fake it. Never store images or files in the database (data URLs, base64): data per app is small. Pages can't load scripts from other sites, fonts only through `meta.fonts`, and images from other sites only over `https:` and not on the Guest plan (the Content-Security-Policy blocks the rest; `x43 errors` lists each block): prefer emoji, inline SVG or Tailwind to image links.
198
+
199
+ - Uploads (Pro only; on other plans `upload` rejects with "Uploads are part of Pro…"): `import { upload } from "cloudapps/client"`; `const { url } = await upload(file)` (a `File` from `<input type="file" accept="image/*,application/pdf">`, at most 10 MiB, PNG, JPEG, WebP, GIF, AVIF or PDF by its content; SVG and HTML are refused). Store `url` (`/_files/<id>`) in a document and use it as `<img src={url}>` or a link. `ctx.files.delete(url)` in a mutation deletes the file when the mutation commits (check that the visitor may). Catch `upload`'s rejection and show its message (too large, wrong type, storage full).
200
+ - Data is shared by default: every visitor sees the same boards, lists, recipes and posts. Make data private only when it is clearly personal (contact messages, emails, a personal journal or health log): return it only to its author (queries filter by `ctx.auth.userId`). The owner reads all of an app's data in the builder's Data tab (x43.fast), so an "inbox" needs no public query.
201
+ - Only show actions the server allows this visitor (Delete only on their own items), and check them in the mutation too.
202
+
203
+ ## Common mistakes
204
+
205
+ - React habits: `className`, `import React`, `onChange` for live text input (use `onInput`), hooks from `"preact"` instead of `"preact/hooks"`.
206
+ - npm packages, `localStorage` or `Date`-based ids for app data. Store data with `ctx.db`; ids come from `insert`. `fetch` in a query or client code, or an API key in the source (use a secret).
207
+ - Growing lists inside a document (comments, check-ins): every patch rewrites the whole document and it counts against the app's data limit. Use another table with a `v.id("parent")` field and an index on it; keep `v.array` for small, bounded lists like tags.
208
+ - Handlers without args: call them with `{}` (`useQuery("list", {})`), not with nothing.
209
+ - Treating `useQuery`'s `undefined` (loading) as empty, or not catching a mutation's rejection.
210
+ - Trusting args: the client can send anything; validate in the mutation and throw a readable message.
211
+ - Collecting a whole table and filtering or sorting it in JS: use an index (`withIndex`, `order`) with `take` or `paginate`. Index order is the index's fields after the equal ones, then insertion; `.order("desc")` for newest first.
212
+ - Indexing an array or object field (only top-level scalars), or a cron more often than the plan allows: the deploy is refused.
213
+ - Deploying with `--no-check` to get past type errors: the Host strips types without checking them, so the app breaks at runtime instead.
214
+ - Building class names from pieces (`"bg-" + color`): Tailwind only sees literal class names.
215
+
216
+ ## CLI reference
217
+
218
+ - `x43 login [--api <url>]`: prints (and opens) a link to x43.fast with a code; the person approves it while signed in there, and the CLI saves its own token in `~/.config/x43/config.json`. `x43 login --token` reads a token from stdin or a hidden prompt instead. `X43_API` and `X43_TOKEN` override the file. Only the person runs it, never you. Not needed to start: `x43 deploy` without a login deploys as a guest.
219
+ - `x43 claim`: after `x43 login`, moves this machine's guest apps to the account (as many as its plan allows; the rest are still deleted after 7 days).
220
+ - `x43 whoami` (account, plan, limits), `x43 apps` (your apps, with a guest app's deletion date), `x43 logout` (revokes this machine's token). An app belongs to whoever deployed it first; deploying to someone else's name is refused.
221
+ - `x43 init [dir] [--template todo|blank]`: a new project; never overwrites files.
222
+ - `x43 check`: import rules and `tsc` with the Host's settings; exit 1 on errors.
223
+ - `x43 deploy [--name <name>] [--no-check]`: check, publish, print the URL (and a warning if `/` couldn't be rendered to HTML).
224
+ - `x43 call … [--commit]`: sandboxed handler calls (see above); `x43 errors`: the live version's runtime errors.
225
+ - `x43 secret set NAME` (value from stdin or a hidden prompt; the user runs it), `x43 secret list`, `x43 secret rm NAME` (Pro).
226
+ - `x43 status` (with when the app is deleted), `x43 open`, `x43 pull <name> [dir]`; `--name <name>` targets another app than `x43.json`'s.
227
+ - `x43 domain add <hostname>` (Pro: the app on the person's own domain; prints a CNAME and a TXT record they add at their DNS provider), `x43 domain status` (pending or active), `x43 domain remove <hostname>`. Only when the person asks for it.
package/CHATS.md ADDED
@@ -0,0 +1,27 @@
1
+ # Use x43 from ChatGPT or Claude
2
+
3
+ Build and deploy x43 apps from a chat, with your chat subscription as the model: add x43 as a connector once, then ask for an app ("build me a guestbook"). The chat writes the code, checks it, deploys it to `https://<name>-app.x43.fast`, tests it and gives you the link. Your apps are the same ones as in the CLI and on x43.fast.
4
+
5
+ The connector's URL is **`https://x43.fast/mcp`**. You need an x43 account (sign in with GitHub; Free or Pro, not a Guest). Whether your chat plan can add custom connectors is up to OpenAI and Anthropic.
6
+
7
+ ## Claude.ai
8
+
9
+ 1. **Customize → Connectors** (Team and Enterprise: an owner adds it under **Organization settings → Connectors**, then each member connects it there).
10
+ 2. **+ Add → Add custom connector**, name it `x43`, enter `https://x43.fast/mcp`, **Continue**, keep the detected sign-in and **Add**.
11
+ 3. Connect: a page on x43.fast opens. Sign in if asked, then **Allow** ("Claude wants to build and deploy apps as you").
12
+ 4. In a chat, turn the connector on (**+ → Connectors**) and ask for an app.
13
+
14
+ ## ChatGPT
15
+
16
+ 1. Open **chatgpt.com/plugins**, the **+** button, **Add custom MCP server**.
17
+ 2. Name `x43`, URL `https://x43.fast/mcp`, OAuth sign-in; accept the warning (**I understand and want to continue**), **Create as a plugin**.
18
+ 3. Sign in on x43.fast when asked and **Allow**.
19
+ 4. In a new chat, type `@` and pick x43, then ask for an app.
20
+
21
+ ## What the chat can do
22
+
23
+ `guide` (how x43 apps are written; the chat reads it first) · `list_apps` · `create_app` · `list_files`, `read_file`, `write_file`, `edit_file` · `check` (type-check) · `deploy` (check, then publish; returns the URL) · `call` (test the live app's queries and mutations; rolled back unless you ask for real data) · `errors` (the live app's runtime errors) · `status`. Your plan's limits apply as everywhere (apps, data, crons).
24
+
25
+ ## Disconnect
26
+
27
+ On x43.fast, the profile menu's **API tokens** list each connected chat by name: the bin icon disconnects it at once. Removing the connector in the chat stops it too.
package/README.md CHANGED
@@ -1,3 +1,74 @@
1
- # Temporary Holding Version
1
+ # x43 CLI
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, check and deploy Cloud apps (capsules) on x43.fast from any coding agent (Claude Code, Codex, T3 Code, …) or by hand. It talks to the Host's publish API (`../mvp/README.md`, "Publish API"); nothing here needs the mvp repo at run time. The guide for agents is [AGENTS.md](AGENTS.md); `x43 init` copies it into every project. Without a terminal, ChatGPT and Claude.ai build through x43's MCP server instead: [CHATS.md](CHATS.md).
4
+
5
+ ## Install
6
+
7
+ Node 22 or newer.
8
+
9
+ ```
10
+ curl -fsSL https://x43.fast/install | sh # installs the `x43` command
11
+ x43 init my-app && cd my-app # a working app + AGENTS.md / CLAUDE.md, so your coding agent knows the format
12
+ x43 deploy # live at https://<name>-app.x43.fast; no account needed (a guest app, deleted after 7 days)
13
+ ```
14
+
15
+ Or with npm: `npm i -g x43.fast` (the package is named after the site; the command is `x43`), or without installing: `npx x43.fast deploy`. Keeping apps past 7 days takes an account: `x43 login`, then `x43 claim`. Claude Code users can also add the skill in `skills/x43` (in this package).
16
+
17
+ From a checkout of this repo: `cd cli && pnpm install && pnpm build && pnpm link --global` (or run `node cli/dist/x43.js`).
18
+
19
+ **Guest**: `x43 deploy` with no saved login (and no `X43_TOKEN`) first asks `POST /api/guest` for a guest account and saves `{ api, token, guest: { code, url } }`. Its apps run on the Guest plan (3 apps, deleted 7 days after their first deploy, a banner on the page); the deploy prints the deletion date, the claim link and `x43 login` then `x43 claim`. `x43 login` keeps the guest's claim (`unclaimed`), and `x43 claim` sends it (`POST /api/claim`), moving the guest's apps to the account (those over its plan's app limit stay and are still deleted on schedule).
20
+
21
+ `x43 login [--api <url>]` is a device login: it asks the API for a code (`POST /api/device`), prints and opens `https://x43.fast/cli?code=XXXX-XXXX`, and polls (`POST /api/device/token`, every 2 s, 10 minutes) until the person, signed in to x43.fast, approves it; the API then hands out a new token for this machine, once. `x43 login --token` reads a token from stdin or a hidden prompt instead (admin, scripts; e.g. `x43 login --token < token-file`). Either way the token is checked against `GET /api/version` and `GET /api/me` and saved as `{ api, token }` in `~/.config/x43/config.json` (mode 600; `$XDG_CONFIG_HOME` is honoured). The token is never a CLI argument. `X43_API` and `X43_TOKEN` override the file; a saved token is only sent to the API it was saved for. `x43 logout` revokes it (`POST /api/logout`) and removes the file.
22
+
23
+ ## The loop
24
+
25
+ ```
26
+ x43 init my-app && cd my-app # todo template (or --template blank), tsconfig.json, types/, AGENTS.md, CLAUDE.md
27
+ # write server/index.ts, client/index.tsx, shared/*.ts
28
+ x43 check # until it prints "check: ok"
29
+ x43 deploy # → https://<name>-app.x43.fast; the first deploy saves the name in x43.json
30
+ x43 call mutation add '{"text":"hi"}' query mine # a sandbox: rolled back, visitors see nothing
31
+ x43 errors # the live version's runtime errors
32
+ ```
33
+
34
+ ## Commands
35
+
36
+ | Command | What it does |
37
+ |---|---|
38
+ | `x43 login [--api <url>] [--token]` | Sign in with a code approved on x43.fast (or, with `--token`, a token from stdin or a hidden prompt); save the API URL (default `https://x43.fast/api`) and the token. |
39
+ | `x43 claim` | After `x43 login`: move this machine's guest apps to the account (`POST /api/claim`). |
40
+ | `x43 whoami` | The account, its plan and the plan's limits (`GET /api/me`). |
41
+ | `x43 apps` | Your apps: name, live version, URL, title (and the deletion date on Guest). |
42
+ | `x43 logout` | Revoke this machine's token and remove the saved login. |
43
+ | `x43 init [dir] [--template todo\|blank]` | Write a working capsule plus editor/agent files. Refuses to overwrite any file. |
44
+ | `x43 check` | The Host's import rules (same Sucrase + es-module-lexer as `bundle.ts`) and `tsc` (TypeScript 7, like the hosted ts-rust checker) with the hosted checker's compiler options and types. Prints `path:line:col TSxxxx message`, exit 1 on errors. |
45
+ | `x43 deploy [--name <name>] [--no-check]` | `check`, then `POST /apps/:name/publish` with every `.ts`/`.tsx` under `server/`, `client/`, `shared/`. The first deploy picks a random unused 6-character name and saves it in `x43.json`. Host errors print as `path:line:col message`, exit 1. Not logged in: deploys as a guest (see Guest above). |
46
+ | `x43 status [--name <name>]` | Name, URL, live version, the runtime it was built with, and when the app is deleted (`expiresAt`). |
47
+ | `x43 call <query\|mutation> <handler> [json] […] [--commit] [--name <name>]` | Run handlers in order on the live app as the user `builder-agent` (`POST /apps/:name/sandbox`): later calls see earlier writes, then everything is rolled back. Prints each JSON result; exit 1 if one failed. `--commit` runs them for real (`POST /apps/:name/call`). |
48
+ | `x43 errors [--name <name>]` | The live version's runtime errors (`GET /apps/:name/errors`): handler throws and browser errors, newest first, with counts. |
49
+ | `x43 secret set <NAME> \| list \| rm <NAME> [--name <name>]` | Pro: the app's secrets (`PUT\|GET\|DELETE /apps/:name/secrets[/NAME]`). `set` reads the value from stdin or a hidden prompt, never the command line; `list` prints names only. Handlers read `ctx.secrets.NAME`. |
50
+ | `x43 pull <name> [dir]` | Download an app's live source into a new project (with `x43.json`, types, AGENTS.md). |
51
+ | `x43 open [--name <name>]` | Print the app's URL, and open it when run in a terminal. |
52
+ | `x43 domain add <hostname> [--name <name>]` | Pro: serve the app on the person's own hostname (`POST /api/domains`); prints the DNS records to add (a CNAME to the Host's target and a TXT to verify ownership). |
53
+ | `x43 domain status` | Your custom domains (`GET /api/domains`): `active` (served over https) or `pending` with its records and Cloudflare's reason. |
54
+ | `x43 domain remove <hostname>` | Stop serving that hostname (`DELETE /api/domains/:hostname`). |
55
+
56
+ Errors are one line (`x43: …`) without a stack trace: not logged in, a refused token (401), the API unreachable, a refusal with the API's own message (403 another account's app or no room left on the plan, 404 bad name, 413 over 1 MiB, …).
57
+
58
+ ## Why no `dev` command
59
+
60
+ The live preview is the deployed app: a deploy takes about a second and runs exactly what visitors get (the Host builds, stores data and keeps pages live). A local Host would need the whole mvp repo (wrangler, workerd, the runtime build), which this CLI is meant to make unnecessary.
61
+
62
+ ## Types
63
+
64
+ `types/` holds the hosted checker's type project: the `cloudapps/server|client` declarations, Preact's types (without their `lib="dom"` reference) and the minimal DOM, plus `types/runtime.json` with the runtime hash they came from and the checker's compiler options. Currently runtime `f74b9dc24a738802` (mvp `9a0ee84` plus Batch B, uncommitted when vendored). Refresh after a runtime change (`src/runtime`, Preact, Tailwind) with:
65
+
66
+ ```
67
+ pnpm vendor-types [path/to/mvp] # default ../mvp; reads dist/runtime/types.json (builds it there if missing)
68
+ ```
69
+
70
+ `x43 check` always uses the CLI's own `types/` and settings, not the project's `tsconfig.json` (that one is for editors). `x43 login` and `x43 status` warn when the Host's runtime differs from these types.
71
+
72
+ ## Development
73
+
74
+ `pnpm test` (node:test: import rules, tsc output, file collection, project writing) · `pnpm typecheck` · `pnpm build` (→ `dist/`). The API schemas in `src/schemas.ts` are copied from `mvp/src/shared/api.ts`; keep them in sync.
package/dist/api.js ADDED
@@ -0,0 +1,126 @@
1
+ // The login (~/.config/x43/config.json, or X43_API / X43_TOKEN) and requests to the Host's publish API.
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { z } from "zod";
6
+ import { Fail } from "./project.js";
7
+ import { Guest, Refused } from "./schemas.js";
8
+ export const DEFAULT_API = "https://x43.fast/api";
9
+ /** A guest's claim: the code `x43 claim` sends, and the link that does the same in the browser. */
10
+ const Claim = z.object({ code: z.string(), url: z.string() });
11
+ // `guest`: the token is a guest account's (no sign-in), with its claim. `unclaimed`: a guest's claim kept
12
+ // across `x43 login`, for `x43 claim`.
13
+ const Login = z.union([
14
+ z.object({ api: z.string(), token: z.string(), guest: Claim }),
15
+ z.object({ api: z.string(), token: z.string(), unclaimed: Claim.optional() }),
16
+ ]);
17
+ const configPath = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "x43", "config.json");
18
+ export function saveLogin(login) {
19
+ const path = configPath();
20
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
21
+ writeFileSync(path, `${JSON.stringify(login, null, 2)}\n`, { mode: 0o600 });
22
+ chmodSync(path, 0o600); // `mode` above only applies to a new file
23
+ return path;
24
+ }
25
+ /** Forgets the saved login (`x43 logout`); returns the file's path if there was one. */
26
+ export function removeLogin() {
27
+ const path = configPath();
28
+ if (!existsSync(path))
29
+ return null;
30
+ rmSync(path);
31
+ return path;
32
+ }
33
+ /** The API and, if there is one, its login. The env overrides the file; a saved token is only sent to the API it was saved for. */
34
+ export function findLogin() {
35
+ const path = configPath();
36
+ const saved = existsSync(path) ? Login.safeParse(JSON.parse(readFileSync(path, "utf8"))) : undefined;
37
+ if (saved && !saved.success)
38
+ throw new Fail(`${path} is malformed; run \`x43 login\` again.`);
39
+ const api = trimApi(process.env.X43_API || saved?.data.api || DEFAULT_API);
40
+ if (process.env.X43_TOKEN)
41
+ return { api, login: { api, token: process.env.X43_TOKEN } };
42
+ return { api, login: saved?.data.api === api ? saved.data : undefined };
43
+ }
44
+ export function loadLogin() {
45
+ const { api, login } = findLogin();
46
+ if (!login)
47
+ throw new Fail(`Not logged in to ${api}. Run \`x43 login${api === DEFAULT_API ? "" : ` --api ${api}`}\` (or set X43_TOKEN), or just \`x43 deploy\` as a guest.`);
48
+ return login;
49
+ }
50
+ /** The login, or (none yet) a new guest account from the API, saved like a login. */
51
+ export async function loginOrGuest() {
52
+ const { api, login } = findLogin();
53
+ if (login)
54
+ return login;
55
+ const guest = await request({ api }, "/guest", Guest, {});
56
+ const created = { api, token: guest.token, guest: { code: guest.claim, url: guest.claimUrl } };
57
+ saveLogin(created);
58
+ return created;
59
+ }
60
+ export const trimApi = (api) => api.replace(/\/+$/, "");
61
+ /**
62
+ * Calls the API and parses its answer with `schema`. 422 is a result (publish errors, a handler's
63
+ * error); any other refusal, a network failure or an unexpected answer becomes a Fail. No token: the
64
+ * device login's own routes. `method`: GET without a body, else POST, unless given.
65
+ */
66
+ export async function request(login, path, schema, body, method) {
67
+ const url = `${login.api}${path}`;
68
+ let res;
69
+ try {
70
+ res = await fetch(url, {
71
+ method: method ?? (body === undefined ? "GET" : "POST"),
72
+ headers: { ...(login.token ? { Authorization: `Bearer ${login.token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
73
+ body: body === undefined ? undefined : JSON.stringify(body),
74
+ signal: AbortSignal.timeout(60_000),
75
+ });
76
+ }
77
+ catch (e) {
78
+ throw new Fail(`Can't reach ${login.api} (${networkReason(e)}).`);
79
+ }
80
+ if (res.status === 401)
81
+ throw new Fail(`${login.api} refused the token (401). Run \`x43 login\` with a valid token.`);
82
+ const json = await res.json().catch(() => undefined);
83
+ if (!res.ok && res.status !== 422) {
84
+ const refused = Refused.safeParse(json);
85
+ throw new Fail(`${url}: ${refused.success ? refused.data.error : "refused"} (HTTP ${res.status}).`);
86
+ }
87
+ const parsed = schema.safeParse(json);
88
+ if (!parsed.success)
89
+ throw new Fail(`${url}: unexpected answer (HTTP ${res.status}). Is ${login.api} a Cloud apps API?`);
90
+ return parsed.data;
91
+ }
92
+ /** `ECONNREFUSED`, `ENOTFOUND`, a timeout…: what fetch's error says, without its stack. */
93
+ function networkReason(e) {
94
+ if (!(e instanceof Error))
95
+ return String(e);
96
+ if (e.name === "TimeoutError")
97
+ return "timed out";
98
+ const cause = e.cause;
99
+ if (typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string")
100
+ return cause.code;
101
+ return cause instanceof Error ? cause.message : e.message;
102
+ }
103
+ const CALL_USAGE = "Usage: x43 call <query|mutation> <handler> [json args] [<query|mutation> <handler> [json args] …] [--commit]";
104
+ /** `x43 call`'s arguments: one or more `<query|mutation> <handler> [json args]` (args default to {}). */
105
+ export function parseCalls(args) {
106
+ const calls = [];
107
+ for (let i = 0; i < args.length;) {
108
+ const [kind, name, next] = args.slice(i, i + 3);
109
+ if ((kind !== "query" && kind !== "mutation") || !name)
110
+ throw new Fail(CALL_USAGE);
111
+ const json = next !== undefined && next !== "query" && next !== "mutation";
112
+ calls.push({ kind, name, args: json ? parseJson(next) : {} });
113
+ i += json ? 3 : 2;
114
+ }
115
+ if (!calls.length)
116
+ throw new Fail(CALL_USAGE);
117
+ return calls;
118
+ }
119
+ function parseJson(text) {
120
+ try {
121
+ return JSON.parse(text);
122
+ }
123
+ catch {
124
+ throw new Fail(`Args must be JSON, e.g. '{"text":"hi"}' (got ${text}).`);
125
+ }
126
+ }
package/dist/check.js ADDED
@@ -0,0 +1,131 @@
1
+ // `x43 check`: the Host's import rules (mirrors mvp/src/host/bundle.ts, with the same Sucrase and
2
+ // es-module-lexer versions) and a real `tsc` run with the hosted checker's compiler options and types.
3
+ import { spawnSync } from "node:child_process";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import { tmpdir } from "node:os";
7
+ import { dirname, join, posix } from "node:path";
8
+ import { init, parse } from "es-module-lexer/minimal";
9
+ import { transform } from "sucrase";
10
+ import { z } from "zod";
11
+ import { Fail, SIDES, TYPES, tsconfig } from "./project.js";
12
+ const ENTRY = { server: "server/index.ts", client: "client/index.tsx" };
13
+ // Bare imports per directory; `preact/jsx-runtime` is what JSX compiles to.
14
+ const ALLOWED = {
15
+ server: ["cloudapps/server"],
16
+ client: ["preact", "preact/hooks", "preact/jsx-runtime", "cloudapps/client"],
17
+ shared: ["preact/jsx-runtime"],
18
+ };
19
+ /** Import and entry errors the Host would refuse the files with, as `path:line:col message`. */
20
+ export async function importErrors(files) {
21
+ await init;
22
+ const errors = Object.values(ENTRY).filter((entry) => !Object.hasOwn(files, entry)).map((path) => `${path}: file is missing`);
23
+ for (const [path, source] of Object.entries(files)) {
24
+ const side = SIDES.find((s) => path.startsWith(`${s}/`));
25
+ if (!side)
26
+ continue;
27
+ const code = strip(path, source);
28
+ if (!code.ok) {
29
+ errors.push(code.error);
30
+ continue;
31
+ }
32
+ const [imports, exports] = parse(code.value);
33
+ if (path === ENTRY.client && !exports.some((e) => e.n === "App")) {
34
+ errors.push(`${path}: must export App (export function App() { … })`);
35
+ }
36
+ for (const { n: specifier, s } of imports) {
37
+ if (specifier === undefined)
38
+ continue; // import.meta, or import() of a computed value
39
+ const error = rule(side, path, specifier, files);
40
+ if (error)
41
+ errors.push(`${locate(path, source, code.value, s, specifier)} ${error}`);
42
+ }
43
+ }
44
+ return errors;
45
+ }
46
+ /** Strips types (only `import type` is erased, like `verbatimModuleSyntax`) and compiles JSX, as the Host does. */
47
+ function strip(path, source) {
48
+ try {
49
+ const { code } = transform(source, {
50
+ transforms: path.endsWith(".tsx") ? ["typescript", "jsx"] : ["typescript"],
51
+ jsxRuntime: "automatic",
52
+ jsxImportSource: "preact",
53
+ production: true,
54
+ keepUnusedImports: true,
55
+ disableESTransforms: true,
56
+ });
57
+ return { ok: true, value: code };
58
+ }
59
+ catch (e) {
60
+ const failure = SyntaxFailure.safeParse(e);
61
+ if (!failure.success)
62
+ throw e;
63
+ const { message, loc } = failure.data;
64
+ // Sucrase appends " (line:col)" to the message; columns are 1-based.
65
+ const text = message.replace(/ \(\d+:\d+\)$/, "");
66
+ return { ok: false, error: loc ? `${path}:${loc.line}:${loc.column} ${text}` : `${path}: ${text}` };
67
+ }
68
+ }
69
+ const SyntaxFailure = z.object({ message: z.string(), loc: z.object({ line: z.number(), column: z.number() }).optional() });
70
+ /** Why an import is refused, or undefined if it's allowed. */
71
+ function rule(side, importer, specifier, files) {
72
+ if (!specifier.startsWith(".")) {
73
+ if (ALLOWED[side].includes(specifier))
74
+ return undefined;
75
+ const list = side === "shared" ? "relative imports" : `${ALLOWED[side].join(", ")} and relative imports`;
76
+ return `Import "${specifier}" is not allowed in ${side} code (allowed: ${list})`;
77
+ }
78
+ const base = posix.join(posix.dirname(importer), specifier).replace(/\/$/, "");
79
+ const file = base.startsWith("..")
80
+ ? undefined
81
+ : [base, `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`].find((p) => /\.tsx?$/.test(p) && Object.hasOwn(files, p));
82
+ if (file === undefined)
83
+ return `Cannot find "${specifier}"`;
84
+ const dirs = side === "shared" ? ["shared/"] : [`${side}/`, "shared/"];
85
+ if (dirs.some((dir) => file.startsWith(dir)))
86
+ return undefined;
87
+ return `"${specifier}" is ${file}; ${side} code can only import ${dirs.join(" and ")} files (type-only imports are fine)`;
88
+ }
89
+ /** `path:line:col` of an import. Sucrase keeps line numbers, so the line is found in the source. */
90
+ function locate(path, source, code, at, specifier) {
91
+ const line = code.slice(0, at).split("\n").length;
92
+ const column = (source.split("\n")[line - 1] ?? "").indexOf(specifier);
93
+ // 0-based index of the specifier = 1-based column of its opening quote, where tsc points too.
94
+ return `${path}:${line}:${column >= 0 ? column : at - code.lastIndexOf("\n", at - 1) - 1}`;
95
+ }
96
+ const TSC = join(dirname(createRequire(import.meta.url).resolve("typescript/package.json")), "bin/tsc");
97
+ /** Type-checks the capsule in `dir` with tsc and the CLI's vendored types (not the project's tsconfig.json). */
98
+ export function typeErrors(dir) {
99
+ const tmp = mkdtempSync(join(tmpdir(), "x43-check-"));
100
+ try {
101
+ const config = join(tmp, "tsconfig.json");
102
+ writeFileSync(config, JSON.stringify(tsconfig(TYPES, SIDES.map((side) => join(dir, side)))));
103
+ const run = spawnSync(process.execPath, [TSC, "-p", config, "--pretty", "false"], { cwd: dir, encoding: "utf8" });
104
+ if (run.error)
105
+ throw new Fail(`Couldn't run tsc: ${run.error.message}`);
106
+ if (run.status === 0)
107
+ return [];
108
+ const errors = formatTsc(run.stdout);
109
+ return errors.length ? errors : [`tsc failed (exit ${run.status}): ${(run.stderr || run.stdout).trim()}`];
110
+ }
111
+ finally {
112
+ rmSync(tmp, { recursive: true, force: true });
113
+ }
114
+ }
115
+ const AT = /^(?<path>.+)\((?<line>\d+),(?<col>\d+)\): error (?<code>TS\d+): (?<text>.*)$/;
116
+ const GLOBAL = /^error (?<code>TS\d+): (?<text>.*)$/;
117
+ /** tsc's `--pretty false` output → `path:line:col TSxxxx message`, elaboration lines kept (indented). */
118
+ export function formatTsc(output) {
119
+ const errors = [];
120
+ for (const line of output.split(/\r?\n/)) {
121
+ const at = AT.exec(line)?.groups;
122
+ const global = GLOBAL.exec(line)?.groups;
123
+ if (at)
124
+ errors.push(`${at.path}:${at.line}:${at.col} ${at.code} ${at.text}`);
125
+ else if (global)
126
+ errors.push(`${global.code} ${global.text}`);
127
+ else if (line.trim() && errors.length)
128
+ errors[errors.length - 1] += `\n${line}`;
129
+ }
130
+ return errors;
131
+ }