@synopackageland/templates 0.38.1 → 0.39.1
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 +6 -7
- package/package.json +1 -1
- package/templates/_shared/AGENTS.md +6 -5
- package/templates/_shared/CLAUDE.md +2 -0
- package/templates/compose/dsm-package.yaml +1 -1
- package/templates/native-go/README.md +28 -11
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
- package/templates/native-go/backend/cmd/app/main.go +9 -2
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/go.sum +2 -2
- package/templates/native-go/backend/internal/api/operations.json +5 -0
- package/templates/native-go/backend/internal/api/permissions.go +7 -0
- package/templates/native-go/backend/internal/api/permissions.json +3 -1
- package/templates/native-go/backend/internal/app/routes.go +2 -46
- package/templates/native-go/backend/internal/app/routes_test.go +4 -25
- package/templates/native-go/dsm-package.yaml +2 -28
- package/templates/native-go/examples/operations.json +112 -0
- package/templates/native-go/i18n/glossary.json +2 -6
- package/templates/native-go/i18n/messages/de.json +0 -5
- package/templates/native-go/i18n/messages/en.json +0 -5
- package/templates/native-go/i18n/messages/es.json +0 -5
- package/templates/native-go/i18n/messages/fr.json +0 -5
- package/templates/native-go/i18n/messages/it.json +0 -5
- package/templates/native-go/i18n/messages/ja.json +0 -5
- package/templates/native-go/i18n/messages/ko.json +0 -5
- package/templates/native-go/i18n/messages/zh-CN.json +0 -5
- package/templates/native-go/i18n/messages/zh-TW.json +0 -5
- package/templates/native-go/operations.json +27 -0
- package/templates/native-go/ui/app.js +0 -25
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +3 -4
- package/templates/native-go-root-ops/frontend/test/executor-gate.test.ts +7 -6
- package/templates/native-go-vue/frontend/package-lock.json +4 -4
- package/templates/native-go-vue/frontend/package.json +1 -1
- package/templates/native-go-vue/frontend/src/App.vue +5 -34
- package/templates/native-go-vue/frontend/test/apps.test.ts +19 -25
- package/templates/native-go-vue/frontend/test/native-controls.test.ts +4 -1
- package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +5 -3
- package/templates/native-node/README.md +27 -7
- package/templates/native-node/backend/app/permissions.json +3 -1
- package/templates/native-node/backend/package-lock.json +1 -1
- package/templates/native-node/backend/src/routes.ts +5 -43
- package/templates/native-node/backend/test/routes.test.ts +3 -23
- package/templates/native-node/dsm-package.yaml +3 -29
- package/templates/native-node/examples/operations.json +112 -0
- package/templates/native-node/operations.json +27 -0
- package/templates/native-node-delegate/operations.patch.json +20 -0
- package/templates/native-node-jobs/operations.patch.json +52 -0
- package/templates/native-node-root-ops/operations.patch.json +20 -0
- package/templates/native-python/README.md +27 -7
- package/templates/native-python/backend/app/permissions.json +3 -1
- package/templates/native-python/backend/app/routes.py +2 -41
- package/templates/native-python/backend/tests/test_routes.py +3 -19
- package/templates/native-python/dsm-package.yaml +3 -29
- package/templates/native-python/examples/operations.json +112 -0
- package/templates/native-python/operations.json +27 -0
- package/templates/native-python-delegate/operations.patch.json +20 -0
- package/templates/native-python-jobs/operations.patch.json +52 -0
- package/templates/native-python-root-ops/operations.patch.json +20 -0
- package/templates/native-rust/README.md +28 -8
- package/templates/native-rust/backend/Cargo.lock +2 -2
- package/templates/native-rust/backend/Cargo.toml +1 -1
- package/templates/native-rust/backend/permissions.json +3 -1
- package/templates/native-rust/backend/src/routes.rs +11 -149
- package/templates/native-rust/dsm-package.yaml +3 -29
- package/templates/native-rust/examples/operations.json +112 -0
- package/templates/native-rust/operations.json +27 -0
- package/templates/native-rust-delegate/backend/Cargo.lock +2 -2
- package/templates/native-rust-delegate/backend/Cargo.toml +1 -1
- package/templates/native-rust-delegate/backend/src/delegate.rs +7 -1
- package/templates/native-rust-delegate/operations.patch.json +20 -0
- package/templates/native-rust-jobs/operations.patch.json +52 -0
- package/templates/native-rust-root-ops/operations.patch.json +20 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"app.loading": "Caricamento...",
|
|
3
2
|
"app.refresh": "Aggiorna",
|
|
4
3
|
"app.title": "Funziona",
|
|
5
4
|
"error.admin_required": "Siamo spiacenti, a questo account utente non sono stati assegnati privilegi appropriati per eseguire questa azione",
|
|
@@ -25,10 +24,6 @@
|
|
|
25
24
|
"error.APP_STOPPED": "L’App è arrestata. Avviarla in Centro pacchetti, quindi riprovare",
|
|
26
25
|
"error.APP_STARTING": "L’App è in fase di avvio. Riprovare tra poco",
|
|
27
26
|
"error.credential_not_ready": "DSM non ha fornito a questa pagina il token di accesso. Accedere di nuovo a DSM con la password, quindi aprire l’App dal desktop di DSM",
|
|
28
|
-
"greeting.adminOnly": "Solo i membri del gruppo administrators possono modificare il saluto",
|
|
29
|
-
"greeting.label": "Nuovo saluto",
|
|
30
|
-
"greeting.save": "Salva",
|
|
31
|
-
"greeting.setBy": "Impostato da {user}",
|
|
32
27
|
"status.backend": "Backend",
|
|
33
28
|
"status.heading": "Stato del backend",
|
|
34
29
|
"status.package": "Nome pacchetto",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"app.loading": "読み込み中...",
|
|
3
2
|
"app.refresh": "最新の情報に更新",
|
|
4
3
|
"app.title": "動作しています",
|
|
5
4
|
"error.admin_required": "申し訳ありませんが、このユーザー アカウントにはこの操作を行うための権限が与えられていません",
|
|
@@ -25,10 +24,6 @@
|
|
|
25
24
|
"error.APP_STOPPED": "App は停止しています。パッケージ センターで起動してから、もう一度お試しください",
|
|
26
25
|
"error.APP_STARTING": "App を起動しています。しばらくしてから再試行してください",
|
|
27
26
|
"error.credential_not_ready": "DSM がこのページにサインイン トークンを提供していません。パスワードで DSM に再度サインインしてから、DSM デスクトップから App を開いてください",
|
|
28
|
-
"greeting.adminOnly": "あいさつを変更できるのは administrators グループのメンバーのみです",
|
|
29
|
-
"greeting.label": "新しいあいさつ",
|
|
30
|
-
"greeting.save": "保存",
|
|
31
|
-
"greeting.setBy": "{user} が設定",
|
|
32
27
|
"status.backend": "バックエンド",
|
|
33
28
|
"status.heading": "バックエンドの状態",
|
|
34
29
|
"status.package": "パッケージ名",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"app.loading": "로드 중…",
|
|
3
2
|
"app.refresh": "새로 고침",
|
|
4
3
|
"app.title": "정상적으로 작동합니다",
|
|
5
4
|
"error.admin_required": "죄송합니다. 이 사용자 계정에는 이 작업을 수행하기에 적절한 권한이 할당되지 않았습니다",
|
|
@@ -25,10 +24,6 @@
|
|
|
25
24
|
"error.APP_STOPPED": "App이 중지되었습니다. 패키지 센터에서 시작한 후 다시 시도하십시오",
|
|
26
25
|
"error.APP_STARTING": "App을 시작하는 중입니다. 잠시 후 다시 시도하십시오",
|
|
27
26
|
"error.credential_not_ready": "DSM이 이 페이지에 로그인 토큰을 제공하지 않았습니다. 암호로 DSM에 다시 로그인한 후 DSM 데스크톱에서 App을 여십시오",
|
|
28
|
-
"greeting.adminOnly": "administrators 그룹의 구성원만 인사말을 변경할 수 있습니다",
|
|
29
|
-
"greeting.label": "새 인사말",
|
|
30
|
-
"greeting.save": "저장",
|
|
31
|
-
"greeting.setBy": "{user}님이 설정함",
|
|
32
27
|
"status.backend": "백엔드",
|
|
33
28
|
"status.heading": "백엔드 상태",
|
|
34
29
|
"status.package": "패키지 이름",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"app.loading": "加载中...",
|
|
3
2
|
"app.refresh": "刷新",
|
|
4
3
|
"app.title": "运行成功",
|
|
5
4
|
"error.admin_required": "抱歉,此用户帐户权限不足,无法执行此操作",
|
|
@@ -25,10 +24,6 @@
|
|
|
25
24
|
"error.APP_STOPPED": "App 已停止。请在套件中心启动后重试",
|
|
26
25
|
"error.APP_STARTING": "App 正在启动,请稍后重试",
|
|
27
26
|
"error.credential_not_ready": "DSM 没有为此页面提供登录令牌。请使用密码重新登录 DSM,然后从 DSM 桌面打开 App",
|
|
28
|
-
"greeting.adminOnly": "只有 administrators 群组的成员可以更改问候语",
|
|
29
|
-
"greeting.label": "新的问候语",
|
|
30
|
-
"greeting.save": "保存",
|
|
31
|
-
"greeting.setBy": "由 {user} 设置",
|
|
32
27
|
"status.backend": "后端",
|
|
33
28
|
"status.heading": "后端状态",
|
|
34
29
|
"status.package": "套件名称",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"app.loading": "載入中...",
|
|
3
2
|
"app.refresh": "重新整理",
|
|
4
3
|
"app.title": "成功運作了",
|
|
5
4
|
"error.admin_required": "抱歉,此使用者帳號權限不足,無法執行此動作",
|
|
@@ -25,10 +24,6 @@
|
|
|
25
24
|
"error.APP_STOPPED": "App 已停止。請在套件中心啟動後再試一次",
|
|
26
25
|
"error.APP_STARTING": "App 正在啟動,請稍後再試",
|
|
27
26
|
"error.credential_not_ready": "DSM 沒有提供此頁面的登入權杖。請以密碼重新登入 DSM,再從 DSM 桌面開啟 App",
|
|
28
|
-
"greeting.adminOnly": "只有 administrators 群組的成員可以變更問候語",
|
|
29
|
-
"greeting.label": "新的問候語",
|
|
30
|
-
"greeting.save": "儲存",
|
|
31
|
-
"greeting.setBy": "由 {user} 設定",
|
|
32
27
|
"status.backend": "後端",
|
|
33
28
|
"status.heading": "後端狀態",
|
|
34
29
|
"status.package": "套件名稱",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": "synopkgland.api",
|
|
3
|
+
"version": 2,
|
|
4
|
+
"operations": {
|
|
5
|
+
"app.status": {
|
|
6
|
+
"summary": "Read App status",
|
|
7
|
+
"ai": "read",
|
|
8
|
+
"cli": [
|
|
9
|
+
"app",
|
|
10
|
+
"status"
|
|
11
|
+
],
|
|
12
|
+
"effect": "read",
|
|
13
|
+
"permission": "administrators",
|
|
14
|
+
"paramsSchema": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": false
|
|
17
|
+
},
|
|
18
|
+
"resultSchema": {
|
|
19
|
+
"type": "object"
|
|
20
|
+
},
|
|
21
|
+
"flow": {
|
|
22
|
+
"type": "invoke",
|
|
23
|
+
"method": "app.status"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -36,23 +36,13 @@
|
|
|
36
36
|
function mount(root, ctx) {
|
|
37
37
|
var t = ctx.t;
|
|
38
38
|
var status = el("dl", { class: "dsmapp-facts" });
|
|
39
|
-
var greeting = el("p", { class: "dsmapp-greeting", text: t("app.loading") });
|
|
40
|
-
var setBy = el("p", { class: "dsmapp-note", hidden: "" });
|
|
41
|
-
var input = el("input", { type: "text", maxlength: "200", "aria-label": t("greeting.label"), placeholder: t("greeting.label") });
|
|
42
|
-
var save = el("button", { type: "submit", text: t("greeting.save") });
|
|
43
39
|
var refreshButton = el("button", { type: "button", class: "dsmapp-secondary", text: t("app.refresh") });
|
|
44
40
|
var message = el("p", { class: "dsmapp-message", role: "status" });
|
|
45
|
-
var form = el("form", { class: "dsmapp-form", hidden: "" }, [input, save]);
|
|
46
|
-
var adminOnly = el("p", { class: "dsmapp-note", hidden: "", text: t("greeting.adminOnly") });
|
|
47
41
|
var portal = ctx.portal ? el("section", { class: "dsmapp-portal" }) : null;
|
|
48
42
|
|
|
49
43
|
root.classList.add("dsmapp");
|
|
50
44
|
root.replaceChildren(
|
|
51
45
|
el("div", { class: "dsmapp-header" }, [el("h1", { text: t("app.title") }), refreshButton]),
|
|
52
|
-
greeting,
|
|
53
|
-
setBy,
|
|
54
|
-
form,
|
|
55
|
-
adminOnly,
|
|
56
46
|
message,
|
|
57
47
|
el("h2", { text: t("status.heading") }),
|
|
58
48
|
status
|
|
@@ -80,24 +70,9 @@
|
|
|
80
70
|
status.appendChild(el("dt", { text: row[0] }));
|
|
81
71
|
status.appendChild(el("dd", { text: row[1] }));
|
|
82
72
|
});
|
|
83
|
-
form.hidden = !data.admin;
|
|
84
|
-
adminOnly.hidden = data.admin;
|
|
85
|
-
}).catch(showError);
|
|
86
|
-
ctx.call("greeting.get").then(function (data) {
|
|
87
|
-
greeting.textContent = data.text;
|
|
88
|
-
setBy.textContent = data.updated_by ? t("greeting.setBy", { user: data.updated_by }) : "";
|
|
89
|
-
setBy.hidden = !data.updated_by;
|
|
90
73
|
}).catch(showError);
|
|
91
74
|
}
|
|
92
75
|
|
|
93
|
-
form.addEventListener("submit", function (event) {
|
|
94
|
-
event.preventDefault();
|
|
95
|
-
save.disabled = true;
|
|
96
|
-
ctx.call("greeting.set", { text: input.value })
|
|
97
|
-
.then(function () { input.value = ""; refresh(); })
|
|
98
|
-
.catch(showError)
|
|
99
|
-
.then(function () { save.disabled = false; });
|
|
100
|
-
});
|
|
101
76
|
refreshButton.addEventListener("click", refresh);
|
|
102
77
|
|
|
103
78
|
ctx.reopen(refresh);
|
|
@@ -76,7 +76,6 @@ test('the app mount forwards ctx.executor to administrator setup only',async()=>
|
|
|
76
76
|
const authorize=vi.fn(async()=>({state:'ready' as const}))
|
|
77
77
|
const call=vi.fn(async(method:string)=>{
|
|
78
78
|
if(method==='app.status')return {package:'DelegateApp',version:'1',user:admin?'admin':'alice',admin}
|
|
79
|
-
if(method==='greeting.get')return {text:'Hello'}
|
|
80
79
|
if(method==='delegate.status')return {state:'needs-admin-approval'}
|
|
81
80
|
if(method==='delegate.list')return {delegations:[]}
|
|
82
81
|
if(method==='delegate.jobs.list')return {jobs:[]}
|
|
@@ -151,7 +150,6 @@ test.skipIf(holdsPages)('an executor awaiting approval adds a notice above the p
|
|
|
151
150
|
const status=vi.fn(async()=>({state:'needs-admin-approval' as const}))
|
|
152
151
|
const call=vi.fn(async(method:string)=>{
|
|
153
152
|
if(method==='app.status')return {package:'DelegateApp',version:'1',user:'admin',admin:true}
|
|
154
|
-
if(method==='greeting.get')return {text:'Hello'}
|
|
155
153
|
if(method==='delegate.status')return {state:'needs-admin-approval'}
|
|
156
154
|
if(method==='delegate.list')return {delegations:[]}
|
|
157
155
|
throw {code:'unknown_method'}
|
|
@@ -159,7 +157,8 @@ test.skipIf(holdsPages)('an executor awaiting approval adds a notice above the p
|
|
|
159
157
|
const root=document.createElement('div');document.body.append(root)
|
|
160
158
|
const app=mount(root,dsmWindow({call,user:{name:'admin',isAdmin:true},executor:{status,authorize:vi.fn(),control:vi.fn()} as never}))
|
|
161
159
|
cleanup.push(()=>{app.unmount();root.remove()});await settle()
|
|
162
|
-
expect(root.textContent).toContain('Some features are unavailable until you approve system access for this app
|
|
160
|
+
expect(root.textContent).toContain('Some features are unavailable until you approve system access for this app')
|
|
163
161
|
expect(root.textContent).toContain('Package status')
|
|
164
|
-
expect(
|
|
162
|
+
expect(root.querySelector('[data-app-status] [data-app-value]')?.textContent).toBe('DelegateApp')
|
|
163
|
+
expect(root.querySelector('textarea')).toBeNull()
|
|
165
164
|
})
|
|
@@ -10,7 +10,6 @@ async function settle(){for(let i=0;i<30;i++){await Promise.resolve();await next
|
|
|
10
10
|
function open(admin:boolean,executor?:Context['executor']){
|
|
11
11
|
const call=vi.fn(async(method:string)=>{
|
|
12
12
|
if(method==='app.status')return {package:'Root',version:'1',user:admin?'admin':'alice',admin}
|
|
13
|
-
if(method==='greeting.get')return {text:'Hello Root'}
|
|
14
13
|
if(method==='delegate.status')return {state:'ready',disabled:false,lifetime:'until-revoked',destinations:['save']}
|
|
15
14
|
if(method==='delegate.destination')return {key:'save',selection:{scope:'home',root:'/home',generation:0},destination:null,status:'missing'}
|
|
16
15
|
if(method==='delegate.list')return {delegations:[]}
|
|
@@ -37,18 +36,19 @@ test('the App pages wait until an administrator approves the executor with the u
|
|
|
37
36
|
password.closest('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));await settle()
|
|
38
37
|
expect(executor.authorize).toHaveBeenCalledWith('one-time')
|
|
39
38
|
expect(root.textContent).toContain('Package status')
|
|
40
|
-
expect(
|
|
39
|
+
expect(root.querySelector('[data-app-status] [data-app-value]')?.textContent).toBe('Root')
|
|
40
|
+
expect(root.querySelector('textarea')).toBeNull()
|
|
41
41
|
})
|
|
42
42
|
test('anyone else is told to ask an administrator for system access',async()=>{
|
|
43
43
|
const root=open(false,awaitingApproval() as unknown as Context['executor']);await settle()
|
|
44
|
-
expect(root.textContent).toContain('Ask an administrator to open this app and approve it once
|
|
44
|
+
expect(root.textContent).toContain('Ask an administrator to open this app and approve it once')
|
|
45
45
|
expect(root.querySelector('input[type=password]')).toBeNull()
|
|
46
46
|
expect(root.textContent).not.toContain('Package status')
|
|
47
47
|
})
|
|
48
48
|
test('without an executor (a dev server) the App pages show at once',async()=>{
|
|
49
49
|
const root=open(false);await settle()
|
|
50
50
|
expect(root.textContent).toContain('Package status')
|
|
51
|
-
expect(root.textContent).not.toContain('Ask an administrator to open this app and approve it once
|
|
51
|
+
expect(root.textContent).not.toContain('Ask an administrator to open this app and approve it once')
|
|
52
52
|
})
|
|
53
53
|
// preload: the App's own reads run alongside the executor's first status read.
|
|
54
54
|
test('while the executor status is read the App pages already load, hidden and inert',async()=>{
|
|
@@ -60,13 +60,14 @@ test('while the executor status is read the App pages already load, hidden and i
|
|
|
60
60
|
expect(hidden?.textContent).toContain('Package status')
|
|
61
61
|
answers.forEach(answer=>answer({state:'ready'}));await settle()
|
|
62
62
|
expect(root.querySelector('.executor-preloading')).toBeNull()
|
|
63
|
-
expect(
|
|
63
|
+
expect(root.querySelector('[data-app-status] [data-app-value]')?.textContent).toBe('Root')
|
|
64
|
+
expect(root.querySelector('textarea')).toBeNull()
|
|
64
65
|
})
|
|
65
66
|
// ExecutorApproval holds the pages: AppShell's ExecutorNotice would only repeat it.
|
|
66
67
|
test('while the executor awaits approval no notice repeats the approval page',async()=>{
|
|
67
68
|
for(const admin of [true,false]){
|
|
68
69
|
const root=open(admin,awaitingApproval() as unknown as Context['executor']);await settle()
|
|
69
|
-
expect(root.textContent,String(admin)).toContain(admin?'Approve this app once':'Ask an administrator to open this app and approve it once
|
|
70
|
+
expect(root.textContent,String(admin)).toContain(admin?'Approve this app once':'Ask an administrator to open this app and approve it once')
|
|
70
71
|
expect(root.textContent,String(admin)).not.toContain('Some features are unavailable')
|
|
71
72
|
}
|
|
72
73
|
})
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"name": "native-dsm-vue-app",
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@lucide/vue": "1.48.0",
|
|
10
|
-
"@synopackageland/sdk": "0.
|
|
10
|
+
"@synopackageland/sdk": "0.47.0",
|
|
11
11
|
"@synopackageland/ui": "0.19.1",
|
|
12
12
|
"vue": "3.5.43"
|
|
13
13
|
},
|
|
@@ -1177,9 +1177,9 @@
|
|
|
1177
1177
|
}
|
|
1178
1178
|
},
|
|
1179
1179
|
"node_modules/@synopackageland/sdk": {
|
|
1180
|
-
"version": "0.
|
|
1181
|
-
"resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.
|
|
1182
|
-
"integrity": "sha512-
|
|
1180
|
+
"version": "0.47.0",
|
|
1181
|
+
"resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.47.0.tgz",
|
|
1182
|
+
"integrity": "sha512-UlJdmYA9kj6ureqkVw6PNlT+4fV7rWyEzlsPGhguHVFv6vQs4wJReBLDWkTa2zj83JtP0gsLMOOq8GU5vXNRKg==",
|
|
1183
1183
|
"license": "MIT",
|
|
1184
1184
|
"peerDependencies": {
|
|
1185
1185
|
"@lucide/vue": "^1.48.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
|
3
|
-
import {Button, Callout,
|
|
3
|
+
import {Button, Callout, KeyValueRow, SettingsSection} from '@synopackageland/ui'
|
|
4
4
|
import {bridgeErrorCopy, sharedFolderAccessFor, type AppStatus, type Context, type ShellPage} from '@synopackageland/sdk'
|
|
5
5
|
import {AppShell, FilesPage, LoginPage, PortalSettings, SharedFolderSettings, createSession, shellPages} from '@synopackageland/sdk/vue'
|
|
6
6
|
import {appCopy} from './i18n'
|
|
@@ -17,8 +17,6 @@ const copy = computed(() => appCopy(props.ctx.locale))
|
|
|
17
17
|
const app = ref<AppStatus>()
|
|
18
18
|
const busy = ref(false)
|
|
19
19
|
const error = ref('')
|
|
20
|
-
const notice = ref('')
|
|
21
|
-
const text = ref('')
|
|
22
20
|
let disposed = false
|
|
23
21
|
let unsubscribe: (() => void) | undefined
|
|
24
22
|
// The CLI substitutes this only for a declared package-account background worker.
|
|
@@ -43,24 +41,9 @@ async function refresh() {
|
|
|
43
41
|
const early = !login.ready.value
|
|
44
42
|
const call: typeof login.call = early ? (method, params) => props.ctx.call(method, params) : login.call
|
|
45
43
|
busy.value = true; error.value = ''
|
|
46
|
-
// An administrator DSM names up front has the greeting read beside the status.
|
|
47
|
-
const startupBatch = props.ctx.user?.isAdmin === true ? props.ctx.batch : undefined
|
|
48
|
-
const read = !startupBatch && props.ctx.user?.isAdmin ? call<{text:string}>('greeting.get') : undefined
|
|
49
|
-
read?.catch(() => {})
|
|
50
44
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const results = await batch([{method:'app.status'},{method:'greeting.get'}])
|
|
54
|
-
const status = results[0]!, greeting = results[1]!
|
|
55
|
-
if(!status.success) throw status.error
|
|
56
|
-
if(!disposed) app.value = status.data as AppStatus
|
|
57
|
-
if(!greeting.success) throw greeting.error
|
|
58
|
-
if(!disposed) text.value = (greeting.data as {text:string}).text
|
|
59
|
-
} else {
|
|
60
|
-
const a = await call<AppStatus>('app.status')
|
|
61
|
-
if(!disposed) app.value = a
|
|
62
|
-
if(a.admin) {const greeting = await (read ?? call<{text:string}>('greeting.get')); if(!disposed) text.value = greeting.text}
|
|
63
|
-
}
|
|
45
|
+
const status = await call<AppStatus>('app.status')
|
|
46
|
+
if(!disposed) app.value = status
|
|
64
47
|
} catch(e) {if(!disposed && !(early && login.enabled.value)) error.value = message(e)}
|
|
65
48
|
finally {if(!disposed) {busy.value=false; if(again) {again=false; void refresh()}}}
|
|
66
49
|
}
|
|
@@ -71,14 +54,8 @@ const portalKey = computed(() => `${app.value?.user}:${portalReads.value}`)
|
|
|
71
54
|
function refreshAll() {
|
|
72
55
|
void refresh(); void panels.value?.refresh(); portalReads.value++
|
|
73
56
|
}
|
|
74
|
-
async function save() {
|
|
75
|
-
if(disposed || busy.value) return
|
|
76
|
-
busy.value = true; error.value='';notice.value=''
|
|
77
|
-
try {await login.call('greeting.set',{text:text.value});if(!disposed) notice.value=copy.value.saved}
|
|
78
|
-
catch(e) {if(!disposed) error.value=message(e)} finally {if(!disposed) busy.value=false}
|
|
79
|
-
}
|
|
80
57
|
watch(login.subject, (subject, previous) => {
|
|
81
|
-
if(!subject) {app.value=undefined;
|
|
58
|
+
if(!subject) {app.value=undefined;error.value=''}
|
|
82
59
|
else if((!previous || previous.user!==subject.user)) void refresh()
|
|
83
60
|
})
|
|
84
61
|
async function signOut() {
|
|
@@ -90,7 +67,7 @@ onMounted(()=>{
|
|
|
90
67
|
void login.start()
|
|
91
68
|
void refresh()
|
|
92
69
|
})
|
|
93
|
-
onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose()
|
|
70
|
+
onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose()})
|
|
94
71
|
</script>
|
|
95
72
|
|
|
96
73
|
<template>
|
|
@@ -100,7 +77,6 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
|
|
|
100
77
|
<ExecutorGate :ctx="ctx" :admin="ctx.user?.isAdmin ?? !!app?.admin">
|
|
101
78
|
<div class="integration-app" :aria-busy="busy" :aria-label="copy.settings">
|
|
102
79
|
<Callout v-if="error" tone="danger" :title="copy.actionRequired"><span role="alert">{{ error }}</span></Callout>
|
|
103
|
-
<p v-if="notice" role="status">{{ notice }}</p>
|
|
104
80
|
<div data-page="status" v-show="page==='status'">
|
|
105
81
|
<SettingsSection :title="copy.statusTitle" :collapsible="false" :loading="busy && !app">
|
|
106
82
|
<div v-if="app" data-app-status>
|
|
@@ -112,11 +88,6 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
|
|
|
112
88
|
</SettingsSection>
|
|
113
89
|
<PortalSettings v-if="ctx.portal && app" :key="portalKey" :portal="ctx.portal" />
|
|
114
90
|
<SharedFolderSettings v-if="app?.admin" :key="portalKey" :shared="sharedFolders" :locale="ctx.locale" />
|
|
115
|
-
<SettingsSection v-if="app?.admin" :title="copy.greeting" :collapsible="false">
|
|
116
|
-
<Form direction="vertical" @submit.prevent="save"><FormItem :label="copy.greeting"><Textarea v-model="text" :disabled="busy" /></FormItem>
|
|
117
|
-
<FormActions><Button variant="primary" :disabled="busy || !text.trim()" @click="save">{{ copy.save }}</Button></FormActions>
|
|
118
|
-
</Form>
|
|
119
|
-
</SettingsSection>
|
|
120
91
|
</div>
|
|
121
92
|
<CapabilityPanels v-if="app" ref="panels" :key="app.user" :ctx="ctx" :admin="app.admin" />
|
|
122
93
|
<FilesPage v-if="page==='status'" :ctx="ctx" />
|
|
@@ -9,8 +9,6 @@ function fixture(id:string){
|
|
|
9
9
|
const reopen:Array<()=>void>=[]
|
|
10
10
|
const call=vi.fn(async(method:string)=>{
|
|
11
11
|
if(method==='app.status')return {package:id,version:'1',user:'admin',admin:true}
|
|
12
|
-
if(method==='greeting.get')return {text:'Hello '+id}
|
|
13
|
-
if(method==='greeting.set')return {}
|
|
14
12
|
throw {code:'unknown_method'}
|
|
15
13
|
})
|
|
16
14
|
const ctx=dsmWindow({call,reopen(fn:()=>void){reopen.push(fn);return ()=>{}}})
|
|
@@ -18,15 +16,14 @@ function fixture(id:string){
|
|
|
18
16
|
const app=mount(root,ctx)
|
|
19
17
|
return {root,app,call,reopen,ctx,close(){app.unmount();root.remove()}}
|
|
20
18
|
}
|
|
21
|
-
test('independent mounts load
|
|
19
|
+
test('independent mounts load only their own package status and no sample operations',async()=>{
|
|
22
20
|
const a=fixture('Alpha'),b=fixture('Beta')
|
|
23
21
|
try{
|
|
24
22
|
await settle()
|
|
25
23
|
expect(a.root.textContent).toContain('Alpha');expect(a.root.textContent).not.toContain('Beta')
|
|
26
|
-
expect(
|
|
27
|
-
|
|
28
|
-
expect(
|
|
29
|
-
expect(b.call.mock.calls.some(([method])=>method==='greeting.set')).toBe(false)
|
|
24
|
+
expect(a.root.querySelector('textarea')).toBeNull()
|
|
25
|
+
expect(a.call.mock.calls.some(([method])=>method.startsWith('greeting.'))).toBe(false)
|
|
26
|
+
expect(b.call.mock.calls.some(([method])=>method.startsWith('greeting.'))).toBe(false)
|
|
30
27
|
const count=b.call.mock.calls.length;a.reopen.forEach(fn=>fn());await settle()
|
|
31
28
|
expect(b.call.mock.calls).toHaveLength(count)
|
|
32
29
|
a.app.unmount();const stopped=a.call.mock.calls.length;a.reopen.forEach(fn=>fn());await settle()
|
|
@@ -76,7 +73,6 @@ test('the app renders and loads status while the session check is still pending'
|
|
|
76
73
|
const call=vi.fn(async(method:string)=>{
|
|
77
74
|
if(method==='identity.status')return new Promise(()=>{})
|
|
78
75
|
if(method==='app.status')return {package:'Eager',version:'1',user:'admin',admin:true}
|
|
79
|
-
if(method==='greeting.get')return {text:'Hello Eager'}
|
|
80
76
|
throw {code:'unknown_method'}
|
|
81
77
|
})
|
|
82
78
|
const ctx=dsmWindow({call})
|
|
@@ -88,17 +84,17 @@ test('the app renders and loads status while the session check is still pending'
|
|
|
88
84
|
expect([...root.querySelectorAll('[data-app-status] [data-app-value]')].map(node=>node.textContent)).toEqual(['Eager','admin','1'])
|
|
89
85
|
}finally{app.unmount();root.remove()}
|
|
90
86
|
})
|
|
91
|
-
test('an administrator
|
|
87
|
+
test('an administrator starts the real status read without sample calls',async()=>{
|
|
92
88
|
const call=vi.fn(async(method:string)=>{
|
|
93
89
|
if(method==='app.status')return new Promise(()=>{})
|
|
94
|
-
if(method==='greeting.get')return {text:'Hello Early'}
|
|
95
90
|
throw {code:'unknown_method'}
|
|
96
91
|
})
|
|
97
92
|
const root=document.createElement('div');document.body.append(root)
|
|
98
93
|
const app=mount(root,dsmWindow({call,user:{name:'admin',isAdmin:true}}))
|
|
99
94
|
try{
|
|
100
95
|
await settle()
|
|
101
|
-
expect(call.mock.calls.map(([method])=>method)).toContain('
|
|
96
|
+
expect(call.mock.calls.map(([method])=>method)).toContain('app.status')
|
|
97
|
+
expect(call.mock.calls.some(([method])=>method.startsWith('greeting.'))).toBe(false)
|
|
102
98
|
}finally{app.unmount();root.remove()}
|
|
103
99
|
})
|
|
104
100
|
test('a status request that raced the session check is retried once the session is confirmed',async()=>{
|
|
@@ -110,7 +106,6 @@ test('a status request that raced the session check is retried once the session
|
|
|
110
106
|
if(!session)return new Promise((_,reject)=>{rejectEarly=reject})
|
|
111
107
|
return {package:'Raced',version:'1',user:'admin',admin:true}
|
|
112
108
|
}
|
|
113
|
-
if(method==='greeting.get')return {text:'Hello Raced'}
|
|
114
109
|
throw {code:'unknown_method'}
|
|
115
110
|
})
|
|
116
111
|
const ctx=dsmWindow({call})
|
|
@@ -162,7 +157,6 @@ test('the status page follows the UI layout rules',async()=>{
|
|
|
162
157
|
let answer:(value:unknown)=>void=()=>{}
|
|
163
158
|
const call=vi.fn(async(method:string)=>{
|
|
164
159
|
if(method==='app.status')return new Promise(resolve=>{answer=resolve})
|
|
165
|
-
if(method==='greeting.get')return {text:'Hello'}
|
|
166
160
|
throw {code:'unknown_method'}
|
|
167
161
|
})
|
|
168
162
|
const root=document.createElement('div');document.body.append(root)
|
|
@@ -175,15 +169,15 @@ test('the status page follows the UI layout rules',async()=>{
|
|
|
175
169
|
expect(root.textContent).not.toContain('Loading package data')
|
|
176
170
|
answer({package:'Pkg',version:'1',user:'admin',admin:true});await settle()
|
|
177
171
|
expect(root.querySelector('[data-ui-skeleton-line]')).toBeNull()
|
|
178
|
-
|
|
179
|
-
expect(root.querySelector('textarea')
|
|
172
|
+
expect([...root.querySelectorAll('[data-app-status] [data-app-value]')].map(node=>node.textContent)).toEqual(['Pkg','admin','1'])
|
|
173
|
+
expect(root.querySelector('textarea')).toBeNull()
|
|
180
174
|
}finally{app.unmount();root.remove()}
|
|
181
175
|
})
|
|
182
176
|
test('a code the bridge runtime adds shows the SDK text in the DSM language',async()=>{
|
|
183
177
|
const f=fixture('Bridge')
|
|
184
178
|
try{
|
|
185
179
|
await settle();f.call.mockRejectedValue({code:'app_unavailable'});f.reopen.forEach(fn=>fn());await settle()
|
|
186
|
-
expect(f.root.textContent).toContain('The App is not available right now. Try again later
|
|
180
|
+
expect(f.root.textContent).toContain('The App is not available right now. Try again later')
|
|
187
181
|
}finally{f.close()}
|
|
188
182
|
})
|
|
189
183
|
test('a Portal or standalone launch lays the App out full-page; a DSM window keeps the window layout',async()=>{
|
|
@@ -237,7 +231,6 @@ test('an administrator sees a pending Shared Folder with the grant step; a plain
|
|
|
237
231
|
const folder={key:'shared',name:'Media',exists:true,required:'write',granted:'read',state:'pending'}
|
|
238
232
|
const call=vi.fn(async(method:string)=>{
|
|
239
233
|
if(method==='app.status')return {package:'Share',version:'1',user:'admin',admin:true}
|
|
240
|
-
if(method==='greeting.get')return {text:'Hi'}
|
|
241
234
|
if(method==='sharedfolder.status')return {account:'Share',folders:[folder]}
|
|
242
235
|
throw {code:'unknown_method'}
|
|
243
236
|
})
|
|
@@ -253,15 +246,16 @@ test('an administrator sees a pending Shared Folder with the grant step; a plain
|
|
|
253
246
|
}finally{app.unmount();root.remove();plain.close()}
|
|
254
247
|
})
|
|
255
248
|
|
|
256
|
-
test('
|
|
249
|
+
test('status-only startup never schedules a sample batch', async () => {
|
|
257
250
|
const calls: string[]=[]
|
|
258
|
-
const batch=vi.fn(async () => [
|
|
259
|
-
const ctx=dsmWindow({user:{name:'admin',isAdmin:true},batch:batch as NonNullable<Context['batch']>,call:async <T,>(method:string)=>{calls.push(method);if(method==='identity.status')return {enabled:false} as T;throw {code:'unknown_method'}}})
|
|
251
|
+
const batch=vi.fn(async () => [])
|
|
252
|
+
const ctx=dsmWindow({user:{name:'admin',isAdmin:true},batch:batch as NonNullable<Context['batch']>,call:async <T,>(method:string)=>{calls.push(method);if(method==='identity.status')return {enabled:false} as T;if(method==='app.status')return {package:'Status',version:'1',user:'admin',admin:true} as T;throw {code:'unknown_method'}}})
|
|
260
253
|
const root=document.createElement('div');document.body.append(root)
|
|
261
254
|
const app=mount(root,ctx)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
255
|
+
try{
|
|
256
|
+
await settle()
|
|
257
|
+
expect(batch).not.toHaveBeenCalled()
|
|
258
|
+
expect(calls).toContain('app.status');expect(calls.some(method=>method.startsWith('greeting.'))).toBe(false)
|
|
259
|
+
expect(root.querySelector('textarea')).toBeNull()
|
|
260
|
+
}finally{app.unmount();root.remove()}
|
|
267
261
|
})
|
|
@@ -14,7 +14,10 @@ test.each(['select','option','textarea','label','input','a'])('no vue template r
|
|
|
14
14
|
// inside a FormItem binds its id/ARIA by itself.
|
|
15
15
|
test('FormItem labels sit above their controls and the UI binds the control id',()=>{
|
|
16
16
|
const items=sources(root).filter(file=>uses(file,'FormItem'))
|
|
17
|
-
|
|
17
|
+
// The base status page has no sample form; capability forms still obey these rules.
|
|
18
|
+
const app=template(join(root,'App.vue'))
|
|
19
|
+
expect(app).toContain('<KeyValueRow')
|
|
20
|
+
expect(app).not.toMatch(/<(?:Form|FormItem|Textarea)[\s>]/)
|
|
18
21
|
expect(items.filter(file=>/<Form(?![^>]*direction="vertical")[\s>]|field\.controlId|#default="\{field\}"/.test(template(file)))).toEqual([])
|
|
19
22
|
})
|
|
20
23
|
// UI built-in text follows the Provider locale (nine locales), so every app root sets it once and no
|
|
@@ -16,16 +16,18 @@ function start(locale:string,call:(method:string)=>Promise<unknown>){
|
|
|
16
16
|
}
|
|
17
17
|
test('the status page and its errors follow the DSM language',async()=>{
|
|
18
18
|
const copy=appCopy('ger')
|
|
19
|
+
let denied=false
|
|
19
20
|
const {root,close}=start('de',async method=>{
|
|
20
|
-
if(method==='app.status')return {package:'Pkg',version:'1',user:'admin',admin:true}
|
|
21
|
-
if(method==='greeting.get')throw {code:'admin_required'}
|
|
21
|
+
if(method==='app.status'){if(denied)throw {code:'admin_required'};return {package:'Pkg',version:'1',user:'admin',admin:true}}
|
|
22
22
|
throw {code:'unknown_method'}
|
|
23
23
|
})
|
|
24
24
|
try{
|
|
25
25
|
await settle()
|
|
26
26
|
expect(buttons(root)).toContain(copy.refresh)
|
|
27
27
|
expect(root.textContent).toContain(copy.statusTitle)
|
|
28
|
-
expect(root.textContent).toContain(copy.
|
|
28
|
+
expect(root.textContent).toContain(copy.packageName)
|
|
29
|
+
expect(root.textContent).not.toContain(copy.greeting)
|
|
30
|
+
denied=true;[...root.querySelectorAll('button')].find(button=>button.textContent?.trim()===copy.refresh)!.click();await settle()
|
|
29
31
|
expect(root.textContent).toContain(copy.errors.admin_required)
|
|
30
32
|
expect(root.querySelector('[aria-label]')?.getAttribute('aria-label')).not.toBe('Package settings')
|
|
31
33
|
}finally{close()}
|
|
@@ -6,7 +6,7 @@ runs as its own DSM package account, needs no containers, and has no root by def
|
|
|
6
6
|
|
|
7
7
|
```text
|
|
8
8
|
DSM main menu → DSM window (ui/app.js)
|
|
9
|
-
│ ctx.call("
|
|
9
|
+
│ ctx.call("app.status") same-origin POST + DSM session
|
|
10
10
|
▼
|
|
11
11
|
/webman/3rdparty/__PACKAGE__/cgi/api.cgi (the SDK runtime, runs as package user)
|
|
12
12
|
1. transport guards: POST, JSON, same origin, ≤ 1 MiB, bounded JSON complexity
|
|
@@ -17,7 +17,7 @@ DSM main menu → DSM window (ui/app.js)
|
|
|
17
17
|
|
|
18
18
|
## Requests and long work
|
|
19
19
|
|
|
20
|
-
SDK/App 0.
|
|
20
|
+
SDK/App 0.47.0 defaults to 300-second requests and a 302-second browser wait.
|
|
21
21
|
Explicit `backend.timeout_seconds` and earlier parent deadlines still apply;
|
|
22
22
|
rebuild the App to use the new SDK. For long NAS work, enable the existing jobs
|
|
23
23
|
capability: return admission and the original job ID promptly, execute in the
|
|
@@ -27,7 +27,7 @@ last queued/running state intact. After a lost admission reply, reuse the same
|
|
|
27
27
|
idempotency key and input instead of replaying a mutation with a new key.
|
|
28
28
|
Jobs default to 30 minutes; approved executor limits remain separate. ctl keeps
|
|
29
29
|
its 62-second transport default; use an explicit `--timeout` or an existing
|
|
30
|
-
nonblocking job. See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.
|
|
30
|
+
nonblocking job. See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.47.0/docs/request-deadlines.md).
|
|
31
31
|
|
|
32
32
|
## Quick start
|
|
33
33
|
|
|
@@ -35,6 +35,8 @@ Needs Node.js 20 or later with `synopkgland` installed. The SPK depends on DSM's
|
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
(cd backend && npm ci && npm test && npm run build) # method tests, then app/*.js
|
|
38
|
+
synopkgland ctl generate
|
|
39
|
+
synopkgland check
|
|
38
40
|
synopkgland build # → dist/__PACKAGE__-0.1.0-0001-{x86_64,aarch64}.spk
|
|
39
41
|
synopkgland inspect dist/__PACKAGE__-0.1.0-0001-x86_64.spk
|
|
40
42
|
```
|
|
@@ -44,8 +46,7 @@ Run `npm run build` in `backend` before every `synopkgland check` or `build`: th
|
|
|
44
46
|
Install: Package Center → Settings → General → Trust Level "Any publisher",
|
|
45
47
|
then Manual Install with the SPK for your NAS's CPU. Open
|
|
46
48
|
**__DISPLAY_NAME__** from the DSM main menu: it shows who you are signed in
|
|
47
|
-
as
|
|
48
|
-
greeting in the package's data folder.
|
|
49
|
+
as and what the backend reports.
|
|
49
50
|
|
|
50
51
|
## Project layout
|
|
51
52
|
|
|
@@ -58,7 +59,7 @@ greeting in the package's data folder.
|
|
|
58
59
|
| `backend/src/routes.ts` | Your methods: `build` registers them | Yes — your backend |
|
|
59
60
|
| `backend/src/jobs.ts`, `root-ops.ts`, `delegate.ts` | Capability examples (stubs unless `init --with` added them) | Yes |
|
|
60
61
|
| `backend/src/main.ts` | Runs the App on the runtime's channel | Rarely |
|
|
61
|
-
| `backend/app/` | `npm run build` output (`*.js`, ignored by git), `package.json` and `permissions.json`: the directory the SPK ships |
|
|
62
|
+
| `backend/app/` | `npm run build` output (`*.js`, ignored by git), `package.json` and `permissions.json`: the directory the SPK ships | No (generated) |
|
|
62
63
|
| `backend/test/` | node:test tests; `call` plays the runtime | Yes |
|
|
63
64
|
| `backend/package.json`, `package-lock.json` | TypeScript (NodeNext) and the Node SDK `@synopackageland/app` from `backend/vendor`, which `synopkgland init` copies from the SDK release | Dependencies |
|
|
64
65
|
| `dist/` | Built SPKs (ignored by git) | No |
|
|
@@ -70,7 +71,8 @@ runtime CGI at `ui/cgi/api.cgi`.
|
|
|
70
71
|
## Add a method
|
|
71
72
|
|
|
72
73
|
Register it in `build`. Every method defaults to DSM administrator membership;
|
|
73
|
-
|
|
74
|
+
declare metadata and access rules in `operations.json`; `ctl generate` writes
|
|
75
|
+
`backend/app/permissions.json`:
|
|
74
76
|
|
|
75
77
|
```ts
|
|
76
78
|
export function build(cfg: Config): App {
|
|
@@ -182,3 +184,21 @@ bounded private copy for the worker; no browser session or NAS picker is used.
|
|
|
182
184
|
Submit with `hash.submit`; use `hash.get` for progress and `hash.cancel` to
|
|
183
185
|
cancel. These handlers call the existing `bg.submit`, `bg.get` and `bg.cancel`
|
|
184
186
|
services; the runtime checks the input path and binds ownership to the caller.
|
|
187
|
+
|
|
188
|
+
## Operation registry
|
|
189
|
+
|
|
190
|
+
Declare each App method once in `operations.json`, including its English summary,
|
|
191
|
+
params/result schemas, effect, permission, flow and required `ai` class. The
|
|
192
|
+
SDK contributes its own hidden operation catalog for enabled capability routes.
|
|
193
|
+
Register the handler, then run `synopkgland ctl generate`, `synopkgland ctl
|
|
194
|
+
generate --check` and `synopkgland check`. Build refuses generated drift or a
|
|
195
|
+
route/spec mismatch. Never maintain commands or operation descriptions in the
|
|
196
|
+
manifest or skill by hand. Read the generated `generated/operations.md`.
|
|
197
|
+
|
|
198
|
+
`examples/operations.json` illustrates read, preview, draft, human-only and hidden.
|
|
199
|
+
It is documentation only: its operations have no handlers and are never shipped
|
|
200
|
+
as commands. Copy a declaration only when implementing a real App capability.
|
|
201
|
+
Draft writes require a non-mutating preview and explicit human confirmation.
|
|
202
|
+
Long operations use the existing job flow; tools own polling and pagination.
|
|
203
|
+
|
|
204
|
+
Use a matching SDK/CLI/templates release containing the registry support.
|