@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.
- 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/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
|
@@ -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.
|
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import {
|
|
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,
|
|
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('
|
|
54
|
-
assert.
|
|
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.
|
|
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
|
-
#
|
|
97
|
+
# Operation metadata has one source. Generate/check before building.
|
|
98
98
|
ctl:
|
|
99
99
|
enabled: true
|
|
100
|
-
|
|
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
|