@frockbot/applet-sdk 0.0.0 → 0.3.13

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/package.json CHANGED
@@ -1,14 +1,60 @@
1
1
  {
2
2
  "name": "@frockbot/applet-sdk",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
3
+ "version": "0.3.13",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Authoring SDK for FrockBot Applets: schema-first Durable Object server, TanStack DB client, component kit, linter, and the `applet` CLI.",
5
7
  "license": "UNLICENSED",
8
+ "exports": {
9
+ "./server": "./src/server/index.ts",
10
+ "./client": "./src/client/index.ts",
11
+ "./kit": "./src/kit/index.tsx",
12
+ "./lint": "./src/lint/index.ts",
13
+ "./protocol": "./src/protocol/index.ts",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "bin": {
17
+ "applet": "./dist/cli.mjs"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "types",
23
+ "template",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "bun scripts/build-cli.ts",
28
+ "prepublishOnly": "bun scripts/build-cli.ts",
29
+ "test": "bun test test spike",
30
+ "typecheck": "tsc --noEmit -p tsconfig.json"
31
+ },
32
+ "dependencies": {
33
+ "@tanstack/db": "0.8.7",
34
+ "@tanstack/react-db": "0.3.7",
35
+ "esbuild": "0.28.2",
36
+ "eslint": "10.9.1",
37
+ "miniflare": "5.20260828.0-alpha",
38
+ "typescript": "5.9.3",
39
+ "typescript-eslint": "8.69.0"
40
+ },
41
+ "peerDependencies": {
42
+ "react": "^19.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/bun": "1.4.0",
46
+ "@types/node": "26.2.0",
47
+ "@types/react": "19.2.18",
48
+ "@types/react-dom": "19.2.4",
49
+ "react": "19.2.8",
50
+ "react-dom": "19.2.8"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
6
55
  "repository": {
7
56
  "type": "git",
8
57
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
58
  "directory": "packages/applet-sdk"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
59
  }
14
60
  }
package/src/cli/bin.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `applet` — the four commands a Bot runs on the Computer.
3
+ *
4
+ * `scripts/build-cli.ts` bundles `./main.ts` (which calls `run` below) into
5
+ * `dist/cli.mjs`, and that file is the package's `bin`: the Computer image has
6
+ * Node and no Bun, and these sources are TypeScript resolving siblings through
7
+ * `.js` specifiers, which plain Node cannot load.
8
+ *
9
+ * applet new <name> [--dir <parent>]
10
+ * applet check [dir]
11
+ * applet build [dir]
12
+ * applet dev [dir] [--port <n>]
13
+ *
14
+ * Output is deliberately spare: diagnostics as `path:line:col message`, one
15
+ * per line, and a non-zero exit when any of them is an error. That is the whole
16
+ * contract a Bot has to remember about this CLI.
17
+ */
18
+
19
+ import { resolve } from "node:path";
20
+
21
+ import { formatDiagnostic } from "../lint/index.js";
22
+ import { buildApplet } from "./build.js";
23
+ import { checkApplet } from "./check.js";
24
+ import { startAppletDev } from "./dev.js";
25
+ import { newApplet } from "./new.js";
26
+
27
+ const USAGE = `applet <command>
28
+
29
+ new <name> [--dir <parent>] scaffold an Applet from the template
30
+ check [dir] type-check and lint
31
+ build [dir] write dist/{server.js,ui.html,manifest.json}
32
+ dev [dir] [--port <n>] serve the built Applet locally
33
+ `;
34
+
35
+ interface Args {
36
+ command?: string;
37
+ positional: string[];
38
+ options: Record<string, string>;
39
+ }
40
+
41
+ export function parseArgs(argv: string[]): Args {
42
+ const positional: string[] = [];
43
+ const options: Record<string, string> = {};
44
+ for (let index = 0; index < argv.length; index += 1) {
45
+ const token = argv[index]!;
46
+ if (token.startsWith("--")) {
47
+ const [flag, inline] = token.slice(2).split("=", 2);
48
+ options[flag!] = inline ?? argv[++index] ?? "";
49
+ continue;
50
+ }
51
+ positional.push(token);
52
+ }
53
+ return { command: positional.shift(), positional, options };
54
+ }
55
+
56
+ function report(diagnostics: Awaited<ReturnType<typeof checkApplet>>): number {
57
+ for (const diagnostic of diagnostics)
58
+ console.error(formatDiagnostic(diagnostic));
59
+ const errors = diagnostics.filter(
60
+ (diagnostic) => diagnostic.severity === "error",
61
+ );
62
+ if (errors.length === 0) {
63
+ console.log("applet check: no problems found");
64
+ return 0;
65
+ }
66
+ console.error(`applet check: ${errors.length} error(s)`);
67
+ return 1;
68
+ }
69
+
70
+ export async function run(argv: string[]): Promise<number> {
71
+ const { command, positional, options } = parseArgs(argv);
72
+ const directory = resolve(positional[0] ?? ".");
73
+
74
+ switch (command) {
75
+ case "new": {
76
+ const name = positional[0];
77
+ if (!name) {
78
+ console.error("applet new needs a name");
79
+ return 2;
80
+ }
81
+ const created = await newApplet({
82
+ name,
83
+ parent: resolve(options.dir ?? "."),
84
+ });
85
+ console.log(`Created ${created.directory} (id ${created.id})`);
86
+ console.log("Next: applet check && applet build");
87
+ return 0;
88
+ }
89
+ case "check":
90
+ return report(await checkApplet(directory));
91
+ case "build": {
92
+ const result = await buildApplet(directory);
93
+ console.log(
94
+ `${result.serverPath} ${result.manifest.hashes.server.slice(0, 12)}`,
95
+ );
96
+ console.log(`${result.uiPath} ${result.manifest.hashes.ui.slice(0, 12)}`);
97
+ console.log(
98
+ `${result.manifestPath} ${result.manifest.tools.length} tool(s): ` +
99
+ result.manifest.tools.map((tool) => tool.name).join(", "),
100
+ );
101
+ return 0;
102
+ }
103
+ case "dev": {
104
+ const port = options.port === undefined ? 0 : Number(options.port);
105
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
106
+ console.error("applet dev --port must be a port number");
107
+ return 2;
108
+ }
109
+ const server = await startAppletDev({ directory, port });
110
+ console.log(server.url.toString());
111
+ const stop = () => {
112
+ void server.dispose().then(() => process.exit(0));
113
+ };
114
+ process.on("SIGINT", stop);
115
+ process.on("SIGTERM", stop);
116
+ await new Promise(() => {});
117
+ return 0;
118
+ }
119
+ default:
120
+ console.log(USAGE);
121
+ return command === undefined || command === "help" ? 0 : 2;
122
+ }
123
+ }
124
+
125
+ // No `import.meta.main` guard: this module is the command table, and
126
+ // `./main.ts` is the entry point the published `dist/cli.mjs` bundles. Two
127
+ // entry points would run every command twice under a Node that implements
128
+ // `import.meta.main`, which is exactly what happened before this comment.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `applet build` — three immutable artifacts from two source files.
3
+ *
4
+ * `dist/server.js` one ESM file whose only import is `cloudflare:workers`,
5
+ * exporting `Applet`, which is the name the kernel mounts.
6
+ * `dist/ui.html` one self-contained page: React, TanStack DB, the kit, and
7
+ * the app inlined. No external URL, because the artifact
8
+ * origin serves it into a sandbox with no network of its own.
9
+ * `dist/manifest.json` `{ contract, tools, hashes }`.
10
+ *
11
+ * The tool declarations come from mounting the built server in Miniflare and
12
+ * calling `health()`, not from reading the source. Static analysis would be a
13
+ * second implementation of `this.tool(...)` that could disagree with the one
14
+ * the kernel actually asks — and the kernel admits a generation by comparing
15
+ * the manifest to the facet's own `health()`, so any disagreement is a failed
16
+ * publish. Running the code is the only derivation that cannot drift.
17
+ */
18
+
19
+ import { createHash, randomUUID } from "node:crypto";
20
+ import { mkdir, writeFile } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+
23
+ import { build as esbuild } from "esbuild";
24
+
25
+ import type { AppletDescriptionV1 } from "../server/applet.js";
26
+ import { readDescriptor, type AppletBuildManifestV1 } from "./manifest.js";
27
+ import { bundlerNodePaths, SDK_ENTRIES } from "./paths.js";
28
+ import { startAppletRuntime } from "./runtime.js";
29
+
30
+ export interface AppletBuildResult {
31
+ directory: string;
32
+ serverPath: string;
33
+ uiPath: string;
34
+ manifestPath: string;
35
+ manifest: AppletBuildManifestV1;
36
+ }
37
+
38
+ function sha256(text: string): string {
39
+ return createHash("sha256").update(text, "utf8").digest("hex");
40
+ }
41
+
42
+ function alias(): Record<string, string> {
43
+ return { ...SDK_ENTRIES };
44
+ }
45
+
46
+ async function bundle(options: {
47
+ stdin: string;
48
+ resolveDir: string;
49
+ platform: "neutral" | "browser";
50
+ format: "esm" | "iife";
51
+ external: string[];
52
+ minify: boolean;
53
+ loaderName: string;
54
+ }): Promise<string> {
55
+ const result = await esbuild({
56
+ stdin: {
57
+ contents: options.stdin,
58
+ resolveDir: options.resolveDir,
59
+ sourcefile: options.loaderName,
60
+ loader: "tsx",
61
+ },
62
+ bundle: true,
63
+ write: false,
64
+ format: options.format,
65
+ platform: options.platform,
66
+ target: "es2022",
67
+ jsx: "automatic",
68
+ minify: options.minify,
69
+ legalComments: "none",
70
+ external: options.external,
71
+ alias: alias(),
72
+ nodePaths: bundlerNodePaths(),
73
+ conditions: ["import", "module", "browser", "default"],
74
+ define: { "process.env.NODE_ENV": '"production"' },
75
+ logLevel: "silent",
76
+ });
77
+ const file = result.outputFiles?.[0];
78
+ if (!file) throw new Error("The bundler produced no output");
79
+ return file.text;
80
+ }
81
+
82
+ function page(title: string, script: string): string {
83
+ // Nothing is fetched: the CSP on the artifact origin blocks every external
84
+ // request, so React, the kit, and the app are all in this one <script>.
85
+ return [
86
+ "<!doctype html>",
87
+ '<html lang="en">',
88
+ "<head>",
89
+ '<meta charset="utf-8">',
90
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
91
+ `<title>${title.replaceAll("<", "&lt;")}</title>`,
92
+ "<style>html,body{margin:0;height:100%;background:var(--frockbot-surface,#ffffff)}</style>",
93
+ "</head>",
94
+ "<body>",
95
+ '<div id="applet-root"></div>',
96
+ `<script>${script}</script>`,
97
+ "</body>",
98
+ "</html>",
99
+ ].join("\n");
100
+ }
101
+
102
+ /** Ask the built module what it declares, by running it. */
103
+ export async function readDescription(
104
+ serverCode: string,
105
+ appletId: string,
106
+ ): Promise<AppletDescriptionV1> {
107
+ const runtime = await startAppletRuntime({
108
+ serverCode,
109
+ appletId,
110
+ token: randomUUID(),
111
+ });
112
+ try {
113
+ // `health()` first: it is what the kernel calls, so a server that cannot
114
+ // mount fails here the way it would fail a publish.
115
+ const health = await runtime.fetch("/health");
116
+ if (!health.ok) {
117
+ throw new Error(`The Applet failed to mount: ${await health.text()}`);
118
+ }
119
+ const response = await runtime.fetch("/describe");
120
+ if (!response.ok) {
121
+ throw new Error(
122
+ `The Applet could not describe its tools: ${await response.text()}`,
123
+ );
124
+ }
125
+ return (await response.json()) as AppletDescriptionV1;
126
+ } finally {
127
+ await runtime.dispose();
128
+ }
129
+ }
130
+
131
+ export async function buildApplet(
132
+ directory: string,
133
+ ): Promise<AppletBuildResult> {
134
+ const descriptor = await readDescriptor(directory);
135
+
136
+ const serverCode = await bundle({
137
+ // `Applet` is the export name the kernel's facet mount looks up; the author
138
+ // writes an ordinary default export and never learns that name.
139
+ stdin:
140
+ 'import AppletClass from "./server";\nexport { AppletClass as Applet };\n',
141
+ resolveDir: directory,
142
+ platform: "neutral",
143
+ format: "esm",
144
+ external: ["cloudflare:workers"],
145
+ minify: false,
146
+ loaderName: "applet-server-entry.ts",
147
+ });
148
+
149
+ const uiScript = await bundle({
150
+ stdin: 'import "./ui";\n',
151
+ resolveDir: directory,
152
+ platform: "browser",
153
+ format: "iife",
154
+ external: [],
155
+ minify: true,
156
+ loaderName: "applet-ui-entry.tsx",
157
+ });
158
+ const html = page(descriptor.displayName, uiScript);
159
+
160
+ const description = await readDescription(serverCode, descriptor.id);
161
+ const manifest: AppletBuildManifestV1 = {
162
+ contract: 1,
163
+ tools: description.tools,
164
+ hashes: { server: sha256(serverCode), ui: sha256(html) },
165
+ };
166
+
167
+ const dist = join(directory, "dist");
168
+ await mkdir(dist, { recursive: true });
169
+ const serverPath = join(dist, "server.js");
170
+ const uiPath = join(dist, "ui.html");
171
+ const manifestPath = join(dist, "manifest.json");
172
+ await writeFile(serverPath, serverCode, "utf8");
173
+ await writeFile(uiPath, html, "utf8");
174
+ await writeFile(
175
+ manifestPath,
176
+ `${JSON.stringify(manifest, null, 2)}\n`,
177
+ "utf8",
178
+ );
179
+
180
+ return { directory, serverPath, uiPath, manifestPath, manifest };
181
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `applet check` — the type checker and the linter, one diagnostic list.
3
+ *
4
+ * The Applet has no `node_modules`: it is source at a durable root. So the
5
+ * compiler options are built here from `paths.ts` rather than from a tsconfig
6
+ * the Bot would have to maintain, and every diagnostic comes back in the one
7
+ * shape the CLI prints.
8
+ */
9
+
10
+ import { readdir } from "node:fs/promises";
11
+ import { join, relative, resolve } from "node:path";
12
+
13
+ import ts from "typescript";
14
+
15
+ import { lintApplet, type AppletDiagnostic } from "../lint/index.js";
16
+ import { readDescriptor } from "./manifest.js";
17
+ import { SDK_WORKERS_TYPES, typeCheckerPaths } from "./paths.js";
18
+
19
+ const COMPILER_OPTIONS: ts.CompilerOptions = {
20
+ target: ts.ScriptTarget.ES2022,
21
+ module: ts.ModuleKind.ESNext,
22
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
23
+ jsx: ts.JsxEmit.ReactJSX,
24
+ strict: true,
25
+ noEmit: true,
26
+ skipLibCheck: true,
27
+ esModuleInterop: true,
28
+ allowSyntheticDefaultImports: true,
29
+ forceConsistentCasingInFileNames: true,
30
+ lib: ["lib.es2022.d.ts", "lib.dom.d.ts"],
31
+ types: [],
32
+ };
33
+
34
+ async function appletSources(directory: string): Promise<string[]> {
35
+ const found: string[] = [];
36
+ const walk = async (current: string): Promise<void> => {
37
+ for (const entry of await readdir(current, { withFileTypes: true })) {
38
+ if (entry.name === "node_modules" || entry.name === "dist") continue;
39
+ if (entry.name.startsWith(".")) continue;
40
+ const path = join(current, entry.name);
41
+ if (entry.isDirectory()) await walk(path);
42
+ else if (/\.(ts|tsx)$/.test(entry.name)) found.push(path);
43
+ }
44
+ };
45
+ await walk(directory);
46
+ return found;
47
+ }
48
+
49
+ /** Type-check the Applet against the SDK's declarations. */
50
+ export async function typeCheckApplet(
51
+ directory: string,
52
+ ): Promise<AppletDiagnostic[]> {
53
+ const root = resolve(directory);
54
+ const files = await appletSources(root);
55
+ if (files.length === 0) {
56
+ return [
57
+ {
58
+ file: "applet.json",
59
+ line: 1,
60
+ column: 1,
61
+ message:
62
+ "No TypeScript sources found; an Applet needs server.ts and ui.tsx.",
63
+ severity: "error",
64
+ },
65
+ ];
66
+ }
67
+ const program = ts.createProgram([...files, SDK_WORKERS_TYPES], {
68
+ ...COMPILER_OPTIONS,
69
+ baseUrl: root,
70
+ paths: typeCheckerPaths(),
71
+ });
72
+ return ts
73
+ .getPreEmitDiagnostics(program)
74
+ .filter(
75
+ (diagnostic) =>
76
+ !diagnostic.file || diagnostic.file.fileName.startsWith(root),
77
+ )
78
+ .map((diagnostic) => {
79
+ const message = ts.flattenDiagnosticMessageText(
80
+ diagnostic.messageText,
81
+ " ",
82
+ );
83
+ if (!diagnostic.file || diagnostic.start === undefined) {
84
+ return {
85
+ file: "applet.json",
86
+ line: 1,
87
+ column: 1,
88
+ message,
89
+ severity: "error" as const,
90
+ };
91
+ }
92
+ const position = diagnostic.file.getLineAndCharacterOfPosition(
93
+ diagnostic.start,
94
+ );
95
+ return {
96
+ file: relative(root, diagnostic.file.fileName),
97
+ line: position.line + 1,
98
+ column: position.character + 1,
99
+ message: `${message} (TS${diagnostic.code})`,
100
+ severity:
101
+ diagnostic.category === ts.DiagnosticCategory.Error
102
+ ? ("error" as const)
103
+ : ("warning" as const),
104
+ };
105
+ });
106
+ }
107
+
108
+ /** Everything `applet check` reports, in source order. */
109
+ export async function checkApplet(
110
+ directory: string,
111
+ ): Promise<AppletDiagnostic[]> {
112
+ const root = resolve(directory);
113
+ const diagnostics: AppletDiagnostic[] = [];
114
+ try {
115
+ await readDescriptor(root);
116
+ } catch (error) {
117
+ diagnostics.push({
118
+ file: "applet.json",
119
+ line: 1,
120
+ column: 1,
121
+ message: error instanceof Error ? error.message : String(error),
122
+ severity: "error",
123
+ });
124
+ return diagnostics;
125
+ }
126
+ diagnostics.push(...(await typeCheckApplet(root)));
127
+ diagnostics.push(...(await lintApplet(root)));
128
+ return diagnostics.sort(
129
+ (left, right) =>
130
+ left.file.localeCompare(right.file) ||
131
+ left.line - right.line ||
132
+ left.column - right.column,
133
+ );
134
+ }
package/src/cli/dev.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `applet dev` — the built Applet on a local port, ready to be opened and
3
+ * screenshotted from the Computer's browser. It opens nothing itself.
4
+ */
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import { readFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+
10
+ import { readDescriptor } from "./manifest.js";
11
+ import { startAppletRuntime, type AppletRuntime } from "./runtime.js";
12
+
13
+ /**
14
+ * In production the shell nests the Applet page and sends it `init`. There is
15
+ * no shell here, so the dev page sends itself the same message — the page-side
16
+ * contract is identical, which is the point of running it this way at all.
17
+ */
18
+ const DEV_THEME_TOKENS: Record<string, string> = {
19
+ surface: "#ffffff",
20
+ "surface-raised": "#ffffff",
21
+ "surface-subtle": "#f3f4f6",
22
+ text: "#16181d",
23
+ "text-muted": "#5b616b",
24
+ border: "#d8dbe0",
25
+ "accent-surface": "#2f6feb",
26
+ "accent-text": "#ffffff",
27
+ "radius-card": "10px",
28
+ };
29
+
30
+ function injectDevInit(
31
+ html: string,
32
+ token: string,
33
+ generationId: string,
34
+ ): string {
35
+ const init = {
36
+ schemaVersion: 1,
37
+ type: "init",
38
+ themeTokens: DEV_THEME_TOKENS,
39
+ packageId: "applets",
40
+ botId: "dev",
41
+ slot: "frockbot.right-panel",
42
+ applet: { socketUrl: "", token, generationId },
43
+ };
44
+ const script =
45
+ `<script>(()=>{const m=${JSON.stringify(init)};` +
46
+ `m.applet.socketUrl=location.origin.replace(/^http/,"ws")+"/socket";` +
47
+ `window.postMessage(m,"*");})();</script>`;
48
+ return html.replace("</body>", `${script}\n</body>`);
49
+ }
50
+
51
+ export interface AppletDevServer extends AppletRuntime {
52
+ token: string;
53
+ }
54
+
55
+ export interface AppletDevOptions {
56
+ directory: string;
57
+ /** 0 picks a free port. */
58
+ port?: number;
59
+ }
60
+
61
+ /** Serve `dist/` from Miniflare. Build first; this does not bundle. */
62
+ export async function startAppletDev(
63
+ options: AppletDevOptions,
64
+ ): Promise<AppletDevServer> {
65
+ const descriptor = await readDescriptor(options.directory);
66
+ const dist = join(options.directory, "dist");
67
+ let serverCode: string;
68
+ let html: string;
69
+ try {
70
+ serverCode = await readFile(join(dist, "server.js"), "utf8");
71
+ html = await readFile(join(dist, "ui.html"), "utf8");
72
+ } catch {
73
+ throw new Error("No dist/ to serve; run `applet build` first");
74
+ }
75
+ const token = randomUUID();
76
+ const runtime = await startAppletRuntime({
77
+ serverCode,
78
+ html: injectDevInit(html, token, "dev"),
79
+ appletId: descriptor.id,
80
+ token,
81
+ port: options.port ?? 0,
82
+ });
83
+ return { ...runtime, token };
84
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The bundled CLI's entry point.
3
+ *
4
+ * `bin.ts` guards its own execution with `import.meta.main` so it can be
5
+ * imported by tests under Bun. `dist/cli.mjs` is only ever executed, and it
6
+ * runs under whatever Node the Computer image ships — where `import.meta.main`
7
+ * may not exist, and where an absent guard would mean a CLI that exits 0 and
8
+ * does nothing. So the bundle gets its own entry with no guard at all.
9
+ */
10
+ import { run } from "./bin.js";
11
+
12
+ try {
13
+ process.exitCode = await run(process.argv.slice(2));
14
+ } catch (error) {
15
+ console.error(error instanceof Error ? error.message : String(error));
16
+ process.exitCode = 1;
17
+ }
@@ -0,0 +1,58 @@
1
+ /** `applet.json`: the three facts the SDK needs before it reads any code. */
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ import { APPLET_CONTRACT_VERSION } from "../protocol/index.js";
7
+ import type { AppletToolDeclarationV1 } from "../server/applet.js";
8
+
9
+ export interface AppletDescriptorV1 {
10
+ /** `/^[a-z][a-z0-9-]{0,31}$/`; the directory name `applet new` creates. */
11
+ id: string;
12
+ displayName: string;
13
+ contract: 1;
14
+ }
15
+
16
+ export interface AppletBuildManifestV1 {
17
+ contract: 1;
18
+ tools: AppletToolDeclarationV1[];
19
+ hashes: { server: string; ui: string };
20
+ }
21
+
22
+ export function decodeDescriptor(input: unknown): AppletDescriptorV1 {
23
+ if (!input || typeof input !== "object")
24
+ throw new Error("applet.json must be an object");
25
+ const value = input as Record<string, unknown>;
26
+ const id = value.id;
27
+ if (typeof id !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(id)) {
28
+ throw new Error('applet.json "id" must match /^[a-z][a-z0-9-]{0,31}$/');
29
+ }
30
+ if (
31
+ typeof value.displayName !== "string" ||
32
+ value.displayName.length === 0 ||
33
+ value.displayName.length > 64
34
+ ) {
35
+ throw new Error('applet.json "displayName" must be 1-64 characters');
36
+ }
37
+ if (value.contract !== APPLET_CONTRACT_VERSION) {
38
+ throw new Error(
39
+ `applet.json "contract" must be ${APPLET_CONTRACT_VERSION}`,
40
+ );
41
+ }
42
+ return { id, displayName: value.displayName, contract: 1 };
43
+ }
44
+
45
+ export async function readDescriptor(
46
+ directory: string,
47
+ ): Promise<AppletDescriptorV1> {
48
+ const path = join(directory, "applet.json");
49
+ let text: string;
50
+ try {
51
+ text = await readFile(path, "utf8");
52
+ } catch {
53
+ throw new Error(
54
+ `No applet.json in ${directory}; run \`applet new <name>\` first`,
55
+ );
56
+ }
57
+ return decodeDescriptor(JSON.parse(text));
58
+ }