@tianacloud/agent-skills 1.0.18 → 1.0.19

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.18",
3
+ "version": "1.0.19",
4
4
  "description": "Tiana Cloud Skills for accounts, SQLite, Git, Functions and Web applications",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
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.18",
4
+ "version": "1.0.19",
5
5
  "description": "Tiana Cloud account, SQLite, Git, Functions 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.18"
7
+ version: "1.0.19"
8
8
  source: https://github.com/tianacloud/agent-skills/tree/main/skills/tiana
9
9
  ---
10
10
 
@@ -51,4 +51,6 @@ CLI 仍管理检查缓存和共同的 24 小时提醒额度;该额度不限制
51
51
 
52
52
  ## 凭据与结果
53
53
 
54
+ 标准方式出现 SQLite、Git 或发布数据连接错误时,阅读[兼容传输](references/compat-transport.md),按同一目标尝试恢复;管理 API 错误继续按管理流程处理。
55
+
54
56
  CLI 保存账号登录态。登录失败或凭据过期时复用原实例完成登录;不读取凭据文件、向命令手动注入 Token 或把密钥写入源码。资源、Endpoint、账号权限及结果不明的处理见[实例与凭据](references/instances-and-tokens.md)。以实际返回的状态和错误判断结果,不把未执行的操作报告为已完成。
@@ -0,0 +1,18 @@
1
+ # 数据连接失败时尝试兼容传输
2
+
3
+ 先按各技能的标准命令执行。SQLite、Git、Web 发布或 Function 源码传输遇到连接断开、握手失败、连接超时等数据连接错误时,可尝试单次命令的 `--transport compat`。部分 Agent 环境不支持标准连接中的 HTTP CONNECT;报错可能只表现为 EOF 或 reset,不必等到错误明确提到 CONNECT。
4
+
5
+ 1. 保留原实例、Endpoint、分支、账号和操作参数。先看错误是否已明确属于登录、权限、证书、配额、SQL 或应用业务问题;这些错误按原流程处理。
6
+ 2. 执行 `tiana --help` 确认当前安装支持 `--transport`。参数放在产品命令之前;无需配置代理地址、代理密码或环境变量。旧 CLI 不支持时,按安装更新流程取得支持版本,不能假称已启用。
7
+ 3. 优先用同一目标的只读命令尝试兼容连接。成功后,本任务访问该目标的数据命令可显式带上参数;CLI 不会自动选择或切换模式。兼容尝试仍失败时,保留两次错误,按实际原因排查,不循环重试。
8
+
9
+ ```sh
10
+ tiana --transport compat sqlite shell INSTANCE -e 'SELECT 1'
11
+ tiana --transport compat git ls-remote origin
12
+ ```
13
+
14
+ Git 保持原 remote 和参数,支持直接 `clone`、`fetch`、`pull`、`push`、`ls-remote`、`submodule` 子命令,例如 `tiana --transport compat git push origin main`。Git 参数仍归 Git 解析;无需 `git run --`,也无需改写 remote URL。Git 来源应为原本使用的 Tiana 仓库;此选项只改变 Tiana Endpoint 连接。
15
+
16
+ 写 SQL、push、发布等操作在断线前可能已成功。先按原流程核对结果,再决定是否执行写操作:SQL 查询目标状态;Git 查询实际 push 目标的远端 ref(`git remote get-url --push REMOTE`,多个 push URL 用 `--all` 分别核对),不要假定 fetch URL 与 push URL 相同;Web/Function 保留原回执、请求 ID 和源码提交,查询原操作。不能因换传输就重放结果未知的写入。
17
+
18
+ 兼容参数覆盖内置 SQLite shell、直接 Git 子命令、Web 的 Gateway 发布/状态查询及 Function 源码数据传输。管理 API、对象存储下载和更新检查沿用原网络方式;`connect` 外部客户端及其 WebSocket 模式保持原用法。兼容方式仍访问原 Endpoint,保留原认证和证书校验;是否改善当前沙盒连接,以实际尝试结果为准。
@@ -4,13 +4,15 @@ description: 使用 Tiana 创建和管理 Go 后端服务,读取及保存 Git
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Tiana Cloud
7
- version: "1.0.18"
7
+ version: "1.0.19"
8
8
  parent: tiana
9
9
  source: https://github.com/tianacloud/agent-skills/tree/main/skills/tiana-function
10
10
  ---
11
11
 
12
12
  # Tiana Function
13
13
 
14
+ 标准源码数据连接失败时,阅读 `tiana` 技能的“兼容传输”指引;结果未知时保留原请求 ID 和源码提交,先查询原操作。
15
+
14
16
  本 skill 负责后端服务的创建与管理、源码、运行参数、用户密钥库、Go 接口、数据库与文件存储访问、编译发布。为 Web 开发后端时,接收 `tiana-web` 技能确定的 Git ID、接口需求和数据依赖;完成后返回服务 ID、source_dir、发布回执、接口契约和参数生效结果,由 Web skill 继续前端接入、绑定、发布及分享验收。
15
17
 
16
18
  安装、账号与环境选择按 `tiana` 技能。共享各 Tiana 技能本会话的实际更新检查记录;尚未检查时先完成该入口的检查,包括从本会话实际加载的 SKILL.md 的 `metadata.version` 取得 `SKILLS_VERSION`,在同一次 shell 调用中赋值并实际执行 `tiana version check --skills-version "$SKILLS_VERSION" --json` 并按结果自动更新 CLI 和整套 Skills、补齐新增技能并核验,在对话中报告结果与重载要求;`should_notify=false` 也执行。两个包各自比较自身 npm 最新版,检查与安装无需 Tiana 登录;暂不登录或不操作云端资源时仍完成此流程。执行前确认 `tiana function --help` 可用;当前最新正式 CLI 仍缺少命令时,报告配套能力尚未可用及两个包的实际版本,等待已验证的配套发布。
@@ -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.18"
7
+ version: "1.0.19"
8
8
  parent: tiana
9
9
  source: https://github.com/tianacloud/agent-skills/tree/main/skills/tiana-git
10
10
  ---
@@ -37,6 +37,8 @@ CLI 仍管理检查缓存和共同的 24 小时提醒额度;该额度不限制
37
37
 
38
38
  ## 同步应用源码
39
39
 
40
+ 标准 Git 数据连接失败时,阅读 `tiana` 技能的“兼容传输”指引;可通过 `tiana --transport compat git` 的直接子命令访问原 remote。结果未知的 push 先核对实际推送目标的 ref。
41
+
40
42
  阅读 [源码与版本](references/source-releases.md),确认项目托管选择和实际推送范围。全新 Tiana 应用创建自己的 Tiana Git;已有外部仓库首次同步时确认用户选择。提交源码与锁文件,排除凭据,普通推送并核验远端对应版本引用。
41
43
 
42
44
  Web 发布时从干净、确定的源码提交构建,在交付记录中保存实际 `git_instance_id`、完整源码提交号和对应发布回执。推送失败停止关联的 Web 发布,结果不明先读取远端状态。Web 构建和上传见 `tiana-web` 技能。
@@ -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.18"
7
+ version: "1.0.19"
8
8
  parent: tiana
9
9
  source: https://github.com/tianacloud/agent-skills/tree/main/skills/tiana-sqlite
10
10
  ---
@@ -38,6 +38,8 @@ CLI 仍管理检查缓存和共同的 24 小时提醒额度;该额度不限制
38
38
 
39
39
  ## 认证与失败
40
40
 
41
+ 标准数据连接断开、握手失败或超时时,阅读 `tiana` 技能的“兼容传输”指引,用原数据库及分支先做只读验证。
42
+
41
43
  CLI 从保存的账号登录态取得连接凭据。缺少或过期时按 `tiana` 的分步登录流程处理,再复用原数据库。不要读取凭据文件、要求用户粘贴 Token 或重新建库解决认证错误。
42
44
 
43
45
  写入中断、输出缺失或结果未知时,用单独的只读查询确认;不自动重放,不能确认时说明不确定性。
@@ -4,13 +4,15 @@ description: 使用 Tiana 创建、修改、预览和发布 Web 应用。用户
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Tiana Cloud
7
- version: "1.0.18"
7
+ version: "1.0.19"
8
8
  parent: tiana
9
9
  source: https://github.com/tianacloud/agent-skills/tree/main/skills/tiana-web
10
10
  ---
11
11
 
12
12
  # Tiana Web
13
13
 
14
+ 标准发布数据连接失败时,阅读 `tiana` 技能的“兼容传输”指引;结果未知时先用原发布回执查询状态。
15
+
14
16
  安装、登录和通用实例管理见 `tiana` 技能,SQL 与连接见 `tiana-sqlite` 技能,后端服务见 `tiana-function` 技能,源码见 `tiana-git` 技能。
15
17
 
16
18
  ## 每个会话一次的更新检查
@@ -73,6 +73,10 @@
73
73
  没有外置素材且发布者选择不分享源码时增加 `--without-source`。声明素材时保留
74
74
  source.zip,并生成与其逐字节相同的独立 `files/`、固定格式/能力及文件清单。
75
75
  复核目标环境已支持并启用该能力。脚本不替代数据、公开内容或应用行为检查。
76
+
77
+ 若返回 `[preflight/...]`,保存原始报告,并逐项向作者说明阶段、字段/文件位置、原因和修复办法,可等义整理,不只转述为“打包失败”。字段位置中的数组下标从0开始;报告截断时说明仍有问题未展示,修复后再次检查。在既有任务授权内修正明确的机械错误;数据库需求、私有数据库访问方式或内容可公开性不明确时,先让作者判断。
78
+
79
+ 修正公开副本后,重新执行第1–2步,直到原命令成功并返回本轮回执;编辑本地文件不会更新已有source.zip。这类前置失败不读取dist、不生成或覆盖安装包;已有output未更新,不能当作本次成功产物。尚未执行的构建材料和服务端检查仍需继续完成。
76
80
  3. 复查脚本输出的大小、SHA-256、源码选择及数据库依赖。有素材时再核对 file_count、file_bytes、file_manifest_sha256、源/独立素材字节和干净构建。安装包上限 32 MiB,单材料 16 MiB,所有解析层累计展开 128 MiB、5,000 个条目;site.tweb 同时遵守 16 MiB 和 4,097 条目的 Web 归档限制。超限时先移除无用依赖、压缩资源或精简材料。
77
81
  4. 执行 `tiana web publish-share-template WEB_ID --file /绝对路径/install.zip --wait --json`。上传峰值包含原模板材料、中转 ZIP 和新材料,全部计入当前租户额度。CLI 申请精确对象授权、直传上传并提交异步发布。
78
82
  5. 用 `tiana web status-share-template WEB_ID --json` 核对当前 `share.package_id`、Web ID、模板名称和 URL。支持配套字段的 CLI 还会保留 share.version、material_version 和 manifest.files;材料版本应等于本次完整安装包摘要。上传响应未知或等待被中断时先查询原状态,不把新上传当作恢复。发布失败时核对错误和中转清理。完整历史能力上线后旧版发布材料继续保留和计费,中转 ZIP 确认删除后才释放其占用额度。
@@ -15,6 +15,7 @@ MAX_FILE = 16 * MIB
15
15
  MAX_EXPANDED = 128 * MIB
16
16
  MAX_ENTRIES = 5000
17
17
  MAX_FILE_SEEDS = 256
18
+ MAX_PREFLIGHT_ISSUES = 20
18
19
  FILE_TYPES = {
19
20
  ".webp": "image/webp", ".png": "image/png", ".jpg": "image/jpeg",
20
21
  ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
@@ -27,6 +28,16 @@ FILE_TYPES = {
27
28
  }
28
29
 
29
30
 
31
+ class TemplatePreflightError(ValueError):
32
+ def __init__(self, issues):
33
+ super().__init__("\n".join([
34
+ "Template preflight failed; install.zip was not written or updated.",
35
+ *issues,
36
+ "Fix the source copy, re-export source.zip and rebuild from the same reviewed commit, then pack again.",
37
+ "Remaining packaging checks have not run; this is not publishing approval.",
38
+ ]))
39
+
40
+
30
41
  def archive(files):
31
42
  output = io.BytesIO()
32
43
  with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as result:
@@ -164,32 +175,220 @@ def unique_json_object(pairs):
164
175
  result = {}
165
176
  for key, value in pairs:
166
177
  if key in result:
167
- raise ValueError(f"Duplicate metadata JSON key: {key}")
178
+ raise TemplatePreflightError([
179
+ "[preflight/metadata] metadata.json: duplicate object key.\n"
180
+ " Fix: keep one intended value per key; remove the duplicate declaration.",
181
+ ])
168
182
  result[key] = value
169
183
  return result
170
184
 
171
185
 
172
- def validate_backend_materials(metadata, snapshot, site):
173
- repositories = {r["key"]: r for r in metadata.get("repositories", [])}
174
- for repo in repositories.values():
175
- prefix = "" if repo["path"] == "." else repo["path"] + "/"
176
- if not any(name.startswith(prefix) for name in snapshot):
177
- raise ValueError(f"Repository source missing: {repo['key']}")
178
- for variable in repo.get("variables", []):
179
- for name in variable["files"]:
180
- if variable["placeholder"].encode() not in snapshot.get(prefix + name, b""):
181
- raise ValueError(f"Source placeholder missing: {repo['key']}/{name}")
182
- for fn in metadata.get("functions", []):
183
- repo = repositories.get(fn["repository"])
184
- if repo is None:
185
- raise ValueError(f"Function repository missing: {fn['key']}")
186
- prefix = "" if repo["path"] == "." else repo["path"] + "/"
187
- directory = fn.get("source_dir", "").rstrip("/")
188
- if fn.get("source_dir") != "/" and (not directory.startswith("/") or any(part in ("", ".", "..") for part in directory[1:].split("/")) or "\\" in directory or "\0" in directory):
189
- raise ValueError("Function source_dir must be a repository path starting with /")
190
- prefix += directory[1:] + "/" if directory else ""
191
- if not any(name.startswith(prefix) and "/" not in name[len(prefix):] and name.endswith(".go") for name in snapshot):
192
- raise ValueError(f"Function entry Go source missing: {fn['key']}")
186
+ def preflight(snapshot, without_source):
187
+ # Only fixed fields and material names enter diagnostics, never input values.
188
+ if "metadata.json" not in snapshot:
189
+ raise TemplatePreflightError([
190
+ "[preflight/metadata] metadata.json: missing.\n"
191
+ " Fix: include metadata.json at the root of the source snapshot.",
192
+ ])
193
+ raw_metadata = snapshot["metadata.json"]
194
+ if len(raw_metadata) > MIB:
195
+ raise TemplatePreflightError([
196
+ "[preflight/metadata] metadata.json: exceeds 1 MiB.\n"
197
+ " Fix: reduce metadata to at most 1 MiB; keep application data outside metadata.",
198
+ ])
199
+ try:
200
+ metadata = json.loads(raw_metadata.decode("utf-8"), object_pairs_hook=unique_json_object)
201
+ except (UnicodeDecodeError, json.JSONDecodeError):
202
+ raise TemplatePreflightError([
203
+ "[preflight/metadata] metadata.json: invalid UTF-8 or JSON.\n"
204
+ " Fix: save a valid UTF-8 JSON object before checking its fields.",
205
+ ]) from None
206
+ if type(metadata) is not dict:
207
+ raise TemplatePreflightError([
208
+ "[preflight/metadata] metadata.json: must be an object.\n"
209
+ " Fix: put the template fields in a JSON object, not a list or scalar.",
210
+ ])
211
+
212
+ issues = []
213
+ missing = object()
214
+
215
+ def issue(location, problem, fix, stage="metadata"):
216
+ if len(issues) < MAX_PREFLIGHT_ISSUES:
217
+ if stage == "metadata":
218
+ location = "metadata.json:" + location
219
+ issues.append(f"[preflight/{stage}] {location}: {problem}.\n Fix: {fix}")
220
+ elif len(issues) == MAX_PREFLIGHT_ISSUES:
221
+ issues.append(f"More problems found; showing the first {MAX_PREFLIGHT_ISSUES}. Fix these and run again.")
222
+
223
+ def string(value, location):
224
+ if value is missing:
225
+ issue(location, "missing required field", "add a nonempty string for this field.")
226
+ elif type(value) is not str or not value.strip():
227
+ issue(location, "requires a nonempty string", "supply text, not null, a number, a list or an object.")
228
+ else:
229
+ try:
230
+ value.encode("utf-8")
231
+ return value
232
+ except UnicodeEncodeError:
233
+ issue(location, "contains invalid Unicode", "replace unpaired Unicode surrogates with valid text.")
234
+ return None
235
+
236
+ def objects(value, location):
237
+ if type(value) is not list:
238
+ issue(location, "requires an array", "use an array of objects; omit this optional field if unused.")
239
+ return
240
+ for index, item in enumerate(value):
241
+ where = f"{location}[{index}]"
242
+ if type(item) is not dict:
243
+ issue(where, "requires an object", "supply the fields of this declaration in a JSON object.")
244
+ else:
245
+ yield where, item
246
+
247
+ def variables(value, location, prefix):
248
+ for where, variable in objects(value, location):
249
+ for field in ("name", "description"):
250
+ string(variable.get(field, missing), f"{where}.{field}")
251
+ placeholder = string(variable.get("placeholder", missing), f"{where}.placeholder")
252
+ files = variable.get("files")
253
+ if type(files) is not list or not files:
254
+ issue(f"{where}.files", "requires a nonempty array of file paths",
255
+ "list the files that contain this variable's placeholder.")
256
+ continue
257
+ for index, value in enumerate(files):
258
+ at = f"{where}.files[{index}]"
259
+ name = string(value, at)
260
+ # site_variables refer to dist, which is checked only after preflight.
261
+ if name is None or prefix is None:
262
+ continue
263
+ content = snapshot.get(prefix + name)
264
+ if content is None:
265
+ issue(at, "declared source file is missing",
266
+ "include this file in source.zip or correct its path relative to the declared source root.")
267
+ elif placeholder is not None and placeholder.encode("utf-8") not in content:
268
+ issue(at, "Source placeholder missing",
269
+ "restore the declared placeholder in this file or correct the variable declaration.")
270
+
271
+ for field in ("name", "display_name", "description"):
272
+ string(metadata.get(field, missing), field)
273
+ if type(metadata.get("database_required")) is not bool:
274
+ issue("database_required", "requires a JSON boolean",
275
+ "use true or false, not a quoted value, after confirming the app's database needs.\n"
276
+ " Declared schema/seeds require true; do not remove required SQL just to pass.")
277
+ if "private_database" in metadata and type(metadata["private_database"]) is not bool:
278
+ issue("private_database", "requires a JSON boolean",
279
+ "use true or false without quotes; true keeps the database off the browser's Web binding.")
280
+
281
+ sql = []
282
+ for field in ("schema", "seeds"):
283
+ if field not in metadata:
284
+ continue
285
+ name = string(metadata[field], field)
286
+ if name is not None:
287
+ sql.append(name)
288
+ if name not in snapshot:
289
+ issue(field, "declared SQL file is missing",
290
+ "include the declared SQL file in source.zip or correct its path relative to the snapshot root.")
291
+ if sql and metadata.get("database_required") is False:
292
+ issue("database_required", "SQL materials require database_required=true",
293
+ "confirm the database dependency and use true for SQL-backed apps; do not remove required SQL just to pass.")
294
+ has_file_seeds = any(name == "template-files" or name.startswith("template-files/") for name in snapshot)
295
+ if "file_seeds" in metadata:
296
+ if metadata["file_seeds"] != "template-files":
297
+ issue("file_seeds", "must be the string template-files",
298
+ "use the declared template-files directory; omit this optional field only when no file seeds are needed.")
299
+ elif not has_file_seeds:
300
+ issue("file_seeds", "declared material files are missing",
301
+ "include the reviewed files under template-files/ in source.zip.")
302
+ elif has_file_seeds:
303
+ issue("file_seeds", "missing declaration for template-files",
304
+ "declare file_seeds as template-files after reviewing the files for public distribution.")
305
+
306
+ variables(metadata.get("variables", []), "variables", "")
307
+ repositories = {}
308
+ for where, repo in objects(metadata.get("repositories", []), "repositories"):
309
+ key = string(repo.get("key", missing), f"{where}.key")
310
+ path = string(repo.get("path", missing), f"{where}.path")
311
+ prefix = None
312
+ if path is not None:
313
+ if path != "." and ("\\" in path or any(part in ("", ".", "..") for part in path.split("/"))):
314
+ issue(f"{where}.path", "requires a relative source directory",
315
+ "use '.' or a relative directory without empty, dot or parent segments.")
316
+ else:
317
+ prefix = "" if path == "." else path + "/"
318
+ if not any(name.startswith(prefix) for name in snapshot):
319
+ issue(f"{where}.path", "Repository source missing",
320
+ "include the reviewed repository snapshot under this directory.")
321
+ prefix = None
322
+ if key is not None:
323
+ if key in repositories:
324
+ issue(f"{where}.key", "duplicate repository key", "give each logical repository a unique key.")
325
+ else:
326
+ repositories[key] = prefix
327
+ variables(repo.get("variables", []), f"{where}.variables", prefix)
328
+ repositories_typed = type(metadata.get("repositories", [])) is list
329
+ if "web_repository" in metadata:
330
+ key = string(metadata["web_repository"], "web_repository")
331
+ if key is not None and repositories_typed and key not in repositories:
332
+ issue("web_repository", "references an undeclared repository",
333
+ "use the key of the declared repository containing the Web source.")
334
+
335
+ function_keys = set()
336
+ for where, fn in objects(metadata.get("functions", []), "functions"):
337
+ key = string(fn.get("key", missing), f"{where}.key")
338
+ string(fn.get("name", missing), f"{where}.name")
339
+ repo = string(fn.get("repository", missing), f"{where}.repository")
340
+ directory = string(fn.get("source_dir", missing), f"{where}.source_dir")
341
+ if key is not None:
342
+ if key in function_keys:
343
+ issue(f"{where}.key", "duplicate function key", "give each function a unique key.")
344
+ function_keys.add(key)
345
+ if repo is not None and repositories_typed and repo not in repositories:
346
+ issue(f"{where}.repository", "Function repository missing", "reference a declared repository key.")
347
+ if directory is not None:
348
+ directory = directory.rstrip("/")
349
+ if fn["source_dir"] != "/" and (not directory.startswith("/") or any(part in ("", ".", "..") for part in directory[1:].split("/")) or "\\" in directory or "\0" in directory):
350
+ issue(f"{where}.source_dir", "requires a repository path starting with /",
351
+ "use the function's actual source directory, without dot or parent segments.")
352
+ elif repo in repositories and repositories[repo] is not None:
353
+ prefix = repositories[repo] + (directory[1:] + "/" if directory else "")
354
+ if not any(name.startswith(prefix) and "/" not in name[len(prefix):] and name.endswith(".go") for name in snapshot):
355
+ issue(f"{where}.source_dir", "Function entry Go source missing",
356
+ "include the function's Go entry source directly in this repository directory.")
357
+ for field in ("parameters", "cors", "database"):
358
+ if field in fn and type(fn[field]) is not dict:
359
+ issue(f"{where}.{field}", "requires an object", "use the documented object fields or omit this optional declaration.")
360
+ database = fn.get("database")
361
+ if type(database) is dict:
362
+ endpoint = string(database.get("endpoint_parameter", missing), f"{where}.database.endpoint_parameter")
363
+ token = string(database.get("token_parameter", missing), f"{where}.database.token_parameter")
364
+ if endpoint is not None and endpoint == token:
365
+ issue(f"{where}.database", "requires distinct endpoint and token parameter names",
366
+ "declare separate backend parameters; credentials are supplied during installation.")
367
+ if metadata.get("database_required") is False:
368
+ issue(f"{where}.database", "requires database_required=true",
369
+ "confirm the backend database dependency; do not remove required configuration just to pass.")
370
+ variables(metadata.get("site_variables", []), "site_variables", None)
371
+
372
+ names = ["showcase/preview.html", "showcase/cover.webp"]
373
+ if not without_source:
374
+ names.append("showcase/prompt.md")
375
+ fixes = [
376
+ "add the self-contained preview HTML to the source snapshot.",
377
+ "add the WebP cover made from the reviewed preview to the source snapshot.",
378
+ "add the customization prompt for source-sharing mode to the source snapshot.",
379
+ ]
380
+ for name, fix in zip(names, fixes):
381
+ if name not in snapshot:
382
+ issue(name, "missing", fix, "showcase")
383
+ elif name == "showcase/preview.html" and len(snapshot[name]) > 500_000:
384
+ issue(name, "exceeds 500,000 bytes",
385
+ "simplify the self-contained preview to at most 500,000 bytes before compression.", "showcase")
386
+ if issues:
387
+ raise TemplatePreflightError(issues)
388
+ return metadata, names
389
+
390
+
391
+ def validate_site_variables(metadata, site):
193
392
  for variable in metadata.get("site_variables", []):
194
393
  for name in variable["files"]:
195
394
  if variable["placeholder"].encode() not in site.get("data/" + name, b""):
@@ -199,25 +398,13 @@ def validate_backend_materials(metadata, snapshot, site):
199
398
  def pack(source_zip, dist, entry, output, without_source=False):
200
399
  source = read_file(Path(source_zip))
201
400
  snapshot, source_size, source_count = source_files(source)
202
- raw_metadata = snapshot["metadata.json"]
203
- if len(raw_metadata) > MIB:
204
- raise ValueError("metadata.json exceeds 1 MiB")
205
- metadata = json.loads(raw_metadata.decode("utf-8"), object_pairs_hook=unique_json_object)
206
- if type(metadata) is not dict:
207
- raise ValueError("metadata.json must be an object")
208
- if type(metadata.get("database_required")) is not bool:
209
- raise ValueError("metadata.json requires an explicit database_required boolean")
401
+ metadata, names = preflight(snapshot, without_source)
210
402
  if without_source and metadata.get("functions"):
211
403
  raise ValueError("Function templates require source.zip to compile their backends")
212
404
  seeds = template_file_materials(snapshot, metadata)
213
405
  if seeds is not None and without_source:
214
406
  raise ValueError("File templates require source.zip for server byte verification; --without-source is only available for templates without file_seeds")
215
- names = ["showcase/preview.html", "showcase/cover.webp"]
216
- if not without_source:
217
- names.append("showcase/prompt.md")
218
407
  files = {name: snapshot[name] for name in names}
219
- if len(files["showcase/preview.html"]) > 500_000:
220
- raise ValueError("showcase/preview.html exceeds 500,000 bytes")
221
408
  sql_names = {}
222
409
  for field, target in (("schema", "schema.sql"), ("seeds", "seed.sql")):
223
410
  name = metadata.get(field)
@@ -248,7 +435,7 @@ def pack(source_zip, dist, entry, output, without_source=False):
248
435
  raise ValueError(f"Build contains a nonregular entry: {relative}")
249
436
  if not entry or "data/" + entry not in site:
250
437
  raise ValueError("Build is missing its fixed entry")
251
- validate_backend_materials(metadata, snapshot, site)
438
+ validate_site_variables(metadata, site)
252
439
  compiled = archive(site)
253
440
  if len(compiled) > MAX_FILE:
254
441
  raise ValueError("site.tweb exceeds 16 MiB")
@@ -295,4 +482,8 @@ if __name__ == "__main__":
295
482
  parser.add_argument("--output", required=True, help="Destination install.zip")
296
483
  parser.add_argument("--without-source", action="store_true", help="No-file templates only: omit source and customization prompt")
297
484
  args = parser.parse_args()
298
- print(json.dumps(pack(args.source, args.dist, args.entry, args.output, args.without_source), ensure_ascii=False))
485
+ try:
486
+ result = pack(args.source, args.dist, args.entry, args.output, args.without_source)
487
+ except TemplatePreflightError as error:
488
+ parser.exit(1, f"{error}\n")
489
+ print(json.dumps(result, ensure_ascii=False))