create-fungi-app 0.0.0-stage → 0.0.2

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 Jamstack Consulting LLC
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,141 @@
1
- # Temporary Holding Version
1
+ # create-fungi-app
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-fungi-app creates a small App with Caps UI and one typed Agar backend
4
+ method, running in a local standalone Oyster. Most people want
5
+ [Oyster](https://docs.fungi.computer/oyster/) to run Apps or
6
+ [Shiitake](https://docs.fungi.computer/shiitake/) to run Agents.
7
+
8
+ ## Quick start
9
+
10
+ Build and test an App locally without Docker, a Fungi Computer, or a Cloudflare
11
+ account.
12
+
13
+ Use Node 24.20.0, npm 11.19.0 and Git for Oyster's pinned build toolchain.
14
+
15
+ ```sh
16
+ npm create fungi-app@latest my-app
17
+ cd my-app
18
+ npm run dev
19
+ ```
20
+
21
+ Use `-- --no-install` to generate files without installing dependencies. In a
22
+ terminal, running `npm create fungi-app@latest` with no directory asks for the
23
+ project name and whether to install; without a terminal, pass the directory
24
+ argument. Existing directories are refused.
25
+
26
+ Open the loopback URL printed by the development command. Click **Say hello** to
27
+ call the backend. Save an edit in `src/` to rebuild the App and reload its page.
28
+
29
+ You can also test the typed backend over HTTP while development is running:
30
+
31
+ ```sh
32
+ curl --fail-with-body http://127.0.0.1:61343/apps/app/methods/greet \
33
+ -H 'Content-Type: application/json' \
34
+ -d '{"name":"Oyster"}'
35
+ # {"message":"Hello, Oyster!"}
36
+ ```
37
+
38
+ Stop development with Ctrl+C. To build a Release without starting a server:
39
+
40
+ ```sh
41
+ npm run build
42
+ ```
43
+
44
+ This writes `dist/release.json` and `dist/artifacts/`; it does not deploy or
45
+ publish anything.
46
+
47
+ ### What runs locally
48
+
49
+ - `npm run dev` uses the existing Oyster builder, then runs
50
+ `wrangler dev --local` on loopback. Workerd executes the host Worker and the
51
+ App's Durable Object backend through Worker Loader. Local R2 serves the built
52
+ UI and assets; Wrangler keeps local state under `.wrangler/`.
53
+ - `npm run build` runs Node/npm and the App compiler on your machine against an
54
+ isolated snapshot of saved files. It does not allocate a build Computer.
55
+ - The template is React/Caps UI, one typed Agar `greet` method, a Durable Object
56
+ facet, and a minimal Oyster Worker/config. It declares no Computer or
57
+ container.
58
+
59
+ Wrangler configurations that include a container-backed Computer need Docker to
60
+ build and run that Computer locally. That is separate from compiling and running
61
+ this App.
62
+
63
+ Without an attached Computer, Computer files, shell commands, processes and port
64
+ previews are unavailable. Hosted repository build jobs also need their
65
+ configured execution backend. The starter does not emulate Team sign-in/grants
66
+ or funded provider services, and does not map your laptop's files into a
67
+ Computer. Development admits local requests as its owner; use it for trusted
68
+ source, not as a production authentication or isolation proof.
69
+
70
+ ### Measured startup
71
+
72
+ In a Linux trial on October 28, 2026, the locally packed candidate took **53.8
73
+ seconds** from starting the scaffold command to the first working browser
74
+ greeting, under the conditions below:
75
+
76
+ | Step | Elapsed |
77
+ | -------------------------------------- | ------- |
78
+ | Scaffold and install | 42.3 s |
79
+ | Scaffold → first working App greeting | 53.8 s |
80
+ | Saved UI/backend edit → changed result | 11.0 s |
81
+ | Separate `npm run build` | 10.6 s |
82
+
83
+ The cold run used a fresh HOME and empty npm cache on Linux, with Docker blocked
84
+ and no inherited Fungi or Cloudflare credentials. Node/npm/Git were already
85
+ installed. The packed-release proof served Fungi packages from local tarballs
86
+ and downloaded other dependencies from npm. Packing and toolchain/browser
87
+ installation were outside the clock. The saved-edit measurement reused the
88
+ running App. These are observed times, not a two-minute guarantee on another
89
+ machine or network.
90
+
91
+ ## Examples
92
+
93
+ To create the App without installing dependencies:
94
+
95
+ ```sh
96
+ npm create fungi-app@latest my-app -- --no-install
97
+ cd my-app
98
+ npm install
99
+ npm run dev
100
+ ```
101
+
102
+ To build a Release from your saved files, run this inside the generated App:
103
+
104
+ ```sh
105
+ npm run build
106
+ ```
107
+
108
+ The build writes `dist/release.json` and `dist/artifacts/`. The generated
109
+ `README.md` shows `oyster install ./dist` (or `./dist/release.json`) using
110
+ `OYSTER_URL` and `OYSTER_OWNER_KEY`. The CLI delegates to the existing
111
+ `@fungi.computer/oyster/pack` uploader; it does not deploy an Oyster.
112
+
113
+ To use committed source instead, push the generated App to a Bonsai repository
114
+ and choose **Make this an App** in repository Settings. The root
115
+ `package.json#fungi.build` supplies the recipe. Registration does not build or
116
+ install. Subsequent accepted main pushes build the new commit on a hosted
117
+ backend and install only after success and within existing approval. New
118
+ permissions require owner consent and leave the previous Release unchanged.
119
+
120
+ ## Core concepts
121
+
122
+ The template declares its backend definition, declaration, and build recipe in
123
+ `package.json`. `fungi.build` owns the UI entry, backend entry, asset bounds,
124
+ and recipe kind. Paths are relative to the App directory. Build and dev read
125
+ that metadata from your saved files; there is no second recipe inside the
126
+ command. `src/backend/methods.ts` owns the input, output and implementation of
127
+ `greet`; the UI calls that method through Agar's host-admitted capability.
128
+
129
+ `npm run dev` uses Oyster's builder and a standalone Worker on Wrangler. Its
130
+ development host admits local requests as its owner. The generated
131
+ `oyster/worker.ts` uses key authentication for a deployed host.
132
+
133
+ `npm run build` captures saved files, including uncommitted edits, in an
134
+ isolated Git snapshot. It leaves your repository history alone and records that
135
+ snapshot in the Release's provenance. Dependency installation uses
136
+ `package-lock.json`. Building creates the Release locally. Installing it into an
137
+ Oyster and publishing packages are separate operations.
138
+
139
+ ## License
140
+
141
+ MIT licensed. See [LICENSE](LICENSE).
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ import { starterIds } from "./ids.js";
3
+ import { spawn } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
5
+ import { watch } from "node:fs";
6
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { resolve, join } from "node:path";
8
+ import { createInterface } from "node:readline";
9
+ import { setTimeout as delay } from "node:timers/promises";
10
+ import { parseArgs } from "node:util";
11
+ import { Cli } from "@fungi.computer/lantern";
12
+ import { readBuildManifest, appBuildRecipe, createAppPublisher, createLocalArtifactWriter, createLocalBuildExecutor, createLocalDirectorySourceCapture, } from "@fungi.computer/oyster/build";
13
+ import { InstallationObservationError, installRelease, } from "@fungi.computer/oyster/pack";
14
+ import { parseReleaseId } from "@fungi.computer/puffball";
15
+ import { releaseSchema } from "@fungi.computer/puffball/effect";
16
+ import * as Effect from "effect/Effect";
17
+ import * as Redacted from "effect/Redacted";
18
+ import * as Result from "effect/Result";
19
+ import * as Schema from "effect/Schema";
20
+ import { z } from "zod";
21
+ const cli = new Cli({ brand: "fungi" });
22
+ const root = process.cwd();
23
+ const lifecycle = new AbortController();
24
+ const shutdown = { interrupted: false };
25
+ process.once("SIGINT", () => {
26
+ shutdown.interrupted = true;
27
+ lifecycle.abort();
28
+ });
29
+ process.once("SIGTERM", () => {
30
+ shutdown.interrupted = true;
31
+ lifecycle.abort();
32
+ });
33
+ async function build(out) {
34
+ const manifest = readBuildManifest(await readFile(join(root, "package.json"), "utf8"));
35
+ const appId = manifest.fungi.appId;
36
+ if (appId === undefined)
37
+ throw new Error("App manifest requires fungi.appId");
38
+ const recipe = appBuildRecipe(manifest, "");
39
+ const publisher = createAppPublisher({
40
+ config: { appId, recipe },
41
+ sourceCapture: createLocalDirectorySourceCapture(root),
42
+ buildExecutor: createLocalBuildExecutor(),
43
+ artifactWriter: createLocalArtifactWriter(join(out, "artifacts"), recipe.assets),
44
+ releaseCatalog: { createRelease: (release) => Promise.resolve(release) },
45
+ });
46
+ const release = await Effect.runPromise(Effect.scoped(publisher.createCandidate(parseReleaseId(starterIds.release.generate()))), { signal: lifecycle.signal });
47
+ await mkdir(out, { recursive: true });
48
+ const encoded = Schema.encodeUnknownSync(Schema.fromJsonString(releaseSchema))(release);
49
+ await writeFile(join(out, "release.json"), `${encoded}\n`);
50
+ return encoded;
51
+ }
52
+ async function dev(port) {
53
+ const directory = join(root, ".botanical/dev");
54
+ const out = join(directory, "release");
55
+ const key = randomBytes(24).toString("hex");
56
+ const origin = `http://127.0.0.1:${String(port)}`;
57
+ await mkdir(directory, { recursive: true });
58
+ await writeFile(join(directory, ".dev.vars"), `OYSTER_OWNER_KEY=${key}\nOYSTER_ORIGIN=${origin}\n`, {
59
+ mode: 0o600,
60
+ });
61
+ const config = z
62
+ .record(z.string(), z.unknown())
63
+ .parse(JSON.parse(await readFile(join(root, "oyster/wrangler.json"), "utf8")));
64
+ await writeFile(join(directory, "wrangler.json"), JSON.stringify({ ...config, main: "dev-worker.ts" }));
65
+ // Wrangler injects its reload script into the local host page. The ordinary
66
+ // standalone Worker retains Oyster's production CSP.
67
+ const developmentCsp = `default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'self'; connect-src 'self' ws://127.0.0.1:${String(port)}; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`;
68
+ const worker = (release) => `import worker from "../../oyster/worker.ts";
69
+ export { OysterBinding, OysterRegistry, OysterInstance, OysterMcp, OysterInstallationExecutor } from "../../oyster/worker.ts";
70
+ export default { async fetch(request, env, context) {
71
+ const headers = new Headers(request.headers);
72
+ headers.set("Authorization", "Bearer " + env.OYSTER_OWNER_KEY);
73
+ const response = await worker.fetch(new Request(request, { headers }), env, context);
74
+ const result = new Response(response.body, response);
75
+ if (new URL(request.url).pathname === "/apps/app/") result.headers.set("Content-Security-Policy", ${JSON.stringify(developmentCsp)});
76
+ result.headers.set("X-Fungi-App-Release", ${JSON.stringify(z.object({ releaseId: z.string() }).parse(JSON.parse(release)).releaseId)});
77
+ return result;
78
+ } };
79
+ `;
80
+ let release = await cli.task("Building App", () => build(out));
81
+ await writeFile(join(directory, "dev-worker.ts"), worker(release));
82
+ const child = spawn(join(root, "node_modules/.bin/wrangler"), [
83
+ "dev",
84
+ "--local",
85
+ "--ip",
86
+ "127.0.0.1",
87
+ "--port",
88
+ String(port),
89
+ "--live-reload",
90
+ "--config",
91
+ join(directory, "wrangler.json"),
92
+ "--persist-to",
93
+ join(root, ".wrangler"),
94
+ ], {
95
+ cwd: root,
96
+ env: { ...process.env, WRANGLER_SEND_METRICS: "false" },
97
+ stdio: ["ignore", "pipe", "pipe"],
98
+ detached: true,
99
+ });
100
+ for (const stream of [child.stdout, child.stderr]) {
101
+ createInterface({ input: stream }).on("line", (line) => {
102
+ cli.info(line.replaceAll(key, "[redacted]"));
103
+ });
104
+ }
105
+ const stopped = new Promise((done, reject) => {
106
+ child.once("error", reject);
107
+ child.once("exit", (code) => {
108
+ if (lifecycle.signal.aborted || code === 0)
109
+ done();
110
+ else
111
+ reject(new Error("Wrangler stopped before the App was closed."));
112
+ });
113
+ });
114
+ // Attach the rejection handler immediately while startup and compilation run.
115
+ void stopped.catch((cause) => {
116
+ lifecycle.abort(cause);
117
+ });
118
+ const stop = () => {
119
+ if (child.exitCode === null &&
120
+ child.signalCode === null &&
121
+ child.pid !== undefined)
122
+ process.kill(-child.pid, "SIGTERM");
123
+ };
124
+ lifecycle.signal.addEventListener("abort", stop, { once: true });
125
+ async function install() {
126
+ const response = await installRelease({
127
+ directory: out,
128
+ url: origin,
129
+ key,
130
+ installationId: "app",
131
+ signal: lifecycle.signal,
132
+ onAdmitted: ({ installationId, requestId }) => {
133
+ cli.info(`Installation ${installationId} accepted (request ${requestId}); observing authoritative status.`);
134
+ },
135
+ });
136
+ if (!response.ok)
137
+ throw new Error(`Oyster refused the App installation (${String(response.status)}).`);
138
+ }
139
+ let timer;
140
+ let rebuilding = false;
141
+ let pending = false;
142
+ const rebuild = async () => {
143
+ pending = true;
144
+ if (rebuilding)
145
+ return;
146
+ rebuilding = true;
147
+ try {
148
+ while (pending && !lifecycle.signal.aborted) {
149
+ pending = false;
150
+ release = await cli.task("Rebuilding App", () => build(out));
151
+ await install();
152
+ await writeFile(join(directory, "dev-worker.ts"), worker(release));
153
+ cli.info("App rebuilt; Wrangler is reloading the page.");
154
+ }
155
+ }
156
+ catch (cause) {
157
+ if (!lifecycle.signal.aborted) {
158
+ Effect.runSync(Effect.logError("App rebuild failed.", Redacted.make(cause)));
159
+ if (cause instanceof InstallationObservationError)
160
+ reportUnconfirmedInstallation(cause);
161
+ else
162
+ cli.info("App rebuild failed. Fix the source and save again.");
163
+ }
164
+ }
165
+ finally {
166
+ rebuilding = false;
167
+ }
168
+ };
169
+ const schedule = () => {
170
+ clearTimeout(timer);
171
+ timer = setTimeout(() => {
172
+ void rebuild();
173
+ }, 150);
174
+ };
175
+ try {
176
+ for (let attempt = 0;; attempt++) {
177
+ lifecycle.signal.throwIfAborted();
178
+ const readiness = await Effect.runPromise(Effect.result(Effect.tryPromise({
179
+ try: () => fetch(`${origin}/login`, { signal: lifecycle.signal }),
180
+ catch: (cause) => new Error("Wrangler readiness request failed.", { cause }),
181
+ })));
182
+ lifecycle.signal.throwIfAborted();
183
+ if (Result.isSuccess(readiness) && readiness.success.ok)
184
+ break;
185
+ if (attempt === 119)
186
+ throw new Error("Wrangler did not start within one minute.", {
187
+ cause: Result.isFailure(readiness)
188
+ ? readiness.failure
189
+ : new Error(`Wrangler returned HTTP ${String(readiness.success.status)}.`),
190
+ });
191
+ await delay(500, undefined, { signal: lifecycle.signal });
192
+ }
193
+ await install();
194
+ watch(join(root, "src"), { recursive: true, signal: lifecycle.signal }, schedule);
195
+ watch(root, { signal: lifecycle.signal }, (_event, filename) => {
196
+ if (filename !== null &&
197
+ ["index.html", "package.json", "package-lock.json"].includes(filename))
198
+ schedule();
199
+ });
200
+ cli.outro(`App ready at ${origin}/apps/app/`);
201
+ await stopped;
202
+ }
203
+ finally {
204
+ lifecycle.abort();
205
+ clearTimeout(timer);
206
+ stop();
207
+ await stopped;
208
+ }
209
+ }
210
+ function reportUnconfirmedInstallation(cause) {
211
+ cli.info(`Installation outcome unconfirmed (request ${cause.requestId}). Oyster may still finish; stopping observation does not cancel it.`);
212
+ }
213
+ try {
214
+ const { positionals, values } = parseArgs({
215
+ allowPositionals: true,
216
+ options: {
217
+ port: { type: "string", default: "61343" },
218
+ help: { type: "boolean", short: "h" },
219
+ },
220
+ });
221
+ if (values.help)
222
+ cli.info("fungi-app build | fungi-app dev [--port 61343]");
223
+ else if (positionals.length !== 1)
224
+ throw new Error("Choose fungi-app build or fungi-app dev.");
225
+ else if (positionals[0] === "build") {
226
+ await cli.task("Building Release", () => build(resolve(root, "dist")));
227
+ cli.outro("Release ready in dist/release.json and dist/artifacts/.");
228
+ }
229
+ else if (positionals[0] === "dev")
230
+ await dev(z.coerce.number().int().min(1).max(65535).parse(values.port));
231
+ else
232
+ throw new Error("Choose fungi-app build or fungi-app dev.");
233
+ }
234
+ catch (cause) {
235
+ if (!shutdown.interrupted) {
236
+ if (cause instanceof InstallationObservationError)
237
+ reportUnconfirmedInstallation(cause);
238
+ Effect.runSync(Effect.logError("App command failed.", Redacted.make(cause)));
239
+ cli.cancel("App command failed.");
240
+ process.exitCode = 1;
241
+ }
242
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { basename, resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { Cli, isCancel } from "@fungi.computer/lantern";
6
+ import { projectName, scaffoldApp } from "./index.js";
7
+ import * as Effect from "effect/Effect";
8
+ import * as Redacted from "effect/Redacted";
9
+ class InputError extends Error {
10
+ }
11
+ const cli = new Cli({ brand: "fungi" });
12
+ try {
13
+ const { positionals, values } = parseArgs({
14
+ allowPositionals: true,
15
+ options: {
16
+ "no-install": { type: "boolean" },
17
+ help: { type: "boolean", short: "h" },
18
+ },
19
+ });
20
+ if (values.help) {
21
+ cli.info("npm create fungi-app@latest my-app [-- --no-install]");
22
+ }
23
+ else {
24
+ if (positionals.length > 1)
25
+ throw new InputError("Provide one project directory, such as my-app.");
26
+ if (positionals.length === 0 && !cli.interactive)
27
+ throw new InputError("Provide a project directory when running without a terminal: npm create fungi-app@latest my-app");
28
+ cli.welcome("Create a Fungi App");
29
+ const name = positionals[0] ??
30
+ (await cli.text({
31
+ message: "Where should we create your App?",
32
+ placeholder: "my-app",
33
+ validate: (value) => projectName.safeParse(value === undefined ? undefined : basename(resolve(value))).success
34
+ ? undefined
35
+ : "Choose a directory with a valid npm package name.",
36
+ }));
37
+ if (isCancel(name)) {
38
+ cli.cancel("App creation cancelled.");
39
+ }
40
+ else {
41
+ const directory = name;
42
+ if (!projectName.safeParse(basename(resolve(directory))).success)
43
+ throw new InputError("Choose a directory with a valid npm package name, such as my-app.");
44
+ await cli.task("Creating your App", async () => scaffoldApp(directory));
45
+ const install = values["no-install"]
46
+ ? false
47
+ : cli.interactive
48
+ ? await cli.confirm({
49
+ message: "Install dependencies with npm?",
50
+ initialValue: true,
51
+ })
52
+ : true;
53
+ if (!isCancel(install) && install) {
54
+ await cli.task("Installing dependencies", () => new Promise((done, reject) => {
55
+ const child = spawn("npm", ["install"], {
56
+ cwd: resolve(directory),
57
+ stdio: "inherit",
58
+ });
59
+ child.once("error", reject);
60
+ child.once("exit", (code) => {
61
+ if (code === 0)
62
+ done();
63
+ else
64
+ reject(new Error("Dependency installation failed."));
65
+ });
66
+ }));
67
+ }
68
+ cli.outro(`Ready. Run:\n cd ${directory}\n${install === true ? "" : " npm install\n"} npm run dev`);
69
+ }
70
+ }
71
+ }
72
+ catch (cause) {
73
+ const code = cause instanceof Error && "code" in cause ? cause.code : undefined;
74
+ const message = cause instanceof InputError
75
+ ? cause.message
76
+ : code === "ERR_PARSE_ARGS_UNKNOWN_OPTION"
77
+ ? "Unknown option. Run create-fungi-app --help for usage."
78
+ : code === "EEXIST"
79
+ ? "That directory already exists. Choose a new directory."
80
+ : "App creation failed.";
81
+ Effect.runSync(Effect.logError("App creation failed.", Redacted.make(cause)));
82
+ cli.cancel(message);
83
+ process.exitCode = 1;
84
+ }
package/dist/ids.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare const starterIds: Readonly<{
2
+ readonly app: import("@fungi.computer/cairn/id").IdCodec<"app-authoring", "app", 1>;
3
+ readonly release: import("@fungi.computer/cairn/id").IdCodec<"app-authoring", "release", 1>;
4
+ }>;
package/dist/ids.js ADDED
@@ -0,0 +1,6 @@
1
+ import { defineIdFamily } from "@fungi.computer/cairn/id";
2
+ export const starterIds = defineIdFamily({
3
+ family: "app-authoring",
4
+ version: 1,
5
+ kinds: { app: { prefix: "app" }, release: { prefix: "rel" } },
6
+ });
@@ -0,0 +1,5 @@
1
+ import * as z from "zod";
2
+ /** A project directory name, also used as its npm package name. */
3
+ export declare const projectName: z.ZodString;
4
+ /** Create a starter App in a new directory. Existing destinations are never overwritten. */
5
+ export declare function scaffoldApp(directory: string): Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ import { starterIds } from "./ids.js";
2
+ import { parseAppId } from "@fungi.computer/puffball";
3
+ import { cp, mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { basename, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import * as z from "zod";
7
+ import validatePackageName from "validate-npm-package-name";
8
+ /** A project directory name, also used as its npm package name. */
9
+ export const projectName = z
10
+ .string()
11
+ .refine((name) => validatePackageName(name).validForNewPackages, {
12
+ message: "Choose a valid npm package name for your project directory.",
13
+ });
14
+ /** Create a starter App in a new directory. Existing destinations are never overwritten. */
15
+ export async function scaffoldApp(directory) {
16
+ const target = resolve(directory);
17
+ const name = projectName.parse(basename(target));
18
+ const template = fileURLToPath(new URL("../template/", import.meta.url));
19
+ await mkdir(target);
20
+ await cp(template, target, { recursive: true });
21
+ await rename(resolve(target, "gitignore"), resolve(target, ".gitignore"));
22
+ const manifest = z
23
+ .object({ fungi: z.record(z.string(), z.unknown()) })
24
+ .loose()
25
+ .parse(JSON.parse(await readFile(resolve(target, "package.json"), "utf8")));
26
+ await writeFile(resolve(target, "package.json"), `${JSON.stringify({ ...manifest, name, fungi: { ...manifest.fungi, appId: parseAppId(starterIds.app.generate()) } }, null, 2)}\n`);
27
+ }
@@ -0,0 +1,38 @@
1
+ <!-- Generated by Botanical API docs (TypeDoc + typedoc-plugin-markdown). Do not edit. -->
2
+
3
+ **create-fungi-app**
4
+
5
+ ---
6
+
7
+ # create-fungi-app
8
+
9
+ ## Variables
10
+
11
+ ### projectName
12
+
13
+ ```ts
14
+ const projectName: ZodString;
15
+ ```
16
+
17
+ A project directory name, also used as its npm package name.
18
+
19
+ ## Functions
20
+
21
+ ### scaffoldApp()
22
+
23
+ ```ts
24
+ function scaffoldApp(directory): Promise<void>;
25
+ ```
26
+
27
+ Create a starter App in a new directory. Existing destinations are never
28
+ overwritten.
29
+
30
+ #### Parameters
31
+
32
+ | Parameter | Type |
33
+ | ----------- | -------- |
34
+ | `directory` | `string` |
35
+
36
+ #### Returns
37
+
38
+ `Promise`\<`void`\>
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "create-fungi-app",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.2",
4
+ "description": "Create a Caps and Agar App that runs on a standalone Oyster",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=24.20.0"
9
+ },
10
+ "bin": {
11
+ "create-fungi-app": "dist/cli.js",
12
+ "fungi-app": "dist/app-cli.js"
13
+ },
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "template",
23
+ "docs/api",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "dependencies": {
28
+ "@fungi.computer/lantern": "0.4.2",
29
+ "zod": "4.4.3",
30
+ "@fungi.computer/oyster": "0.15.0",
31
+ "@fungi.computer/puffball": "0.5.4",
32
+ "effect": "4.0.2",
33
+ "validate-npm-package-name": "7.0.2",
34
+ "@fungi.computer/cairn": "0.1.0"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/fungi-computer/create-fungi-app.git"
39
+ }
6
40
  }
@@ -0,0 +1,99 @@
1
+ # My App
2
+
3
+ This App has Caps UI and one typed backend method. Oyster hosts both surfaces;
4
+ the UI calls `greet` through Agar's `app.methods` capability.
5
+
6
+ Use Node 24.20.0, npm 11.19.0 and Git, then:
7
+
8
+ ```sh
9
+ npm install
10
+ npm run dev
11
+ ```
12
+
13
+ No Docker, Fungi Computer, or Cloudflare account is needed. Open the printed
14
+ loopback URL and click **Say hello**, or test the same backend directly:
15
+
16
+ ```sh
17
+ curl --fail-with-body http://127.0.0.1:61343/apps/app/methods/greet \
18
+ -H 'Content-Type: application/json' \
19
+ -d '{"name":"Oyster"}'
20
+ # {"message":"Hello, Oyster!"}
21
+ ```
22
+
23
+ Development runs a standalone Oyster on loopback with Wrangler. Save an edit in
24
+ `src/` to rebuild the App and reload its page. The development host admits local
25
+ requests as its owner; use the ordinary key-authenticated `oyster/worker.ts` for
26
+ a deployed host, with `OYSTER_OWNER_KEY` as a secret and `OYSTER_ORIGIN` set to
27
+ its public origin. Each App Instance records that origin when it is installed.
28
+ The host serves `/mcp` (send `Authorization: Bearer $OYSTER_OWNER_KEY`) only for
29
+ requests whose host matches `OYSTER_ORIGIN`; any other host gets a plain 404.
30
+
31
+ The host Worker, Durable Objects, Worker Loader and Release assets run locally;
32
+ state stays under `.wrangler/`. No Computer or container is configured. Computer
33
+ files, shell commands, processes and port previews need an attached Computer.
34
+ Team sign-in/grants and funded provider services are not emulated. See the
35
+ [create-fungi-app quickstart](https://docs.fungi.computer/create-fungi-app/quickstart/)
36
+ for the measured cold-start time and the local/hosted boundary.
37
+
38
+ `src/backend/methods.ts` declares the method's input and output schemas and its
39
+ handler. `src/backend/index.ts` exports the Durable Object facet. The
40
+ `fungi.backendDefinition` manifest entry tells Oyster's builder where the
41
+ definition lives; the builder derives the App's API and declaration from it and
42
+ the manifest's `fungi.app`.
43
+
44
+ ```sh
45
+ npm run build
46
+ ```
47
+
48
+ This writes `dist/release.json` and `dist/artifacts/`. Build captures the
49
+ current saved files into an isolated Git snapshot, including uncommitted edits;
50
+ it does not modify your repository or create a customer commit. The Release's
51
+ provenance names that exact snapshot. Dependency installation uses
52
+ `package-lock.json`.
53
+
54
+ To install the Release into an existing Oyster, set its URL and owner key, then
55
+ use the ordinary Oyster CLI:
56
+
57
+ ```sh
58
+ export OYSTER_URL=https://your-oyster.example
59
+ export OYSTER_OWNER_KEY='<your owner key>'
60
+ npx oyster install ./dist --installation-id my-app
61
+ # The manifest path is also accepted:
62
+ npx oyster install ./dist/release.json --installation-id my-app
63
+ ```
64
+
65
+ Both forms use `installRelease` from `@fungi.computer/oyster/pack`: artifacts
66
+ upload before the installation request. A failed upload or installation exits
67
+ nonzero; the command does not provision or deploy an Oyster.
68
+
69
+ For repository-driven authoring, commit the App's root `package.json` and its
70
+ `fungi.build` recipe on main, push to a repository in Bonsai, then open its
71
+ Settings and choose **Make this an App**. Registration reads committed metadata,
72
+ not dirty working files, and does not build or install. Later accepted main
73
+ pushes build that exact commit on an attached hosted backend. Successful builds
74
+ may reuse existing approval; new permissions produce `needs_consent` and leave
75
+ any previous Release running. Local-only Computers return `no_backend`.
76
+
77
+ The same Release format is used by Fungi. Building creates the Release locally;
78
+ it does not install or publish it.
79
+
80
+ ## Models and mascots
81
+
82
+ When adding Quaternius art, add `@fungi.computer/art-library` and `three` as
83
+ dependencies. Discover models through `@fungi.computer/art-library/models`: use
84
+ `models` and `searchModels(models, query)` to find candidates, then
85
+ `requireModel(models, id)` to pin a catalog ID rather than guessing filenames.
86
+
87
+ Load the selected asset with `modelLoader()` from
88
+ `@fungi.computer/art-library/three` and its `loadAsync(modelUrl)` method. This
89
+ loader wires the Meshopt decoder needed by compressed models; a bare
90
+ `GLTFLoader` does not.
91
+
92
+ Include the chosen model (and any referenced buffers/textures) in the Release's
93
+ built assets and use the emitted asset URL; the catalog row's `file` is not a
94
+ public Oyster URL. Keep `fungi.build.assetBase` as `"./"` and URLs
95
+ release-scoped under `/ui/<installation>/<release>/`. Resolve emitted relative
96
+ paths against the App document base, for example
97
+ `new URL("./assets/mascot.glb", document.baseURI).href` if that is the emitted
98
+ path. Never hard-code `/models/...`: its leading slash escapes the Release
99
+ prefix and requests an asset from the host root.
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ .botanical/
3
+ .wrangler/
4
+ dist/
5
+ .dev.vars
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="latte">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>My App</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,10 @@
1
+ import { createOysterWorker } from "@fungi.computer/oyster/workers";
2
+ // Every invocation frame mints its binding stubs from this one entrypoint.
3
+ export {
4
+ OysterBinding,
5
+ OysterInstance,
6
+ OysterMcp,
7
+ OysterRegistry,
8
+ OysterInstallationExecutor,
9
+ } from "@fungi.computer/oyster/workers";
10
+ export default createOysterWorker();
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "../node_modules/wrangler/config-schema.json",
3
+ "name": "my-app-oyster",
4
+ "main": "worker.ts",
5
+ "compatibility_date": "2026-09-04",
6
+ "compatibility_flags": ["nodejs_compat"],
7
+ "worker_loaders": [
8
+ {
9
+ "binding": "LOADER"
10
+ }
11
+ ],
12
+ "r2_buckets": [
13
+ {
14
+ "binding": "RELEASES",
15
+ "bucket_name": "app-releases"
16
+ }
17
+ ],
18
+ "durable_objects": {
19
+ "bindings": [
20
+ {
21
+ "name": "MCP",
22
+ "class_name": "OysterMcp"
23
+ },
24
+ {
25
+ "name": "REGISTRY",
26
+ "class_name": "OysterRegistry"
27
+ },
28
+ {
29
+ "name": "INSTANCES",
30
+ "class_name": "OysterInstance"
31
+ }
32
+ ]
33
+ },
34
+ "migrations": [
35
+ {
36
+ "tag": "v1",
37
+ "new_sqlite_classes": ["OysterRegistry", "OysterInstance", "OysterMcp"]
38
+ }
39
+ ]
40
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "dependencies": {
3
+ "@fungi.computer/agar": "0.8.0",
4
+ "@fungi.computer/caps": "0.8.0",
5
+ "react": "18.3.1",
6
+ "react-dom": "18.3.1",
7
+ "zod": "4.4.3"
8
+ },
9
+ "devDependencies": {
10
+ "create-fungi-app": "0.0.2",
11
+ "@fungi.computer/oyster": "0.15.0",
12
+ "typescript": "5.9.3",
13
+ "@types/react": "18.3.3",
14
+ "@types/react-dom": "18.3.0",
15
+ "@cloudflare/workers-types": "5.20260828.1",
16
+ "wrangler": "4.127.1",
17
+ "vite": "8.2.2",
18
+ "@vitejs/plugin-react": "6.1.1"
19
+ },
20
+ "name": "my-app",
21
+ "version": "0.0.0",
22
+ "private": true,
23
+ "type": "module",
24
+ "scripts": {
25
+ "dev": "fungi-app dev",
26
+ "build": "fungi-app build"
27
+ },
28
+ "fungi": {
29
+ "app": {
30
+ "displayName": "My App",
31
+ "capabilities": [],
32
+ "handlers": [],
33
+ "roles": []
34
+ },
35
+ "backendDefinition": "src/backend/methods.ts",
36
+ "build": {
37
+ "kind": "source-html",
38
+ "entry": "index.html",
39
+ "assetBase": "./",
40
+ "assets": {
41
+ "maxManifestBytes": 1048576,
42
+ "maxObjects": 1024,
43
+ "maxObjectBytes": 16777216,
44
+ "maxTotalBytes": 67108864
45
+ },
46
+ "maxOutputBytes": 67108864,
47
+ "backend": {
48
+ "entry": "src/backend/index.ts",
49
+ "exportName": "MyApp",
50
+ "compatibilityDate": "2026-09-04",
51
+ "runtime": {
52
+ "kind": "durable-facet",
53
+ "stateSchema": 1
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,49 @@
1
+ import { useState } from "react";
2
+ import type { AppRoot } from "@fungi.computer/agar";
3
+ import { appMethods } from "@fungi.computer/agar/backend";
4
+ import { Button } from "@fungi.computer/caps/components/button";
5
+ import {
6
+ CardContent,
7
+ CardFooter,
8
+ CardHeader,
9
+ CardTitle,
10
+ CenteredCard,
11
+ } from "@fungi.computer/caps/components/card";
12
+ import { greetingInput, greetingOutput } from "./backend/methods.js";
13
+
14
+ export function App({ session }: { session: AppRoot }) {
15
+ const [message, setMessage] = useState("Your App is running inside Oyster.");
16
+ const [busy, setBusy] = useState(false);
17
+ const [failure, setFailure] = useState<Error | null>(null);
18
+ async function greet() {
19
+ setBusy(true);
20
+ setFailure(null);
21
+ try {
22
+ const result = await session.capabilities
23
+ .require(appMethods)
24
+ .call("greet", JSON.stringify(greetingInput.parse({ name: "friend" })));
25
+ setMessage(greetingOutput.parse(result).message);
26
+ } catch (cause) {
27
+ setFailure(
28
+ new Error("Could not reach the backend. Try again.", { cause }),
29
+ );
30
+ } finally {
31
+ setBusy(false);
32
+ }
33
+ }
34
+ return (
35
+ <CenteredCard>
36
+ <CardHeader>
37
+ <CardTitle level={2}>My App</CardTitle>
38
+ </CardHeader>
39
+ <CardContent>
40
+ <p role="status">{failure?.message ?? message}</p>
41
+ </CardContent>
42
+ <CardFooter>
43
+ <Button loading={busy} disabled={busy} onClick={() => void greet()}>
44
+ Say hello
45
+ </Button>
46
+ </CardFooter>
47
+ </CenteredCard>
48
+ );
49
+ }
@@ -0,0 +1,4 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import { appBackend } from "./methods.js";
3
+
4
+ export const MyApp = appBackend.implement(class extends DurableObject {});
@@ -0,0 +1,21 @@
1
+ import * as z from "zod";
2
+ import {
3
+ defineAppBackend,
4
+ defineAppMethod,
5
+ } from "@fungi.computer/agar/backend-definition";
6
+
7
+ export const greetingInput = z.object({ name: z.string().trim().min(1) });
8
+ export const greetingOutput = z.object({ message: z.string() });
9
+
10
+ export const appBackend = defineAppBackend({
11
+ methods: {
12
+ greet: defineAppMethod({
13
+ description: "Say hello",
14
+ effect: "read",
15
+ requires: [],
16
+ input: greetingInput,
17
+ output: greetingOutput,
18
+ handler: ({ name }) => ({ message: `Hello, ${name}!` }),
19
+ }),
20
+ },
21
+ });
@@ -0,0 +1,13 @@
1
+ import { connectApp } from "@fungi.computer/agar";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App.js";
4
+ import "@fungi.computer/caps/styles.css";
5
+ import "@fungi.computer/caps/fonts.css";
6
+
7
+ const root = document.getElementById("app");
8
+ if (root === null) throw new Error("App mount is missing.");
9
+ const session = await connectApp({
10
+ host: "parent",
11
+ });
12
+ window.addEventListener("pagehide", () => session.dispose(), { once: true });
13
+ createRoot(root).render(<App session={session} />);
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "lib": ["ES2022"],
5
+ "types": ["@cloudflare/workers-types"]
6
+ },
7
+ "include": ["src/backend", "oyster"]
8
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM", "ESNext.Disposable"],
7
+ "types": [],
8
+ "strict": true,
9
+ "jsx": "react-jsx",
10
+ "noEmit": true
11
+ },
12
+ "include": ["src/App.tsx", "src/main.tsx", "src/backend/methods.ts"]
13
+ }