@synopackageland/templates 0.9.9 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +29 -29
  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/messages/de.json +4 -0
  10. package/templates/native-go/i18n/messages/en.json +4 -0
  11. package/templates/native-go/i18n/messages/es.json +4 -0
  12. package/templates/native-go/i18n/messages/fr.json +4 -0
  13. package/templates/native-go/i18n/messages/it.json +4 -0
  14. package/templates/native-go/i18n/messages/ja.json +4 -0
  15. package/templates/native-go/i18n/messages/ko.json +4 -0
  16. package/templates/native-go/i18n/messages/zh-CN.json +4 -0
  17. package/templates/native-go/i18n/messages/zh-TW.json +4 -0
  18. package/templates/native-go/ui/app.js +2 -1
  19. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +24 -3
  20. package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
  21. package/templates/native-go-root-ops/backend/internal/app/executor.go +2 -65
  22. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +10 -82
  23. package/templates/native-go-root-ops/frontend/src/ExecutorGate.vue +16 -0
  24. package/templates/native-go-root-ops/frontend/test/executor-gate.test.ts +60 -0
  25. package/templates/native-go-vue/README.md +24 -8
  26. package/templates/native-go-vue/frontend/package-lock.json +9 -9
  27. package/templates/native-go-vue/frontend/package.json +2 -2
  28. package/templates/native-go-vue/frontend/src/App.vue +36 -36
  29. package/templates/native-go-vue/frontend/src/ExecutorGate.vue +10 -0
  30. package/templates/native-go-vue/frontend/test/apps.test.ts +65 -12
  31. package/templates/native-go-vue/frontend/test/context.ts +8 -0
  32. package/templates/native-go-vue/frontend/test/native-controls.test.ts +3 -4
  33. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +2 -2
  34. package/templates/native-node/README.md +153 -0
  35. package/templates/native-node/backend/app/package.json +4 -0
  36. package/templates/native-node/backend/app/permissions.json +5 -0
  37. package/templates/native-node/backend/package-lock.json +59 -0
  38. package/templates/native-node/backend/package.json +17 -0
  39. package/templates/native-node/backend/src/delegate.ts +5 -0
  40. package/templates/native-node/backend/src/jobs.ts +5 -0
  41. package/templates/native-node/backend/src/main.ts +4 -0
  42. package/templates/native-node/backend/src/params.ts +11 -0
  43. package/templates/native-node/backend/src/root-ops.ts +5 -0
  44. package/templates/native-node/backend/src/routes.ts +97 -0
  45. package/templates/native-node/backend/src/service.ts +22 -0
  46. package/templates/native-node/backend/test/routes.test.ts +75 -0
  47. package/templates/native-node/backend/test/support.ts +67 -0
  48. package/templates/native-node/backend/tsconfig.build.json +8 -0
  49. package/templates/native-node/backend/tsconfig.json +18 -0
  50. package/templates/native-node/dsm-package.yaml +80 -0
  51. package/templates/native-node/gitignore +4 -0
  52. package/templates/native-node-delegate/backend/src/delegate.ts +35 -0
  53. package/templates/native-node-delegate/backend/test/delegate.test.ts +51 -0
  54. package/templates/native-node-jobs/backend/src/jobs.ts +38 -0
  55. package/templates/native-node-jobs/backend/test/jobs.test.ts +73 -0
  56. package/templates/native-node-root-ops/backend/src/root-ops.ts +16 -0
  57. package/templates/native-node-root-ops/backend/test/root-ops.test.ts +23 -0
  58. package/templates/native-node-vue/README.md +56 -0
  59. package/templates/native-node-vue/gitignore +4 -0
  60. package/templates/native-python/README.md +150 -0
  61. package/templates/native-python/backend/app/delegate.py +5 -0
  62. package/templates/native-python/backend/app/jobs.py +5 -0
  63. package/templates/native-python/backend/app/main.py +5 -0
  64. package/templates/native-python/backend/app/params.py +10 -0
  65. package/templates/native-python/backend/app/permissions.json +5 -0
  66. package/templates/native-python/backend/app/root_ops.py +5 -0
  67. package/templates/native-python/backend/app/routes.py +107 -0
  68. package/templates/native-python/backend/tests/support.py +72 -0
  69. package/templates/native-python/backend/tests/test_routes.py +73 -0
  70. package/templates/native-python/dsm-package.yaml +77 -0
  71. package/templates/native-python/gitignore +2 -0
  72. package/templates/native-python-delegate/backend/app/delegate.py +42 -0
  73. package/templates/native-python-delegate/backend/tests/test_delegate.py +58 -0
  74. package/templates/native-python-jobs/backend/app/jobs.py +40 -0
  75. package/templates/native-python-jobs/backend/tests/test_jobs.py +72 -0
  76. package/templates/native-python-root-ops/backend/app/root_ops.py +18 -0
  77. package/templates/native-python-root-ops/backend/tests/test_root_ops.py +34 -0
  78. package/templates/native-python-vue/README.md +56 -0
  79. package/templates/native-python-vue/gitignore +3 -0
  80. package/templates/native-rust/README.md +74 -296
  81. package/templates/native-rust/backend/Cargo.lock +6 -6
  82. package/templates/native-rust/backend/Cargo.toml +2 -10
  83. package/templates/native-rust/backend/src/delegate.rs +6 -0
  84. package/templates/native-rust/backend/src/jobs.rs +6 -0
  85. package/templates/native-rust/backend/src/main.rs +12 -0
  86. package/templates/native-rust/backend/src/root_ops.rs +6 -0
  87. package/templates/native-rust/backend/src/routes.rs +352 -0
  88. package/templates/native-rust/backend/src/test_support.rs +91 -0
  89. package/templates/native-rust/dsm-package.yaml +10 -21
  90. package/templates/native-rust-delegate/backend/src/delegate.rs +171 -0
  91. package/templates/native-rust-jobs/backend/src/jobs.rs +240 -0
  92. package/templates/native-rust-root-ops/backend/src/root_ops.rs +83 -0
  93. package/templates/native-rust-vue/README.md +35 -34
  94. package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +0 -207
  95. package/templates/native-rust/backend/src/app/background/mod.rs +0 -424
  96. package/templates/native-rust/backend/src/app/background/owner.rs +0 -77
  97. package/templates/native-rust/backend/src/app/background/owner_tests.rs +0 -174
  98. package/templates/native-rust/backend/src/app/background/schedule.rs +0 -126
  99. package/templates/native-rust/backend/src/app/background/schedule_tests.rs +0 -108
  100. package/templates/native-rust/backend/src/app/background/tests.rs +0 -505
  101. package/templates/native-rust/backend/src/app/background/work.rs +0 -78
  102. package/templates/native-rust/backend/src/app/background/work_tests.rs +0 -123
  103. package/templates/native-rust/backend/src/app/background_routes.rs +0 -22
  104. package/templates/native-rust/backend/src/app/delegate_config.rs +0 -188
  105. package/templates/native-rust/backend/src/app/delegate_routes.rs +0 -22
  106. package/templates/native-rust/backend/src/app/identity.rs +0 -56
  107. package/templates/native-rust/backend/src/app/migrations.rs +0 -79
  108. package/templates/native-rust/backend/src/app/mod.rs +0 -30
  109. package/templates/native-rust/backend/src/app/portal.rs +0 -317
  110. package/templates/native-rust/backend/src/app/root_routes.rs +0 -23
  111. package/templates/native-rust/backend/src/app/routes.rs +0 -315
  112. package/templates/native-rust/backend/src/app/session_files.rs +0 -19
  113. package/templates/native-rust/backend/src/app/shared_folder.rs +0 -383
  114. package/templates/native-rust/backend/src/app/test_support.rs +0 -202
  115. package/templates/native-rust/backend/src/app/uninstall.rs +0 -38
  116. package/templates/native-rust/backend/src/bin/api.rs +0 -72
  117. package/templates/native-rust/backend/src/bin/background-worker.rs +0 -37
  118. package/templates/native-rust/backend/src/build_env.rs +0 -155
  119. package/templates/native-rust/backend/src/lib.rs +0 -6
  120. package/templates/native-rust/backend/src/permissions.rs +0 -54
  121. package/templates/native-rust/backend/src/process.rs +0 -91
  122. package/templates/native-rust/i18n/glossary.json +0 -665
  123. package/templates/native-rust/i18n/messages/de.json +0 -32
  124. package/templates/native-rust/i18n/messages/en.json +0 -32
  125. package/templates/native-rust/i18n/messages/es.json +0 -32
  126. package/templates/native-rust/i18n/messages/fr.json +0 -32
  127. package/templates/native-rust/i18n/messages/it.json +0 -32
  128. package/templates/native-rust/i18n/messages/ja.json +0 -32
  129. package/templates/native-rust/i18n/messages/ko.json +0 -32
  130. package/templates/native-rust/i18n/messages/zh-CN.json +0 -32
  131. package/templates/native-rust/i18n/messages/zh-TW.json +0 -32
  132. package/templates/native-rust/ui/app.css +0 -45
  133. package/templates/native-rust/ui/app.js +0 -111
  134. package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +0 -92
  135. package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +0 -52
  136. package/templates/native-rust-jobs/backend/src/app/background_routes.rs +0 -109
  137. package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +0 -475
@@ -0,0 +1,153 @@
1
+ # __DISPLAY_NAME__
2
+
3
+ A native DSM package (`__PACKAGE__`): a Node.js App behind the SDK's runtime,
4
+ and a UI that opens in its own DSM window. It installs as an **unsigned** SPK,
5
+ runs as its own DSM package account, needs no containers, and has no root by default.
6
+
7
+ ```text
8
+ DSM main menu → DSM window (ui/app.js)
9
+ │ ctx.call("greeting.get") same-origin POST + DSM session
10
+ ▼
11
+ /webman/3rdparty/__PACKAGE__/cgi/api.cgi (the SDK runtime, runs as package user)
12
+ 1. transport guards: POST, JSON, same origin, ≤ 1 MiB, bounded JSON complexity
13
+ 2. one DSM authentication, the entry gate and app/permissions.json
14
+ 3. DSM's Node.js v20 package runs `backend/app/main.js` once per call
15
+ 4. your method, which may ask the runtime for services (files, jobs, executor)
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ Needs Node.js 20 or later with `synopkgland` installed. The SPK depends on DSM's **Node.js v20** package (INFO `install_dep_packages`).
21
+
22
+ ```bash
23
+ (cd backend && npm ci && npm test && npm run build) # method tests, then app/*.js
24
+ synopkgland build # → dist/__PACKAGE__-0.1.0-0001-{x86_64,aarch64}.spk
25
+ synopkgland inspect dist/__PACKAGE__-0.1.0-0001-x86_64.spk
26
+ ```
27
+
28
+ Run `npm run build` in `backend` before every `synopkgland check` or `build`: the SPK ships the compiled `backend/app`.
29
+
30
+ Install: Package Center → Settings → General → Trust Level "Any publisher",
31
+ then Manual Install with the SPK for your NAS's CPU. Open
32
+ **__DISPLAY_NAME__** from the DSM main menu: it shows who you are signed in
33
+ as, what the backend reports, and (for administrators) a form that stores a
34
+ greeting in the package's data folder.
35
+
36
+ ## Project layout
37
+
38
+ | Path | What it is | Edit it? |
39
+ | --- | --- | --- |
40
+ | `dsm-package.yaml` | Package identity, version/build, arches, where backend and UI come from | Yes |
41
+ | `ui/app.js`, `ui/app.css` | The app inside the DSM window | Yes — your UI |
42
+ | `i18n/messages/<locale>.json` | UI text in nine languages | Yes |
43
+ | `i18n/glossary.json` | DSM's own UI terms, and which messages must use them | Add terms |
44
+ | `backend/src/routes.ts` | Your methods: `build` registers them | Yes — your backend |
45
+ | `backend/src/jobs.ts`, `root-ops.ts`, `delegate.ts` | Capability examples (stubs unless `init --with` added them) | Yes |
46
+ | `backend/src/main.ts` | Runs the App on the runtime's channel | Rarely |
47
+ | `backend/app/` | `npm run build` output (`*.js`, ignored by git), `package.json` and `permissions.json`: the directory the SPK ships | Only `permissions.json` |
48
+ | `backend/test/` | node:test tests; `call` plays the runtime | Yes |
49
+ | `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 |
50
+ | `dist/` | Built SPKs (ignored by git) | No |
51
+
52
+ `build` generates everything else: `INFO`, `conf/privilege`
53
+ (`run-as: package`), lifecycle scripts, icons, the DSM window wrapper and the
54
+ runtime CGI at `ui/cgi/api.cgi`.
55
+
56
+ ## Add a method
57
+
58
+ Register it in `build`. Every method defaults to DSM administrator membership;
59
+ all access rules live in `backend/app/permissions.json`:
60
+
61
+ ```ts
62
+ export function build(cfg: Config): App {
63
+ const app = new App()
64
+ ...
65
+ app.method('notes.list', (ctx, params) => listNotes(cfg, ctx, params)) // new: administrators by default
66
+ return app
67
+ }
68
+
69
+ function listNotes(cfg: Config, ctx: Context, params: unknown) {
70
+ const { folder } = strings(params, 'folder') // anything else is bad_request
71
+ return { owner: ctx.user, folder, notes: [] }
72
+ }
73
+ ```
74
+
75
+ Add a test to `backend/test/` with `call(app, 'notes.list', { folder: 'work' }, { services })`. Call it from the UI with `ctx.call("notes.list", { folder: "work" })`.
76
+
77
+ Throw `new AppError(code, message)` (or `AppError.badRequest(message)`) for an error the browser may see; anything else is `internal`, with the stack in the package's log. Call runtime services through `service()` in `src/service.ts`: it passes the runtime's refusals on to the browser.
78
+
79
+ The runtime refuses before your App runs: `session_required`, `admin_required`,
80
+ `unknown_method`, `request_timeout` and the transport guards' codes. The UI
81
+ shows `error.<code>` from its catalogs.
82
+
83
+ ### Who can call what
84
+
85
+ The shipped policy is:
86
+
87
+ ```json
88
+ {"schema": 1, "default": "administrators", "methods": {}}
89
+ ```
90
+
91
+ The default must remain `administrators`. An intentional exception belongs in
92
+ `methods`, for example `"notes.list": "authenticated"`; `"public"` explicitly
93
+ allows anonymous calls. Such exceptions also enable ordinary-user launcher
94
+ access.
95
+
96
+ | JSON rule | No session | Ordinary user | Administrators group member |
97
+ | --- | --- | --- | --- |
98
+ | absent or `administrators` | `session_required` | `admin_required` | runs |
99
+ | explicit `authenticated` | `session_required` | runs | runs |
100
+ | explicit `public` | runs | runs | runs |
101
+ | method the App does not register | `session_required` | `unknown_method` | `unknown_method` |
102
+
103
+ Missing or invalid JSON, duplicate keys, unknown permissions and rules for
104
+ methods the App does not register fail closed. Changing policy requires
105
+ rebuilding the SPK.
106
+
107
+ ## Capabilities
108
+
109
+ `synopkgland init --with` adds examples that use the runtime's services through
110
+ `ctx.runtime`; each comes with its tests:
111
+
112
+ | `--with` | Methods | Runtime services |
113
+ | --- | --- | --- |
114
+ | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `files.read` with the caller's session, `bg.submit`, `bg.get`, `bg.cancel` |
115
+ | `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
116
+ | `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
117
+
118
+ A service refuses with its own code (`limit_exceeded`, `read_failed`, the
119
+ executor's refusals); the examples pass it on to the browser. `synopkgland add`
120
+ works on Go backends only: start a Node.js App with the capabilities it needs.
121
+
122
+ ## The SDK
123
+
124
+ `backend.sdk` in dsm-package.yaml names the SDK release: the build ships its runtime and its Node SDK as `backend/app/node_modules/@synopackageland/app`, and reads the method list from `main.js --describe`. `backend/vendor/@synopackageland/app` is the same SDK for tests and type checks; replace it with the new release's when you change `backend.sdk`. Upgrade the SDK by changing that version; `synopkgland check`
125
+ refuses versions this CLI does not support. The bridge protocol, the services
126
+ and their codes are documented in the SDK repository:
127
+ <https://github.com/synopackageland/syno-pkg-land-sdk> (`spec/bridge/v1`).
128
+
129
+ ## UI and languages
130
+
131
+ The UI is the same as the Go template's. `ui/app.js` registers
132
+ `window.DsmPackageApps[<package>].mount(root, ctx)`; `ctx.call(method, params)`
133
+ is the only transport and `ctx.t(key, values)` reads
134
+ `i18n/messages/<locale>.json` (`en`, `zh-TW`, `zh-CN`, `ja`, `ko`, `de`,
135
+ `fr`, `es`, `it`). Add `error.<code>` for every code your methods return.
136
+ `synopkgland check` and `build` refuse a language file with missing or extra
137
+ keys, a dropped `{placeholder}` or a listed message that drifts from the
138
+ glossary. `--ui vue` gives the Vue shell in `frontend/` instead.
139
+
140
+ ## Data, upgrades, uninstall
141
+
142
+ The App stores data in `/var/packages/__PACKAGE__/var` (`SYNOPKGLAND_DATA_DIR`,
143
+ kept across upgrades). Bump `build` for every SPK you hand out and `version`
144
+ for feature releases. Uninstalling keeps the data unless the administrator
145
+ ticks **Also delete the app data listed above** in the uninstall wizard.
146
+
147
+ ## Support matrix
148
+
149
+ | | Status |
150
+ | --- | --- |
151
+ | Build hosts | macOS, Linux |
152
+ | Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
153
+ | DSM | 7.1 or later declared (`os_min_ver`) |
@@ -0,0 +1,4 @@
1
+ {
2
+ "private": true,
3
+ "type": "module"
4
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "schema": 1,
3
+ "default": "administrators",
4
+ "methods": {}
5
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "backend",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "backend",
8
+ "dependencies": {
9
+ "@synopackageland/app": "file:vendor/@synopackageland/app"
10
+ },
11
+ "devDependencies": {
12
+ "@types/node": "18.19.130",
13
+ "typescript": "5.9.3"
14
+ }
15
+ },
16
+ "node_modules/@synopackageland/app": {
17
+ "resolved": "vendor/@synopackageland/app",
18
+ "link": true
19
+ },
20
+ "node_modules/@types/node": {
21
+ "version": "18.19.130",
22
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
23
+ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
24
+ "dev": true,
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "undici-types": "~5.26.4"
28
+ }
29
+ },
30
+ "node_modules/typescript": {
31
+ "version": "5.9.3",
32
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
33
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
34
+ "dev": true,
35
+ "license": "Apache-2.0",
36
+ "bin": {
37
+ "tsc": "bin/tsc",
38
+ "tsserver": "bin/tsserver"
39
+ },
40
+ "engines": {
41
+ "node": ">=14.17"
42
+ }
43
+ },
44
+ "node_modules/undici-types": {
45
+ "version": "5.26.5",
46
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
47
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
48
+ "dev": true,
49
+ "license": "MIT"
50
+ },
51
+ "vendor/@synopackageland/app": {
52
+ "version": "0.0.0",
53
+ "license": "MIT",
54
+ "engines": {
55
+ "node": ">=18"
56
+ }
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "backend",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "tsc -p tsconfig.build.json",
7
+ "typecheck": "tsc --noEmit",
8
+ "test": "rm -rf build && tsc && node --test build/test/*.test.js"
9
+ },
10
+ "dependencies": {
11
+ "@synopackageland/app": "file:vendor/@synopackageland/app"
12
+ },
13
+ "devDependencies": {
14
+ "@types/node": "18.19.130",
15
+ "typescript": "5.9.3"
16
+ }
17
+ }
@@ -0,0 +1,5 @@
1
+ // Saves to a user's destination: `synopkgland init --with delegate` replaces this stub with an example.
2
+ import type { App } from '@synopackageland/app'
3
+ import type { Config } from './routes.js'
4
+
5
+ export function register(_app: App, _cfg: Config): void {}
@@ -0,0 +1,5 @@
1
+ // Background jobs: `synopkgland init --with jobs` replaces this stub with an example.
2
+ import type { App } from '@synopackageland/app'
3
+ import type { Config } from './routes.js'
4
+
5
+ export function register(_app: App, _cfg: Config): void {}
@@ -0,0 +1,4 @@
1
+ // The App: the runtime starts it once per call (spec/bridge/v1).
2
+ import { build, configFromEnv } from './routes.js'
3
+
4
+ await build(configFromEnv()).run()
@@ -0,0 +1,11 @@
1
+ // Request params, checked strictly: the browser sends what it likes.
2
+ import { AppError } from '@synopackageland/app'
3
+
4
+ /** params as an object with exactly these string fields, else bad_request. */
5
+ export function strings<K extends string>(params: unknown, ...names: K[]): Record<K, string> {
6
+ const ok = typeof params === 'object' && params !== null && !Array.isArray(params)
7
+ && Object.keys(params).sort().join() === [...names].sort().join()
8
+ && names.every((name) => typeof (params as Record<string, unknown>)[name] === 'string')
9
+ if (!ok) throw AppError.badRequest(`params must be {${[...names].sort().join(', ')}}`)
10
+ return params as Record<K, string>
11
+ }
@@ -0,0 +1,5 @@
1
+ // Root operations: `synopkgland init --with root-ops` replaces this stub with an example.
2
+ import type { App } from '@synopackageland/app'
3
+ import type { Config } from './routes.js'
4
+
5
+ export function register(_app: App, _cfg: Config): void {}
@@ -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.1
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