@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.
- package/README.md +29 -29
- package/package.json +2 -2
- package/templates/_shared/AGENTS.md +53 -15
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
- package/templates/native-go/backend/cmd/app/main.go +15 -1
- package/templates/native-go/backend/cmd/app/main_test.go +33 -0
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/go.sum +2 -2
- package/templates/native-go/i18n/messages/de.json +4 -0
- package/templates/native-go/i18n/messages/en.json +4 -0
- package/templates/native-go/i18n/messages/es.json +4 -0
- package/templates/native-go/i18n/messages/fr.json +4 -0
- package/templates/native-go/i18n/messages/it.json +4 -0
- package/templates/native-go/i18n/messages/ja.json +4 -0
- package/templates/native-go/i18n/messages/ko.json +4 -0
- package/templates/native-go/i18n/messages/zh-CN.json +4 -0
- package/templates/native-go/i18n/messages/zh-TW.json +4 -0
- package/templates/native-go/ui/app.js +2 -1
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +24 -3
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
- package/templates/native-go-root-ops/backend/internal/app/executor.go +2 -65
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +10 -82
- package/templates/native-go-root-ops/frontend/src/ExecutorGate.vue +16 -0
- package/templates/native-go-root-ops/frontend/test/executor-gate.test.ts +60 -0
- package/templates/native-go-vue/README.md +24 -8
- package/templates/native-go-vue/frontend/package-lock.json +9 -9
- package/templates/native-go-vue/frontend/package.json +2 -2
- package/templates/native-go-vue/frontend/src/App.vue +36 -36
- package/templates/native-go-vue/frontend/src/ExecutorGate.vue +10 -0
- package/templates/native-go-vue/frontend/test/apps.test.ts +65 -12
- package/templates/native-go-vue/frontend/test/context.ts +8 -0
- package/templates/native-go-vue/frontend/test/native-controls.test.ts +3 -4
- package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +2 -2
- package/templates/native-node/README.md +153 -0
- package/templates/native-node/backend/app/package.json +4 -0
- package/templates/native-node/backend/app/permissions.json +5 -0
- package/templates/native-node/backend/package-lock.json +59 -0
- package/templates/native-node/backend/package.json +17 -0
- package/templates/native-node/backend/src/delegate.ts +5 -0
- package/templates/native-node/backend/src/jobs.ts +5 -0
- package/templates/native-node/backend/src/main.ts +4 -0
- package/templates/native-node/backend/src/params.ts +11 -0
- package/templates/native-node/backend/src/root-ops.ts +5 -0
- package/templates/native-node/backend/src/routes.ts +97 -0
- package/templates/native-node/backend/src/service.ts +22 -0
- package/templates/native-node/backend/test/routes.test.ts +75 -0
- package/templates/native-node/backend/test/support.ts +67 -0
- package/templates/native-node/backend/tsconfig.build.json +8 -0
- package/templates/native-node/backend/tsconfig.json +18 -0
- package/templates/native-node/dsm-package.yaml +80 -0
- package/templates/native-node/gitignore +4 -0
- package/templates/native-node-delegate/backend/src/delegate.ts +35 -0
- package/templates/native-node-delegate/backend/test/delegate.test.ts +51 -0
- package/templates/native-node-jobs/backend/src/jobs.ts +38 -0
- package/templates/native-node-jobs/backend/test/jobs.test.ts +73 -0
- package/templates/native-node-root-ops/backend/src/root-ops.ts +16 -0
- package/templates/native-node-root-ops/backend/test/root-ops.test.ts +23 -0
- package/templates/native-node-vue/README.md +56 -0
- package/templates/native-node-vue/gitignore +4 -0
- package/templates/native-python/README.md +150 -0
- package/templates/native-python/backend/app/delegate.py +5 -0
- package/templates/native-python/backend/app/jobs.py +5 -0
- package/templates/native-python/backend/app/main.py +5 -0
- package/templates/native-python/backend/app/params.py +10 -0
- package/templates/native-python/backend/app/permissions.json +5 -0
- package/templates/native-python/backend/app/root_ops.py +5 -0
- package/templates/native-python/backend/app/routes.py +107 -0
- package/templates/native-python/backend/tests/support.py +72 -0
- package/templates/native-python/backend/tests/test_routes.py +73 -0
- package/templates/native-python/dsm-package.yaml +77 -0
- package/templates/native-python/gitignore +2 -0
- package/templates/native-python-delegate/backend/app/delegate.py +42 -0
- package/templates/native-python-delegate/backend/tests/test_delegate.py +58 -0
- package/templates/native-python-jobs/backend/app/jobs.py +40 -0
- package/templates/native-python-jobs/backend/tests/test_jobs.py +72 -0
- package/templates/native-python-root-ops/backend/app/root_ops.py +18 -0
- package/templates/native-python-root-ops/backend/tests/test_root_ops.py +34 -0
- package/templates/native-python-vue/README.md +56 -0
- package/templates/native-python-vue/gitignore +3 -0
- package/templates/native-rust/README.md +74 -296
- package/templates/native-rust/backend/Cargo.lock +6 -6
- package/templates/native-rust/backend/Cargo.toml +2 -10
- package/templates/native-rust/backend/src/delegate.rs +6 -0
- package/templates/native-rust/backend/src/jobs.rs +6 -0
- package/templates/native-rust/backend/src/main.rs +12 -0
- package/templates/native-rust/backend/src/root_ops.rs +6 -0
- package/templates/native-rust/backend/src/routes.rs +352 -0
- package/templates/native-rust/backend/src/test_support.rs +91 -0
- package/templates/native-rust/dsm-package.yaml +10 -21
- package/templates/native-rust-delegate/backend/src/delegate.rs +171 -0
- package/templates/native-rust-jobs/backend/src/jobs.rs +240 -0
- package/templates/native-rust-root-ops/backend/src/root_ops.rs +83 -0
- package/templates/native-rust-vue/README.md +35 -34
- package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +0 -207
- package/templates/native-rust/backend/src/app/background/mod.rs +0 -424
- package/templates/native-rust/backend/src/app/background/owner.rs +0 -77
- package/templates/native-rust/backend/src/app/background/owner_tests.rs +0 -174
- package/templates/native-rust/backend/src/app/background/schedule.rs +0 -126
- package/templates/native-rust/backend/src/app/background/schedule_tests.rs +0 -108
- package/templates/native-rust/backend/src/app/background/tests.rs +0 -505
- package/templates/native-rust/backend/src/app/background/work.rs +0 -78
- package/templates/native-rust/backend/src/app/background/work_tests.rs +0 -123
- package/templates/native-rust/backend/src/app/background_routes.rs +0 -22
- package/templates/native-rust/backend/src/app/delegate_config.rs +0 -188
- package/templates/native-rust/backend/src/app/delegate_routes.rs +0 -22
- package/templates/native-rust/backend/src/app/identity.rs +0 -56
- package/templates/native-rust/backend/src/app/migrations.rs +0 -79
- package/templates/native-rust/backend/src/app/mod.rs +0 -30
- package/templates/native-rust/backend/src/app/portal.rs +0 -317
- package/templates/native-rust/backend/src/app/root_routes.rs +0 -23
- package/templates/native-rust/backend/src/app/routes.rs +0 -315
- package/templates/native-rust/backend/src/app/session_files.rs +0 -19
- package/templates/native-rust/backend/src/app/shared_folder.rs +0 -383
- package/templates/native-rust/backend/src/app/test_support.rs +0 -202
- package/templates/native-rust/backend/src/app/uninstall.rs +0 -38
- package/templates/native-rust/backend/src/bin/api.rs +0 -72
- package/templates/native-rust/backend/src/bin/background-worker.rs +0 -37
- package/templates/native-rust/backend/src/build_env.rs +0 -155
- package/templates/native-rust/backend/src/lib.rs +0 -6
- package/templates/native-rust/backend/src/permissions.rs +0 -54
- package/templates/native-rust/backend/src/process.rs +0 -91
- package/templates/native-rust/i18n/glossary.json +0 -665
- package/templates/native-rust/i18n/messages/de.json +0 -32
- package/templates/native-rust/i18n/messages/en.json +0 -32
- package/templates/native-rust/i18n/messages/es.json +0 -32
- package/templates/native-rust/i18n/messages/fr.json +0 -32
- package/templates/native-rust/i18n/messages/it.json +0 -32
- package/templates/native-rust/i18n/messages/ja.json +0 -32
- package/templates/native-rust/i18n/messages/ko.json +0 -32
- package/templates/native-rust/i18n/messages/zh-CN.json +0 -32
- package/templates/native-rust/i18n/messages/zh-TW.json +0 -32
- package/templates/native-rust/ui/app.css +0 -45
- package/templates/native-rust/ui/app.js +0 -111
- package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +0 -92
- package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +0 -52
- package/templates/native-rust-jobs/backend/src/app/background_routes.rs +0 -109
- 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,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,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,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,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: {}
|