@synopackageland/templates 0.9.8 → 0.10.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 (133) hide show
  1. package/README.md +27 -27
  2. package/package.json +2 -2
  3. package/templates/_shared/AGENTS.md +53 -15
  4. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
  5. package/templates/native-go/backend/cmd/app/main.go +15 -1
  6. package/templates/native-go/backend/cmd/app/main_test.go +33 -0
  7. package/templates/native-go/backend/go.mod +1 -1
  8. package/templates/native-go/backend/go.sum +2 -2
  9. package/templates/native-go/i18n/glossary.json +3 -1
  10. package/templates/native-go/i18n/messages/de.json +6 -0
  11. package/templates/native-go/i18n/messages/en.json +6 -0
  12. package/templates/native-go/i18n/messages/es.json +6 -0
  13. package/templates/native-go/i18n/messages/fr.json +6 -0
  14. package/templates/native-go/i18n/messages/it.json +6 -0
  15. package/templates/native-go/i18n/messages/ja.json +6 -0
  16. package/templates/native-go/i18n/messages/ko.json +6 -0
  17. package/templates/native-go/i18n/messages/zh-CN.json +6 -0
  18. package/templates/native-go/i18n/messages/zh-TW.json +6 -0
  19. package/templates/native-go/ui/app.js +2 -1
  20. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +3 -2
  21. package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
  22. package/templates/native-go-vue/README.md +19 -7
  23. package/templates/native-go-vue/frontend/package-lock.json +9 -9
  24. package/templates/native-go-vue/frontend/package.json +2 -2
  25. package/templates/native-go-vue/frontend/src/App.vue +31 -36
  26. package/templates/native-go-vue/frontend/test/apps.test.ts +65 -12
  27. package/templates/native-go-vue/frontend/test/context.ts +8 -0
  28. package/templates/native-go-vue/frontend/test/native-controls.test.ts +3 -4
  29. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +2 -2
  30. package/templates/native-node/README.md +153 -0
  31. package/templates/native-node/backend/app/package.json +4 -0
  32. package/templates/native-node/backend/app/permissions.json +5 -0
  33. package/templates/native-node/backend/package-lock.json +59 -0
  34. package/templates/native-node/backend/package.json +17 -0
  35. package/templates/native-node/backend/src/delegate.ts +5 -0
  36. package/templates/native-node/backend/src/jobs.ts +5 -0
  37. package/templates/native-node/backend/src/main.ts +4 -0
  38. package/templates/native-node/backend/src/params.ts +11 -0
  39. package/templates/native-node/backend/src/root-ops.ts +5 -0
  40. package/templates/native-node/backend/src/routes.ts +97 -0
  41. package/templates/native-node/backend/src/service.ts +22 -0
  42. package/templates/native-node/backend/test/routes.test.ts +75 -0
  43. package/templates/native-node/backend/test/support.ts +67 -0
  44. package/templates/native-node/backend/tsconfig.build.json +8 -0
  45. package/templates/native-node/backend/tsconfig.json +18 -0
  46. package/templates/native-node/dsm-package.yaml +80 -0
  47. package/templates/native-node/gitignore +4 -0
  48. package/templates/native-node-delegate/backend/src/delegate.ts +35 -0
  49. package/templates/native-node-delegate/backend/test/delegate.test.ts +51 -0
  50. package/templates/native-node-jobs/backend/src/jobs.ts +38 -0
  51. package/templates/native-node-jobs/backend/test/jobs.test.ts +73 -0
  52. package/templates/native-node-root-ops/backend/src/root-ops.ts +16 -0
  53. package/templates/native-node-root-ops/backend/test/root-ops.test.ts +23 -0
  54. package/templates/native-node-vue/README.md +56 -0
  55. package/templates/native-node-vue/gitignore +4 -0
  56. package/templates/native-python/README.md +150 -0
  57. package/templates/native-python/backend/app/delegate.py +5 -0
  58. package/templates/native-python/backend/app/jobs.py +5 -0
  59. package/templates/native-python/backend/app/main.py +5 -0
  60. package/templates/native-python/backend/app/params.py +10 -0
  61. package/templates/native-python/backend/app/permissions.json +5 -0
  62. package/templates/native-python/backend/app/root_ops.py +5 -0
  63. package/templates/native-python/backend/app/routes.py +107 -0
  64. package/templates/native-python/backend/tests/support.py +72 -0
  65. package/templates/native-python/backend/tests/test_routes.py +73 -0
  66. package/templates/native-python/dsm-package.yaml +77 -0
  67. package/templates/native-python/gitignore +2 -0
  68. package/templates/native-python-delegate/backend/app/delegate.py +42 -0
  69. package/templates/native-python-delegate/backend/tests/test_delegate.py +58 -0
  70. package/templates/native-python-jobs/backend/app/jobs.py +40 -0
  71. package/templates/native-python-jobs/backend/tests/test_jobs.py +72 -0
  72. package/templates/native-python-root-ops/backend/app/root_ops.py +18 -0
  73. package/templates/native-python-root-ops/backend/tests/test_root_ops.py +34 -0
  74. package/templates/native-python-vue/README.md +56 -0
  75. package/templates/native-python-vue/gitignore +3 -0
  76. package/templates/native-rust/README.md +74 -296
  77. package/templates/native-rust/backend/Cargo.lock +6 -6
  78. package/templates/native-rust/backend/Cargo.toml +2 -10
  79. package/templates/native-rust/backend/src/delegate.rs +6 -0
  80. package/templates/native-rust/backend/src/jobs.rs +6 -0
  81. package/templates/native-rust/backend/src/main.rs +12 -0
  82. package/templates/native-rust/backend/src/root_ops.rs +6 -0
  83. package/templates/native-rust/backend/src/routes.rs +352 -0
  84. package/templates/native-rust/backend/src/test_support.rs +91 -0
  85. package/templates/native-rust/dsm-package.yaml +10 -21
  86. package/templates/native-rust-delegate/backend/src/delegate.rs +171 -0
  87. package/templates/native-rust-jobs/backend/src/jobs.rs +240 -0
  88. package/templates/native-rust-root-ops/backend/src/root_ops.rs +83 -0
  89. package/templates/native-rust-vue/README.md +35 -34
  90. package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +0 -207
  91. package/templates/native-rust/backend/src/app/background/mod.rs +0 -424
  92. package/templates/native-rust/backend/src/app/background/owner.rs +0 -77
  93. package/templates/native-rust/backend/src/app/background/owner_tests.rs +0 -174
  94. package/templates/native-rust/backend/src/app/background/schedule.rs +0 -126
  95. package/templates/native-rust/backend/src/app/background/schedule_tests.rs +0 -108
  96. package/templates/native-rust/backend/src/app/background/tests.rs +0 -505
  97. package/templates/native-rust/backend/src/app/background/work.rs +0 -78
  98. package/templates/native-rust/backend/src/app/background/work_tests.rs +0 -123
  99. package/templates/native-rust/backend/src/app/background_routes.rs +0 -22
  100. package/templates/native-rust/backend/src/app/delegate_config.rs +0 -188
  101. package/templates/native-rust/backend/src/app/delegate_routes.rs +0 -22
  102. package/templates/native-rust/backend/src/app/identity.rs +0 -56
  103. package/templates/native-rust/backend/src/app/migrations.rs +0 -79
  104. package/templates/native-rust/backend/src/app/mod.rs +0 -30
  105. package/templates/native-rust/backend/src/app/portal.rs +0 -317
  106. package/templates/native-rust/backend/src/app/root_routes.rs +0 -23
  107. package/templates/native-rust/backend/src/app/routes.rs +0 -315
  108. package/templates/native-rust/backend/src/app/session_files.rs +0 -19
  109. package/templates/native-rust/backend/src/app/shared_folder.rs +0 -383
  110. package/templates/native-rust/backend/src/app/test_support.rs +0 -202
  111. package/templates/native-rust/backend/src/app/uninstall.rs +0 -38
  112. package/templates/native-rust/backend/src/bin/api.rs +0 -72
  113. package/templates/native-rust/backend/src/bin/background-worker.rs +0 -37
  114. package/templates/native-rust/backend/src/build_env.rs +0 -155
  115. package/templates/native-rust/backend/src/lib.rs +0 -6
  116. package/templates/native-rust/backend/src/permissions.rs +0 -54
  117. package/templates/native-rust/backend/src/process.rs +0 -91
  118. package/templates/native-rust/i18n/glossary.json +0 -663
  119. package/templates/native-rust/i18n/messages/de.json +0 -30
  120. package/templates/native-rust/i18n/messages/en.json +0 -30
  121. package/templates/native-rust/i18n/messages/es.json +0 -30
  122. package/templates/native-rust/i18n/messages/fr.json +0 -30
  123. package/templates/native-rust/i18n/messages/it.json +0 -30
  124. package/templates/native-rust/i18n/messages/ja.json +0 -30
  125. package/templates/native-rust/i18n/messages/ko.json +0 -30
  126. package/templates/native-rust/i18n/messages/zh-CN.json +0 -30
  127. package/templates/native-rust/i18n/messages/zh-TW.json +0 -30
  128. package/templates/native-rust/ui/app.css +0 -45
  129. package/templates/native-rust/ui/app.js +0 -111
  130. package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +0 -92
  131. package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +0 -52
  132. package/templates/native-rust-jobs/backend/src/app/background_routes.rs +0 -109
  133. package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +0 -475
@@ -0,0 +1,97 @@
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'
9
+ import * as delegate from './delegate.js'
10
+ import * as jobs from './jobs.js'
11
+ import { strings } from './params.js'
12
+ import * as rootOps from './root-ops.js'
13
+
14
+ /** Where the App's state lives; the runtime sets the environment. */
15
+ export interface Config {
16
+ packageId: string
17
+ /** The package's own data directory (/var/packages/<package>/var), kept across upgrades. */
18
+ dataDir: string
19
+ packagesRoot: string
20
+ }
21
+
22
+ export function configFromEnv(): Config {
23
+ const env = process.env
24
+ return { packageId: env.SYNOPKGLAND_PACKAGE ?? '', dataDir: env.SYNOPKGLAND_DATA_DIR ?? '', packagesRoot: '/var/packages' }
25
+ }
26
+
27
+ export function build(cfg: Config): App {
28
+ const app = new App()
29
+ 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
+ // Each capability module is a stub until `synopkgland init --with` adds its example.
33
+ for (const capability of [jobs, rootOps, delegate]) capability.register(app, cfg)
34
+ return app
35
+ }
36
+
37
+ async function status(cfg: Config, ctx: Context) {
38
+ const result: Record<string, unknown> = {
39
+ package: cfg.packageId,
40
+ version: installedVersion(cfg),
41
+ user: ctx.user,
42
+ admin: ctx.admin,
43
+ process: { uid: process.getuid!(), gid: process.getgid!() },
44
+ language: `Node.js ${process.versions.node}`,
45
+ platform: `${process.platform}/${process.arch}`,
46
+ time: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
47
+ }
48
+ try {
49
+ // Served only when dsm-package.yaml declares a portal.
50
+ result.portal = await ctx.runtime('portal.status')
51
+ } catch {
52
+ // No portal.
53
+ }
54
+ return result
55
+ }
56
+
57
+ /** The version in the package's INFO, or "" outside DSM. */
58
+ function installedVersion(cfg: Config): string {
59
+ try {
60
+ const info = readFileSync(join(cfg.packagesRoot, cfg.packageId, 'INFO'), 'utf8')
61
+ return /^version="?([^"\n]*)"?$/m.exec(info)?.[1] ?? ''
62
+ } catch {
63
+ return ''
64
+ }
65
+ }
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
+ }
@@ -0,0 +1,22 @@
1
+ // The runtime's services, with their refusals passed on to the browser.
2
+ import { AppError, ServiceError, type Context } from '@synopackageland/app'
3
+
4
+ /**
5
+ * ctx.runtime. A refusal the browser may see (limit_exceeded, read_failed,
6
+ * SCOPE_DENIED, ...) reaches it as the App's own error; any other code, such
7
+ * as the runtime's session_required, stays internal.
8
+ */
9
+ export async function service<T>(ctx: Context, op: string, params: unknown = null): Promise<T> {
10
+ try {
11
+ return (await ctx.runtime(op, params)) as T
12
+ } catch (e) {
13
+ if (!(e instanceof ServiceError)) throw e
14
+ let passed: AppError
15
+ try {
16
+ passed = new AppError(e.code, e.message)
17
+ } catch {
18
+ throw e
19
+ }
20
+ throw passed
21
+ }
22
+ }
@@ -0,0 +1,75 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterEach, beforeEach, test } from 'node:test'
6
+ import { build, type Config } from '../src/routes.js'
7
+ import { call } from './support.js'
8
+
9
+ let root: string
10
+ let cfg: Config
11
+
12
+ beforeEach(() => {
13
+ root = mkdtempSync(join(tmpdir(), 'app-'))
14
+ cfg = { packageId: 'ExampleApp', dataDir: join(root, 'var'), packagesRoot: join(root, 'packages') }
15
+ mkdirSync(cfg.dataDir)
16
+ })
17
+ afterEach(() => rmSync(root, { recursive: true, force: true }))
18
+
19
+ function writeInfo(text: string) {
20
+ mkdirSync(join(cfg.packagesRoot, 'ExampleApp'), { recursive: true })
21
+ writeFileSync(join(cfg.packagesRoot, 'ExampleApp', 'INFO'), text)
22
+ }
23
+
24
+ test('app.status reports the caller and the installed version', async () => {
25
+ writeInfo('package="ExampleApp"\nversion="0.1.0-0001"\n')
26
+ const reply = await call(build(cfg), 'app.status', null, { user: 'alice', admin: false })
27
+ assert.equal(reply.ok, true, JSON.stringify(reply.error))
28
+ const status = reply.data
29
+ assert.deepEqual([status.package, status.version, status.user, status.admin], ['ExampleApp', '0.1.0-0001', 'alice', false])
30
+ assert.deepEqual(status.process, { uid: process.getuid!(), gid: process.getgid!() })
31
+ assert.match(status.language, /^Node\.js \d+\./)
32
+ assert.match(status.platform, /^\w+\/\w+$/)
33
+ assert.match(status.time, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/)
34
+ })
35
+
36
+ test('app.status without INFO has no version', async () => {
37
+ assert.equal((await call(build(cfg), 'app.status')).data.version, '')
38
+ })
39
+
40
+ test('app.status shows the portal when the runtime offers it', async () => {
41
+ const portal = { enabled: true, alias: 'example' }
42
+ const reply = await call(build(cfg), 'app.status', null, { services: { 'portal.status': () => portal } })
43
+ assert.deepEqual(reply.data.portal, portal)
44
+ assert.deepEqual(reply.services, [['portal.status', null]])
45
+ })
46
+
47
+ test('app.status without a portal leaves it out', async () => {
48
+ const reply = await call(build(cfg), 'app.status')
49
+ assert.equal(reply.ok, true, JSON.stringify(reply.error))
50
+ assert.equal('portal' in reply.data, false)
51
+ })
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), [])
75
+ })
@@ -0,0 +1,67 @@
1
+ // Calls an App method the way the runtime does: over a channel, with the
2
+ // hello, the call, the services the method asks for and its result.
3
+ import { PassThrough } from 'node:stream'
4
+ import { createInterface } from 'node:readline'
5
+ import type { App } from '@synopackageland/app'
6
+
7
+ /** A service's refusal: the runtime answers the svc with this code. */
8
+ export class Refusal extends Error {
9
+ constructor(readonly code: string, message = code) {
10
+ super(message)
11
+ }
12
+ }
13
+
14
+ /** Each op the method may ask for: returns the result or throws a Refusal. An op it lacks is unknown_service. */
15
+ export type Services = Record<string, (params: unknown) => unknown>
16
+
17
+ export interface Reply {
18
+ ok: boolean
19
+ data?: any
20
+ error?: { code: string; message: string }
21
+ /** The services the method asked for, in order. */
22
+ services: [string, unknown][]
23
+ }
24
+
25
+ export interface Caller {
26
+ user?: string
27
+ admin?: boolean
28
+ services?: Services
29
+ }
30
+
31
+ export async function call(app: App, method: string, params: unknown = null, caller: Caller = {}): Promise<Reply> {
32
+ const { user = 'admin', admin = true, services = {} } = caller
33
+ const toApp = new PassThrough()
34
+ const fromApp = new PassThrough()
35
+ const serving = app.serve(toApp, fromApp)
36
+ const send = (frame: object) => toApp.write(JSON.stringify(frame) + '\n')
37
+ const lines = createInterface({ input: fromApp })[Symbol.asyncIterator]()
38
+ send({ t: 'hello', versions: [1] })
39
+ await lines.next()
40
+ const ctx = { user, admin, locale: 'en', request_id: '0'.repeat(32), deadline_ms: 30000 }
41
+ send({ t: 'call', id: '1', method, params, ctx })
42
+ const asked: [string, unknown][] = []
43
+ try {
44
+ for (let line = await lines.next(); !line.done; line = await lines.next()) {
45
+ const frame = JSON.parse(line.value)
46
+ if (frame.t === 'result') return { ok: frame.ok, data: frame.data, error: frame.error, services: asked }
47
+ asked.push([frame.op, frame.params])
48
+ send(await answer(services, frame))
49
+ }
50
+ throw new Error('the App ended the channel without a result')
51
+ } finally {
52
+ toApp.end()
53
+ await serving
54
+ }
55
+ }
56
+
57
+ async function answer(services: Services, frame: { id: string; op: string; params: unknown }): Promise<object> {
58
+ const reply = { t: 'svc_result', id: frame.id }
59
+ try {
60
+ const service = services[frame.op]
61
+ if (!service) throw new Refusal('unknown_service', `no service ${frame.op}`)
62
+ return { ...reply, ok: true, result: await service(frame.params) }
63
+ } catch (e) {
64
+ if (!(e instanceof Refusal)) throw e
65
+ return { ...reply, ok: false, error: { code: e.code, message: e.message } }
66
+ }
67
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "app"
6
+ },
7
+ "include": ["src/**/*.ts"]
8
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "exactOptionalPropertyTypes": true,
10
+ "noUnusedLocals": true,
11
+ "noUnusedParameters": true,
12
+ "noFallthroughCasesInSwitch": true,
13
+ "verbatimModuleSyntax": true,
14
+ "rootDir": ".",
15
+ "outDir": "build"
16
+ },
17
+ "include": ["src/**/*.ts", "test/**/*.ts"]
18
+ }
@@ -0,0 +1,80 @@
1
+ # Standalone native DSM package: an unsigned SPK that runs as its own package
2
+ # `synopkgland build` reads this file.
3
+ schema: 2
4
+
5
+ # INFO.package: letters and digits, starting with a letter. It also names the
6
+ # DSM app (SYNO.SDS.<package>) and the CGI URL /webman/3rdparty/<package>/.
7
+ package: __PACKAGE__
8
+ version: 0.1.0
9
+ # Increase for every SPK you hand out with the same version (INFO 0.1.0-0001).
10
+ build: 1
11
+ displayname: __DISPLAY_NAME__
12
+ description: A native DSM package with a Node.js backend and a DSM window UI.
13
+ # Package Center and DSM window icon: a square PNG of at least 256x256, or a
14
+ # folder of icon_<size>.png. Omit to use the SDK default icon.
15
+ # icon: assets/icon.png
16
+ maintainer: Your Name
17
+ # Optional INFO/website metadata. Omit fields you do not need.
18
+ # metadata:
19
+ # contact_email: you@example.com
20
+ # support_email: support@example.com
21
+ # homepage: https://example.com
22
+ # source_url: https://github.com/you/your-app
23
+ # issues_url: https://github.com/you/your-app/issues
24
+ # license: MIT
25
+ # long_description:
26
+ # en: |-
27
+ # Explain what the app does and how to use it.
28
+ # zh-TW: Translated description.
29
+ # changelog: # text, or per-language like long_description
30
+ # en: |-
31
+ # - Most important change first. Omit to use Release notes.
32
+ # zh-TW: |-
33
+ # - Translated changes.
34
+ # screenshots: [https://example.com/screenshot.png]
35
+ os_min_ver: "7.1-42661"
36
+
37
+ # One SPK per architecture. Supported: x86_64, aarch64, armv7 (add it here to
38
+ # build it; armv7 is built but not NAS-verified).
39
+ arches: [x86_64, aarch64]
40
+
41
+ # The SDK's Go runtime is the CGI: it checks the DSM session, the entry gate
42
+ # and app/permissions.json, then runs `node main.js` in backend/app as the
43
+ # package account, once per call, with Package Center's Node.js (node.package
44
+ # becomes an install dependency). backend/src compiles to backend/app: run
45
+ # `npm ci` and `npm run build` in backend before check and build. sdk is the
46
+ # SDK release whose runtime and Node SDK (node_modules/@synopackageland/app)
47
+ # the build ships; the build reads the methods from `main.js --describe`.
48
+ backend:
49
+ runtime: node
50
+ dir: backend/app
51
+ entry: main.js
52
+ sdk: 0.10.0
53
+ node: { package: Node.js_v20 }
54
+
55
+ ui:
56
+ # Static files served at /webman/3rdparty/<package>/app/. app.js and app.css
57
+ # are required; anything else (images, chunks) is copied as is.
58
+ dir: ui
59
+
60
+ # UI languages: i18n/messages/<locale>.json for en, zh-TW, zh-CN, ja, ko, de,
61
+ # fr, es and it (DSM's other languages fall back to English). build checks
62
+ # that every language has the en.json keys and placeholders, and that the keys
63
+ # listed in the glossary use DSM's own wording.
64
+ i18n:
65
+ messages: i18n/messages
66
+ glossary: i18n/glossary.json
67
+ # Package Center text per language (INFO displayname_<lang>/description_<lang>).
68
+ # English comes from displayname/description above.
69
+ info:
70
+ zh-TW: { description: 以 Node.js 後端與 DSM 視窗介面打造的原生 DSM 套件。 }
71
+ zh-CN: { description: 以 Node.js 后端与 DSM 窗口界面构建的原生 DSM 套件。 }
72
+ ja: { description: Node.js バックエンドと DSM ウィンドウ UI を備えたネイティブ DSM パッケージ。 }
73
+ ko: { description: Node.js 백엔드와 DSM 창 UI를 갖춘 네이티브 DSM 패키지. }
74
+ de: { description: Ein natives DSM-Paket mit Node.js-Backend und DSM-Fenster-Oberfläche. }
75
+ fr: { description: Un paquet DSM natif avec un backend Node.js et une interface en fenêtre DSM. }
76
+ es: { description: Un paquete nativo de DSM con backend en Node.js e interfaz en ventana de DSM. }
77
+ it: { description: Un pacchetto DSM nativo con backend Node.js e interfaccia in una finestra DSM. }
78
+
79
+ # Optional SDK capabilities; delegate/rootOps share administrator-approved executor setup.
80
+ capabilities: {}
@@ -0,0 +1,4 @@
1
+ dist/
2
+ node_modules/
3
+ backend/build/
4
+ backend/app/*.js
@@ -0,0 +1,35 @@
1
+ // Saves to a user's destination: write a report and move it there.
2
+ //
3
+ // The browser picks the destination with the SDK's Background saves page and
4
+ // sends its delegation ID; the executor checks that it is the caller's grant
5
+ // and writes the file as the caller, never as root. A save that failed may be
6
+ // repeated with the same job ID: the same file is sent again, and the executor
7
+ // copies it at most once.
8
+ import { mkdirSync, statSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { AppError, type App, type Context } from '@synopackageland/app'
11
+ import { strings } from './params.js'
12
+ import type { Config } from './routes.js'
13
+ import { service } from './service.js'
14
+
15
+ const JOB_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/
16
+
17
+ export function register(app: App, cfg: Config): void {
18
+ app.method('report.save', (ctx, params) => save(cfg, ctx, params))
19
+ }
20
+
21
+ function save(cfg: Config, ctx: Context, params: unknown) {
22
+ const { delegation_id, job_id } = strings(params, 'delegation_id', 'job_id')
23
+ if (!JOB_ID.test(job_id)) throw AppError.badRequest("job_id must be 1 to 128 letters, digits, '.', '_' or '-'")
24
+ const name = `report-${job_id}.txt`
25
+ // The runtime's delegate service moves files from here.
26
+ const staging = join(cfg.dataDir, 'executor-staging')
27
+ mkdirSync(staging, { recursive: true, mode: 0o700 })
28
+ const path = join(staging, name)
29
+ try {
30
+ writeFileSync(path, `Report for ${ctx.user}, ${new Date().toISOString().replace(/\.\d+Z$/, 'Z')}\n`, { flag: 'wx', mode: 0o600 })
31
+ } catch (e) {
32
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
33
+ }
34
+ return service(ctx, 'delegate.move_from_staging', { delegation_id, job_id, staging: name, name, size: statSync(path).size })
35
+ }
@@ -0,0 +1,51 @@
1
+ import assert from 'node:assert/strict'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterEach, beforeEach, test } from 'node:test'
6
+ import { build, type Config } from '../src/routes.js'
7
+ import { Refusal, call, type Services } from './support.js'
8
+
9
+ const SAVED = { state: 'queued' }
10
+ let cfg: Config
11
+ let staging: string
12
+
13
+ beforeEach(() => {
14
+ const root = mkdtempSync(join(tmpdir(), 'app-'))
15
+ cfg = { packageId: 'ExampleApp', dataDir: root, packagesRoot: root }
16
+ staging = join(root, 'executor-staging')
17
+ })
18
+ afterEach(() => rmSync(cfg.dataDir, { recursive: true, force: true }))
19
+
20
+ const save = (user = 'alice', move: Services[string] = () => SAVED) =>
21
+ call(build(cfg), 'report.save', { delegation_id: 'd1', job_id: 'job-1' }, { user, admin: false, services: { 'delegate.move_from_staging': move } })
22
+
23
+ test('report.save moves a private report to the caller\'s destination', async () => {
24
+ const reply = await save()
25
+ assert.deepEqual(reply.data, SAVED)
26
+ const path = join(staging, 'report-job-1.txt')
27
+ assert.match(readFileSync(path, 'utf8'), /alice/)
28
+ assert.equal(statSync(path).mode & 0o777, 0o600)
29
+ const move = { delegation_id: 'd1', job_id: 'job-1', staging: 'report-job-1.txt', name: 'report-job-1.txt', size: statSync(path).size }
30
+ assert.deepEqual(reply.services, [['delegate.move_from_staging', move]])
31
+ })
32
+
33
+ test('a repeated report.save sends the same file again', async () => {
34
+ const first = (await save()).services
35
+ const report = readFileSync(join(staging, 'report-job-1.txt'), 'utf8')
36
+ assert.deepEqual((await save('bob')).services, first)
37
+ assert.equal(readFileSync(join(staging, 'report-job-1.txt'), 'utf8'), report)
38
+ })
39
+
40
+ test('report.save passes on the executor\'s refusal', async () => {
41
+ const reply = await save('alice', () => { throw new Refusal('DELEGATION_DENIED', 'The destination is no longer allowed.') })
42
+ assert.equal(reply.error?.code, 'DELEGATION_DENIED')
43
+ })
44
+
45
+ test('report.save refuses invalid params before writing', async () => {
46
+ for (const params of [null, { delegation_id: 'd1' }, { delegation_id: 'd1', job_id: '../x' }, { delegation_id: 'd1', job_id: '' }, { delegation_id: 1, job_id: 'j' }]) {
47
+ const reply = await call(build(cfg), 'report.save', params)
48
+ assert.deepEqual([reply.error?.code, reply.services], ['bad_request', []], JSON.stringify(params))
49
+ }
50
+ assert.equal(existsSync(staging), false)
51
+ })
@@ -0,0 +1,38 @@
1
+ // Background jobs: hash a NAS file the caller may read.
2
+ //
3
+ // The SDK's worker runs each job as the package account: it hashes the job's
4
+ // input (SHA-256) and keeps the result for the job's owner, the caller. The
5
+ // runtime serves files.read with the caller's DSM session and bg.* for the
6
+ // caller's own jobs only.
7
+ import { copyFileSync, mkdirSync, unlinkSync } from 'node:fs'
8
+ import { randomUUID } from 'node:crypto'
9
+ import { basename, join } from 'node:path'
10
+ import { AppError, type App, type Context } from '@synopackageland/app'
11
+ import { strings } from './params.js'
12
+ import type { Config } from './routes.js'
13
+ import { service } from './service.js'
14
+
15
+ /** A job's input is at most 512 KiB, copied into the job when it is submitted. */
16
+ const MAX_INPUT = 512 * 1024
17
+
18
+ export function register(app: App, cfg: Config): void {
19
+ app.method('hash.submit', (ctx, params) => submit(cfg, ctx, params))
20
+ app.method('hash.get', (ctx, params) => service(ctx, 'bg.get', strings(params, 'id')))
21
+ app.method('hash.cancel', (ctx, params) => service(ctx, 'bg.cancel', strings(params, 'id')))
22
+ }
23
+
24
+ async function submit(cfg: Config, ctx: Context, params: unknown) {
25
+ const { key, path } = strings(params, 'key', 'path')
26
+ const read = await service<{ path: string; size: number }>(ctx, 'files.read', { path })
27
+ if (read.size > MAX_INPUT) throw new AppError('limit_exceeded', 'The file is larger than allowed.')
28
+ // bg.submit takes its input from the data directory; the call's spool is elsewhere.
29
+ const inputs = join(cfg.dataDir, 'hash-inputs')
30
+ mkdirSync(inputs, { recursive: true, mode: 0o700 })
31
+ const input = join(inputs, randomUUID())
32
+ copyFileSync(read.path, input)
33
+ try {
34
+ return await service(ctx, 'bg.submit', { key, name: basename(path), input })
35
+ } finally {
36
+ unlinkSync(input)
37
+ }
38
+ }
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict'
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, sep } from 'node:path'
5
+ import { afterEach, beforeEach, test } from 'node:test'
6
+ import { build, type Config } from '../src/routes.js'
7
+ import { Refusal, call } from './support.js'
8
+
9
+ const JOB = { job: { id: 'j1', state: 'queued' }, duplicate: false }
10
+ let root: string
11
+ let cfg: Config
12
+ let submitted: Record<string, unknown>[]
13
+
14
+ beforeEach(() => {
15
+ root = mkdtempSync(join(tmpdir(), 'app-'))
16
+ cfg = { packageId: 'ExampleApp', dataDir: join(root, 'var'), packagesRoot: join(root, 'packages') }
17
+ mkdirSync(cfg.dataDir)
18
+ submitted = []
19
+ })
20
+ afterEach(() => rmSync(root, { recursive: true, force: true }))
21
+
22
+ function spool(content: Buffer) {
23
+ const path = join(root, 'spool')
24
+ writeFileSync(path, content)
25
+ return { path, size: content.length }
26
+ }
27
+
28
+ // The runtime reads the input file while it answers bg.submit.
29
+ function submit(params: unknown) {
30
+ const p = params as { input: string }
31
+ submitted.push({ ...p, content: readFileSync(p.input, 'utf8') })
32
+ return JOB
33
+ }
34
+
35
+ test('hash.submit hashes a copy of the caller\'s file', async () => {
36
+ const services = { 'files.read': () => spool(Buffer.from('hello')), 'bg.submit': submit }
37
+ const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/report.txt' }, { services })
38
+ assert.deepEqual(reply.data, JOB)
39
+ assert.deepEqual(reply.services.map(([op]) => op), ['files.read', 'bg.submit'])
40
+ assert.deepEqual(reply.services[0]![1], { path: '/home/report.txt' })
41
+ const [job] = submitted as [{ key: string; name: string; content: string; input: string }]
42
+ assert.deepEqual([job.key, job.name, job.content], ['k1', 'report.txt', 'hello'])
43
+ assert.ok(job.input.startsWith(cfg.dataDir + sep), job.input)
44
+ assert.equal(existsSync(job.input), false, 'the copy is removed once submitted')
45
+ })
46
+
47
+ test('hash.submit refuses a file over the job input limit', async () => {
48
+ const services = { 'files.read': () => spool(Buffer.alloc(512 * 1024 + 1)), 'bg.submit': submit }
49
+ const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/big.bin' }, { services })
50
+ assert.equal(reply.error?.code, 'limit_exceeded')
51
+ assert.deepEqual(submitted, [])
52
+ })
53
+
54
+ test('hash.submit passes on the runtime\'s refusal', async () => {
55
+ const services = { 'files.read': () => { throw new Refusal('read_failed', 'Unable to read the file.') } }
56
+ const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/a' }, { services })
57
+ assert.equal(reply.error?.code, 'read_failed')
58
+ })
59
+
60
+ test('hash.submit refuses invalid params before any service', async () => {
61
+ for (const params of [null, { key: 'k1' }, { key: 1, path: '/home/a' }, { key: 'k1', path: '/home/a', x: 1 }]) {
62
+ const reply = await call(build(cfg), 'hash.submit', params)
63
+ assert.deepEqual([reply.error?.code, reply.services], ['bad_request', []], JSON.stringify(params))
64
+ }
65
+ })
66
+
67
+ test('hash.get and hash.cancel act on the caller\'s job', async () => {
68
+ for (const [method, op] of [['hash.get', 'bg.get'], ['hash.cancel', 'bg.cancel']] as const) {
69
+ const reply = await call(build(cfg), method, { id: 'j1' }, { services: { [op]: () => JOB.job } })
70
+ assert.deepEqual([reply.data, reply.services], [JOB.job, [[op, { id: 'j1' }]]])
71
+ assert.equal((await call(build(cfg), method, {})).error?.code, 'bad_request')
72
+ }
73
+ })
@@ -0,0 +1,16 @@
1
+ // Root operations: run root/whoami.sh as root.
2
+ //
3
+ // The executor runs only the root programs an administrator approved, and
4
+ // checks the caller's DSM session and Application Privileges itself; the App
5
+ // names the program and never runs anything as root.
6
+ import { AppError, type App } from '@synopackageland/app'
7
+ import type { Config } from './routes.js'
8
+ import { service } from './service.js'
9
+
10
+ export function register(app: App, _cfg: Config): void {
11
+ app.method('whoami.run', (ctx, params) => {
12
+ const none = params === null || (typeof params === 'object' && !Array.isArray(params) && Object.keys(params).length === 0)
13
+ if (!none) throw AppError.badRequest('whoami.run takes no params')
14
+ return service(ctx, 'root.run', { name: 'whoami' })
15
+ })
16
+ }
@@ -0,0 +1,23 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { build, type Config } from '../src/routes.js'
4
+ import { Refusal, call } from './support.js'
5
+
6
+ const cfg: Config = { packageId: 'ExampleApp', dataDir: '/nonexistent', packagesRoot: '/nonexistent' }
7
+
8
+ test('whoami.run runs the approved root program', async () => {
9
+ const output = { exit_code: 0, stdout: '0\n', stderr: '' }
10
+ for (const params of [null, {}]) {
11
+ const reply = await call(build(cfg), 'whoami.run', params, { services: { 'root.run': () => output } })
12
+ assert.deepEqual([reply.data, reply.services], [output, [['root.run', { name: 'whoami' }]]])
13
+ }
14
+ })
15
+
16
+ test('whoami.run passes on the executor\'s refusal', async () => {
17
+ const services = { 'root.run': () => { throw new Refusal('EXECUTOR_PAUSED', 'The executor is paused.') } }
18
+ assert.equal((await call(build(cfg), 'whoami.run', {}, { services })).error?.code, 'EXECUTOR_PAUSED')
19
+ })
20
+
21
+ test('whoami.run takes no params', async () => {
22
+ assert.equal((await call(build(cfg), 'whoami.run', { name: 'other' })).error?.code, 'bad_request')
23
+ })