ais6-app 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,31 @@
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
+ Check an AI System 6 app the way a desk checks it before installing it.
4
+
5
+ ```
6
+ npx ais6-app check # the app folder you are in
7
+ npx ais6-app check path/to/app --build
8
+ ```
9
+
10
+ It runs on a repository (source and build), on a built `dist/`, or on a
11
+ vendored app tree, and prints one line per check: `OK`, `NO` (a desk would
12
+ refuse the app) or `--` (a warning). The exit code is 0 when everything
13
+ passes, 1 when something is refused, 2 for a usage error.
14
+
15
+ | Check | What it looks at |
16
+ |---|---|
17
+ | 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 |
18
+ | kit | the bridge client `ais6-bridge.js` beside the manifest (and `kit.css`, as a warning) |
19
+ | bridge only | no reach into the parent page, no desk globals, no `localStorage`, `sessionStorage`, `indexedDB`, cookies, `BroadcastChannel` or `SharedWorker` |
20
+ | versions | the manifest, `package.json` and the version the code connects with agree (a mismatch makes the desk refuse the connection) |
21
+ | repository | `README.md`, `README.zh-CN.md`, `LICENSE`, a build script |
22
+ | safe to publish | no keys or tokens, no local machine paths, no private files (`.env`, keys, editor and agent folders) |
23
+
24
+ Options: `--build` runs `npm run build` first; `--json` prints the result as
25
+ JSON; `--own-storage idbfs` and `--engine-files a.js,b.js` apply the
26
+ exceptions a desk grants a compiled engine.
27
+
28
+ The same rules are imported by the AI System 6 desk's own pin, boundary and
29
+ release checks, so an app that passes here passes there.
30
+
31
+ Documentation: <https://aisystem6.aaronlau.me/sdk/>. Licence: MIT.
@@ -0,0 +1,25 @@
1
+ # ais6-app
2
+
3
+ 按 AI System 6 桌面在安装前的检查方式,检查你的 app。
4
+
5
+ ```
6
+ npx ais6-app check # 当前所在的 app 文件夹
7
+ npx ais6-app check path/to/app --build
8
+ ```
9
+
10
+ 可以对仓库(源码与构建)、构建好的 `dist/` 或已托管的 app 树运行,每项检查打印一行:`OK`、`NO`(桌面会拒绝这个 app)或 `--`(警告)。全部通过时退出码为 0,有项目被拒为 1,用法错误为 2。
11
+
12
+ | 检查 | 看什么 |
13
+ |---|---|
14
+ | manifest | id、版本、协议、kit 范围;每个窗口的名称、所属 app、入口页、标题、class、能力(封闭集合)、服务、菜单、框权限;中英文字符串 |
15
+ | kit | manifest 旁的桥接客户端 `ais6-bridge.js`(`kit.css` 缺少时只警告) |
16
+ | 只经桥接 | 不伸手进父页面,不碰桌面全局,不用 `localStorage`、`sessionStorage`、`indexedDB`、cookie、`BroadcastChannel` 或 `SharedWorker` |
17
+ | 版本 | manifest、`package.json` 与代码连接时声明的版本一致(不一致时桌面会拒绝连接) |
18
+ | 仓库 | `README.md`、`README.zh-CN.md`、`LICENSE`、构建脚本 |
19
+ | 可以发布 | 没有密钥或令牌、没有本机路径、没有私密文件(`.env`、密钥、编辑器与 agent 的文件夹) |
20
+
21
+ 选项:`--build` 先运行 `npm run build`;`--json` 以 JSON 输出;`--own-storage idbfs` 与 `--engine-files a.js,b.js` 套用桌面给编译引擎的例外。
22
+
23
+ AI System 6 桌面自己的钉版、边界与发布检查导入的是同一套规则,所以在这里通过的 app,在桌面那里也会通过。
24
+
25
+ 文档:<https://aisystem6.aaronlau.me/sdk/>。许可证:MIT。
@@ -0,0 +1,58 @@
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
+ //
6
+ // Exit code 0 when every check passes, 1 when one fails, 2 on a usage error.
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { runCheck } from "../src/check.mjs";
11
+
12
+ const args = process.argv.slice(2);
13
+ const command = args[0];
14
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
15
+
16
+ if (!command || command === "--help" || command === "-h" || command === "help") {
17
+ console.log(`ais6-app ${version}
18
+
19
+ Usage:
20
+ ais6-app check [dir] [--build] [--json] check an app folder (default: .)
21
+
22
+ --build run "npm run build" first and check the built dist/
23
+ --json print the result as JSON
24
+ --own-storage idbfs storage the desk lets a compiled engine own
25
+ --engine-files a.js,b generated engine files that are not read
26
+
27
+ The checks: the manifest (ids, versions, protocol, kit range, windows,
28
+ capabilities, services, strings in English and Chinese, frame permissions),
29
+ the kit files, bridge-only access (no parent page, desk globals or desk
30
+ storage), versions that agree (manifest, package.json, the code's connect),
31
+ the repository files, and nothing unsafe to publish (keys, local paths,
32
+ private files). Docs: https://aisystem6.aaronlau.me/sdk/`);
33
+ process.exit(command ? 0 : 2);
34
+ }
35
+ if (command === "--version" || command === "-v") { console.log(version); process.exit(0); }
36
+ if (command !== "check") { console.error(`unknown command "${command}"; try ais6-app --help`); process.exit(2); }
37
+
38
+ const valueOf = (flag) => { const index = args.indexOf(flag); return index >= 0 ? String(args[index + 1] || "") : ""; };
39
+ const listOf = (flag) => valueOf(flag).split(",").map((part) => part.trim()).filter(Boolean);
40
+ const flagValues = new Set(["--own-storage", "--engine-files"].map((flag) => valueOf(flag)).filter(Boolean));
41
+ const dir = resolve(args.slice(1).find((arg) => !arg.startsWith("--") && !flagValues.has(arg)) || ".");
42
+ const result = runCheck(dir, {
43
+ build: args.includes("--build"),
44
+ ownStorage: listOf("--own-storage"),
45
+ engineFiles: listOf("--engine-files"),
46
+ log: (line) => { if (!args.includes("--json")) console.log(line); },
47
+ });
48
+ if (args.includes("--json")) {
49
+ console.log(JSON.stringify({ ok: result.ok, sections: result.sections }, null, 2));
50
+ } else {
51
+ for (const { name, problems, warnings = [] } of result.sections) {
52
+ if (!problems.length) console.log(`OK ${name}`);
53
+ else for (const problem of problems) console.log(`NO ${name}: ${problem}`);
54
+ for (const warning of warnings) console.log(`-- ${name}: ${warning}`);
55
+ }
56
+ 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.");
57
+ }
58
+ process.exit(result.ok ? 0 : 1);
package/package.json CHANGED
@@ -1,6 +1,34 @@
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.1.0",
4
+ "description": "Check an AI System 6 app 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
+ },
16
+ "files": [
17
+ "bin",
18
+ "src",
19
+ "README.md",
20
+ "README.zh-CN.md",
21
+ "LICENSE"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "homepage": "https://aisystem6.aaronlau.me/sdk/publishing",
27
+ "keywords": [
28
+ "ai-system-6",
29
+ "app",
30
+ "checker",
31
+ "iframe",
32
+ "bridge"
33
+ ]
34
+ }
@@ -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/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,4 @@
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";
@@ -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
+ }
@@ -0,0 +1,66 @@
1
+ // What must never be published: credentials, local machine paths and private
2
+ // files. The desk's release scan imports the same patterns and adds its own
3
+ // list of private names, which is not part of this package.
4
+
5
+ import { readFileSync, statSync } from "node:fs";
6
+ import { relative, sep } from "node:path";
7
+ import { listFiles } from "./files.mjs";
8
+
9
+ export const credentialPatterns = [
10
+ { name: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}/g },
11
+ { name: "OpenAI-style API key", pattern: /\bsk-(?!ant-)[A-Za-z0-9]{20,}/g },
12
+ { name: "GitHub token", pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g },
13
+ { name: "GitHub fine-grained token", pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
14
+ { name: "AWS access key id", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
15
+ { name: "Slack token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
16
+ { name: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
17
+ { name: "private key block", pattern: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g },
18
+ { name: "DeepSeek-style key", pattern: /\bsk-[a-f0-9]{32}\b/g },
19
+ ];
20
+
21
+ export const localPathPatterns = [
22
+ { name: "local user home path", pattern: /(?<![\w/.])\/(?:Users|home)\/[A-Za-z0-9._-]+(?:\/[^\s"'`()<>\[\],;:]*)*/g },
23
+ { name: "temporary local path", pattern: /(?<![\w/.])\/(?:private\/)?var\/(?:folders|tmp)\/[^\s"'`()<>\[\],;:]*/g },
24
+ { name: "Windows user path", pattern: /\b[A-Z]:\\Users\\[^\\\s"'`]+/g },
25
+ { name: "private AI conversation link", pattern: /https?:\/\/(?:chatgpt\.com|chat\.openai\.com)\/(?:c|backend-api)\/[^\s"'`()<>\[\]]+|https?:\/\/claude\.ai\/chat\/[^\s"'`()<>\[\]]+/g },
26
+ ];
27
+
28
+ export const forbiddenPathPatterns = [
29
+ { name: "environment file with real values", pattern: /(?:^|\/)\.env(?!\.example$)(?:\.|$)/ },
30
+ { name: "private key / certificate", pattern: /\.(?:pem|key|p12|pfx|keystore|jks)$/i },
31
+ { name: "SSH material", pattern: /(?:^|\/)(?:id_rsa|id_ed25519|id_ecdsa|\.ssh)(?:\/|$)/ },
32
+ { name: "npm/yarn credentials", pattern: /(?:^|\/)\.(?:npmrc|yarnrc)$/ },
33
+ { name: "git metadata", pattern: /(?:^|\/)\.git(?:\/|$)/ },
34
+ { name: "local database", pattern: /\.(?:sqlite|sqlite3|db)$/i },
35
+ { name: "agent/editor config", pattern: /(?:^|\/)\.(?:claude|vscode|idea)(?:\/|$)/ },
36
+ { name: "working notes not meant to publish", pattern: /(?:^|\/)(?:drafts|codex-snapshots|memory)(?:\/|$)/ },
37
+ // An app may ship these on purpose (a GPL app's corresponding source, a
38
+ // source map); `ais6-app check` reports them as warnings, the desk's own
39
+ // release scan refuses them.
40
+ { name: "archive", pattern: /\.(?:zip|tar|tgz|gz|rar|7z)$/i, appWarning: true },
41
+ { name: "source map", pattern: /\.map$/i, appWarning: true },
42
+ ];
43
+
44
+ const TEXT = /\.(?:m?[jt]sx?|json|html?|css|svg|md|txt|ya?ml|toml|xml|sh|py|cfg|ini|csv)$/i;
45
+ const MAX_TEXT_BYTES = 4 * 1024 * 1024;
46
+
47
+ /** Findings in a tree about to be published: [{ file, line?, kind, name }]. */
48
+ // `skipFiles`: generated engine glue (Emscripten), whose virtual file system
49
+ // paths (/home/web_user) are not anyone's machine.
50
+ export function scanForPublishing(dir, { skip = ["node_modules", "dist"], skipFiles = [] } = {}) {
51
+ const findings = [];
52
+ for (const file of listFiles(dir, { ignore: [...skip, ".git"] })) {
53
+ const rel = relative(dir, file).split(sep).join("/");
54
+ if (skipFiles.some((name) => rel === name || rel.endsWith(`/${name}`))) continue;
55
+ for (const rule of forbiddenPathPatterns) if (rule.pattern.test(rel)) findings.push({ file: rel, kind: rule.appWarning ? "warning" : "path", name: rule.name });
56
+ if (!TEXT.test(rel) || statSync(file).size > MAX_TEXT_BYTES) continue;
57
+ const lines = readFileSync(file, "utf8").split("\n");
58
+ lines.forEach((text, index) => {
59
+ for (const rule of [...credentialPatterns, ...localPathPatterns]) {
60
+ rule.pattern.lastIndex = 0;
61
+ if (rule.pattern.test(text)) findings.push({ file: rel, line: index + 1, kind: credentialPatterns.includes(rule) ? "credential" : "local", name: rule.name });
62
+ }
63
+ });
64
+ }
65
+ return findings;
66
+ }