@tianacloud/agent-skills 1.0.5 → 1.0.6

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@tianacloud/agent-skills",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Tiana Cloud Skills for accounts, SQLite, Git and Web applications",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,6 +45,7 @@
45
45
  "validate:versions": "node scripts/validate-versions.mjs",
46
46
  "validate:workbuddy": "node scripts/validate-workbuddy.mjs",
47
47
  "validate:ci": "npm run validate:skills && npm run validate:references && npm run validate:plugin && npm run validate:versions && npm run validate:workbuddy && npm run validate:connector",
48
+ "test:templates": "python3 -B -m unittest discover -s tests -p test_share_template_pack.py",
48
49
  "test:installer": "node --test scripts/installer.test.mjs",
49
50
  "test:release": "node --test scripts/release.test.mjs"
50
51
  },
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "tiana",
4
- "version": "1.0.5",
4
+ "version": "1.0.6",
5
5
  "description": "Tiana Cloud account, SQLite, Git and Web application workflows",
6
6
  "author": {
7
7
  "name": "Tiana Cloud",
@@ -4,7 +4,7 @@ description: 使用 Tiana Cloud 管理实例、安装 CLI、登录和恢复账
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Tiana Cloud
7
- version: "1.0.5"
7
+ version: "1.0.6"
8
8
  source: https://github.com/tianacloud/agent-skills/tree/main/skills/tiana
9
9
  ---
10
10
 
@@ -14,7 +14,7 @@ metadata:
14
14
 
15
15
  ## 每个会话一次的更新检查
16
16
 
17
- 四个 Tiana 技能共享当前会话的更新检查记录。CLI 已满足最低版本要求且本会话尚未尝试检查时,执行 `tiana version check --skills-version 1.0.5 --json`,使用实际加载的 metadata.version。调用前在会话上下文中记为已尝试;后续轮次、重复加载或切换技能均复用该记录,检查失败也不自动重试。上下文压缩或任务交接时保留记录,新会话重新检查。
17
+ 四个 Tiana 技能共享当前会话的更新检查记录。CLI 已满足最低版本要求且本会话尚未尝试检查时,执行 `tiana version check --skills-version 1.0.6 --json`,使用实际加载的 metadata.version。调用前在会话上下文中记为已尝试;后续轮次、重复加载或切换技能均复用该记录,检查失败也不自动重试。上下文压缩或任务交接时保留记录,新会话重新检查。
18
18
 
19
19
  仅在 `should_notify=true` 时简短提醒并询问是否升级;用户选择稍后或检查不可用时继续原任务。CLI 仍管理检查缓存和共同的 24 小时提醒额度。升级及重载见[开始使用](references/getting-started.md#更新)。
20
20
 
@@ -4,7 +4,7 @@ description: 使用 Tiana Git 管理 Git 实例和原生仓库,关联项目远
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Tiana Cloud
7
- version: "1.0.5"
7
+ version: "1.0.6"
8
8
  parent: tiana
9
9
  source: https://github.com/tianacloud/agent-skills/tree/main/skills/tiana-git
10
10
  ---
@@ -15,7 +15,7 @@ metadata:
15
15
 
16
16
  ## 每个会话一次的更新检查
17
17
 
18
- 四个 Tiana 技能共享当前会话的更新检查记录。CLI 已满足最低版本要求且本会话尚未尝试检查时,执行 `tiana version check --skills-version 1.0.5 --json`,使用实际加载的 metadata.version。调用前在会话上下文中记为已尝试;后续轮次、重复加载或切换技能均复用该记录,检查失败也不自动重试。上下文压缩或任务交接时保留记录,新会话重新检查。
18
+ 四个 Tiana 技能共享当前会话的更新检查记录。CLI 已满足最低版本要求且本会话尚未尝试检查时,执行 `tiana version check --skills-version 1.0.6 --json`,使用实际加载的 metadata.version。调用前在会话上下文中记为已尝试;后续轮次、重复加载或切换技能均复用该记录,检查失败也不自动重试。上下文压缩或任务交接时保留记录,新会话重新检查。
19
19
 
20
20
  仅在 `should_notify=true` 时简短提醒并询问是否升级;用户选择稍后或检查不可用时继续原任务。CLI 仍管理检查缓存和共同的 24 小时提醒额度。升级及重载见[开始使用](../tiana/references/getting-started.md#更新)。
21
21
 
@@ -4,7 +4,7 @@ description: 查询 Tiana SQLite 表结构、执行 SQL、选择数据库分支
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Tiana Cloud
7
- version: "1.0.5"
7
+ version: "1.0.6"
8
8
  parent: tiana
9
9
  source: https://github.com/tianacloud/agent-skills/tree/main/skills/tiana-sqlite
10
10
  ---
@@ -15,7 +15,7 @@ metadata:
15
15
 
16
16
  ## 每个会话一次的更新检查
17
17
 
18
- 四个 Tiana 技能共享当前会话的更新检查记录。CLI 已满足最低版本要求且本会话尚未尝试检查时,执行 `tiana version check --skills-version 1.0.5 --json`,使用实际加载的 metadata.version。调用前在会话上下文中记为已尝试;后续轮次、重复加载或切换技能均复用该记录,检查失败也不自动重试。上下文压缩或任务交接时保留记录,新会话重新检查。
18
+ 四个 Tiana 技能共享当前会话的更新检查记录。CLI 已满足最低版本要求且本会话尚未尝试检查时,执行 `tiana version check --skills-version 1.0.6 --json`,使用实际加载的 metadata.version。调用前在会话上下文中记为已尝试;后续轮次、重复加载或切换技能均复用该记录,检查失败也不自动重试。上下文压缩或任务交接时保留记录,新会话重新检查。
19
19
 
20
20
  仅在 `should_notify=true` 时简短提醒并询问是否升级;用户选择稍后或检查不可用时继续原任务。CLI 仍管理检查缓存和共同的 24 小时提醒额度。升级及重载见[开始使用](../tiana/references/getting-started.md#更新)。
21
21
 
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: tiana-web
3
- description: 使用 Tiana 创建、修改、预览和发布 Web 应用。用户要求制作账单、笔记、日历或其他 Web app 时使用;根据实时模板目录和需求决定采用模板或直接开发,并连接 SQLite 和 Git。
3
+ description: 使用 Tiana 创建、修改、预览和发布 Web 应用。用户要求制作账单、笔记、日历或其他 Web app 时使用;根据实时模板目录和需求决定采用模板或直接开发,并连接 SQLite 和 Git。用户要求把已有 Web app 发布为模板、分享可安装应用链接时也使用。
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Tiana Cloud
7
- version: "1.0.5"
7
+ version: "1.0.6"
8
8
  parent: tiana
9
9
  source: https://github.com/tianacloud/agent-skills/tree/main/skills/tiana-web
10
10
  ---
@@ -15,13 +15,17 @@ metadata:
15
15
 
16
16
  ## 每个会话一次的更新检查
17
17
 
18
- 四个 Tiana 技能共享当前会话的更新检查记录。CLI 已满足最低版本要求且本会话尚未尝试检查时,执行 `tiana version check --skills-version 1.0.5 --json`,使用实际加载的 metadata.version。调用前在会话上下文中记为已尝试;后续轮次、重复加载或切换技能均复用该记录,检查失败也不自动重试。上下文压缩或任务交接时保留记录,新会话重新检查。
18
+ 四个 Tiana 技能共享当前会话的更新检查记录。CLI 已满足最低版本要求且本会话尚未尝试检查时,执行 `tiana version check --skills-version 1.0.6 --json`,使用实际加载的 metadata.version。调用前在会话上下文中记为已尝试;后续轮次、重复加载或切换技能均复用该记录,检查失败也不自动重试。上下文压缩或任务交接时保留记录,新会话重新检查。
19
19
 
20
20
  仅在 `should_notify=true` 时简短提醒并询问是否升级;用户选择稍后或检查不可用时继续原任务。CLI 仍管理检查缓存和共同的 24 小时提醒额度。升级及重载见[开始使用](../tiana/references/getting-started.md#更新)。
21
21
 
22
- ## 每次任务必须查询当前模板目录
22
+ ## 将已有应用发布为模板
23
23
 
24
- **每次创建、修改或发布 Web 应用,都必须实际执行 `tiana web list-template --json`。同一会话之前查过、记得某个模板、用户未提模板,都不能代替这次调用。** 有 `data.next_cursor` 时,以 `--after CURSOR` 继续查询本次需求需要的目录页。检查命令实际成功;当前目录不可用时说明结果并按通用方式继续,不能从旧列表选包。
24
+ 用户要求“把某个 Web App 发布为模板”或分享可安装链接时,阅读[用户模板分享](references/share-template.md),在独立副本中整理源码、结构与个人无关 seed,再打包、上传并交付安装链接。默认包含源码,可让用户选择仅安装;必须制作预览和封面。无法判断 seed 是否涉及个人信息时,先让用户选择。用户提供分享链接要求做同款时,也按该文档取得源码并开始新工程。此分支直接处理指定应用。
25
+
26
+ ## 开发与普通发布:查询当前模板目录
27
+
28
+ **每次创建、修改或普通发布 Web 应用,都必须实际执行 `tiana web list-template --json`。同一会话之前查过、记得某个模板、用户未提模板,都不能代替这次调用。** 有 `data.next_cursor` 时,以 `--after CURSOR` 继续查询本次需求需要的目录页。检查命令实际成功;当前目录不可用时说明结果并按通用方式继续,不能从旧列表选包。
25
29
 
26
30
  用户只需描述应用。根据本次返回的 `name`、`description` 判断核心功能、数据模型与可定制范围:合适的模板可加速制作,局部差异在工程中修改;没有合适模板就直接开发。已有工程继续修改自己的工程。以需求为依据决定,不要求用户选择模板,也不硬编码模板名称或模板版本。
27
31
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  哈希深链接在直接打开或刷新后也应恢复当前路由;若页面数据需异步读取,应在读取完成后恢复该路由对应的状态。服务端先检查登录会话和应用访问权限,再返回此 HTML。未登录的托管请求跳转到所属环境的 Console 登录页,经绑定应用和固定站点回跳地址的短时授权码返回站点,站点后端兑换独立的 host-only 会话。回跳保留哈希片段;站点退出只注销站点会话。授权通过后,文档加载当前归档的 JavaScript 和 CSS。
10
10
 
11
- 应用与 Console 使用独立 origin;同一环境的多个应用共享站点 origin,路径划分不提供浏览器同源隔离。Bootstrap、session、logout 和 connection 使用站点同源入口。
11
+ 每个应用使用独立的 `<web_id>.<site-host>` origin,并与 Console 分开。托管入口保留 `/web/<web_id>/` 路径,Bootstrap、session、logout 和 connection 使用该应用的同源入口。访问地址使用 MGR/CLI 返回的 endpoint;应用资源继续使用相对路径。
12
12
 
13
13
  模板提供 `<div id="app"></div>`。`entry` 指定的模块按正常方式启动并渲染应用。需要数据库连接时,调用 `window.tiana.connection()` 获取连接元信息,使用 `window.tiana.auth` provider 提供动态凭据;凭据只保存在内存中。加载指示器是临时的,与应用样式隔离。
14
14
 
@@ -84,7 +84,7 @@ tiana web status WEB_ID --json
84
84
 
85
85
  当前内容查询的 `state` 是应用运行状态;回执查询的 `state` 是上传操作状态。JSON 外层 `status: succeeded` 只表示这次查询成功。当前查询的 `publish` / `publish_id` 为空是合理的,不从摘要推导发布 ID。配套新版 CLI 的 `query_kind: current / receipt` 可区分两类查询;缺少该附加字段时依据是否传入 `--publish-id` 判断。
86
86
 
87
- 4. 托管入口使用所属环境 Site origin 的 `/web/<id>/`,Console 详情为 `/console/web/<id>`。报告 Web/实例 ID、publish_id、归档校验和、上传与激活状态,以及可选数据库/Git 关联。环境不具备候选能力时报告具体阻塞并提供本地开发地址;不自行修改部署。
87
+ 4. 托管入口使用 MGR/CLI 返回的 `endpoint`,形式为 `https://<id>.<site-host>/web/<id>/`;应用主机根地址也会转向该入口。Console 详情为 `/console/web/<id>`。报告 Web/实例 ID、publish_id、归档校验和、上传与激活状态,以及可选数据库/Git 关联。环境不具备候选能力时报告具体阻塞并提供本地开发地址;不自行修改部署。
88
88
 
89
89
  ## 应用列表与删除
90
90
 
@@ -0,0 +1,58 @@
1
+ # 将已有 Web App 发布为模板
2
+
3
+ 分享的是可独立安装的材料。发布流程在工作副本中准备材料,最后更新源 Web 的稳定分享链接。
4
+
5
+ ## 1. 核实应用与交付范围
6
+
7
+ 读取 `tiana web publish-share-template --help` 和 `tiana web status-share-template --help`,确认当前 CLI 支持这组命令。读取目标 Web 详情,记录当前租户、真实 Web ID、固定入口、SQLite/Git 绑定和可构建源码位置。取得对应源码,在技能目录之外建立独立工作副本,保留用户原工程和数据。
8
+
9
+ 默认分享源码;根据工程情况可让用户选择「包含源码,允许安装和做同款」或「仅安装,不分享源码」。仅安装仍需准备同份经过公开检查的构建、安装说明、预览和封面。明确 `database_required`:运行需要 SQLite 就填 `true`,纯前端且没有 SQLite 调用就填 `false`;是否有 schema.sql 不能代替这个判断。
10
+
11
+ 完成条件:应用身份、源码选择、SQLite 依赖、真实入口和构建方法都有依据。
12
+
13
+ ## 2. 筛选结构和初始数据
14
+
15
+ 读取已确认关联数据库的 `sqlite_schema`,结合源码整理应用必要的表、索引、视图和触发器,按依赖顺序形成 schema.sql。使用明确列名和筛选条件导出 seed,逐表说明用途:
16
+
17
+ | 内容 | 初始数据处理 |
18
+ | --- | --- |
19
+ | 固定分类、单位、枚举、系统字典 | 只保留已核实与个人无关的必要列和记录 |
20
+ | 用户、客户、账目、笔记、日程、消息、文件记录、操作历史 | 保留应用必要结构,数据留空;预览另造合成示例 |
21
+ | 系统初值和用户自定义内容混合 | 只有可靠区分条件时才导出通用初值 |
22
+ | 用途、归属或可公开性不明确 | 提问后再生成 seed |
23
+
24
+ 有疑问时说明表和字段的用途,给出「留空」「合成示例」「仅保留列出的通用初值」供用户选择。问题与报告使用字段名和统计数量,不回显隐私值。分享应用不等于授权分享个人数据;不要直接把整库 dump 当作模板。用户文件默认不带,明确可公开且应用必需的静态素材才能进入材料。
25
+
26
+ 在临时空 SQLite 中依次执行 schema、seed;遇到第一个错误就停止修正。核对行数、主外键、约束及应用首次启动。完成条件:每张 seed 表都有明确的数据来源说明,初始化可在空库成功完成。
27
+
28
+ ## 3. 准备公开副本、预览和封面
29
+
30
+ 根部 metadata.json 填写 name、display_name、description、database_required、可选 schema/seeds 和 variables。应用运行时用 `window.tiana.appId`、`window.tiana.connection()` 和现有账号授权 SDK 取得新安装的上下文。需要在源码或 SQL 中替换的值用 `__TIANA_<名称>_<随机十六进制串>__`,variables 记录全部出现文件。安装 SQL 支持 WEB_ID、SQLITE_INSTANCE_ID、APP_NAME;SQL 占位符优先放在字符串字面量内。
31
+
32
+ 逐项检查源码、依赖锁文件、构建资源、图片、SQL 和展示文案,移除作者的账号绑定、凭据、环境文件、数据库副本、日志、缓存及 Git 管理文件。源码保留重建必需的工程文件;仅安装模式也要检查 JS bundle 和 source map 是否暴露私有内容。
33
+
34
+ 制作 `showcase/preview.html`:复用应用视觉和代表流程,用合成数据和页面内状态实现可操作演示。自包含、压缩前不超过 500,000 字节,能在 `sandbox="allow-scripts"` 且无外部网络的 iframe 内工作。在桌面和手机尺寸实际操作验证,用同份验收预览截图制作 `showcase/cover.webp`。包含源码时另写 `showcase/prompt.md`,说明功能、适用场景、数据模型和可定制点;安装落地页会补充真实分享链接和 CLI 拉取方式。
35
+
36
+ 图标复用源码已有图;缺少时按[应用图标](csr-apps.md#应用图标)制作真实 PNG,并核对干净构建的图标。完成条件:公开副本可独立构建,预览可操作,封面来自该预览,所有材料不含私有数据。
37
+
38
+ ## 4. 打包和发布
39
+
40
+ 1. 在工作副本形成已检查的源码提交,用 `git archive --format=zip --output=/绝对路径/source.zip COMMIT` 导出该提交;从同一提交另取干净目录安装依赖并构建。
41
+ 2. 使用本技能[打包脚本](../scripts/pack-template.py),入口和目录取工程实际值:
42
+
43
+ ```sh
44
+ python3 /技能实际路径/scripts/pack-template.py --source /绝对路径/source.zip --dist /干净构建目录/dist --entry assets/app.js --output /绝对路径/install.zip
45
+ ```
46
+
47
+ 用户选择仅安装时增加 `--without-source`。脚本封装 install.json、site.tweb、预览、封面、实际提供的 SQL 和可选源码;它不代替数据和公开内容检查。
48
+ 3. 复查脚本输出的大小、SHA-256、源码选择及数据库依赖。安装包上限 32 MiB,单材料 16 MiB,所有解析层累计展开 128 MiB、5,000 个条目;site.tweb 同时遵守 16 MiB 和 4,097 条目的 Web 归档限制。超限时先移除无用依赖、压缩资源或精简材料。
49
+ 4. 执行 `tiana web publish-share-template WEB_ID --file /绝对路径/install.zip --wait --json`。上传峰值包含原模板材料、中转 ZIP 和新材料,全部计入当前租户额度。CLI 申请精确对象授权、直传上传并提交异步发布。
50
+ 5. 用 `tiana web status-share-template WEB_ID --json` 核对当前 `share.package_id`、Web ID、模板名称和 URL。上传响应未知或等待被中断时先查询该状态;发布失败时检查错误和清理进度,再准备新的发布。发布完成后后台立即清理中转 ZIP,确认删除后释放其占用额度;再次上传时重新申请授权。
51
+
52
+ 完成条件:服务端当前分享指向本次发布的 package_id,才能报告发布成功。交付稳定安装链接、材料摘要、源码选择、数据库结构和 seed 处理说明。链接在应用详情的“分享模板”页可复制,并显示成功安装次数。更新模板沿用该链接;同租户再次安装会复用已安装的应用。
53
+
54
+ ## 撤回和做同款
55
+
56
+ 用户要求撤回时执行 `tiana web withdraw-share-template WEB_ID --json`,再查询状态说明当前分享和清理进度。撤回影响后续安装,已安装应用继续独立使用;重新发布保留链接和累计安装次数。
57
+
58
+ 用户拿分享链接要求做同款时,执行 `tiana web init-share-template LINK --dir PROJECT_DIR --json`。读取回执中的 prompt 和工程 metadata.json,再按[模板初始化](template-init.md)及普通 Web 开发流程创建自己的新应用。没有源码的分享只支持落地页安装。做同款从新工程开始,原模板的安装来源与统计不跟踪这次开发。
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """Package a reviewed source snapshot and its clean build as an installable template."""
3
+ import argparse
4
+ import hashlib
5
+ import io
6
+ import json
7
+ import stat
8
+ import struct
9
+ from pathlib import Path, PurePosixPath
10
+ import zipfile
11
+
12
+ MIB = 1 << 20
13
+ MAX_FILE = 16 * MIB
14
+ MAX_EXPANDED = 128 * MIB
15
+ MAX_ENTRIES = 5000
16
+
17
+
18
+ def archive(files):
19
+ output = io.BytesIO()
20
+ with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as result:
21
+ for name, data in sorted(files.items()):
22
+ result.writestr(name, data)
23
+ return output.getvalue()
24
+
25
+
26
+ def read_file(file):
27
+ if file.is_symlink() or not file.is_file():
28
+ raise ValueError(f"Material must be a regular file: {file.name}")
29
+ if file.stat().st_size > MAX_FILE:
30
+ raise ValueError(f"Material exceeds 16 MiB: {file.name}")
31
+ with file.open("rb") as stream:
32
+ value = stream.read(MAX_FILE + 1)
33
+ if len(value) > MAX_FILE:
34
+ raise ValueError(f"Material exceeds 16 MiB: {file.name}")
35
+ return value
36
+
37
+
38
+ def directory_count(data):
39
+ end = next((pos for pos in range(len(data) - 22, max(-1, len(data) - 65558), -1)
40
+ if data[pos:pos + 4] == b"PK\x05\x06" and pos + 22 + struct.unpack_from("<H", data, pos + 20)[0] == len(data)), -1)
41
+ if end < 0:
42
+ raise ValueError("Invalid source ZIP directory")
43
+ disk, directory_disk, local_count, count, length, offset = struct.unpack_from("<HHHHII", data, end + 4)
44
+ if disk or directory_disk or local_count != count or count > MAX_ENTRIES or offset + length != end:
45
+ raise ValueError("Source ZIP exceeds entry budget or has an invalid directory")
46
+ actual = 0
47
+ while offset < end:
48
+ if offset + 46 > end or data[offset:offset + 4] != b"PK\x01\x02":
49
+ raise ValueError("Invalid source ZIP entry")
50
+ actual += 1
51
+ if actual > MAX_ENTRIES:
52
+ raise ValueError("Source ZIP exceeds 5,000 entries")
53
+ offset += 46 + sum(struct.unpack_from("<HHH", data, offset + 28))
54
+ if actual != count or offset != end:
55
+ raise ValueError("Invalid source ZIP entry count")
56
+ return actual
57
+
58
+
59
+ def source_files(data):
60
+ count = directory_count(data)
61
+ files, total, seen = {}, 0, set()
62
+ with zipfile.ZipFile(io.BytesIO(data)) as source:
63
+ for item in source.infolist():
64
+ name = item.filename.rstrip("/")
65
+ parts = name.split("/")
66
+ mode = item.external_attr >> 16
67
+ if (not name or "\\" in name or "\0" in name or ":" in name
68
+ or any(part in ("", ".", "..") or part.lower() == ".git" for part in parts)
69
+ or PurePosixPath(name).is_absolute() or name in seen
70
+ or stat.S_IFMT(mode) not in (0, stat.S_IFREG, stat.S_IFDIR)):
71
+ raise ValueError(f"Unsafe source ZIP path or type: {name}")
72
+ seen.add(name)
73
+ if item.file_size > MAX_FILE or total + item.file_size > MAX_EXPANDED:
74
+ raise ValueError("Source ZIP exceeds expanded size budget")
75
+ with source.open(item) as stream:
76
+ value = stream.read(min(MAX_FILE, MAX_EXPANDED - total) + 1)
77
+ if len(value) > MAX_FILE or total + len(value) > MAX_EXPANDED:
78
+ raise ValueError("Source ZIP exceeds actual expanded size budget")
79
+ total += len(value)
80
+ if not item.is_dir():
81
+ files[name] = value
82
+ return files, total, count
83
+
84
+
85
+ def pack(source_zip, dist, entry, output, without_source=False):
86
+ source = read_file(Path(source_zip))
87
+ snapshot, source_size, source_count = source_files(source)
88
+ metadata = json.loads(snapshot["metadata.json"])
89
+ if type(metadata.get("database_required")) is not bool:
90
+ raise ValueError("metadata.json requires an explicit database_required boolean")
91
+ names = ["showcase/preview.html", "showcase/cover.webp"]
92
+ if not without_source:
93
+ names.append("showcase/prompt.md")
94
+ files = {name: snapshot[name] for name in names}
95
+ if len(files["showcase/preview.html"]) > 500_000:
96
+ raise ValueError("showcase/preview.html exceeds 500,000 bytes")
97
+ sql_names = {}
98
+ for field, target in (("schema", "schema.sql"), ("seeds", "seed.sql")):
99
+ name = metadata.get(field)
100
+ if name:
101
+ if not metadata["database_required"]:
102
+ raise ValueError("SQL materials require database_required=true")
103
+ files[target] = snapshot[name]
104
+ sql_names[name] = target
105
+ site = {"web.yaml": b""}
106
+ dist = Path(dist)
107
+ site_size = 0
108
+ for file in sorted(dist.rglob("*")):
109
+ if file.is_symlink():
110
+ raise ValueError(f"Build contains a symlink: {file.relative_to(dist)}")
111
+ if file.is_file():
112
+ data = read_file(file)
113
+ site_size += len(data)
114
+ if site_size > MAX_FILE or len(site) >= 4097:
115
+ raise ValueError("Build exceeds the Web archive resource budget")
116
+ site["data/" + file.relative_to(dist).as_posix()] = data
117
+ if not entry or "data/" + entry not in site:
118
+ raise ValueError("Build is missing its fixed entry")
119
+ compiled = archive(site)
120
+ if len(compiled) > MAX_FILE:
121
+ raise ValueError("site.tweb exceeds 16 MiB")
122
+ variables = []
123
+ for variable in metadata.get("variables", []):
124
+ targets = [sql_names[name] for name in variable["files"] if name in sql_names]
125
+ if targets:
126
+ if variable["name"] not in ("WEB_ID", "SQLITE_INSTANCE_ID", "APP_NAME"):
127
+ raise ValueError(f"Unsupported installation SQL variable: {variable['name']}")
128
+ variables.append({**variable, "files": targets})
129
+ if not without_source:
130
+ files["source.zip"] = source
131
+ if "data/icon.png" in site:
132
+ files["icon.png"] = site["data/icon.png"]
133
+ files["site.tweb"] = compiled
134
+ manifest = {"metadata": metadata, "entry": entry, "database_required": metadata["database_required"], "variables": variables}
135
+ files["install.json"] = (json.dumps(manifest, ensure_ascii=False) + "\n").encode()
136
+ expanded = sum(map(len, files.values())) + site_size + (0 if without_source else source_size)
137
+ entries = len(files) + len(site) + (0 if without_source else source_count)
138
+ if expanded > MAX_EXPANDED or entries > MAX_ENTRIES or any(len(value) > MAX_FILE for value in files.values()):
139
+ raise ValueError("Package exceeds cumulative extraction budget")
140
+ data = archive(files)
141
+ if len(data) > 32 * MIB:
142
+ raise ValueError("install.zip exceeds 32 MiB")
143
+ Path(output).write_bytes(data)
144
+ return {"name": metadata["name"], "file": str(Path(output).resolve()), "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "has_source": not without_source, "database_required": metadata["database_required"], "expanded_bytes": expanded, "entries": entries}
145
+
146
+
147
+ if __name__ == "__main__":
148
+ parser = argparse.ArgumentParser(description=__doc__)
149
+ parser.add_argument("--source", required=True, help="Reviewed source.zip from the build's source commit")
150
+ parser.add_argument("--dist", required=True, help="Clean build output directory")
151
+ parser.add_argument("--entry", required=True, help="Fixed entry relative to dist")
152
+ parser.add_argument("--output", required=True, help="Destination install.zip")
153
+ parser.add_argument("--without-source", action="store_true", help="Publish installation materials without source or customization prompt")
154
+ args = parser.parse_args()
155
+ print(json.dumps(pack(args.source, args.dist, args.entry, args.output, args.without_source), ensure_ascii=False))