ais6-app 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Lau
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,40 @@
1
- # Temporary Holding Version
1
+ # ais6-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 an AI System 6 app, and check it the way a desk checks it before
4
+ installing it.
5
+
6
+ ```
7
+ npx ais6-app create my-app # a new TypeScript + Preact app
8
+ npx ais6-app check # the app folder you are in
9
+ npx ais6-app check path/to/app --build
10
+ ```
11
+
12
+ `create` writes a working app into an empty folder: a typed bridge client,
13
+ `public/app.manifest.json` with one window and a File menu, the kit files,
14
+ strings in English and Chinese, Vitest tests, Biome, a README pair and an MIT
15
+ licence. Options: `--id` (default: the folder name), `--name`, `--name-zh`,
16
+ `--author`. `npm run check` in the new folder runs types, lint, tests, the
17
+ build and `ais6-app check`; it passes before you change anything.
18
+
19
+ It runs on a repository (source and build), on a built `dist/`, or on a
20
+ vendored app tree, and prints one line per check: `OK`, `NO` (a desk would
21
+ refuse the app) or `--` (a warning). The exit code is 0 when everything
22
+ passes, 1 when something is refused, 2 for a usage error.
23
+
24
+ | Check | What it looks at |
25
+ |---|---|
26
+ | manifest | id, version, protocol, kit range; each window's name, app, entry page, title, classes, capabilities (the closed set), services, menus, frame permissions; strings in English and Chinese |
27
+ | kit | the bridge client `ais6-bridge.js` beside the manifest (and `kit.css`, as a warning) |
28
+ | bridge only | no reach into the parent page, no desk globals, no `localStorage`, `sessionStorage`, `indexedDB`, cookies, `BroadcastChannel` or `SharedWorker` |
29
+ | versions | the manifest, `package.json` and the version the code connects with agree (a mismatch makes the desk refuse the connection) |
30
+ | repository | `README.md`, `README.zh-CN.md`, `LICENSE`, a build script |
31
+ | safe to publish | no keys or tokens, no local machine paths, no private files (`.env`, keys, editor and agent folders) |
32
+
33
+ Options: `--build` runs `npm run build` first; `--json` prints the result as
34
+ JSON; `--own-storage idbfs` and `--engine-files a.js,b.js` apply the
35
+ exceptions a desk grants a compiled engine.
36
+
37
+ The same rules are imported by the AI System 6 desk's own pin, boundary and
38
+ release checks, so an app that passes here passes there.
39
+
40
+ Documentation: <https://aisystem6.aaronlau.me/sdk/>. Licence: MIT.
@@ -0,0 +1,33 @@
1
+ <!-- canonical-source: packages/ais6-app/README.md -->
2
+ <!-- source-sha256: 246087a9156aa7044435339b6056107420709aba7633d78c2507709caddb1ab1 -->
3
+
4
+ > 英文版为准;本说明仅供人类参考。
5
+
6
+ # ais6-app
7
+
8
+ 新建 AI System 6 app,并按桌面在安装前的检查方式检查它。
9
+
10
+ ```
11
+ npx ais6-app create my-app # 新建一个 TypeScript + Preact app
12
+ npx ais6-app check # 当前所在的 app 文件夹
13
+ npx ais6-app check path/to/app --build
14
+ ```
15
+
16
+ `create` 在空文件夹里写出一个能运行的 app:带类型的桥接客户端、只有一个窗口和「文件」菜单的 `public/app.manifest.json`、kit 文件、中英文字符串、Vitest 测试、Biome、README 中英两份和 MIT 许可证。选项:`--id`(默认用文件夹名)、`--name`、`--name-zh`、`--author`。在新文件夹里运行 `npm run check` 会依次跑类型、lint、测试、构建和 `ais6-app check`;什么都不改也能通过。
17
+
18
+ 可以对仓库(源码与构建)、构建好的 `dist/` 或已托管的 app 树运行,每项检查打印一行:`OK`、`NO`(桌面会拒绝这个 app)或 `--`(警告)。全部通过时退出码为 0,有项目被拒为 1,用法错误为 2。
19
+
20
+ | 检查 | 看什么 |
21
+ |---|---|
22
+ | manifest | id、版本、协议、kit 范围;每个窗口的名称、所属 app、入口页、标题、class、能力(封闭集合)、服务、菜单、框权限;中英文字符串 |
23
+ | kit | manifest 旁的桥接客户端 `ais6-bridge.js`(`kit.css` 缺少时只警告) |
24
+ | 只经桥接 | 不伸手进父页面,不碰桌面全局,不用 `localStorage`、`sessionStorage`、`indexedDB`、cookie、`BroadcastChannel` 或 `SharedWorker` |
25
+ | 版本 | manifest、`package.json` 与代码连接时声明的版本一致(不一致时桌面会拒绝连接) |
26
+ | 仓库 | `README.md`、`README.zh-CN.md`、`LICENSE`、构建脚本 |
27
+ | 可以发布 | 没有密钥或令牌、没有本机路径、没有私密文件(`.env`、密钥、编辑器与 agent 的文件夹) |
28
+
29
+ 选项:`--build` 先运行 `npm run build`;`--json` 以 JSON 输出;`--own-storage idbfs` 与 `--engine-files a.js,b.js` 套用桌面给编译引擎的例外。
30
+
31
+ AI System 6 桌面自己的钉版、边界与发布检查导入的是同一套规则,所以在这里通过的 app,在桌面那里也会通过。
32
+
33
+ 文档:<https://aisystem6.aaronlau.me/sdk/>。许可证:MIT。
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // ais6-app — check an AI System 6 app the way a desk checks it.
3
+ //
4
+ // ais6-app check [dir] [--build] [--json] [--own-storage idbfs] [--engine-files a.js,b.js]
5
+ // ais6-app create <dir> [--id my-app] [--name "My App"] [--name-zh 我的应用] [--author "Name"]
6
+ //
7
+ // Exit code 0 when every check passes, 1 when one fails, 2 on a usage error.
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { runCheck } from "../src/check.mjs";
12
+ import { createApp } from "../src/create.mjs";
13
+
14
+ const args = process.argv.slice(2);
15
+ const command = args[0];
16
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
17
+
18
+ if (!command || command === "--help" || command === "-h" || command === "help") {
19
+ console.log(`ais6-app ${version}
20
+
21
+ Usage:
22
+ ais6-app check [dir] [--build] [--json] check an app folder (default: .)
23
+ ais6-app create <dir> [--id] [--name] [--name-zh] [--author]
24
+ a new TypeScript + Preact app
25
+
26
+ --build run "npm run build" first and check the built dist/
27
+ --json print the result as JSON
28
+ --own-storage idbfs storage the desk lets a compiled engine own
29
+ --engine-files a.js,b generated engine files that are not read
30
+
31
+ The checks: the manifest (ids, versions, protocol, kit range, windows,
32
+ capabilities, services, strings in English and Chinese, frame permissions),
33
+ the kit files, bridge-only access (no parent page, desk globals or desk
34
+ storage), versions that agree (manifest, package.json, the code's connect),
35
+ the repository files, and nothing unsafe to publish (keys, local paths,
36
+ private files). Docs: https://aisystem6.aaronlau.me/sdk/`);
37
+ process.exit(command ? 0 : 2);
38
+ }
39
+ if (command === "--version" || command === "-v") { console.log(version); process.exit(0); }
40
+ const valueOf = (flag) => { const index = args.indexOf(flag); return index >= 0 ? String(args[index + 1] || "") : ""; };
41
+
42
+ if (command === "create") {
43
+ const named = new Set(["--id", "--name", "--name-zh", "--author"].map((flag) => valueOf(flag)).filter(Boolean));
44
+ const target = args.slice(1).find((arg) => !arg.startsWith("--") && !named.has(arg));
45
+ if (!target) { console.error("ais6-app create needs a folder: ais6-app create my-app"); process.exit(2); }
46
+ try {
47
+ const made = createApp(target, {
48
+ id: valueOf("--id") || undefined,
49
+ name: valueOf("--name") || undefined,
50
+ nameZh: valueOf("--name-zh") || undefined,
51
+ author: valueOf("--author") || undefined,
52
+ });
53
+ console.log(`Created ${made.id} (window ${made.window}) in ${made.dir}, ${made.files.length} files.
54
+
55
+ Next:
56
+ cd ${target}
57
+ npm install
58
+ npm run dev # the app on its own page
59
+ npm run check # types, lint, tests, build and ais6-app check`);
60
+ process.exit(0);
61
+ } catch (error) {
62
+ console.error(error.message);
63
+ process.exit(2);
64
+ }
65
+ }
66
+ if (command !== "check") { console.error(`unknown command "${command}"; try ais6-app --help`); process.exit(2); }
67
+
68
+ const listOf = (flag) => valueOf(flag).split(",").map((part) => part.trim()).filter(Boolean);
69
+ const flagValues = new Set(["--own-storage", "--engine-files"].map((flag) => valueOf(flag)).filter(Boolean));
70
+ const dir = resolve(args.slice(1).find((arg) => !arg.startsWith("--") && !flagValues.has(arg)) || ".");
71
+ const result = runCheck(dir, {
72
+ build: args.includes("--build"),
73
+ ownStorage: listOf("--own-storage"),
74
+ engineFiles: listOf("--engine-files"),
75
+ log: (line) => { if (!args.includes("--json")) console.log(line); },
76
+ });
77
+ if (args.includes("--json")) {
78
+ console.log(JSON.stringify({ ok: result.ok, sections: result.sections }, null, 2));
79
+ } else {
80
+ for (const { name, problems, warnings = [] } of result.sections) {
81
+ if (!problems.length) console.log(`OK ${name}`);
82
+ else for (const problem of problems) console.log(`NO ${name}: ${problem}`);
83
+ for (const warning of warnings) console.log(`-- ${name}: ${warning}`);
84
+ }
85
+ console.log(result.ok ? "\nThis app passes every check a desk makes before installing it." : "\nFix the NO lines above; a desk would refuse this app.");
86
+ }
87
+ process.exit(result.ok ? 0 : 1);
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "ais6-app",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Create an AI System 6 app, and check it the way a desk checks it before installing it.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "ais6-app": "bin/ais6-app.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.mjs",
12
+ "./manifest": "./src/manifest.mjs",
13
+ "./boundary": "./src/boundary.mjs",
14
+ "./publish": "./src/publish.mjs",
15
+ "./create": "./src/create.mjs"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "src",
20
+ "template",
21
+ "README.md",
22
+ "README.zh-CN.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "homepage": "https://aisystem6.aaronlau.me/sdk/publishing",
29
+ "keywords": [
30
+ "ai-system-6",
31
+ "app",
32
+ "checker",
33
+ "iframe",
34
+ "bridge"
35
+ ]
36
+ }
@@ -0,0 +1,61 @@
1
+ // An app reaches its desk through the bridge only. These are the reaches it
2
+ // may not make: the parent page, the desk's globals and storage the desk owns.
3
+ // The desk's boundary census imports the same rules.
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { relative, sep } from "node:path";
7
+ import { listFiles } from "./files.mjs";
8
+
9
+ export const TEXT_EXTENSIONS = /\.(?:m?js|html?|svg|css|json)$/i;
10
+
11
+ export const APP_SIDE_RULES = Object.freeze([
12
+ // Bare parent/top, or window/self/globalThis.parent|top — not Object3D.parent
13
+ // (three.js and similar scene-graph walks).
14
+ { id: "parent-or-top", pattern: /(?:(?:^|[^.\w$])(?:parent|top)|(?:window|self|globalThis)\s*\.\s*(?:parent|top))\s*\.\s*(?!postMessage\b)[A-Za-z_$]/, why: "reaches into the page that holds the frame; use the bridge" },
15
+ { id: "indexedDB", pattern: /\bindexedDB\b/, why: "the desk owns storage; use state.*" },
16
+ { id: "localStorage", pattern: /\blocalStorage\b/, why: "the desk owns storage; use state.*" },
17
+ { id: "sessionStorage", pattern: /\bsessionStorage\b/, why: "the desk owns storage; use state.*" },
18
+ { id: "desk-global", pattern: /AISystem6/, why: "names a desk global; use the bridge" },
19
+ { id: "cookie", pattern: /document\s*\.\s*cookie/, why: "cookies are the desk's" },
20
+ { id: "BroadcastChannel", pattern: /\bBroadcastChannel\b/, why: "talks past the bridge to other frames" },
21
+ { id: "SharedWorker", pattern: /\bSharedWorker\b/, why: "talks past the bridge to other frames" },
22
+ ]);
23
+
24
+ // Storage an app may own by exception, when the desk's pin says so: a
25
+ // compiled engine's own file system (Emscripten IDBFS). `page` excuses no
26
+ // rule; it only lets the named engine files go unread.
27
+ export const OWN_STORAGE = Object.freeze({ idbfs: Object.freeze(["indexedDB"]), page: Object.freeze([]) });
28
+
29
+ /**
30
+ * App-side findings for every text file under `dir`: [{ file, line, rule, why }].
31
+ * `isBridgeClient(path, bytes)` says whether a file is a kit bridge client
32
+ * (which is the one file that may talk to the parent); `engineFiles` are
33
+ * generated engine glue that is not read.
34
+ */
35
+ export function scanAppTree(dir, { ownStorage = [], engineFiles = [], isBridgeClient = defaultBridgeCheck, skip = [] } = {}) {
36
+ const engine = new Set(Array.isArray(engineFiles) ? engineFiles : []);
37
+ const excused = new Set((Array.isArray(ownStorage) ? ownStorage : []).flatMap((name) => OWN_STORAGE[name] || []));
38
+ const skipped = (rel) => skip.some((prefix) => rel === prefix || rel.startsWith(`${prefix}/`));
39
+ const findings = [];
40
+ for (const file of listFiles(dir)) {
41
+ if (!TEXT_EXTENSIONS.test(file)) continue;
42
+ const rel = relative(dir, file).split(sep).join("/");
43
+ if (engine.has(rel) || skipped(rel)) continue;
44
+ const data = readFileSync(file);
45
+ if (file.endsWith(`${sep}ais6-bridge.js`) && isBridgeClient(rel, data)) continue;
46
+ data.toString("utf8").split("\n").forEach((text, index) => {
47
+ for (const rule of APP_SIDE_RULES) {
48
+ if (excused.has(rule.id)) continue;
49
+ if (rule.pattern.test(text)) findings.push({ file: rel, line: index + 1, rule: rule.id, why: rule.why });
50
+ }
51
+ });
52
+ }
53
+ return findings;
54
+ }
55
+
56
+ // Outside the desk: a file named ais6-bridge.js that announces itself as the
57
+ // kit's bridge client (its header and its AIS6Bridge export).
58
+ function defaultBridgeCheck(rel, data) {
59
+ const text = data.toString("utf8");
60
+ return /root\.AIS6Bridge\s*=\s*Object\.freeze\(/.test(text) && /protocol: PROTOCOL/.test(text);
61
+ }
package/src/check.mjs ADDED
@@ -0,0 +1,103 @@
1
+ // `ais6-app check`: everything a desk verifies before it installs an app,
2
+ // run on the app's own folder. Works on a repository (source + build), on a
3
+ // built `dist/`, or on a vendored tree.
4
+
5
+ import { execFileSync } from "node:child_process";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join, relative, sep } from "node:path";
8
+ import { manifestProblems } from "./manifest.mjs";
9
+ import { scanAppTree } from "./boundary.mjs";
10
+ import { scanForPublishing } from "./publish.mjs";
11
+ import { listFiles } from "./files.mjs";
12
+
13
+ const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
14
+
15
+ /** Where the manifest and the served tree are, for a folder of any shape. */
16
+ export function locate(dir) {
17
+ const repo = existsSync(join(dir, "package.json")) ? dir : null;
18
+ if (existsSync(join(dir, "dist", "app.manifest.json"))) return { repo, tree: join(dir, "dist"), manifest: join(dir, "dist", "app.manifest.json"), built: true };
19
+ if (existsSync(join(dir, "app.manifest.json"))) return { repo, tree: dir, manifest: join(dir, "app.manifest.json"), built: true };
20
+ if (existsSync(join(dir, "public", "app.manifest.json"))) return { repo, tree: dir, manifest: join(dir, "public", "app.manifest.json"), built: false };
21
+ return { repo, tree: null, manifest: null, built: false };
22
+ }
23
+
24
+ // The version the app's code hands to connect(), where it names one.
25
+ function clientVersions(dir) {
26
+ const found = new Set();
27
+ for (const file of listFiles(dir, { ignore: ["node_modules", ".git", "dist", "tests", "test", "vendor", "assets"] })) {
28
+ if (!/\.(?:m?[jt]sx?)$/.test(file) || file.endsWith(`${sep}ais6-bridge.js`)) continue;
29
+ const text = readFileSync(file, "utf8");
30
+ for (const match of text.matchAll(/\b(?:APP_VERSION|VERSION)\s*=\s*["'](\d+\.\d+\.\d+[^"']*)["']/g)) found.add(match[1]);
31
+ }
32
+ return [...found];
33
+ }
34
+
35
+ /**
36
+ * Runs every check. Returns { sections: [{ name, problems: [string] }], ok }.
37
+ * `build: true` runs `npm run build` first when the folder is a repository.
38
+ */
39
+ export function runCheck(dir, { build = false, ownStorage = [], engineFiles = [], log = () => {} } = {}) {
40
+ const sections = [];
41
+ const section = (name, problems, warnings = []) => sections.push({ name, problems, warnings });
42
+ if (build && existsSync(join(dir, "package.json"))) {
43
+ log("building (npm run build)…");
44
+ try {
45
+ execFileSync("npm", ["run", "build"], { cwd: dir, stdio: "pipe" });
46
+ } catch (error) {
47
+ section("build", [`npm run build failed: ${String(error.stderr || error.message).split("\n").filter(Boolean).slice(-3).join(" / ")}`]);
48
+ return { sections, ok: false };
49
+ }
50
+ }
51
+ const where = locate(dir);
52
+ if (!where.manifest) {
53
+ section("manifest", ["no app.manifest.json here, in dist/ or in public/"]);
54
+ return { sections, ok: false };
55
+ }
56
+ let manifest = null;
57
+ try { manifest = readJson(where.manifest); } catch (error) { section("manifest", [`app.manifest.json is not valid JSON: ${error.message}`]); }
58
+ if (manifest) {
59
+ const servedRoot = where.built ? where.tree : join(dir, "public");
60
+ const fileExists = (rel) => existsSync(join(servedRoot, rel)) || (!where.built && existsSync(join(dir, rel)));
61
+ const warnings = [];
62
+ section("manifest", manifestProblems(manifest, { fileExists, warnings }), warnings);
63
+ }
64
+
65
+ const kit = [];
66
+ const kitWarnings = [];
67
+ const kitRoot = where.built ? where.tree : join(dir, "public");
68
+ if (!existsSync(join(kitRoot, "ais6-bridge.js"))) kit.push("ais6-bridge.js is missing: copy the kit's bridge client next to the manifest");
69
+ if (!existsSync(join(kitRoot, "kit.css"))) kitWarnings.push("kit.css is not here: fine for an app that draws everything itself, otherwise copy the kit stylesheet");
70
+ section("kit", kit, kitWarnings);
71
+
72
+ const boundaryRoot = where.built ? where.tree : dir;
73
+ const findings = scanAppTree(boundaryRoot, { ownStorage, engineFiles, skip: where.built ? [] : ["node_modules", "dist", "tests", "test", "tools", "build"] });
74
+ section("bridge only", findings.map((hit) => `${hit.file}:${hit.line} ${hit.rule}: ${hit.why}`));
75
+
76
+ // A client that connects with another version than the manifest's is
77
+ // refused by the desk (version-mismatch) and falls back to standalone mode
78
+ // inside the frame: no state, no snapshots, no services.
79
+ const versions = [];
80
+ if (manifest) {
81
+ const wanted = String(manifest.version || "");
82
+ if (where.repo) {
83
+ const pkg = readJson(join(where.repo, "package.json"));
84
+ if (pkg.version && pkg.version !== wanted) versions.push(`package.json version ${pkg.version} is not the manifest's ${wanted}`);
85
+ }
86
+ for (const seen of clientVersions(where.repo || where.tree)) if (seen !== wanted) versions.push(`the code connects as version ${seen}, the manifest says ${wanted}`);
87
+ }
88
+ section("versions", versions);
89
+
90
+ if (where.repo) {
91
+ const repoProblems = [];
92
+ const pkg = readJson(join(where.repo, "package.json"));
93
+ for (const file of ["README.md", "README.zh-CN.md", "LICENSE"]) if (!existsSync(join(where.repo, file))) repoProblems.push(`${file} is missing`);
94
+ if (!pkg.scripts?.build) repoProblems.push("package.json has no build script");
95
+ section("repository", repoProblems);
96
+ }
97
+ const publishHits = scanForPublishing(where.repo || where.tree, { skipFiles: engineFiles });
98
+ const describe = (hit) => `${hit.file}${hit.line ? `:${hit.line}` : ""} ${hit.name}`;
99
+ section("safe to publish",
100
+ publishHits.filter((hit) => hit.kind !== "warning").map(describe),
101
+ publishHits.filter((hit) => hit.kind === "warning").map((hit) => `${describe(hit)}: say in LICENSES.md what it is`));
102
+ return { sections, ok: sections.every((entry) => entry.problems.length === 0), manifest, tree: where.tree && relative(dir, where.tree) || "." };
103
+ }
package/src/create.mjs ADDED
@@ -0,0 +1,64 @@
1
+ // `ais6-app create <dir>`: a new app from the TypeScript + Preact template,
2
+ // ready to build and to pass `ais6-app check` before any change.
3
+
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { basename, join, relative, resolve } from "node:path";
6
+ import { ID_PATTERN } from "./manifest.mjs";
7
+
8
+ const TEMPLATE = new URL("../template/", import.meta.url);
9
+ // npm drops a published .gitignore, so the template ships it under another name.
10
+ const RENAMES = { _gitignore: ".gitignore" };
11
+
12
+ /** "word-count" → "wordCount": the window and app name the desk addresses. */
13
+ export function windowNameFor(id) {
14
+ return id.replace(/-([a-z0-9])/g, (match, next) => next.toUpperCase());
15
+ }
16
+
17
+ /** "word-count" → "Word Count". */
18
+ export function titleFor(id) {
19
+ return id.split("-").filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
20
+ }
21
+
22
+ function walk(dir, out = []) {
23
+ for (const name of readdirSync(dir).sort()) {
24
+ const path = join(dir, name);
25
+ if (statSync(path).isDirectory()) walk(path, out);
26
+ else out.push(path);
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Writes the template into `dir`. Returns { dir, id, window, files }.
33
+ * Throws with a plain sentence when the id is not one a desk accepts or the
34
+ * folder is not empty.
35
+ */
36
+ export function createApp(target, { id, name, nameZh, author = "The authors", year = new Date().getFullYear() } = {}) {
37
+ const dir = resolve(target);
38
+ const appId = id || basename(dir);
39
+ if (!ID_PATTERN.test(appId)) throw new Error(`"${appId}" is not an app id a desk accepts: lower case letters, digits and dashes, starting with a letter (pass --id)`);
40
+ if (existsSync(dir) && readdirSync(dir).length) throw new Error(`${dir} is not empty; choose a new folder`);
41
+ const title = name || titleFor(appId);
42
+ const values = {
43
+ __APP_ID__: appId,
44
+ __WINDOW__: windowNameFor(appId),
45
+ __NAME__: title,
46
+ __NAME_ZH__: nameZh || title,
47
+ __AUTHOR__: author,
48
+ __YEAR__: String(year),
49
+ __CHECKER_VERSION__: JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version,
50
+ };
51
+ const root = TEMPLATE.pathname ? decodeURIComponent(TEMPLATE.pathname) : String(TEMPLATE);
52
+ const files = [];
53
+ for (const source of walk(root)) {
54
+ const rel = relative(root, source).split("/").map((part) => RENAMES[part] || part).join("/");
55
+ const out = join(dir, rel);
56
+ mkdirSync(join(out, ".."), { recursive: true });
57
+ const text = readFileSync(source, "utf8");
58
+ // The kit files are copied byte for byte; everything else gets the app's names.
59
+ const filled = /(^|\/)public\/(ais6-bridge\.js|kit\.css)$/.test(rel) ? text : text.replace(/__[A-Z_]+__/g, (key) => values[key] ?? key);
60
+ writeFileSync(out, filled);
61
+ files.push(rel);
62
+ }
63
+ return { dir, id: appId, window: values.__WINDOW__, files };
64
+ }
package/src/files.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** Every file under `dir`, sorted, skipping the folders a build never ships. */
5
+ export function listFiles(dir, { ignore = ["node_modules", ".git"] } = {}) {
6
+ const out = [];
7
+ const walk = (current) => {
8
+ for (const name of readdirSync(current).sort()) {
9
+ if (ignore.includes(name)) continue;
10
+ const full = join(current, name);
11
+ const stat = statSync(full);
12
+ if (stat.isDirectory()) walk(full);
13
+ else if (stat.isFile()) out.push(full);
14
+ }
15
+ };
16
+ if (existsSync(dir)) walk(dir);
17
+ return out;
18
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ export { runCheck, locate } from "./check.mjs";
2
+ export { manifestProblems, allowProblems, kitRangeAllows, CAPABILITIES, FRAME_PERMISSIONS, PROTOCOLS, KIT_MAJOR, ID_PATTERN } from "./manifest.mjs";
3
+ export { scanAppTree, APP_SIDE_RULES, OWN_STORAGE } from "./boundary.mjs";
4
+ export { scanForPublishing, credentialPatterns, localPathPatterns, forbiddenPathPatterns } from "./publish.mjs";
5
+ export { createApp, windowNameFor, titleFor } from "./create.mjs";
@@ -0,0 +1,120 @@
1
+ // What a desk accepts in app.manifest.json (bridge protocol 1, kit 1). The
2
+ // desk's own pin and verify steps import these, so an app that passes here
3
+ // passes there.
4
+
5
+ export const PROTOCOLS = Object.freeze([1]);
6
+ export const KIT_MAJOR = 1;
7
+ export const ID_PATTERN = /^[a-z][a-z0-9-]{1,40}$/;
8
+ export const WINDOW_PATTERN = /^[a-z][A-Za-z0-9]{1,60}$/;
9
+ export const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
10
+ export const CLASS_LIST_PATTERN = /^[a-z][a-z0-9-]*(\s+[a-z][a-z0-9-]*)*$/;
11
+
12
+ // The closed set of capabilities a window may declare. The desk's bridge host
13
+ // keeps the same list (a contract asserts the two agree).
14
+ export const CAPABILITIES = Object.freeze(["state", "files", "receipts", "intents", "snapshot", "model", "windows", "appearance", "access", "wake", "services", "context"]);
15
+
16
+ // The iframe permissions a window may ask for (`allow`).
17
+ export const FRAME_PERMISSIONS = Object.freeze(["fullscreen", "gamepad"]);
18
+
19
+ /** ">=1 <2", "1", "^1": does a kit major satisfy the app's range? */
20
+ export function kitRangeAllows(range, major = KIT_MAJOR) {
21
+ const text = String(range ?? "").trim();
22
+ if (!text) return false;
23
+ if (/^\^?\d+$/.test(text)) return Number(text.replace("^", "")) === major;
24
+ return text.split(/\s+/).every((part) => {
25
+ const match = /^(>=|<=|>|<|=)?(\d+)$/.exec(part);
26
+ if (!match) return false;
27
+ const value = Number(match[2]);
28
+ switch (match[1] || "=") {
29
+ case ">=": return major >= value;
30
+ case "<=": return major <= value;
31
+ case ">": return major > value;
32
+ case "<": return major < value;
33
+ default: return major === value;
34
+ }
35
+ });
36
+ }
37
+
38
+ /** The frame permission and status class rules alone (the desk's pin step). */
39
+ export function allowProblems(manifest) {
40
+ const problems = [];
41
+ for (const entry of manifest?.windows || []) {
42
+ if (entry.statusClass !== undefined && !(typeof entry.statusClass === "string" && CLASS_LIST_PATTERN.test(entry.statusClass))) {
43
+ problems.push(`window ${entry.window}: statusClass must be class names`);
44
+ }
45
+ if (entry.allow === undefined) continue;
46
+ if (typeof entry.allow !== "string") { problems.push(`window ${entry.window}: allow must be a string`); continue; }
47
+ for (const token of entry.allow.split(";").map((part) => part.trim()).filter(Boolean)) {
48
+ if (!FRAME_PERMISSIONS.includes(token)) problems.push(`window ${entry.window}: allow "${token}" is not one of ${FRAME_PERMISSIONS.join(", ")}`);
49
+ }
50
+ }
51
+ return problems;
52
+ }
53
+
54
+ function menuKeys(items, keys = []) {
55
+ for (const item of Array.isArray(items) ? items : []) {
56
+ if (item?.labelKey) keys.push(item.labelKey);
57
+ if (item?.items) menuKeys(item.items, keys);
58
+ }
59
+ return keys;
60
+ }
61
+
62
+ /**
63
+ * Every problem a desk would refuse the manifest for, as plain sentences.
64
+ * `fileExists(path)` (optional) answers whether an entry page is present.
65
+ */
66
+ export function manifestProblems(manifest, { fileExists, warnings = [] } = {}) {
67
+ const problems = [];
68
+ const fail = (message) => problems.push(message);
69
+ // A desk shows a key it does not find in the manifest from its own tables
70
+ // (close, cut, refresh…) and otherwise as the bare key: worth a warning, not
71
+ // a refusal.
72
+ const warn = (message) => warnings.push(message);
73
+ if (!manifest || typeof manifest !== "object") return ["the manifest is not a JSON object"];
74
+ if (!ID_PATTERN.test(String(manifest.id || ""))) fail(`id "${manifest.id ?? ""}" must be lower case letters, digits and dashes (2–41 characters, starting with a letter)`);
75
+ if (!VERSION_PATTERN.test(String(manifest.version || ""))) fail(`version "${manifest.version ?? ""}" must be a semantic version such as 1.0.0`);
76
+ if (!PROTOCOLS.includes(manifest.protocol)) fail(`protocol ${manifest.protocol} is not one this desk speaks (${PROTOCOLS.join(", ")})`);
77
+ if (!kitRangeAllows(manifest.kit)) fail(`kit range "${manifest.kit ?? ""}" does not include kit ${KIT_MAJOR}`);
78
+ const strings = manifest.strings || {};
79
+ const en = strings.en || {};
80
+ const zh = strings.zh || {};
81
+ if (!strings.en || !strings.zh) fail("strings must have both en and zh tables");
82
+ const enOnly = Object.keys(en).filter((key) => !(key in zh));
83
+ const zhOnly = Object.keys(zh).filter((key) => !(key in en));
84
+ if (enOnly.length) fail(`strings missing in zh: ${enOnly.join(", ")}`);
85
+ if (zhOnly.length) fail(`strings missing in en: ${zhOnly.join(", ")}`);
86
+ const windows = Array.isArray(manifest.windows) ? manifest.windows : [];
87
+ if (!windows.length) fail("windows must list at least one window");
88
+ const seen = new Set();
89
+ for (const win of windows) {
90
+ const name = String(win?.window || "");
91
+ const label = `window ${name || "(unnamed)"}`;
92
+ if (!WINDOW_PATTERN.test(name)) fail(`${label}: window must be a camelCase name`);
93
+ if (seen.has(name)) fail(`${label}: declared twice`);
94
+ seen.add(name);
95
+ if (!win.app || typeof win.app !== "string") fail(`${label}: app is required`);
96
+ if (!win.entry || typeof win.entry !== "string" || /^(?:[a-z]+:|\/)/i.test(win.entry) || win.entry.includes("..")) fail(`${label}: entry must be a page inside the app`);
97
+ else if (fileExists && !fileExists(win.entry.split(/[?#]/)[0])) fail(`${label}: entry ${win.entry} does not exist`);
98
+ if (!win.titleKey) fail(`${label}: titleKey is required`);
99
+ else if (!(win.titleKey in en)) warn(`${label}: titleKey ${win.titleKey} is not in the manifest's strings (the desk must already know it)`);
100
+ for (const key of ["windowClass", "paneClass", "frameClass"]) {
101
+ if (win[key] !== undefined && !(typeof win[key] === "string" && CLASS_LIST_PATTERN.test(win[key]))) fail(`${label}: ${key} must be class names`);
102
+ }
103
+ const caps = win.capabilities;
104
+ if (!Array.isArray(caps)) fail(`${label}: capabilities must be a list`);
105
+ else {
106
+ const unknown = caps.filter((cap) => !CAPABILITIES.includes(cap));
107
+ if (unknown.length) fail(`${label}: unknown capabilities ${unknown.join(", ")} (the closed set is ${CAPABILITIES.join(", ")})`);
108
+ }
109
+ if (win.services !== undefined) {
110
+ if (!Array.isArray(win.services) || win.services.some((service) => typeof service !== "string" || !service)) fail(`${label}: services must be a list of names`);
111
+ else if (win.services.length && !(caps || []).includes("services")) fail(`${label}: lists services but not the services capability`);
112
+ }
113
+ if (win.size !== undefined && (typeof win.size !== "object" || Array.isArray(win.size))) fail(`${label}: size must be { width, height }`);
114
+ if (win.menus !== undefined && !Array.isArray(win.menus)) fail(`${label}: menus must be a list`);
115
+ const missing = menuKeys(win.menus).filter((key) => !(key in en) && !/^(?:menu_[a-z]+|close|cut|copy|paste|clear|select_all|undo|redo)$/.test(key));
116
+ if (missing.length) warn(`${label}: menu labels not in the manifest's strings (the desk must already know them): ${[...new Set(missing)].join(", ")}`);
117
+ }
118
+ problems.push(...allowProblems(manifest));
119
+ return problems;
120
+ }