@tianacloud/agent-skills 1.0.17 → 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/README.md +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/tiana/SKILL.md +3 -1
- package/skills/tiana/references/compat-transport.md +18 -0
- package/skills/tiana/references/instances-and-tokens.md +2 -0
- package/skills/tiana-function/SKILL.md +11 -3
- package/skills/tiana-function/assets/file-storage/explicit-web/main.go +80 -0
- package/skills/tiana-function/assets/file-storage/tenant-root/main.go +99 -0
- package/skills/tiana-function/references/commands.md +3 -1
- package/skills/tiana-function/references/file-storage.md +160 -0
- package/skills/tiana-function/references/go-backend.md +8 -4
- package/skills/tiana-function/references/sqlite-backend.md +55 -0
- package/skills/tiana-git/SKILL.md +3 -1
- package/skills/tiana-sqlite/SKILL.md +5 -1
- package/skills/tiana-web/SKILL.md +3 -1
- package/skills/tiana-web/references/share-template.md +4 -0
- package/skills/tiana-web/scripts/pack-template.py +228 -37
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ CLI 主包及当前系统的原生平台包全部来自 npm,提供 tiana 和 g
|
|
|
52
52
|
|
|
53
53
|
开发者也可以向已运行的 Agent 要求:“设置 `TIANA_API_ORIGIN=https://console.tianacloud-staging.net`,安装 Tiana skills,然后创建一个账单应用。”技能会在本任务每次 CLI/Git 调用中传递这一选择。staging CLI 自动加载内置 CA;开发者需已有 VPN/DNS 和浏览器 CA 信任。仅在安装器子进程中 export 一次不会配置后续命令。
|
|
54
54
|
|
|
55
|
-
WorkBuddy Connector 固定 online,配置和当前使用说明见 [Connector](docs/workbuddy-connector.md)。
|
|
55
|
+
WorkBuddy Connector 固定 online,配置和当前使用说明见 [Connector](https://github.com/tianacloud/agent-skills/blob/main/docs/workbuddy-connector.md)。
|
|
56
56
|
|
|
57
57
|
## 检查与更新
|
|
58
58
|
|
package/package.json
CHANGED
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.
|
|
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",
|
package/skills/tiana/SKILL.md
CHANGED
|
@@ -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.
|
|
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,保留原认证和证书校验;是否改善当前沙盒连接,以实际尝试结果为准。
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
账号凭据由 CLI 的安全存储管理。不要在聊天、源码、构建文件或 Git URL 中复制凭据。缺少权限、配额超限与登录失效是不同问题,按实际错误处理。
|
|
8
8
|
|
|
9
|
+
后端服务需要独立 Instance Token 时,使用 `tiana token create INSTANCE --name NAME --json`;先用 `--help` 核实配套 CLI 能力。该 Token 覆盖实例各分支,默认不过期,可用 `--expires-at` 指定 Unix 秒。明文只在创建成功回执中交付,直接保存到密钥库;完整步骤见 `tiana-function` 的“SQLite 访问配置”。回执缺少明文或结果未知时先在 Console 检查原 Token,重复请求不能恢复明文。
|
|
10
|
+
|
|
9
11
|
浏览器应用通过 `window.tiana.connection()` 获取连接元信息,通过共享的 `window.tiana.auth` 获取动态 access token。应用不使用 `connection.tianaToken` 快照、不传静态 Token,也不回退到实例 Token。
|
|
10
12
|
|
|
11
13
|
CLI 本地预览优先复用已有 CLI 登录,通过一次性本地启动链接授权浏览器;没有可用账号时才走独立浏览器登录。刷新和持久化由 CLI/SDK 负责,refresh token 不交给页面。access token 具有账号/租户级权限,清单中的数据库 ID 不收窄凭据权限。
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tiana-function
|
|
3
|
-
description: 使用 Tiana 创建和管理 Go 后端服务,读取及保存 Git 源码、编译发布、修改参数与 CORS、开发 WebSocket
|
|
3
|
+
description: 使用 Tiana 创建和管理 Go 后端服务,读取及保存 Git 源码、编译发布、修改参数与 CORS、开发 WebSocket 实时接口、在函数中访问文件存储;为 Web 应用开发访问 SQLite 的后端服务,或初始化用户密钥库、列出密钥名称与版本、写入和删除密钥时使用。前端页面开发配合 tiana-web。
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
6
|
author: Tiana Cloud
|
|
7
|
-
version: "1.0.
|
|
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
|
-
|
|
14
|
+
标准源码数据连接失败时,阅读 `tiana` 技能的“兼容传输”指引;结果未知时保留原请求 ID 和源码提交,先查询原操作。
|
|
15
|
+
|
|
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 仍缺少命令时,报告配套能力尚未可用及两个包的实际版本,等待已验证的配套发布。
|
|
17
19
|
|
|
@@ -29,10 +31,16 @@ metadata:
|
|
|
29
31
|
|
|
30
32
|
数据库 Endpoint 使用明文参数;Token 和第三方密钥先按[用户密钥库](references/secrets.md)写入密钥,再保存为后端服务的密钥引用参数,不写入 Git、前端源码、业务响应或日志。缺少实际凭据时说明待配置项,不使用占位值宣称联调成功。Web 的数据访问方式、资源绑定与模板制作由 Tiana Web 负责。
|
|
31
33
|
|
|
34
|
+
后端服务需要访问 SQLite 时,必须先完成 [SQLite 访问配置](references/sqlite-backend.md):从实例取得 Endpoint、用 CLI 创建 Instance Token、写入密钥库并配置参数,再发布函数和验证真实查询。执行前通过 `tiana token create --help` 核对配套 CLI 能力。
|
|
35
|
+
|
|
32
36
|
调用方身份通过 SDK 从请求上下文读取,业务需要区分用户数据时使用该身份,不能相信请求体中自报的用户 ID。完成接口核验后,将所需调用身份与数据范围一并交给 Web skill 验证。
|
|
33
37
|
|
|
34
38
|
App 作者专属的初始化和权限管理接口,按 [App 作者与初始权限](references/go-backend.md#app-作者与初始权限)使用 `caller.IsAppOwner()` 校验;作者指本次来源 Web 的创建者,与租户角色分开。前端的 `is_app_owner` 展示由 Tiana Web 负责。
|
|
35
39
|
|
|
40
|
+
## 文件存储
|
|
41
|
+
|
|
42
|
+
函数需要上传、读取、列目录、删除文件或返回浏览器文件 URL 时,先阅读[函数文件存储](references/file-storage.md),按函数所属租户选择根目录或 Web 子目录,再接入示例。存储连接与凭据由平台注入;业务参数只保存业务配置。结合可信调用身份实现业务权限,完成真实文件读写及浏览器授权访问验收。
|
|
43
|
+
|
|
36
44
|
## WebSocket
|
|
37
45
|
|
|
38
46
|
开发实时连接时,按 [WebSocket 实时连接](references/go-backend.md#websocket-实时连接)完成 Go 服务、浏览器原生连接、源码保存和发布验证,再交给 Web skill 绑定与托管验收。
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"net/http"
|
|
10
|
+
"os"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/tianacloud/sdk-go/files"
|
|
14
|
+
"github.com/tianacloud/sdk-go/function"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func main() {
|
|
18
|
+
http.HandleFunc("/generate", func(w http.ResponseWriter, r *http.Request) {
|
|
19
|
+
caller, ok := function.CallerFromRequest(r)
|
|
20
|
+
if !ok || !caller.IsAppOwner() {
|
|
21
|
+
http.Error(w, "仅 App 作者可执行", http.StatusForbidden)
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
if r.Method != http.MethodPost {
|
|
25
|
+
w.Header().Set("Allow", "POST")
|
|
26
|
+
http.Error(w, "需要 POST", http.StatusMethodNotAllowed)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
config, err := function.ReadConfig()
|
|
30
|
+
if err != nil {
|
|
31
|
+
http.Error(w, "读取业务配置失败", http.StatusInternalServerError)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
webID, err := config.GetContext(r.Context(), "REPORT_WEB_ID")
|
|
35
|
+
if err != nil {
|
|
36
|
+
http.Error(w, "请配置 REPORT_WEB_ID", http.StatusInternalServerError)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
result, err := generateReport(webID)
|
|
40
|
+
if err != nil {
|
|
41
|
+
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
w.Header().Set("Content-Type", "application/json")
|
|
45
|
+
_ = json.NewEncoder(w).Encode(result)
|
|
46
|
+
})
|
|
47
|
+
if err := http.ListenAndServe(":"+os.Getenv("PORT"), nil); err != nil {
|
|
48
|
+
panic(err)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// generateReport can also be called by a business job without an HTTP request.
|
|
53
|
+
func generateReport(webID string) (map[string]any, error) {
|
|
54
|
+
client, err := function.FilesByWebApp(webID)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return nil, err
|
|
57
|
+
}
|
|
58
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
59
|
+
defer cancel()
|
|
60
|
+
const path = "reports/task.txt"
|
|
61
|
+
content := []byte("generated report\n")
|
|
62
|
+
receipt, err := client.Put(ctx, path, bytes.NewReader(content), files.PutOptions{
|
|
63
|
+
Size: int64(len(content)), ContentType: "text/plain; charset=utf-8", Overwrite: true,
|
|
64
|
+
})
|
|
65
|
+
if err == nil {
|
|
66
|
+
return map[string]any{"state": "written", "receipt": receipt}, nil
|
|
67
|
+
}
|
|
68
|
+
var fileErr *files.Error
|
|
69
|
+
if !errors.As(err, &fileErr) || fileErr.Outcome != "unknown" || fileErr.WriteID == "" {
|
|
70
|
+
return nil, err
|
|
71
|
+
}
|
|
72
|
+
// Inspect the current object once. A failed write is never automatically replayed.
|
|
73
|
+
checkCtx, checkCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
74
|
+
defer checkCancel()
|
|
75
|
+
metadata, checkErr := client.Metadata(checkCtx, path)
|
|
76
|
+
if checkErr == nil && metadata.WriteID == fileErr.WriteID && metadata.Size == int64(len(content)) {
|
|
77
|
+
return map[string]any{"state": "current_object_matches", "metadata": metadata}, nil
|
|
78
|
+
}
|
|
79
|
+
return nil, fmt.Errorf("写入结果未确认,请按业务状态处理;write_id=%s delete_confirmed=%t: %w", fileErr.WriteID, fileErr.DeleteConfirmed, err)
|
|
80
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"net/http"
|
|
11
|
+
"os"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/tianacloud/sdk-go/files"
|
|
15
|
+
"github.com/tianacloud/sdk-go/function"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func main() {
|
|
19
|
+
http.HandleFunc("/file-demo", func(w http.ResponseWriter, r *http.Request) {
|
|
20
|
+
caller, ok := function.CallerFromRequest(r)
|
|
21
|
+
if !ok || !caller.IsAppOwner() {
|
|
22
|
+
http.Error(w, "仅 App 作者可执行", http.StatusForbidden)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
if r.Method != http.MethodPost {
|
|
26
|
+
w.Header().Set("Allow", "POST")
|
|
27
|
+
http.Error(w, "需要 POST", http.StatusMethodNotAllowed)
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
result, err := exercise(function.ContextFromRequest(r))
|
|
31
|
+
if err != nil {
|
|
32
|
+
status := http.StatusBadGateway
|
|
33
|
+
var fileErr *files.Error
|
|
34
|
+
if errors.As(err, &fileErr) {
|
|
35
|
+
switch fileErr.Code {
|
|
36
|
+
case "FILE_EXISTS":
|
|
37
|
+
status = http.StatusConflict
|
|
38
|
+
case "FILE_WRITE_BLOCKED":
|
|
39
|
+
status = http.StatusForbidden
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
http.Error(w, err.Error(), status)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
w.Header().Set("Content-Type", "application/json")
|
|
46
|
+
_ = json.NewEncoder(w).Encode(result)
|
|
47
|
+
})
|
|
48
|
+
if err := http.ListenAndServe(":"+os.Getenv("PORT"), nil); err != nil {
|
|
49
|
+
panic(err)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func exercise(ctx context.Context) (map[string]any, error) {
|
|
54
|
+
client := function.Files()
|
|
55
|
+
capabilities, err := client.Capabilities(ctx)
|
|
56
|
+
if err != nil {
|
|
57
|
+
return nil, err
|
|
58
|
+
}
|
|
59
|
+
path := fmt.Sprintf("file-demo/%d.txt", time.Now().UnixNano())
|
|
60
|
+
content := []byte("tenant root file\n")
|
|
61
|
+
receipt, err := client.Put(ctx, path, bytes.NewReader(content), files.PutOptions{
|
|
62
|
+
Size: int64(len(content)), ContentType: "text/plain; charset=utf-8",
|
|
63
|
+
})
|
|
64
|
+
if err != nil {
|
|
65
|
+
return nil, err
|
|
66
|
+
}
|
|
67
|
+
metadata, err := client.Metadata(ctx, receipt.Path)
|
|
68
|
+
if err != nil {
|
|
69
|
+
return nil, err
|
|
70
|
+
}
|
|
71
|
+
listed := 0
|
|
72
|
+
for cursor := ""; ; {
|
|
73
|
+
page, err := client.List(ctx, "file-demo/", files.ListOptions{Cursor: cursor})
|
|
74
|
+
if err != nil {
|
|
75
|
+
return nil, err
|
|
76
|
+
}
|
|
77
|
+
listed += len(page.Entries)
|
|
78
|
+
if page.NextCursor == "" {
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
cursor = page.NextCursor
|
|
82
|
+
}
|
|
83
|
+
response, err := client.Get(ctx, receipt.Path, files.GetOptions{})
|
|
84
|
+
if err != nil {
|
|
85
|
+
return nil, err
|
|
86
|
+
}
|
|
87
|
+
read, readErr := io.Copy(io.Discard, response.Body)
|
|
88
|
+
closeErr := response.Body.Close()
|
|
89
|
+
if err := errors.Join(readErr, closeErr); err != nil {
|
|
90
|
+
return nil, err
|
|
91
|
+
}
|
|
92
|
+
if err := client.Delete(ctx, receipt.Path); err != nil {
|
|
93
|
+
return nil, err
|
|
94
|
+
}
|
|
95
|
+
return map[string]any{
|
|
96
|
+
"capabilities": capabilities, "written": receipt, "metadata": metadata,
|
|
97
|
+
"listed": listed, "read_bytes": read, "deleted": true,
|
|
98
|
+
}, nil
|
|
99
|
+
}
|
|
@@ -45,7 +45,9 @@ tiana function logs FUNCTION_ID --build SUBMISSION_ID --json
|
|
|
45
45
|
|
|
46
46
|
先列出代码实际需要的参数名和用途,例如 `SQLITE_ENDPOINT`(数据库连接地址)、`SQLITE_TOKEN`(该数据库实例 Token)、`PAYMENT_API_KEY`(第三方接口密钥)。使用已核实的目标资源取得实际值;缺少第三方密钥时请用户在 Console 配置,不要求在对话中粘贴。
|
|
47
47
|
|
|
48
|
-
Console 操作:打开目标后端服务
|
|
48
|
+
Console 操作:打开目标后端服务 →「参数配置」→「参数列表」,选择明文或密钥引用,填写参数名和值并保存。参数名须与代码 Get 的名称一致;密钥引用填写密钥库中的 key。
|
|
49
|
+
|
|
50
|
+
为 SQLite 准备 Endpoint 和实例 Token 时,先按 [SQLite 访问配置](sqlite-backend.md)完成凭据交付,再使用本节更新参数。
|
|
49
51
|
|
|
50
52
|
CLI 操作:
|
|
51
53
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# 函数文件存储
|
|
2
|
+
|
|
3
|
+
使用 Go SDK `v1.2.0`。在函数源码目录固定依赖:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
go get github.com/tianacloud/sdk-go@v1.2.0
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
SDK 至少需要 Go 1.25;平台 Go 1.26 工具链满足要求。服务端部署验收与正式 SDK 发布完成后,本技能随配套版本发布。
|
|
10
|
+
|
|
11
|
+
## 选择目录与权限
|
|
12
|
+
|
|
13
|
+
导入 `github.com/tianacloud/sdk-go/function` 与 `github.com/tianacloud/sdk-go/files`。
|
|
14
|
+
|
|
15
|
+
| 构造入口 | 当前根目录 |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `function.Files()` | 函数所属租户的 files 根 |
|
|
18
|
+
| `function.FilesByWebApp(webID)` | 同一租户根下的 `<webID>/` |
|
|
19
|
+
| `function.FilesFromRequest(r)` | 从 Gateway 注入的 `Caller.WebID` 选择 Web 子目录;无来源 Web 返回 `files.ErrNoWebContext` |
|
|
20
|
+
|
|
21
|
+
构造不发请求。`FilesByWebApp` 和 `FilesFromRequest` 返回 `(*files.Client, error)`;`Files` 直接返回客户端。平台已配置 OSS,函数代码无需账号密钥、bucket、endpoint 或租户根路径。
|
|
22
|
+
|
|
23
|
+
客户端始终属于**函数资源所属租户**,与调用用户的租户无关。填写另一租户的 Web ID 只会选择本函数租户里的同名子目录。共享函数要访问特定 Web,使用 `FilesByWebApp`;使用可信来源 Web 时选 `FilesFromRequest`。同一租户内的 Web 子目录是目录选择,业务访问权限由 handler 根据 `CallerFromRequest` 判断。不要把请求体里的租户、用户或 Web ID 当作授权依据。
|
|
24
|
+
|
|
25
|
+
所有方法接收未进行 URL 编码的相对路径,例如 `reports/账单.csv`;列表、元数据和写入回执中的 Path 也相对当前客户端根。路径不能以 `/` 开头,目录段不能为 `.` 或 `..`。`List(ctx, "", ...)` 列当前根;列表分页用返回的 `NextCursor` 原样交回同一个客户端和目录,直到为空。
|
|
26
|
+
|
|
27
|
+
## 无入站请求时显式选择 Web
|
|
28
|
+
|
|
29
|
+
可直接编译的 [显式 Web 示例](../assets/file-storage/explicit-web/main.go) 提供 `POST /generate`,由 App 作者调用;先在函数业务参数中设置 `REPORT_WEB_ID`。其中 `generateReport(webID)` 不依赖 HTTP request,创建自有 context,写入后遇到未知结果只核对当前 Metadata,不重放写入。同一对象从租户客户端访问的路径是 `<webID>/reports/task.txt`。如果把该方法用于异步业务任务,沿函数技能的 jobs 用法管理任务生命周期。
|
|
30
|
+
|
|
31
|
+
例如业务已有确定的 Web ID,创建自有 context 后查询;client 的构造不需要 HTTP request:
|
|
32
|
+
|
|
33
|
+
```go
|
|
34
|
+
func reportMetadata(ctx context.Context, webID string) (files.Metadata, error) {
|
|
35
|
+
client, err := function.FilesByWebApp(webID)
|
|
36
|
+
if err != nil { return files.Metadata{}, err }
|
|
37
|
+
return client.Metadata(ctx, "reports/latest.txt")
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
补充导入 `context`。`function.Files().Metadata(ctx, webID+"/reports/latest.txt")` 指向同一个对象,返回 Path 包含 Web 前缀;显式 Web 客户端返回的 Path 是 `reports/latest.txt`。两种 GetURL 分别使用租户根或 Web 文件入口授权范围,前端必须授权对应范围。
|
|
42
|
+
|
|
43
|
+
## 上传并返回浏览器地址
|
|
44
|
+
|
|
45
|
+
下面的完整 HTTP 程序允许来源 Web 的作者生成固定报表,并允许该 Web 的已登录用户下载。实际应用根据自己的业务模型决定读写权限、文件名和内容。
|
|
46
|
+
|
|
47
|
+
```go
|
|
48
|
+
package main
|
|
49
|
+
|
|
50
|
+
import (
|
|
51
|
+
"bytes"
|
|
52
|
+
"encoding/json"
|
|
53
|
+
"io"
|
|
54
|
+
"net/http"
|
|
55
|
+
"os"
|
|
56
|
+
|
|
57
|
+
"github.com/tianacloud/sdk-go/files"
|
|
58
|
+
"github.com/tianacloud/sdk-go/function"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
func main() {
|
|
62
|
+
http.HandleFunc("/report", report)
|
|
63
|
+
if err := http.ListenAndServe(":"+os.Getenv("PORT"), nil); err != nil {
|
|
64
|
+
panic(err)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func report(w http.ResponseWriter, r *http.Request) {
|
|
69
|
+
caller, present := function.CallerFromRequest(r)
|
|
70
|
+
if !present || caller.UserID == "" {
|
|
71
|
+
http.Error(w, "请登录", http.StatusUnauthorized)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
client, err := function.FilesFromRequest(r)
|
|
75
|
+
if err != nil {
|
|
76
|
+
http.Error(w, "需要从 Web 应用访问", http.StatusBadRequest)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
ctx := function.ContextFromRequest(r)
|
|
80
|
+
const path = "reports/latest.txt"
|
|
81
|
+
switch r.Method {
|
|
82
|
+
case http.MethodPost:
|
|
83
|
+
if !caller.IsAppOwner() {
|
|
84
|
+
http.Error(w, "仅 App 作者可生成报表", http.StatusForbidden)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
content := []byte("本次报表内容\n")
|
|
88
|
+
receipt, err := client.Put(ctx, path, bytes.NewReader(content), files.PutOptions{
|
|
89
|
+
Size: int64(len(content)), ContentType: "text/plain; charset=utf-8", Overwrite: true,
|
|
90
|
+
})
|
|
91
|
+
if err != nil {
|
|
92
|
+
http.Error(w, "报表写入未确认,请查询文件状态", http.StatusBadGateway)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
publicURL, err := client.GetURL(ctx, receipt.Path)
|
|
96
|
+
if err != nil {
|
|
97
|
+
http.Error(w, "文件已写入,获取访问地址失败", http.StatusBadGateway)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
w.Header().Set("Content-Type", "application/json")
|
|
101
|
+
_ = json.NewEncoder(w).Encode(map[string]any{"path": receipt.Path, "url": publicURL, "write_id": receipt.WriteID})
|
|
102
|
+
case http.MethodGet:
|
|
103
|
+
response, err := client.Get(ctx, path, files.GetOptions{Range: r.Header.Get("Range")})
|
|
104
|
+
if err != nil {
|
|
105
|
+
http.Error(w, "读取报表失败", http.StatusBadGateway)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
defer response.Body.Close()
|
|
109
|
+
for _, key := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag"} {
|
|
110
|
+
if value := response.Header.Get(key); value != "" { w.Header().Set(key, value) }
|
|
111
|
+
}
|
|
112
|
+
w.WriteHeader(response.StatusCode)
|
|
113
|
+
if _, err := io.Copy(w, response.Body); err != nil { return }
|
|
114
|
+
default:
|
|
115
|
+
w.Header().Set("Allow", "GET, POST")
|
|
116
|
+
http.Error(w, "不支持的方法", http.StatusMethodNotAllowed)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`PutOptions.Size` 是准确字节数,包含零字节;最大单文件 5 GiB。请求体已有可靠 ContentLength 时可直接将 `r.Body` 流式传给 Put;未知长度先在业务允许的大小内准备临时内容并确定长度。不要为流式接口无界读取整个上传体。默认同名冲突,显式 `Overwrite: true` 会先删除再上传,操作不是原子的,失败后旧文件可能已经删除。
|
|
122
|
+
|
|
123
|
+
`GetURL` 返回当前环境的完整公网文件入口 URL,和前端 SDK 一样仍需要浏览器自身的文件授权;它不是免授权下载链接,也不安装 Cookie。把此 URL 交给已经通过前端文件 SDK 完成授权的页面。函数自己的 Get 直接返回文件流,调用方负责 Close;可用 `GetOptions{Method: http.MethodHead}` 或 Range,传入的 context 可取消请求。
|
|
124
|
+
|
|
125
|
+
## 查询、分页和删除
|
|
126
|
+
|
|
127
|
+
可直接编译的 [租户根示例](../assets/file-storage/tenant-root/main.go) 提供 `POST /file-demo`,由 App 作者调用。它创建唯一的演示对象,读取 Capabilities、上传、查询元数据、遍历演示目录的所有页、流式读取并关闭 Body,最后删除刚创建的对象。请求失败时会返回错误而不自动重放;若演示中断,用返回的写入状态或目录查询处理遗留对象。
|
|
128
|
+
|
|
129
|
+
两个源码示例分别作为 `main.go` 放到独立目录,执行 `go mod init example.com/file-demo`,按本技能的配套 SDK 版本执行 `go get`,再运行 `go mod tidy && go build .`;保存生成的 go.mod/go.sum。平台 Go 1.26 工具链满足 SDK 的 Go 1.25 最低要求。它们与上面的来源 Web 程序分别发布为独立函数入口;通过 CLI/Console 取得真实调用入口,不把 `supervisor.tiana.com` 当作客户端公网地址。
|
|
130
|
+
|
|
131
|
+
下面片段在已有 `ctx`、`client` 的业务方法中使用,错误交给该方法的返回值处理:
|
|
132
|
+
|
|
133
|
+
```go
|
|
134
|
+
for cursor := ""; ; {
|
|
135
|
+
page, err := client.List(ctx, "reports/", files.ListOptions{Cursor: cursor})
|
|
136
|
+
if err != nil { return err }
|
|
137
|
+
for _, entry := range page.Entries {
|
|
138
|
+
// 使用 entry.Path、IsDir、Size、ETag、LastModified 构造业务结果。
|
|
139
|
+
_ = entry
|
|
140
|
+
}
|
|
141
|
+
if page.NextCursor == "" { break }
|
|
142
|
+
cursor = page.NextCursor
|
|
143
|
+
}
|
|
144
|
+
metadata, err := client.Metadata(ctx, "reports/latest.txt")
|
|
145
|
+
if err != nil { return err }
|
|
146
|
+
_ = metadata // Size、ETag、ContentType、LastModified、WriteID。
|
|
147
|
+
capabilities, err := client.Capabilities(ctx)
|
|
148
|
+
if err != nil { return err }
|
|
149
|
+
_ = capabilities // MaxFileBytes、最近已应用的 WriteBlocked。
|
|
150
|
+
// 用户明确要求删除时,在业务权限判断后执行:
|
|
151
|
+
if err := client.Delete(ctx, "reports/latest.txt"); err != nil { return err }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Delete 删除当前对象,不存在也成功。每次 List 计 100 文件 read 消耗;Get、Metadata 不增加该 List 消耗。存储字节异步核对,额度状态存在传播窗口;`WriteBlocked` 不是实时剩余容量。禁止写入时 Put/Overwrite 失败,文件 API 的读、列目录、删除仍可用;Gateway 的整租户准入可能阻止新的函数业务调用,释放空间也可使用 Console/CLI 文件管理。
|
|
155
|
+
|
|
156
|
+
## 写入结果与验收
|
|
157
|
+
|
|
158
|
+
用 `errors.As(err, &fileErr)` 读取 `*files.Error` 的 Code、Stage、Outcome、DeleteConfirmed、WriteID 和 RequestID。`Outcome == "unknown"` 表示结果未确认;不要自动重放 Put 或 Overwrite。用 Metadata 核对当前对象的 WriteID、大小及业务内容;WriteID 只是写入标识,不提供幂等或历史版本保证。请求取消也可能发生在 OSS 已接收写入之后。
|
|
159
|
+
|
|
160
|
+
发布前编译示例并保存真实 go.mod/go.sum。发布后在目标函数验证一次上传、Metadata、流式/Range 读取、分页和删除;验证来源 Web、明确选择 Web 及无 Web 上下文的行为。需要交付公网 URL 时,继续在对应已登录 Web 中验证前端文件授权与访问。平台未注入文件配置时会返回 `FILE_NOT_CONFIGURED`,报告服务配置缺口,不在函数源码中填入 OSS 凭据。
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
后端服务是监听 `PORT` 环境变量指定端口的 Go HTTP 程序。平台固定 Go 1.26 工具链。source_dir 包含 package main,所属 go.mod 可在该目录或仓库内上级目录,多个入口可共享模块;依赖由构建流程下载。本页的 Get(value, error) 与密钥引用示例使用下列配套 SDK;将解析后的 go.mod、go.sum 一起保存到源码仓库。
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
go get github.com/tianacloud/sdk-go@v1.
|
|
6
|
+
go get github.com/tianacloud/sdk-go@v1.2.0
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
最小程序返回 JSON:
|
|
@@ -68,7 +68,7 @@ req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
|
68
68
|
|
|
69
69
|
## 访问 SQLite
|
|
70
70
|
|
|
71
|
-
先用 `tiana-sqlite` 技能核实数据库实例、表结构及初始化 SQL
|
|
71
|
+
先用 `tiana-sqlite` 技能核实数据库实例、表结构及初始化 SQL,再按 [SQLite 访问配置](sqlite-backend.md)通过 CLI 创建实例 Token 并保存参数。`SQLITE_ENDPOINT` 使用 `sqlite show --json` 返回的 `data.connection.hostname`,不含 URL scheme 或端口。连接端口沿用 SDK 默认值。只在后端服务内读取并传给 SDK:
|
|
72
72
|
|
|
73
73
|
```go
|
|
74
74
|
endpoint, endpointErr := params.Get("SQLITE_ENDPOINT")
|
|
@@ -96,11 +96,15 @@ defer db.Close()
|
|
|
96
96
|
|
|
97
97
|
对应导入为 `database/sql`、`time`、`tiana "github.com/tianacloud/sdk-go"`、`tianasqlite "github.com/tianacloud/sdk-go-sqlite"`。OpenDB 创建句柄并不证明数据库已连接,继续执行实际查询并检查错误。后续 SQL 使用 `r.Context()` 与占位符绑定业务值。对需要按用户隔离的数据,从 SDK 读取可信请求身份并在查询条件中限定所属用户;不要提供任意 SQL 执行接口。
|
|
98
98
|
|
|
99
|
-
沙箱内包含公共 CA
|
|
99
|
+
沙箱内包含公共 CA 包,SDK 默认使用系统信任。只有 tmpfs 用于临时写入,持久业务数据保存到 SQLite 或平台文件存储。
|
|
100
|
+
|
|
101
|
+
## 访问文件存储
|
|
102
|
+
|
|
103
|
+
需要持久保存业务文件、流式下载或生成前端可用文件 URL 时,按[函数文件存储](file-storage.md)使用 `function.Files()`、`FilesByWebApp` 或 `FilesFromRequest`。文件连接由平台配置,业务代码使用相对路径。
|
|
100
104
|
|
|
101
105
|
## App 作者与初始权限
|
|
102
106
|
|
|
103
|
-
在需要作者权限的 HTTP handler 内使用 Go SDK `v1.
|
|
107
|
+
在需要作者权限的 HTTP handler 内使用 Go SDK `v1.2.0`(包含参数 Get 错误语义和密钥引用),并将实际依赖固定到 go.mod:
|
|
104
108
|
|
|
105
109
|
```go
|
|
106
110
|
caller, present := function.CallerFromRequest(r)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SQLite 访问配置
|
|
2
|
+
|
|
3
|
+
本流程需要提供 `tiana token create` 的配套 CLI。先执行 `tiana token create --help`;缺少命令时说明配套能力尚未可用。沿用用户已选定的登录。
|
|
4
|
+
|
|
5
|
+
## 准备资源
|
|
6
|
+
|
|
7
|
+
1. 核实 SQLite 实例和目标分支;缺少数据库才创建。用 `sqlite shell` 准备业务表与少量可核验数据。
|
|
8
|
+
2. 创建或复用后端服务,记录完整 `FUNCTION_ID`。创建、源码目录和发布按 [后端服务命令](commands.md)。
|
|
9
|
+
3. 执行 `tiana secrets init --json`。确认 `data.state=ACTIVE` 后继续;若仍 accepted,保留原实例/任务,稍后再次 init 查看同一密钥库的状态。
|
|
10
|
+
|
|
11
|
+
## 创建凭据并配置
|
|
12
|
+
|
|
13
|
+
下例为 POSIX shell 和 Python 3。先将 `DB_ID`、`FUNCTION_ID` 设为已确认的完整实例 ID;需要非默认分支时,在 sqlite show 中增加 `--branch NAME`。`secret_key` 为本函数独立的 key,已有函数的更新只修改本次数据库参数。
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
set -e
|
|
17
|
+
umask 077
|
|
18
|
+
work_dir=$(mktemp -d)
|
|
19
|
+
secret_key="functions/$FUNCTION_ID/sqlite-token"
|
|
20
|
+
tiana sqlite show "$DB_ID" --json > "$work_dir/database.json"
|
|
21
|
+
tiana token create "$DB_ID" --name "$FUNCTION_ID SQLite" --json > "$work_dir/token.json"
|
|
22
|
+
python3 - "$work_dir/token.json" "$secret_key" <<'PY'
|
|
23
|
+
import json, subprocess, sys
|
|
24
|
+
with open(sys.argv[1]) as f:
|
|
25
|
+
receipt = json.load(f)
|
|
26
|
+
assert receipt['status'] == 'succeeded' and receipt['data']['token']
|
|
27
|
+
subprocess.run(['tiana', 'secrets', 'set', sys.argv[2], '--stdin', '--json'],
|
|
28
|
+
input=receipt['data']['token'], text=True, check=True)
|
|
29
|
+
PY
|
|
30
|
+
tiana function parameters get "$FUNCTION_ID" --json > "$work_dir/current.json"
|
|
31
|
+
python3 - "$work_dir" "$secret_key" <<'PY'
|
|
32
|
+
import json, pathlib, sys
|
|
33
|
+
root = pathlib.Path(sys.argv[1])
|
|
34
|
+
connection = json.loads((root / 'database.json').read_text())['data']['connection']
|
|
35
|
+
parameters = json.loads((root / 'current.json').read_text())['data']['parameters']
|
|
36
|
+
parameters['SQLITE_ENDPOINT'] = {'kind': 'plain', 'value': connection['hostname']}
|
|
37
|
+
parameters['SQLITE_TOKEN'] = {'kind': 'secret', 'value': sys.argv[2]}
|
|
38
|
+
(root / 'parameters.json').write_text(json.dumps(parameters))
|
|
39
|
+
PY
|
|
40
|
+
tiana function parameters set "$FUNCTION_ID" --file "$work_dir/parameters.json" --json > "$work_dir/applied.json"
|
|
41
|
+
python3 - "$work_dir/applied.json" <<'PY'
|
|
42
|
+
import json, sys
|
|
43
|
+
with open(sys.argv[1]) as f:
|
|
44
|
+
result = json.load(f)
|
|
45
|
+
print('Parameter application:', result['data']['application'])
|
|
46
|
+
PY
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`token create` 默认不过期,业务有到期要求时增加 `--expires-at UNIX_SECONDS`。退出码非零时停止后续步骤;保留 token ID、request ID 和错误,不因重跑整段脚本签发另一枚 Token。未交付明文或响应未知时先在 Console 查看原 Token,必要时撤销后明确创建替代凭据。密钥写入失败而原回执仍在时,从原回执恢复写入。
|
|
50
|
+
|
|
51
|
+
参数 set 替换整个对象,上例保留其余参数。`applied` 或 `next_start` 后执行真实函数请求;`pending` 先核对配置下发状态。保留受限临时目录用于未完成步骤的恢复;凭据写入、参数保存及验证成功后删除本次临时目录,源码和交付记录只保留资源身份、密钥 key 和结果。
|
|
52
|
+
|
|
53
|
+
## 使用参数
|
|
54
|
+
|
|
55
|
+
按 [Go 后端服务开发](go-backend.md#访问-sqlite)读取 `SQLITE_ENDPOINT` 和 `SQLITE_TOKEN` 并连接数据库。Endpoint 使用主机名,端口沿用 SDK 默认值。完成源码发布后,通过应用的业务接口执行实际查询,确认返回预期数据;仅创建数据库句柄或编译成功不能确认连接成功。
|
|
@@ -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.
|
|
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.
|
|
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,12 +38,16 @@ CLI 仍管理检查缓存和共同的 24 小时提醒额度;该额度不限制
|
|
|
38
38
|
|
|
39
39
|
## 认证与失败
|
|
40
40
|
|
|
41
|
+
标准数据连接断开、握手失败或超时时,阅读 `tiana` 技能的“兼容传输”指引,用原数据库及分支先做只读验证。
|
|
42
|
+
|
|
41
43
|
CLI 从保存的账号登录态取得连接凭据。缺少或过期时按 `tiana` 的分步登录流程处理,再复用原数据库。不要读取凭据文件、要求用户粘贴 Token 或重新建库解决认证错误。
|
|
42
44
|
|
|
43
45
|
写入中断、输出缺失或结果未知时,用单独的只读查询确认;不自动重放,不能确认时说明不确定性。
|
|
44
46
|
|
|
45
47
|
## 应用连接
|
|
46
48
|
|
|
49
|
+
Go 后端服务访问 SQLite 时,转到 `tiana-function` 的“SQLite 访问配置”,通过 CLI 创建 Instance Token、保存密钥引用和 Endpoint,再验证实际 SQL。浏览器直连按下文流程使用账号鉴权。
|
|
50
|
+
|
|
47
51
|
JavaScript 应用阅读 [JS SDK 与 SQL 协议](references/js-sdk.md),使用参数化 SQL。应用必须使用支持 `auth` 的 SDK 和 Bootstrap 的 `window.tiana.auth`,通过 `connection()` 获取连接元信息;不传静态 Token、不保留旧版回退。能力缺失时报告未就绪,不把 Token 写进源码。终端连接见 [CLI 连接](references/cli.md),Rust 客户端见 [Rust SDK](references/rust-sdk.md)。
|
|
48
52
|
|
|
49
53
|
## 数据库分支
|
|
@@ -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.
|
|
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
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|