@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.
Files changed (72) hide show
  1. package/README.md +6 -7
  2. package/package.json +1 -1
  3. package/templates/_shared/AGENTS.md +6 -5
  4. package/templates/_shared/CLAUDE.md +2 -0
  5. package/templates/compose/dsm-package.yaml +1 -1
  6. package/templates/native-go/README.md +28 -11
  7. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
  8. package/templates/native-go/backend/cmd/app/main.go +9 -2
  9. package/templates/native-go/backend/go.mod +1 -1
  10. package/templates/native-go/backend/go.sum +2 -2
  11. package/templates/native-go/backend/internal/api/operations.json +5 -0
  12. package/templates/native-go/backend/internal/api/permissions.go +7 -0
  13. package/templates/native-go/backend/internal/api/permissions.json +3 -1
  14. package/templates/native-go/backend/internal/app/routes.go +2 -46
  15. package/templates/native-go/backend/internal/app/routes_test.go +4 -25
  16. package/templates/native-go/dsm-package.yaml +2 -28
  17. package/templates/native-go/examples/operations.json +112 -0
  18. package/templates/native-go/i18n/glossary.json +2 -6
  19. package/templates/native-go/i18n/messages/de.json +0 -5
  20. package/templates/native-go/i18n/messages/en.json +0 -5
  21. package/templates/native-go/i18n/messages/es.json +0 -5
  22. package/templates/native-go/i18n/messages/fr.json +0 -5
  23. package/templates/native-go/i18n/messages/it.json +0 -5
  24. package/templates/native-go/i18n/messages/ja.json +0 -5
  25. package/templates/native-go/i18n/messages/ko.json +0 -5
  26. package/templates/native-go/i18n/messages/zh-CN.json +0 -5
  27. package/templates/native-go/i18n/messages/zh-TW.json +0 -5
  28. package/templates/native-go/operations.json +27 -0
  29. package/templates/native-go/ui/app.js +0 -25
  30. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +3 -4
  31. package/templates/native-go-root-ops/frontend/test/executor-gate.test.ts +7 -6
  32. package/templates/native-go-vue/frontend/package-lock.json +4 -4
  33. package/templates/native-go-vue/frontend/package.json +1 -1
  34. package/templates/native-go-vue/frontend/src/App.vue +5 -34
  35. package/templates/native-go-vue/frontend/test/apps.test.ts +19 -25
  36. package/templates/native-go-vue/frontend/test/native-controls.test.ts +4 -1
  37. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +5 -3
  38. package/templates/native-node/README.md +27 -7
  39. package/templates/native-node/backend/app/permissions.json +3 -1
  40. package/templates/native-node/backend/package-lock.json +1 -1
  41. package/templates/native-node/backend/src/routes.ts +5 -43
  42. package/templates/native-node/backend/test/routes.test.ts +3 -23
  43. package/templates/native-node/dsm-package.yaml +3 -29
  44. package/templates/native-node/examples/operations.json +112 -0
  45. package/templates/native-node/operations.json +27 -0
  46. package/templates/native-node-delegate/operations.patch.json +20 -0
  47. package/templates/native-node-jobs/operations.patch.json +52 -0
  48. package/templates/native-node-root-ops/operations.patch.json +20 -0
  49. package/templates/native-python/README.md +27 -7
  50. package/templates/native-python/backend/app/permissions.json +3 -1
  51. package/templates/native-python/backend/app/routes.py +2 -41
  52. package/templates/native-python/backend/tests/test_routes.py +3 -19
  53. package/templates/native-python/dsm-package.yaml +3 -29
  54. package/templates/native-python/examples/operations.json +112 -0
  55. package/templates/native-python/operations.json +27 -0
  56. package/templates/native-python-delegate/operations.patch.json +20 -0
  57. package/templates/native-python-jobs/operations.patch.json +52 -0
  58. package/templates/native-python-root-ops/operations.patch.json +20 -0
  59. package/templates/native-rust/README.md +28 -8
  60. package/templates/native-rust/backend/Cargo.lock +2 -2
  61. package/templates/native-rust/backend/Cargo.toml +1 -1
  62. package/templates/native-rust/backend/permissions.json +3 -1
  63. package/templates/native-rust/backend/src/routes.rs +11 -149
  64. package/templates/native-rust/dsm-package.yaml +3 -29
  65. package/templates/native-rust/examples/operations.json +112 -0
  66. package/templates/native-rust/operations.json +27 -0
  67. package/templates/native-rust-delegate/backend/Cargo.lock +2 -2
  68. package/templates/native-rust-delegate/backend/Cargo.toml +1 -1
  69. package/templates/native-rust-delegate/backend/src/delegate.rs +7 -1
  70. package/templates/native-rust-delegate/operations.patch.json +20 -0
  71. package/templates/native-rust-jobs/operations.patch.json +52 -0
  72. 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((root.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Hello')
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((root.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Hello Root')
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((root.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Hello Root')
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.43.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.43.0",
1181
- "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.43.0.tgz",
1182
- "integrity": "sha512-i/HbHkk50SEbzs1rNS7IEuS3ydLK3cMZb9wJqr6aGOjNROiGTy3nBo9MkpKmXYoksTuYkBt94gSP3Q7DfKFHBw==",
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",
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@lucide/vue": "1.48.0",
11
- "@synopackageland/sdk": "0.43.0",
11
+ "@synopackageland/sdk": "0.47.0",
12
12
  "@synopackageland/ui": "0.19.1",
13
13
  "vue": "3.5.43"
14
14
  },
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
3
- import {Button, Callout, Form, FormActions, FormItem, KeyValueRow, SettingsSection, Textarea} from '@synopackageland/ui'
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
- if(startupBatch) {
52
- const batch = early ? startupBatch : login.batch
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;text.value='';notice.value='';error.value=''}
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();text.value=''})
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 and save their own package greeting',async()=>{
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((a.root.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Hello Alpha')
27
- ;[...a.root.querySelectorAll('button')].find(n=>n.textContent==='Save')!.click();await settle()
28
- expect(a.call).toHaveBeenCalledWith('greeting.set',{text:'Hello Alpha'})
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 DSM names up front has the greeting read beside the status',async()=>{
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('greeting.get')
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
- // Labels sit above their fields.
179
- expect(root.querySelector('textarea')!.closest('[data-form-item]')!.getAttribute('data-direction')).toBe('vertical')
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('batches startup reads for a known administrator', async () => {
249
+ test('status-only startup never schedules a sample batch', async () => {
257
250
  const calls: string[]=[]
258
- const batch=vi.fn(async () => [{success:true as const,data:{package:'Batch',version:'1',user:'admin',admin:true}},{success:true as const,data:{text:'Hello Batch'}}])
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
- await settle()
263
- expect(batch).toHaveBeenCalledWith([{method:'app.status'},{method:'greeting.get'}])
264
- expect(calls).not.toContain('app.status');expect(calls).not.toContain('greeting.get')
265
- expect((root.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Hello Batch')
266
- app.unmount();root.remove()
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
- expect(items.length).toBeGreaterThan(0)
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.greeting)
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("greeting.get") same-origin POST + DSM session
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.43.0 defaults to 300-second requests and a 302-second browser wait.
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.43.0/docs/request-deadlines.md).
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, what the backend reports, and (for administrators) a form that stores a
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 | Only `permissions.json` |
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
- all access rules live in `backend/app/permissions.json`:
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.
@@ -1,5 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "default": "administrators",
4
- "methods": {}
4
+ "methods": {
5
+ "app.status": "administrators"
6
+ }
5
7
  }
@@ -49,7 +49,7 @@
49
49
  "license": "MIT"
50
50
  },
51
51
  "vendor/@synopackageland/app": {
52
- "version": "0.43.0",
52
+ "version": "0.47.0",
53
53
  "license": "MIT",
54
54
  "engines": {
55
55
  "node": ">=22"