@synopackageland/templates 0.38.1 → 0.39.0

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 (62) 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/operations.json +27 -0
  19. package/templates/native-go/ui/app.js +0 -25
  20. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +3 -4
  21. package/templates/native-go-root-ops/frontend/test/executor-gate.test.ts +7 -6
  22. package/templates/native-go-vue/frontend/package-lock.json +4 -4
  23. package/templates/native-go-vue/frontend/package.json +1 -1
  24. package/templates/native-go-vue/frontend/src/App.vue +5 -34
  25. package/templates/native-go-vue/frontend/test/apps.test.ts +19 -25
  26. package/templates/native-go-vue/frontend/test/native-controls.test.ts +4 -1
  27. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +5 -3
  28. package/templates/native-node/README.md +27 -7
  29. package/templates/native-node/backend/app/permissions.json +3 -1
  30. package/templates/native-node/backend/package-lock.json +1 -1
  31. package/templates/native-node/backend/src/routes.ts +5 -43
  32. package/templates/native-node/backend/test/routes.test.ts +3 -23
  33. package/templates/native-node/dsm-package.yaml +3 -29
  34. package/templates/native-node/examples/operations.json +112 -0
  35. package/templates/native-node/operations.json +27 -0
  36. package/templates/native-node-delegate/operations.patch.json +20 -0
  37. package/templates/native-node-jobs/operations.patch.json +52 -0
  38. package/templates/native-node-root-ops/operations.patch.json +20 -0
  39. package/templates/native-python/README.md +27 -7
  40. package/templates/native-python/backend/app/permissions.json +3 -1
  41. package/templates/native-python/backend/app/routes.py +2 -41
  42. package/templates/native-python/backend/tests/test_routes.py +3 -19
  43. package/templates/native-python/dsm-package.yaml +3 -29
  44. package/templates/native-python/examples/operations.json +112 -0
  45. package/templates/native-python/operations.json +27 -0
  46. package/templates/native-python-delegate/operations.patch.json +20 -0
  47. package/templates/native-python-jobs/operations.patch.json +52 -0
  48. package/templates/native-python-root-ops/operations.patch.json +20 -0
  49. package/templates/native-rust/README.md +28 -8
  50. package/templates/native-rust/backend/Cargo.lock +2 -2
  51. package/templates/native-rust/backend/Cargo.toml +1 -1
  52. package/templates/native-rust/backend/permissions.json +3 -1
  53. package/templates/native-rust/backend/src/routes.rs +11 -149
  54. package/templates/native-rust/dsm-package.yaml +3 -29
  55. package/templates/native-rust/examples/operations.json +112 -0
  56. package/templates/native-rust/operations.json +27 -0
  57. package/templates/native-rust-delegate/backend/Cargo.lock +2 -2
  58. package/templates/native-rust-delegate/backend/Cargo.toml +1 -1
  59. package/templates/native-rust-delegate/backend/src/delegate.rs +7 -1
  60. package/templates/native-rust-delegate/operations.patch.json +20 -0
  61. package/templates/native-rust-jobs/operations.patch.json +52 -0
  62. package/templates/native-rust-root-ops/operations.patch.json +20 -0
@@ -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"
@@ -1,14 +1,10 @@
1
- // The App's methods. Register a method in build() and give it a rule in
2
- // app/permissions.json unless only administrators may call it: the runtime
3
- // has checked the DSM session, the entry gate and that rule before a handler
4
- // runs.
5
- import { randomUUID } from 'node:crypto'
6
- import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
7
- import { dirname, join } from 'node:path'
8
- import { App, AppError, type Context } from '@synopackageland/app'
1
+ // Register real handlers here; operations.json owns metadata and permissions.
2
+ // Run ctl generate and ctl generate --check after adding a handler.
3
+ import { readFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { App, type Context } from '@synopackageland/app'
9
6
  import * as delegate from './delegate.js'
10
7
  import * as jobs from './jobs.js'
11
- import { strings } from './params.js'
12
8
  import * as rootOps from './root-ops.js'
13
9
 
14
10
  /** Where the App's state lives; the runtime sets the environment. */
@@ -27,8 +23,6 @@ export function configFromEnv(): Config {
27
23
  export function build(cfg: Config): App {
28
24
  const app = new App()
29
25
  app.method('app.status', (ctx) => status(cfg, ctx))
30
- app.method('greeting.get', () => getGreeting(cfg))
31
- app.method('greeting.set', (ctx, params) => setGreeting(cfg, ctx, params))
32
26
  // Each capability module is a stub until `synopkgland init --with` adds its example.
33
27
  for (const capability of [jobs, rootOps, delegate]) capability.register(app, cfg)
34
28
  return app
@@ -63,35 +57,3 @@ function installedVersion(cfg: Config): string {
63
57
  return ''
64
58
  }
65
59
  }
66
-
67
- const greetingPath = (cfg: Config) => join(cfg.dataDir, 'greeting.json')
68
-
69
- function getGreeting(cfg: Config): unknown {
70
- try {
71
- return JSON.parse(readFileSync(greetingPath(cfg), 'utf8'))
72
- } catch (e) {
73
- if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { text: 'Hello' }
74
- throw e
75
- }
76
- }
77
-
78
- function setGreeting(cfg: Config, ctx: Context, params: unknown) {
79
- const { text } = strings(params, 'text')
80
- const length = [...text].length
81
- if (length < 1 || length > 200) throw AppError.badRequest('text must be 1 to 200 characters')
82
- const greeting = { text, updated_by: ctx.user }
83
- writeFileAtomic(greetingPath(cfg), JSON.stringify(greeting))
84
- return greeting
85
- }
86
-
87
- /** Replaces path in one rename, so readers never see half a file. */
88
- export function writeFileAtomic(path: string, data: string): void {
89
- const tmp = join(dirname(path), `.tmp-${randomUUID()}`)
90
- try {
91
- writeFileSync(tmp, data, { flag: 'wx' })
92
- renameSync(tmp, path)
93
- } catch (e) {
94
- try { unlinkSync(tmp) } catch { /* never written */ }
95
- throw e
96
- }
97
- }
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict'
2
- import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { afterEach, beforeEach, test } from 'node:test'
@@ -50,26 +50,6 @@ test('app.status without a portal leaves it out', async () => {
50
50
  assert.equal('portal' in reply.data, false)
51
51
  })
52
52
 
53
- test('greeting.get defaults to Hello', async () => {
54
- assert.deepEqual((await call(build(cfg), 'greeting.get')).data, { text: 'Hello' })
55
- })
56
-
57
- test('greeting.set stores the text and who set it', async () => {
58
- const app = build(cfg)
59
- const reply = await call(app, 'greeting.set', { text: 'Hi 👋' }, { user: 'alice' })
60
- assert.deepEqual(reply.data, { text: 'Hi 👋', updated_by: 'alice' })
61
- assert.deepEqual((await call(app, 'greeting.get')).data, { text: 'Hi 👋', updated_by: 'alice' })
62
- assert.deepEqual(readdirSync(cfg.dataDir), ['greeting.json'])
63
- })
64
-
65
- test('greeting.set counts characters, not UTF-16 units', async () => {
66
- assert.equal((await call(build(cfg), 'greeting.set', { text: '👋'.repeat(200) })).ok, true)
67
- })
68
-
69
- test('greeting.set refuses invalid params and keeps the old text', async () => {
70
- for (const params of [null, [], {}, { text: '' }, { text: 'x'.repeat(201) }, { text: 5 }, { text: 'hi', extra: 1 }]) {
71
- const reply = await call(build(cfg), 'greeting.set', params)
72
- assert.equal(reply.error?.code, 'bad_request', JSON.stringify(params))
73
- }
74
- assert.deepEqual(readdirSync(cfg.dataDir), [])
53
+ test('sample operations are absent', async () => {
54
+ for(const method of ['greeting.get', 'greeting.set']) assert.equal((await call(build(cfg), method)).ok, false)
75
55
  })
@@ -49,7 +49,7 @@ backend:
49
49
  runtime: node
50
50
  dir: backend/app
51
51
  entry: main.js
52
- sdk: 0.43.0
52
+ sdk: 0.47.0
53
53
  node: { package: Node.js_v22 }
54
54
 
55
55
  ui:
@@ -94,33 +94,7 @@ capabilities: {}
94
94
  # backup:
95
95
  # hyper_backup: false
96
96
 
97
- # Explicit AI commands. Internal/UI methods are not automatically exposed.
97
+ # Operation metadata has one source. Generate/check before building.
98
98
  ctl:
99
99
  enabled: true
100
- commands:
101
- status.show:
102
- cli: [status]
103
- summary: Show this App's status and verified caller
104
- effect: read
105
- flow: {type: invoke, method: app.status}
106
- paramsSchema: {type: object, additionalProperties: false}
107
- examples: [{}]
108
- greeting.get:
109
- cli: [greeting, get]
110
- summary: Read the greeting
111
- effect: read
112
- flow: {type: invoke, method: greeting.get}
113
- paramsSchema: {type: object, additionalProperties: false}
114
- examples: [{}]
115
- greeting.set:
116
- cli: [greeting, set]
117
- summary: Set the greeting
118
- effect: write
119
- flow: {type: invoke, method: greeting.set}
120
- paramsSchema:
121
- type: object
122
- required: [text]
123
- additionalProperties: false
124
- properties:
125
- text: {type: string, minLength: 1, maxLength: 200, description: Greeting text, examples: [Hello]}
126
- examples: [{text: Hello}]
100
+ registry: operations.json