@synopackageland/cli 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +7 -3
- package/assets/go/route-audit/main.go +8 -0
- package/dist/cli.js +1 -0
- package/dist/error-registry.js +5 -0
- package/dist/skill.js +5 -0
- package/dist/standalone/daemon.d.ts +10 -1
- package/dist/standalone/daemon.js +53 -17
- package/dist/standalone/inspect-spk.d.ts +2 -0
- package/dist/standalone/inspect-spk.js +43 -9
- package/dist/standalone/manifest.d.ts +2 -0
- package/dist/standalone/manifest.js +18 -2
- package/dist/standalone/native-layout.d.ts +2 -0
- package/dist/standalone/native-layout.js +14 -4
- package/dist/standalone/package-dependencies.d.ts +17 -0
- package/dist/standalone/package-dependencies.js +57 -0
- package/dist/standalone/spk.js +6 -6
- package/package.json +2 -2
- package/skills/SKILL.md +21 -2
- package/skills/reference/daemon-mode.md +90 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.40.0 (2026-10-29)
|
|
4
|
+
|
|
5
|
+
- `daemon.backend: true` runs the App's own native backend
|
|
6
|
+
(`ui/cgi/api.cgi`) as the package daemon with `args`, instead of a separate
|
|
7
|
+
`exec`. The binary keeps the route audit and build digests of the CGI
|
|
8
|
+
build. `targets` becomes optional; with `daemon.node` the CLI writes its
|
|
9
|
+
Node launcher and passes `SYNOPKGLAND_NODE` and
|
|
10
|
+
`SYNOPKGLAND_DAEMON_PAYLOAD` so the backend can start Node children itself.
|
|
11
|
+
It needs an SDK whose daemon controller supports backend daemons
|
|
12
|
+
(`STANDALONE_DAEMON_BUILD_FAILED` otherwise) and cannot be combined with
|
|
13
|
+
`identity_gateway`. `inspect` verifies the `backend` config and the
|
|
14
|
+
backend's mode.
|
|
15
|
+
- New skill reference `daemon-mode`: when a resident backend daemon pays off
|
|
16
|
+
and when to stay with the CGI, with a decision table, the factors to
|
|
17
|
+
measure and decision steps.
|
|
18
|
+
- The route audit accepts `api.ServeIngress` (SDK) like `cgi.Serve`: called
|
|
19
|
+
directly, and never with a private or raw HTTP handler.
|
|
20
|
+
- Pin templates 0.35.0 and its SDK/App 0.37.0 defaults. `daemon.backend` and
|
|
21
|
+
`api.ServeIngress` need SDK/App 0.37.0.
|
|
22
|
+
|
|
23
|
+
## 0.39.0 (2026-10-29)
|
|
24
|
+
|
|
25
|
+
- Add schema-2 `dependencies.packages` for DSM package dependencies independent
|
|
26
|
+
of the App language or daemon interpreter. Each entry has a validated `name`
|
|
27
|
+
and optional quoted numeric `min_version`. Build merges, sorts and
|
|
28
|
+
deduplicates these with Node backend and `daemon.node` requirements into
|
|
29
|
+
one INFO `install_dep_packages` field; distinct specified minimum versions
|
|
30
|
+
for one package fail validation. Daemon executable, arguments, environment
|
|
31
|
+
and supervision are unchanged. Native layouts and Compose are supported.
|
|
32
|
+
`check --json` reports normalized dependencies, build metadata records them,
|
|
33
|
+
and `inspect` verifies normalization, INFO agreement and inferred Node
|
|
34
|
+
requirements. Document `/var/packages/<name>/target`, the manifest field
|
|
35
|
+
and the difference from `daemon.node` in the manifest/daemon guides and App
|
|
36
|
+
skill. Add parser, merge/conflict, compiled CLI, artifact and mutation tests.
|
|
37
|
+
- Make the native gateway listener-wait test assert both listeners close,
|
|
38
|
+
without assuming a scheduling-dependent minimum number of polls.
|
|
39
|
+
|
|
3
40
|
## 0.38.0 (2026-10-29)
|
|
4
41
|
|
|
5
42
|
- Audit direct `ipc.StartPeerUnix` calls and reject constructor aliases.
|
package/README.md
CHANGED
|
@@ -49,6 +49,10 @@ See the [upstream scaffold and release checklist](skills/reference/upstream-fork
|
|
|
49
49
|
Existing native products with their own payload layout use `backend: {kind: native}` in
|
|
50
50
|
`dsm-package.yaml`.
|
|
51
51
|
|
|
52
|
+
Declare language-independent DSM installation requirements with
|
|
53
|
+
[`dependencies.packages`](docs/manifest.md). They merge with inferred Node
|
|
54
|
+
requirements without changing the App's executable or runtime.
|
|
55
|
+
|
|
52
56
|
A backend in Python, Node or Rust uses `backend: {runtime: python|node|rust, ...}`: the CGI is
|
|
53
57
|
the SDK's Go runtime, which checks the DSM session and `permissions.json` and starts the App
|
|
54
58
|
from `target/app` as the package account for each request (see `skills/reference/create.md`).
|
|
@@ -74,7 +78,7 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
|
|
|
74
78
|
packages. Private mirrors can use explicitly configured Go authentication and
|
|
75
79
|
checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
|
|
76
80
|
|
|
77
|
-
CLI 0.
|
|
81
|
+
CLI 0.40.0 uses templates ~0.35.0, whose default SDK pin is 0.37.0. Compose import and SDK defaults
|
|
78
82
|
read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
|
|
79
83
|
Every App method must be declared in the permissions table. Dynamic names and
|
|
80
84
|
custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
|
|
@@ -85,8 +89,8 @@ unimported development commands are excluded. Use
|
|
|
85
89
|
authentication. See [App entry and registration](docs/app-entry.md).
|
|
86
90
|
|
|
87
91
|
`init` and `add` read their templates from the locally installed
|
|
88
|
-
`@synopackageland/templates`. CLI 0.
|
|
89
|
-
update can select the latest templates patch in the 0.
|
|
92
|
+
`@synopackageland/templates`. CLI 0.40.0 depends on `~0.35.0`: npm installation or
|
|
93
|
+
update can select the latest templates patch in the 0.35 minor line. Templates
|
|
90
94
|
patches need only a templates release; update the npm installation to receive them.
|
|
91
95
|
The CLI never fetches templates at runtime, so offline use works after installation;
|
|
92
96
|
commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
|
|
@@ -600,6 +600,10 @@ func main() {
|
|
|
600
600
|
if !direct[selector] {
|
|
601
601
|
problem(selector.Pos(), "route constructor aliases cannot be audited; register methods directly")
|
|
602
602
|
}
|
|
603
|
+
case "ServeIngress":
|
|
604
|
+
if !direct[selector] {
|
|
605
|
+
problem(selector.Pos(), "ingress constructor aliases cannot be audited")
|
|
606
|
+
}
|
|
603
607
|
}
|
|
604
608
|
}
|
|
605
609
|
if module == "net/http" {
|
|
@@ -659,6 +663,10 @@ func main() {
|
|
|
659
663
|
if module == "net/http/cgi" && selector.Sel.Name == "Serve" && len(call.Args) > 0 && reusesBoundary(call.Args[0], src, sources, safe, map[ast.Node]bool{}) {
|
|
660
664
|
problem(call.Pos(), "private or raw HTTP handler reused outside its SDK boundary")
|
|
661
665
|
}
|
|
666
|
+
// The daemon ingress serves the same browser requests as cgi.Serve.
|
|
667
|
+
if module == sdkModule+"/api" && selector.Sel.Name == "ServeIngress" && len(call.Args) > 1 && reusesBoundary(call.Args[1], src, sources, safe, map[ast.Node]bool{}) {
|
|
668
|
+
problem(call.Pos(), "private or raw HTTP handler reused outside its SDK boundary")
|
|
669
|
+
}
|
|
662
670
|
if module == "github.com/synopackageland/syno-pkg-land-sdk/dsm" && selector.Sel.Name == "NewEntryGate" {
|
|
663
671
|
out.EntryGate = true
|
|
664
672
|
}
|
package/dist/cli.js
CHANGED
|
@@ -405,6 +405,7 @@ async function runCliWithin(argv) {
|
|
|
405
405
|
emit("check", {
|
|
406
406
|
package: loaded.manifest.package,
|
|
407
407
|
warnings: validation.warnings ?? [],
|
|
408
|
+
...("dependencies" in loaded.manifest && loaded.manifest.dependencies ? { dependencies: loaded.manifest.dependencies } : {}),
|
|
408
409
|
...("installSettings" in loaded.manifest && loaded.manifest.installSettings ? { installSettings: loaded.manifest.installSettings } : {}),
|
|
409
410
|
...("hyperBackup" in loaded.manifest && loaded.manifest.hyperBackup ? { hyperBackup: backupRecord(loaded.manifest.hyperBackup) } : {}),
|
|
410
411
|
...("identityGateway" in loaded.manifest && gatewayAdmissionReport(loaded.manifest.identityGateway, loaded.manifest.entry) ? { identityGateway: gatewayAdmissionReport(loaded.manifest.identityGateway, loaded.manifest.entry) } : {}),
|
package/dist/error-registry.js
CHANGED
|
@@ -283,6 +283,11 @@ export const ERROR_REGISTRY = {
|
|
|
283
283
|
description: "dsm-package.yaml contains an unsupported field.",
|
|
284
284
|
suggestion: "Remove the field; a Standalone Package does not accept custom lifecycle scripts or DSM conf.",
|
|
285
285
|
},
|
|
286
|
+
STANDALONE_DEPENDENCIES_INVALID: {
|
|
287
|
+
code: "STANDALONE_DEPENDENCIES_INVALID",
|
|
288
|
+
description: "The DSM package dependencies are invalid or conflict with an inferred dependency.",
|
|
289
|
+
suggestion: "Use dependencies.packages entries with only name and optional quoted numeric min_version; use one identical minimum version per package, including daemon.node.",
|
|
290
|
+
},
|
|
286
291
|
STANDALONE_SCHEMA_INVALID: {
|
|
287
292
|
code: "STANDALONE_SCHEMA_INVALID",
|
|
288
293
|
description: "The schema version in dsm-package.yaml is not supported.",
|
package/dist/skill.js
CHANGED
|
@@ -57,6 +57,11 @@ const SKILL_CATALOG = [
|
|
|
57
57
|
file: "reference/backup.md",
|
|
58
58
|
when: "Change or opt out of Hyper Backup application backup",
|
|
59
59
|
},
|
|
60
|
+
{
|
|
61
|
+
id: "daemon-mode",
|
|
62
|
+
file: "reference/daemon-mode.md",
|
|
63
|
+
when: "Decide whether the backend should run as a resident daemon (daemon.backend)",
|
|
64
|
+
},
|
|
60
65
|
{ id: "deploy", file: "reference/deploy.md", when: "Deploy to a NAS" },
|
|
61
66
|
{
|
|
62
67
|
id: "diagnose",
|
|
@@ -7,8 +7,13 @@ import type { StandaloneError, StandaloneManifest } from "./manifest.js";
|
|
|
7
7
|
export interface Daemon {
|
|
8
8
|
/** Optional bounded recovery; omitted means off. SDK 0.26.0 or later. */
|
|
9
9
|
restart?: DaemonRestart;
|
|
10
|
-
/** Fixed foreground executable relative to daemon/payload. */
|
|
10
|
+
/** Fixed foreground executable relative to daemon/payload; empty for a backend daemon. */
|
|
11
11
|
exec: string;
|
|
12
|
+
/**
|
|
13
|
+
* The App's own audited backend (ui/cgi/api.cgi) runs as the daemon with
|
|
14
|
+
* `args`; `node`, if set, is then only a runtime for the backend's children.
|
|
15
|
+
*/
|
|
16
|
+
backend?: true;
|
|
12
17
|
/** Architecture-specific project directories, copied without links. */
|
|
13
18
|
targets: Partial<Record<NativeArchFamily, string>>;
|
|
14
19
|
args: string[];
|
|
@@ -56,6 +61,10 @@ export declare function daemonConfig(manifest: StandaloneManifest): string;
|
|
|
56
61
|
* launcher with its entry first, and ingress names the socket to listen on.
|
|
57
62
|
*/
|
|
58
63
|
export declare function runnableDaemon(pkg: string, daemon: Daemon): Daemon;
|
|
64
|
+
/** What a backend daemon learns about the Node runtime it may start children with. */
|
|
65
|
+
export declare const backendNodeEnv: () => Record<string, string>;
|
|
66
|
+
/** daemonctl runs ui/cgi/api.cgi (`"backend": true`) from SDK/App 0.37.0. */
|
|
67
|
+
export declare function requireBackendDaemonSource(source: string, daemon: Daemon | undefined): void;
|
|
59
68
|
export declare function buildDaemonControl(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
|
|
60
69
|
export declare function copyDaemonPayload(manifest: StandaloneManifest, arch: NativeArchFamily, target: string, controller: string): void;
|
|
61
70
|
/** Copies a bounded, link-free package-account payload tree. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { operationBudgetSeconds, runtimeBudgetSdkSupported } from "../runtime/runtime-budgets.js";
|
|
2
2
|
import { goChildEnv } from "../runtime/go-env.js";
|
|
3
|
-
import { chmodSync, existsSync, copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, copyFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem, } from "../build-spk/go-toolchain.js";
|
|
6
6
|
import { sdkSource, appSdkVersion } from "../runtime/sdk-source.js";
|
|
@@ -35,6 +35,7 @@ exec "$node" "$@"
|
|
|
35
35
|
}
|
|
36
36
|
const fields = new Set([
|
|
37
37
|
"exec",
|
|
38
|
+
"backend",
|
|
38
39
|
"targets",
|
|
39
40
|
"args",
|
|
40
41
|
"env",
|
|
@@ -69,19 +70,25 @@ export function readDaemon(value, root, arches, fail) {
|
|
|
69
70
|
const invalid = () => {
|
|
70
71
|
fail({
|
|
71
72
|
code: "STANDALONE_DAEMON_INVALID",
|
|
72
|
-
message: "daemon accepts only fixed exec, targets, args, env and timeouts; paths must be inside the project, and the environment does not accept loader/shell/system variables.",
|
|
73
|
+
message: "daemon accepts only fixed exec (or backend: true), targets, args, env and timeouts; paths must be inside the project, and the environment does not accept loader/shell/system variables.",
|
|
73
74
|
});
|
|
74
75
|
return undefined;
|
|
75
76
|
};
|
|
76
77
|
if (!record(value) || Object.keys(value).some((k) => !fields.has(k)))
|
|
77
78
|
return invalid();
|
|
78
|
-
|
|
79
|
+
const backend = value.backend === true;
|
|
80
|
+
if (value.backend !== undefined && !backend)
|
|
79
81
|
return invalid();
|
|
80
|
-
if (!
|
|
81
|
-
|
|
82
|
+
if (backend ? value.exec !== undefined : !(value.node === undefined ? relativeExec(value.exec) : relativeEntry(value.exec)))
|
|
83
|
+
return invalid();
|
|
84
|
+
// A backend daemon needs a payload only for its children's files.
|
|
85
|
+
const payload = !backend || value.targets !== undefined;
|
|
86
|
+
if (payload &&
|
|
87
|
+
(!record(value.targets) ||
|
|
88
|
+
Object.keys(value.targets).length !== arches.length))
|
|
82
89
|
return invalid();
|
|
83
90
|
const targets = {};
|
|
84
|
-
for (const arch of arches) {
|
|
91
|
+
for (const arch of payload ? arches : []) {
|
|
85
92
|
const path = value.targets[arch];
|
|
86
93
|
if (typeof path !== "string" ||
|
|
87
94
|
!path.trim() ||
|
|
@@ -123,8 +130,11 @@ export function readDaemon(value, root, arches, fail) {
|
|
|
123
130
|
return undefined;
|
|
124
131
|
if (ingress && Object.keys(env).some((key) => key in ingressEnv("", ingress)))
|
|
125
132
|
return invalid();
|
|
133
|
+
if (backend && node && Object.keys(env).some((key) => key in backendNodeEnv()))
|
|
134
|
+
return invalid();
|
|
126
135
|
return {
|
|
127
|
-
exec: value.exec,
|
|
136
|
+
exec: backend ? "" : value.exec,
|
|
137
|
+
...(backend ? { backend: true } : {}),
|
|
128
138
|
targets,
|
|
129
139
|
args,
|
|
130
140
|
...(uninstall ? { uninstall_args: uninstall } : {}),
|
|
@@ -171,7 +181,7 @@ export function daemonConfig(manifest) {
|
|
|
171
181
|
: runnableDaemon(manifest.package, manifest.daemon);
|
|
172
182
|
return (JSON.stringify({
|
|
173
183
|
package: manifest.package,
|
|
174
|
-
exec,
|
|
184
|
+
...(!manifest.compose && manifest.daemon?.backend ? { backend: true } : { exec }),
|
|
175
185
|
args,
|
|
176
186
|
...(uninstall_args ? { uninstall_args } : {}),
|
|
177
187
|
...(restart ? { restart } : {}),
|
|
@@ -187,8 +197,12 @@ export function daemonConfig(manifest) {
|
|
|
187
197
|
* launcher with its entry first, and ingress names the socket to listen on.
|
|
188
198
|
*/
|
|
189
199
|
export function runnableDaemon(pkg, daemon) {
|
|
190
|
-
const entry = daemon.node && [`\${SYNOPKG_PKGDEST}/daemon/payload/${daemon.exec}`];
|
|
191
|
-
const env =
|
|
200
|
+
const entry = daemon.node && !daemon.backend && [`\${SYNOPKG_PKGDEST}/daemon/payload/${daemon.exec}`];
|
|
201
|
+
const env = {
|
|
202
|
+
...daemon.env,
|
|
203
|
+
...(daemon.ingress ? ingressEnv(pkg, daemon.ingress) : {}),
|
|
204
|
+
...(daemon.backend && daemon.node ? backendNodeEnv() : {}),
|
|
205
|
+
};
|
|
192
206
|
return {
|
|
193
207
|
...daemon,
|
|
194
208
|
...(entry ? { exec: NODE_LAUNCHER, args: [...entry, ...daemon.args] } : {}),
|
|
@@ -196,6 +210,19 @@ export function runnableDaemon(pkg, daemon) {
|
|
|
196
210
|
env: Object.fromEntries(Object.keys(env).sort().map((key) => [key, env[key]])),
|
|
197
211
|
};
|
|
198
212
|
}
|
|
213
|
+
/** What a backend daemon learns about the Node runtime it may start children with. */
|
|
214
|
+
export const backendNodeEnv = () => ({
|
|
215
|
+
SYNOPKGLAND_DAEMON_PAYLOAD: "${SYNOPKG_PKGDEST}/daemon/payload",
|
|
216
|
+
SYNOPKGLAND_NODE: `\${SYNOPKG_PKGDEST}/daemon/payload/${NODE_LAUNCHER}`,
|
|
217
|
+
});
|
|
218
|
+
/** daemonctl runs ui/cgi/api.cgi (`"backend": true`) from SDK/App 0.37.0. */
|
|
219
|
+
export function requireBackendDaemonSource(source, daemon) {
|
|
220
|
+
if (!daemon?.backend)
|
|
221
|
+
return;
|
|
222
|
+
const main = join(source, "daemonctl/main.go");
|
|
223
|
+
if (!existsSync(main) || !readFileSync(main, "utf8").includes("backendExecutable"))
|
|
224
|
+
throw new StandaloneBuildError("STANDALONE_DAEMON_BUILD_FAILED", "daemon.backend needs an SDK whose daemon controller can run the backend (SDK/App 0.37.0 or later); upgrade the App SDK.");
|
|
225
|
+
}
|
|
199
226
|
export async function buildDaemonControl(manifest, arch, work, run) {
|
|
200
227
|
if (!manifest.daemon && !(manifest.compose && manifest.identityGateway))
|
|
201
228
|
return undefined;
|
|
@@ -203,6 +230,7 @@ export async function buildDaemonControl(manifest, arch, work, run) {
|
|
|
203
230
|
const sdk = daemonSdkVersion(manifest);
|
|
204
231
|
const source = runtimeBudgetSource(sdkSource(sdk).goDir, manifest);
|
|
205
232
|
requireDaemonRestartSource(source, sdk, manifest.daemon?.restart);
|
|
233
|
+
requireBackendDaemonSource(source, manifest.daemon);
|
|
206
234
|
const result = await run("go", [
|
|
207
235
|
"build",
|
|
208
236
|
"-trimpath",
|
|
@@ -233,16 +261,16 @@ export function copyDaemonPayload(manifest, arch, target, controller) {
|
|
|
233
261
|
copyPayloadTree(manifest.root, source, join(target, "payload"));
|
|
234
262
|
else
|
|
235
263
|
mkdirSync(join(target, "payload"), { recursive: true });
|
|
236
|
-
if (!manifest.compose && manifest.daemon?.
|
|
264
|
+
if (!manifest.compose && manifest.daemon?.backend) {
|
|
265
|
+
// The CLI installs the backend itself at ui/cgi/api.cgi.
|
|
266
|
+
if (manifest.daemon.node)
|
|
267
|
+
writeNodeLauncher(source, target, manifest.daemon.node);
|
|
268
|
+
}
|
|
269
|
+
else if (!manifest.compose && manifest.daemon?.node) {
|
|
237
270
|
const exec = join(source, daemon.exec);
|
|
238
271
|
if (!existsSync(exec) || !lstatSync(exec).isFile())
|
|
239
272
|
throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `daemon.exec must name the Node entry file inside the payload: ${exec}`);
|
|
240
|
-
|
|
241
|
-
throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `The daemon payload must not contain ${NODE_LAUNCHER.split("/")[0]}/; the CLI writes its Node launcher there.`);
|
|
242
|
-
mkdirSync(join(target, "payload", dirname(NODE_LAUNCHER)), { recursive: true });
|
|
243
|
-
chmodSync(join(target, "payload", dirname(NODE_LAUNCHER)), 0o755);
|
|
244
|
-
writeFileSync(join(target, "payload", NODE_LAUNCHER), renderNodeLauncher(manifest.daemon.node));
|
|
245
|
-
chmodSync(join(target, "payload", NODE_LAUNCHER), 0o755);
|
|
273
|
+
writeNodeLauncher(source, target, manifest.daemon.node);
|
|
246
274
|
}
|
|
247
275
|
else if (!manifest.compose) {
|
|
248
276
|
const exec = join(source, daemon.exec);
|
|
@@ -257,6 +285,14 @@ export function copyDaemonPayload(manifest, arch, target, controller) {
|
|
|
257
285
|
mode: 0o644,
|
|
258
286
|
});
|
|
259
287
|
}
|
|
288
|
+
function writeNodeLauncher(source, target, node) {
|
|
289
|
+
if (source && existsSync(join(source, NODE_LAUNCHER.split("/")[0])))
|
|
290
|
+
throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `The daemon payload must not contain ${NODE_LAUNCHER.split("/")[0]}/; the CLI writes its Node launcher there.`);
|
|
291
|
+
mkdirSync(join(target, "payload", dirname(NODE_LAUNCHER)), { recursive: true });
|
|
292
|
+
chmodSync(join(target, "payload", dirname(NODE_LAUNCHER)), 0o755);
|
|
293
|
+
writeFileSync(join(target, "payload", NODE_LAUNCHER), renderNodeLauncher(node));
|
|
294
|
+
chmodSync(join(target, "payload", NODE_LAUNCHER), 0o755);
|
|
295
|
+
}
|
|
260
296
|
/** Copies a bounded, link-free package-account payload tree. */
|
|
261
297
|
export function copyPayloadTree(root, source, target) {
|
|
262
298
|
const invalid = (message) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PackageDependencies } from "./package-dependencies.js";
|
|
1
2
|
import { type InstallSettings } from './install-settings.js';
|
|
2
3
|
import { type WebLaunchMode } from "../contract/web-launch.js";
|
|
3
4
|
import { type ComposeApp } from "./compose.js";
|
|
@@ -9,6 +10,7 @@ import type { EntryMode } from "./manifest.js";
|
|
|
9
10
|
import { type UninstallCleanupItem } from "../runtime/uninstall-cleanup.js";
|
|
10
11
|
export type SpkKind = "native-go" | "native-python" | "native-node" | "native-rust" | "binary" | "container" | "native-layout";
|
|
11
12
|
export interface SpkInspection {
|
|
13
|
+
dependencies?: PackageDependencies;
|
|
12
14
|
installSettings?: InstallSettings;
|
|
13
15
|
/** Hyper Backup application backup, as sdk-package.json records it. */
|
|
14
16
|
hyperBackup?: HyperBackup;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inspectPackageDependencies, dependencyInfo } from "./package-dependencies.js";
|
|
1
2
|
import { readInstallSettings, settingsInstallSteps, settingsConfig, renderSettingsHook } from './install-settings.js';
|
|
2
3
|
import { MODULE_UI_ENTRY, uiTreeDigest } from "./ui-assets.js";
|
|
3
4
|
import { nativePortal } from "./portal.js";
|
|
@@ -23,7 +24,7 @@ import { familyOfArchToken, NATIVE_ARCH_FAMILIES, } from "../build-spk/dsm-arch.
|
|
|
23
24
|
import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DSM, PORTAL_MIN_DSM_REASON, } from "../build-spk/dsm-version.js";
|
|
24
25
|
import { ELF_MACHINE } from "./binary.js";
|
|
25
26
|
import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
|
|
26
|
-
import { daemonConfig, readDaemon, readLifecycleTimeouts, renderNodeLauncher, nodeDependency, NODE_LAUNCHER } from "./daemon.js";
|
|
27
|
+
import { backendNodeEnv, daemonConfig, readDaemon, readLifecycleTimeouts, renderNodeLauncher, nodeDependency, NODE_LAUNCHER } from "./daemon.js";
|
|
27
28
|
import { DAEMON_INGRESS_CONFIG, daemonIngressMinDsmProblem, readDaemonIngress, renderDaemonIngressConfig } from "./daemon-ingress.js";
|
|
28
29
|
import { daemonRestartSdkProblem, readDaemonRestart } from "./daemon-restart.js";
|
|
29
30
|
import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
|
|
@@ -66,7 +67,12 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
66
67
|
await tar.x({ file: spkPath, cwd: dir });
|
|
67
68
|
const findings = [];
|
|
68
69
|
if (!expectedKind && existsSync(join(dir, "native-layout.json"))) {
|
|
69
|
-
|
|
70
|
+
let dependencies;
|
|
71
|
+
try {
|
|
72
|
+
dependencies = inspectPackageDependencies(JSON.parse(readFileSync(join(dir, "native-layout.json"), "utf8")).dependencies, findings);
|
|
73
|
+
}
|
|
74
|
+
catch { /* inspectNativeLayout reports a malformed inventory. */ }
|
|
75
|
+
const info = readInfo(join(dir, "INFO"), findings, "native-layout", false, undefined, dependencies);
|
|
70
76
|
const payload = await listPayload(join(dir, "package.tgz"), findings);
|
|
71
77
|
const target = join(dir, "layout-inspection");
|
|
72
78
|
mkdirSync(target);
|
|
@@ -83,6 +89,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
83
89
|
findings.push(`Missing INFO ${key}`);
|
|
84
90
|
const icon = inspectPackageIcons(dir, findings);
|
|
85
91
|
return {
|
|
92
|
+
...(dependencies ? { dependencies } : {}),
|
|
86
93
|
kind: "native-layout",
|
|
87
94
|
package: info.package,
|
|
88
95
|
version: info.version,
|
|
@@ -134,6 +141,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
134
141
|
findings.push("sdk-package.json does not match the schema 2 capability inventory.");
|
|
135
142
|
}
|
|
136
143
|
}
|
|
144
|
+
const dependencies = inspectPackageDependencies(metadata.dependencies, findings);
|
|
137
145
|
const daemonRestart = recoverDaemonRestart(metadata.daemon_restart, metadata.daemon_sdk, findings);
|
|
138
146
|
await inspectModuleUi(dir, paths, metadata.ui_module, findings);
|
|
139
147
|
if (metadata.daemon_restart !== undefined && (metadata.compose !== undefined || !paths.has("daemon/control")))
|
|
@@ -152,6 +160,9 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
152
160
|
const integrations = metadata.integrations ?? [];
|
|
153
161
|
if (metadata.compose !== undefined) {
|
|
154
162
|
const result = await inspectUnifiedCompose(dir, payload, { ...metadata, installSettings }, findings);
|
|
163
|
+
if (dependencies) {
|
|
164
|
+
result.dependencies = dependencies;
|
|
165
|
+
}
|
|
155
166
|
if (installSettings)
|
|
156
167
|
result.installSettings = installSettings;
|
|
157
168
|
if (hyperBackup)
|
|
@@ -181,7 +192,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
181
192
|
? undefined
|
|
182
193
|
: await recoverCompose(dir, metadata.compose, findings);
|
|
183
194
|
const daemonNode = recoverDaemonNode(metadata.daemon_node, findings);
|
|
184
|
-
const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined), daemonNode);
|
|
195
|
+
const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined), daemonNode, dependencies);
|
|
185
196
|
if (combined &&
|
|
186
197
|
(!info.os_min_ver || !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)))
|
|
187
198
|
findings.push("compose identity_gateway requires DSM >= 7.2.1");
|
|
@@ -230,6 +241,8 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
230
241
|
const bridge = compiledKind && kind !== "native-go"
|
|
231
242
|
? await checkBridgeApp(dir, metadata.backend, info, payload, findings)
|
|
232
243
|
: undefined;
|
|
244
|
+
if (dependencies && bridge?.node?.package && !dependencies.packages.some(d => d.name === bridge.node.package))
|
|
245
|
+
findings.push("DEPENDENCIES_INVALID build metadata dependencies omit the Node backend package.");
|
|
233
246
|
const hasExecutor = (metadata.capabilities ?? []).some((name) => name === "rootOps" || name === "delegate" || name === "webapi") || paths.has("executor/executor");
|
|
234
247
|
if (standalone && portal)
|
|
235
248
|
findings.push(...inspectPortalInfo(info, hasExecutor));
|
|
@@ -315,6 +328,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
315
328
|
findings.push(`${CGI_PAYLOAD_PATH} has mode ${cgiEntry.mode}; it should be 0755`);
|
|
316
329
|
}
|
|
317
330
|
return {
|
|
331
|
+
...(dependencies ? { dependencies } : {}),
|
|
318
332
|
kind,
|
|
319
333
|
...(identityGateway ? { identityGateway } : {}),
|
|
320
334
|
...(installSettings ? { installSettings } : {}),
|
|
@@ -691,7 +705,7 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
|
|
|
691
705
|
}
|
|
692
706
|
/** Replay the SDK planner over archived source bytes, not merely a mutable lock. */
|
|
693
707
|
async function inspectUnifiedCompose(dir, payload, metadata, findings) {
|
|
694
|
-
const info = readInfo(join(dir, "INFO"), findings, "binary", true);
|
|
708
|
+
const info = readInfo(join(dir, "INFO"), findings, "binary", true, undefined, inspectPackageDependencies(metadata.dependencies, findings));
|
|
695
709
|
const base = {
|
|
696
710
|
kind: "container",
|
|
697
711
|
package: info.package,
|
|
@@ -956,6 +970,7 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
|
|
|
956
970
|
Object.keys(config).some((key) => ![
|
|
957
971
|
"package",
|
|
958
972
|
"exec",
|
|
973
|
+
"backend",
|
|
959
974
|
"args",
|
|
960
975
|
"env",
|
|
961
976
|
"timeouts",
|
|
@@ -967,8 +982,11 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
|
|
|
967
982
|
throw new Error("package/config mismatch");
|
|
968
983
|
if (!isDeepStrictEqual(config.restart, extras.restart))
|
|
969
984
|
throw new Error("daemon restart differs from its build declaration");
|
|
985
|
+
const backend = config.backend === true;
|
|
986
|
+
if (config.backend !== undefined && !backend)
|
|
987
|
+
throw new Error("invalid backend flag");
|
|
970
988
|
const daemon = readDaemon({
|
|
971
|
-
exec: config.exec,
|
|
989
|
+
...(backend ? { backend: true } : { exec: config.exec }),
|
|
972
990
|
args: config.args,
|
|
973
991
|
uninstall_args: config.uninstall_args,
|
|
974
992
|
env: config.env,
|
|
@@ -980,6 +998,12 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
|
|
|
980
998
|
throw new Error("invalid config");
|
|
981
999
|
// The packaged config already carries the launcher argv; only the launcher itself is checked here.
|
|
982
1000
|
if (extras.node &&
|
|
1001
|
+
backend &&
|
|
1002
|
+
(daemon.env.SYNOPKGLAND_NODE !== backendNodeEnv().SYNOPKGLAND_NODE ||
|
|
1003
|
+
readOptional(join(unpacked, "daemon/payload", NODE_LAUNCHER)) !== renderNodeLauncher(extras.node)))
|
|
1004
|
+
throw new Error("Node launcher differs from its build declaration");
|
|
1005
|
+
if (extras.node &&
|
|
1006
|
+
!backend &&
|
|
983
1007
|
(daemon.exec !== NODE_LAUNCHER ||
|
|
984
1008
|
!daemon.args[0]?.startsWith("${SYNOPKG_PKGDEST}/daemon/payload/") ||
|
|
985
1009
|
readOptional(join(unpacked, "daemon/payload", NODE_LAUNCHER)) !== renderNodeLauncher(extras.node)))
|
|
@@ -993,7 +1017,8 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
|
|
|
993
1017
|
JSON.stringify(daemon.args) !== '["run"]' ||
|
|
994
1018
|
daemon.uninstall_args))
|
|
995
1019
|
throw new Error("compose gateway must run without a child");
|
|
996
|
-
|
|
1020
|
+
// A backend daemon runs the audited CGI binary.
|
|
1021
|
+
if (payload.find((p) => p.path === (backend ? CGI_PAYLOAD_PATH : `daemon/payload/${daemon.exec}`))?.mode !==
|
|
997
1022
|
"0755")
|
|
998
1023
|
throw new Error("missing executable");
|
|
999
1024
|
const manifest = {
|
|
@@ -1064,7 +1089,7 @@ function archivedInfoTranslations(info) {
|
|
|
1064
1089
|
}
|
|
1065
1090
|
return translations;
|
|
1066
1091
|
}
|
|
1067
|
-
function readInfo(path, findings, kind, compose = false, daemonNode) {
|
|
1092
|
+
function readInfo(path, findings, kind, compose = false, daemonNode, dependencies) {
|
|
1068
1093
|
if (!existsSync(path)) {
|
|
1069
1094
|
findings.push("INFO is missing");
|
|
1070
1095
|
return {};
|
|
@@ -1073,6 +1098,8 @@ function readInfo(path, findings, kind, compose = false, daemonNode) {
|
|
|
1073
1098
|
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
1074
1099
|
const match = line.match(/^([a-z_]+)="(.*)"$/);
|
|
1075
1100
|
if (match) {
|
|
1101
|
+
if (match[1] === "install_dep_packages" && Object.hasOwn(info, match[1]))
|
|
1102
|
+
findings.push("INFO install_dep_packages must appear only once");
|
|
1076
1103
|
info[match[1]] = match[2];
|
|
1077
1104
|
}
|
|
1078
1105
|
}
|
|
@@ -1098,7 +1125,14 @@ function readInfo(path, findings, kind, compose = false, daemonNode) {
|
|
|
1098
1125
|
: kind === "native-node"
|
|
1099
1126
|
? /^Node\.js_v[0-9]{2}([><=].*)?$/
|
|
1100
1127
|
: undefined;
|
|
1101
|
-
if (
|
|
1128
|
+
if (dependencies) {
|
|
1129
|
+
if ((info.install_dep_packages ?? "") !== dependencyInfo(dependencies))
|
|
1130
|
+
findings.push("INFO install_dep_packages does not match build metadata dependencies");
|
|
1131
|
+
const inferred = daemonNode && dependencies.packages.find(d => d.name === daemonNode.package);
|
|
1132
|
+
if (daemonNode && (!inferred || daemonNode.min_version !== undefined && inferred.min_version !== daemonNode.min_version))
|
|
1133
|
+
findings.push("DEPENDENCIES_INVALID build metadata dependencies conflict with daemon.node");
|
|
1134
|
+
}
|
|
1135
|
+
else if (daemonNode && info.install_dep_packages !== nodeDependency(daemonNode))
|
|
1102
1136
|
findings.push(`daemon.node requires INFO install_dep_packages="${nodeDependency(daemonNode)}"`);
|
|
1103
1137
|
else if (!daemonNode &&
|
|
1104
1138
|
info.install_dep_packages &&
|
|
@@ -1453,7 +1487,7 @@ async function inspectInstallSettings(dir, payload, metadata, findings) {
|
|
|
1453
1487
|
const root = join(dir, 'install-settings-inspection');
|
|
1454
1488
|
mkdirSync(root);
|
|
1455
1489
|
await tar.x({ file: join(dir, 'package.tgz'), cwd: root });
|
|
1456
|
-
const info = readInfo(join(dir, 'INFO'), findings, 'binary', Boolean(metadata.compose));
|
|
1490
|
+
const info = readInfo(join(dir, 'INFO'), findings, 'binary', Boolean(metadata.compose), undefined, inspectPackageDependencies(metadata.dependencies, findings));
|
|
1457
1491
|
const gateway = metadata.compose?.gateway ?? metadata.native_gateway;
|
|
1458
1492
|
const m = { package: info.package, os_min_ver: info.os_min_ver, capabilities: {}, installSettings: settings, identityGateway: gateway };
|
|
1459
1493
|
try {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PackageDependencies } from "./package-dependencies.js";
|
|
1
2
|
import { type InstallSettings } from './install-settings.js';
|
|
2
3
|
import { type WebLaunchMode } from "../contract/web-launch.js";
|
|
3
4
|
import { type WebAPIProfiles } from "../runtime/webapi-profile.js";
|
|
@@ -51,6 +52,7 @@ export interface StandaloneManifest {
|
|
|
51
52
|
commands: Record<string, import("../ctl/descriptor.js").Operation>;
|
|
52
53
|
};
|
|
53
54
|
metadata?: PackageMetadata;
|
|
55
|
+
dependencies?: PackageDependencies;
|
|
54
56
|
root: string;
|
|
55
57
|
package: string;
|
|
56
58
|
version: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readPackageDependencies, packageDependencies, dependencyError } from "./package-dependencies.js";
|
|
1
2
|
import { readInstallSettings } from './install-settings.js';
|
|
2
3
|
import { auditGoRoutes } from './route-audit.js';
|
|
3
4
|
import { BACKGROUND_WORKER_MAIN } from './background.js';
|
|
@@ -55,6 +56,7 @@ export const KNOWN_FIELDS = new Set([
|
|
|
55
56
|
"capabilities",
|
|
56
57
|
"i18n",
|
|
57
58
|
"daemon",
|
|
59
|
+
"dependencies",
|
|
58
60
|
"identity_gateway",
|
|
59
61
|
"compose",
|
|
60
62
|
"install",
|
|
@@ -247,6 +249,19 @@ function readManifest(raw, c) {
|
|
|
247
249
|
const lifecycleTimeouts = timeoutOnly ? readLifecycleTimeouts(raw.daemon.timeouts, (e) => c.fail(e.code, e.message)) : undefined;
|
|
248
250
|
const daemon = arches &&
|
|
249
251
|
readDaemon(timeoutOnly ? undefined : raw.daemon, c.root, arches, (e) => c.fail(e.code, e.message));
|
|
252
|
+
let dependencies;
|
|
253
|
+
try {
|
|
254
|
+
const explicit = readPackageDependencies(raw.dependencies);
|
|
255
|
+
if (backend) {
|
|
256
|
+
const effective = packageDependencies({ backend, daemon, dependencies: explicit });
|
|
257
|
+
if (explicit || effective.packages.length)
|
|
258
|
+
dependencies = effective;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
const problem = dependencyError(error);
|
|
263
|
+
c.fail(problem.code, problem.message);
|
|
264
|
+
}
|
|
250
265
|
const gatewayRaw = isRecord(raw.identity_gateway) ? raw.identity_gateway : {};
|
|
251
266
|
if (raw.compose !== undefined && isRecord(raw.ctl) && raw.ctl.enabled === true)
|
|
252
267
|
c.fail("CTL_UNSUPPORTED_BACKEND", "Compose does not support automatic ctl descriptions; disable ctl.");
|
|
@@ -297,8 +312,8 @@ function readManifest(raw, c) {
|
|
|
297
312
|
if (problem)
|
|
298
313
|
c.fail("STANDALONE_DAEMON_RESTART_SDK_REQUIRED", problem);
|
|
299
314
|
}
|
|
300
|
-
if ((daemon?.ingress || daemon?.node) && raw.identity_gateway !== undefined)
|
|
301
|
-
c.fail("STANDALONE_DAEMON_INVALID", "daemon.node and daemon.ingress run the App's own daemon; identity_gateway runs its gateway as the daemon. Use one or the other.");
|
|
315
|
+
if ((daemon?.ingress || daemon?.node || daemon?.backend) && raw.identity_gateway !== undefined)
|
|
316
|
+
c.fail("STANDALONE_DAEMON_INVALID", "daemon.backend, daemon.node and daemon.ingress run the App's own daemon; identity_gateway runs its gateway as the daemon. Use one or the other.");
|
|
302
317
|
if (compose && identityGateway && compose.sdk !== identityGateway.sdk)
|
|
303
318
|
c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose and identity_gateway must use the same SDK release.");
|
|
304
319
|
if (compose &&
|
|
@@ -507,6 +522,7 @@ function readManifest(raw, c) {
|
|
|
507
522
|
...(rootOps ? { rootOps } : {}),
|
|
508
523
|
...(rootPrograms ? { rootPrograms } : {}),
|
|
509
524
|
...(daemon ? { daemon } : {}),
|
|
525
|
+
...(dependencies ? { dependencies } : {}),
|
|
510
526
|
...(lifecycleTimeouts ? { lifecycleTimeouts } : {}),
|
|
511
527
|
...(identityGateway ? { identityGateway } : {}),
|
|
512
528
|
...(compose ? { compose } : {}),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PackageDependencies } from "./package-dependencies.js";
|
|
1
2
|
import { type PackageMetadata } from "../package-metadata.js";
|
|
2
3
|
import type { CommandRunner } from './build.js';
|
|
3
4
|
import { type Toolkit } from './toolkit.js';
|
|
@@ -21,6 +22,7 @@ type Binary = {
|
|
|
21
22
|
};
|
|
22
23
|
export interface NativeLayout {
|
|
23
24
|
metadata?: PackageMetadata;
|
|
25
|
+
dependencies?: PackageDependencies;
|
|
24
26
|
root: string;
|
|
25
27
|
package: string;
|
|
26
28
|
version: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readPackageDependencies, dependencyInfo, inspectPackageDependencies } from "./package-dependencies.js";
|
|
1
2
|
import { portalInfoFields } from "../portal/info.js";
|
|
2
3
|
import { validateMetadata, metadataInfoFields } from "../package-metadata.js";
|
|
3
4
|
/** Existing native products: the CLI owns archives/INFO, the project owns its layout. */
|
|
@@ -106,13 +107,15 @@ export function isNativeLayout(root) {
|
|
|
106
107
|
}
|
|
107
108
|
export function loadNativeLayout(root) {
|
|
108
109
|
try {
|
|
109
|
-
const m = object(parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8')), ['schema', 'backend', 'package', 'version', 'build', 'displayname', 'description', 'maintainer', 'metadata', 'os_min_ver', 'privilege', 'arches', 'layout', 'native', 'icon', 'reproducible', 'portal']);
|
|
110
|
+
const m = object(parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8')), ['schema', 'backend', 'package', 'version', 'build', 'displayname', 'description', 'maintainer', 'metadata', 'os_min_ver', 'privilege', 'arches', 'layout', 'native', 'icon', 'reproducible', 'portal', 'dependencies']);
|
|
110
111
|
if (m.schema !== 2)
|
|
111
112
|
fail('schema 2 required; migrate schema: 1 to schema: 2 and add backend: {kind: native}');
|
|
112
113
|
const backend = object(m.backend, ['kind']);
|
|
113
114
|
if (backend.kind !== 'native' || (m.privilege !== undefined && m.privilege !== 'none'))
|
|
114
115
|
fail('backend.kind: native and package-only privilege required');
|
|
115
116
|
m.privilege = 'none';
|
|
117
|
+
if (m.dependencies !== undefined)
|
|
118
|
+
m.dependencies = readPackageDependencies(m.dependencies);
|
|
116
119
|
if (m.metadata !== undefined)
|
|
117
120
|
m.metadata = validateMetadata(m.metadata);
|
|
118
121
|
if (existsSync(join(root, 'compose.yaml')))
|
|
@@ -330,11 +333,12 @@ export async function buildNativeLayout(options) {
|
|
|
330
333
|
await createReproducibleTar(target, join(stage, 'package.tgz'), { gzip: true });
|
|
331
334
|
rmSync(target, { recursive: true });
|
|
332
335
|
const fields = { package: m.package, version, arch: layoutInfoArch(arch), os_min_ver: m.os_min_ver, displayname: m.displayname, description: m.description, maintainer: m.maintainer, dsmuidir: 'ui', dsmappname: `SYNO.SDS.${m.package}.Instance`, startable: 'yes', thirdparty: 'yes', checksum: createHash('md5').update(readFileSync(join(stage, 'package.tgz'))).digest('hex') };
|
|
333
|
-
const
|
|
336
|
+
const deps = dependencyInfo(m.dependencies ?? { packages: [] });
|
|
337
|
+
const info = portalInfoFields([...Object.entries(fields), ...(deps ? [["install_dep_packages", deps]] : []), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)], Boolean(m.portal));
|
|
334
338
|
if (new Set(info.map(([k]) => k)).size !== info.length)
|
|
335
339
|
fail('INFO anchor collides with an INFO field');
|
|
336
340
|
writeFileSync(join(stage, 'INFO'), info.map(([k, v]) => `${k}="${v.replace(/([\\"$`])/g, '\\$1')}"\n`).join(''));
|
|
337
|
-
writeFileSync(join(stage, 'native-layout.json'), JSON.stringify({ format: 1, arch, binaries: binaries.map(({ path, linkage }) => ({ path, linkage })), files, ...(m.layout.anchors ? { anchors: m.layout.anchors } : {}), ...(m.portal ? { portal: m.portal } : {}) }));
|
|
341
|
+
writeFileSync(join(stage, 'native-layout.json'), JSON.stringify({ format: 1, arch, dependencies: m.dependencies ?? { packages: [] }, binaries: binaries.map(({ path, linkage }) => ({ path, linkage })), files, ...(m.layout.anchors ? { anchors: m.layout.anchors } : {}), ...(m.portal ? { portal: m.portal } : {}) }));
|
|
338
342
|
const output = join(outDir, `${m.package}-${version}-${arch}.spk`);
|
|
339
343
|
await createReproducibleTar(stage, output);
|
|
340
344
|
return output;
|
|
@@ -351,9 +355,15 @@ export function inspectNativeLayout(dir, payload, info) {
|
|
|
351
355
|
try {
|
|
352
356
|
if (!/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(info.package ?? '') || !/^\d+(\.\d+){0,3}-\d+$/.test(info.version ?? '') || !/^7\.\d+(\.\d+)?-\d+$/.test(info.os_min_ver ?? ''))
|
|
353
357
|
fail('invalid native INFO identity/version/DSM');
|
|
354
|
-
const inv = object(JSON.parse(readFileSync(join(dir, 'native-layout.json'), 'utf8')), ['format', 'arch', 'binaries', 'files', 'anchors', 'portal']);
|
|
358
|
+
const inv = object(JSON.parse(readFileSync(join(dir, 'native-layout.json'), 'utf8')), ['format', 'arch', 'binaries', 'files', 'anchors', 'portal', 'dependencies']);
|
|
355
359
|
if (inv.format !== 1 || !Object.hasOwn(LAYOUT_ARCHES, inv.arch) || layoutInfoArch(inv.arch) !== info.arch)
|
|
356
360
|
fail('invalid native layout inventory');
|
|
361
|
+
const errors = [];
|
|
362
|
+
const dependencies = inspectPackageDependencies(inv.dependencies, errors);
|
|
363
|
+
if (errors.length)
|
|
364
|
+
fail(errors.join("; "));
|
|
365
|
+
if ((info.install_dep_packages ?? "") !== dependencyInfo(dependencies ?? { packages: [] }))
|
|
366
|
+
fail("INFO install_dep_packages differs from native layout dependencies");
|
|
357
367
|
checkLayoutMetadata(dir, true, info.package);
|
|
358
368
|
const actual = layoutFiles(payload);
|
|
359
369
|
if (JSON.stringify(actual) !== JSON.stringify(inv.files))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StandaloneManifest, StandaloneError } from "./manifest.js";
|
|
2
|
+
export interface PackageDependency {
|
|
3
|
+
name: string;
|
|
4
|
+
min_version?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface PackageDependencies {
|
|
7
|
+
packages: PackageDependency[];
|
|
8
|
+
}
|
|
9
|
+
/** Fixed INFO tokens, never shell expressions or dependency operators. */
|
|
10
|
+
export declare function readPackageDependencies(value: unknown): PackageDependencies | undefined;
|
|
11
|
+
/** Unversioned requirements accept a specified minimum; distinct minima conflict. */
|
|
12
|
+
export declare function mergePackageDependencies(...groups: PackageDependency[][]): PackageDependency[];
|
|
13
|
+
export declare function packageDependencies(manifest: Pick<StandaloneManifest, "backend" | "daemon" | "dependencies">): PackageDependencies;
|
|
14
|
+
export declare function dependencyInfo(dependencies: PackageDependencies): string;
|
|
15
|
+
/** Old build records have no field; new records must already be normalized. */
|
|
16
|
+
export declare function inspectPackageDependencies(value: unknown, findings: string[]): PackageDependencies | undefined;
|
|
17
|
+
export declare function dependencyError(error: unknown): StandaloneError;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
const record = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
3
|
+
const NAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
|
|
4
|
+
const VERSION = /^\d+(?:\.\d+){0,3}(?:-\d+)?$/;
|
|
5
|
+
/** Fixed INFO tokens, never shell expressions or dependency operators. */
|
|
6
|
+
export function readPackageDependencies(value) {
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
if (!record(value) || Object.keys(value).some(k => k !== "packages") || !Array.isArray(value.packages))
|
|
10
|
+
throw new Error("dependencies accepts only packages: an array of {name, min_version?}.");
|
|
11
|
+
const packages = value.packages.map((entry, i) => {
|
|
12
|
+
if (!record(entry) || Object.keys(entry).some(k => k !== "name" && k !== "min_version") ||
|
|
13
|
+
typeof entry.name !== "string" || !NAME.test(entry.name))
|
|
14
|
+
throw new Error(`dependencies.packages[${i}].name must start with a letter and contain only ASCII letters, digits, _, . or - (1-64 characters); only name and min_version are accepted.`);
|
|
15
|
+
if (entry.min_version !== undefined && (typeof entry.min_version !== "string" || entry.min_version.length > 64 || !VERSION.test(entry.min_version)))
|
|
16
|
+
throw new Error(`dependencies.packages[${i}].min_version must be a quoted numeric version with 1-4 components and an optional numeric -build (at most 64 characters).`);
|
|
17
|
+
return { name: entry.name, ...(entry.min_version !== undefined ? { min_version: entry.min_version } : {}) };
|
|
18
|
+
});
|
|
19
|
+
return { packages: mergePackageDependencies(packages) };
|
|
20
|
+
}
|
|
21
|
+
/** Unversioned requirements accept a specified minimum; distinct minima conflict. */
|
|
22
|
+
export function mergePackageDependencies(...groups) {
|
|
23
|
+
const byName = new Map();
|
|
24
|
+
for (const dep of groups.flat()) {
|
|
25
|
+
const prev = byName.get(dep.name);
|
|
26
|
+
if (prev?.min_version !== undefined && dep.min_version !== undefined && prev.min_version !== dep.min_version)
|
|
27
|
+
throw new Error(`Conflicting min_version for package ${dep.name}: ${prev.min_version} and ${dep.min_version}. Use one identical minimum version.`);
|
|
28
|
+
byName.set(dep.name, { name: dep.name, ...(prev?.min_version !== undefined ? { min_version: prev.min_version } : dep.min_version !== undefined ? { min_version: dep.min_version } : {}) });
|
|
29
|
+
}
|
|
30
|
+
return [...byName.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
31
|
+
}
|
|
32
|
+
export function packageDependencies(manifest) {
|
|
33
|
+
const node = "node" in manifest.backend ? manifest.backend.node : undefined;
|
|
34
|
+
const daemonNode = manifest.daemon?.node;
|
|
35
|
+
return { packages: mergePackageDependencies(readPackageDependencies(manifest.dependencies)?.packages ?? [], node ? [{ name: node.package }] : [], daemonNode ? [{ name: daemonNode.package, ...(daemonNode.min_version ? { min_version: daemonNode.min_version } : {}) }] : []) };
|
|
36
|
+
}
|
|
37
|
+
export function dependencyInfo(dependencies) {
|
|
38
|
+
return dependencies.packages.map(d => d.name + (d.min_version === undefined ? "" : `>=${d.min_version}`)).join(":");
|
|
39
|
+
}
|
|
40
|
+
/** Old build records have no field; new records must already be normalized. */
|
|
41
|
+
export function inspectPackageDependencies(value, findings) {
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
try {
|
|
45
|
+
const normalized = readPackageDependencies(value);
|
|
46
|
+
if (!isDeepStrictEqual(normalized, value))
|
|
47
|
+
throw new Error("dependencies must be sorted, deduplicated and normalized.");
|
|
48
|
+
return normalized;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
findings.push(`DEPENDENCIES_INVALID build metadata dependencies: ${error.message}`);
|
|
52
|
+
return { packages: [] };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function dependencyError(error) {
|
|
56
|
+
return { code: "STANDALONE_DEPENDENCIES_INVALID", message: error.message };
|
|
57
|
+
}
|
package/dist/standalone/spk.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { packageDependencies, dependencyInfo } from "./package-dependencies.js";
|
|
1
2
|
import { settingsInstallSteps, settingsConfig } from './install-settings.js';
|
|
2
3
|
import { backupConfig, backupHookProblem, backupRecord, renderBackupScripts, BACKUP_PAYLOAD_DIR, BACKUP_SCRIPTS_DIR, BACKUP_SCRIPT_MODES } from './hyper-backup.js';
|
|
3
4
|
import { nativeGatewaySupported, nativeGatewaySdk, renderNativeGatewayConfig, NATIVE_GATEWAY_CONFIG, legacyGatewayNotice, gatewayEntryPath } from './native-gateway.js';
|
|
@@ -31,7 +32,7 @@ import { copyAppTree } from "./bridge-app.js";
|
|
|
31
32
|
import { isBridgeBackend } from "./bridge-backend.js";
|
|
32
33
|
import { hostScriptName, renderHostAdapterScript } from "./host-adapter.js";
|
|
33
34
|
import { writeComposePayload } from "./compose.js";
|
|
34
|
-
import { copyDaemonPayload, daemonSdkVersion
|
|
35
|
+
import { copyDaemonPayload, daemonSdkVersion } from "./daemon.js";
|
|
35
36
|
import { DAEMON_INGRESS_CONFIG, renderDaemonIngressConfig } from "./daemon-ingress.js";
|
|
36
37
|
import { gatewayCgiPaths, gatewayExternalAccess, GATEWAY_CONFIG_PATH, GATEWAY_EXEC, identityGatewayConfig, identityGatewayDeclaration, } from "./identity-gateway.js";
|
|
37
38
|
import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit, BRIDGE_WORKER_ARGS, } from "./background.js";
|
|
@@ -244,6 +245,7 @@ export async function assembleStandaloneSpk(options) {
|
|
|
244
245
|
.digest("hex");
|
|
245
246
|
writeFile(join(staging, "sdk-package.json"), JSON.stringify({
|
|
246
247
|
schema: 2,
|
|
248
|
+
dependencies: packageDependencies(manifest),
|
|
247
249
|
...(moduleVersion ? { ui_module: moduleVersion } : {}),
|
|
248
250
|
...(manifest.installSettings ? { installSettings: manifest.installSettings } : {}),
|
|
249
251
|
...(backup ? { hyperBackup: backupRecord(backup) } : {}),
|
|
@@ -389,11 +391,9 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
|
|
|
389
391
|
"adminurl",
|
|
390
392
|
gatewayEntryPath(manifest),
|
|
391
393
|
]);
|
|
392
|
-
const
|
|
393
|
-
if (
|
|
394
|
-
fields.push(["install_dep_packages",
|
|
395
|
-
if (manifest.daemon?.node)
|
|
396
|
-
fields.push(["install_dep_packages", nodeDependency(manifest.daemon.node)]);
|
|
394
|
+
const dependencies = dependencyInfo(packageDependencies(manifest));
|
|
395
|
+
if (dependencies)
|
|
396
|
+
fields.push(["install_dep_packages", dependencies]);
|
|
397
397
|
fields.push(...metadataInfoFields(manifest.metadata));
|
|
398
398
|
// Background packages need prestart: it runs before DSM's systemd-user-unit acquire.
|
|
399
399
|
if (manifest.daemon || manifest.compose || manifest.capabilities.jobs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@napi-rs/keyring": "^2.1.1",
|
|
30
|
-
"@synopackageland/templates": "~0.
|
|
30
|
+
"@synopackageland/templates": "~0.35.0",
|
|
31
31
|
"ajv": "^8.17.1",
|
|
32
32
|
"commander": "^13.1.0",
|
|
33
33
|
"json-canonicalize": "^3.0.1",
|
package/skills/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
|
|
|
5
5
|
|
|
6
6
|
# Independent Synology SPKs
|
|
7
7
|
|
|
8
|
-
The CLI installs templates through `~0.
|
|
8
|
+
The CLI installs templates through `~0.35.0`. A templates patch needs only a
|
|
9
9
|
templates release; npm install/update selects a patch in that minor line. The CLI
|
|
10
10
|
uses the locally installed templates and reads their SDK pin for defaults; it
|
|
11
11
|
never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
|
|
@@ -24,7 +24,7 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
|
|
|
24
24
|
|
|
25
25
|
For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
|
|
26
26
|
|
|
27
|
-
Use SDK/App 0.
|
|
27
|
+
Use SDK/App 0.37.0 and CLI 0.40.0 for App entry enforcement. Declare every App method
|
|
28
28
|
in the permissions table; dynamic names and unregistered HTTP dispatchers are rejected.
|
|
29
29
|
Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
|
|
30
30
|
paths that rely on upstream authentication. Read
|
|
@@ -99,8 +99,27 @@ Never install, uninstall, publish or modify NAS data without task authorization.
|
|
|
99
99
|
|
|
100
100
|
Native projects use schema 2 capabilities and `--with files,jobs,login,root-ops,delegate`; use `add` for existing projects. Prefer `check --json`, `build --json`, `inspect <spk> --json`, `deploy --dry-run --json`, and `status --host <profile> --json` for structured results. Read the conflict policy in reference/create.md before adding overlays.
|
|
101
101
|
|
|
102
|
+
For DSM Package Center dependencies independent of the App language, declare
|
|
103
|
+
`dependencies: {packages: [{name: Node.js_v22, min_version: "22.19.0"}]}` in
|
|
104
|
+
`dsm-package.yaml`; `min_version` is optional. Names are 1-64 ASCII letters,
|
|
105
|
+
digits, `_`, `.` or `-`, starting with a letter. Versions are quoted numeric
|
|
106
|
+
strings with 1-4 dotted components and optional numeric `-build` (64 characters
|
|
107
|
+
maximum); other keys and unsafe INFO syntax fail validation. Requirements
|
|
108
|
+
merge with a Node backend or `daemon.node`, sort and deduplicate; two distinct
|
|
109
|
+
specified minimum versions for one package fail. `check --json` reports them;
|
|
110
|
+
build records them and `inspect` verifies exact agreement with INFO. This
|
|
111
|
+
works with native layouts and Compose too (host packages are not container
|
|
112
|
+
mounts). It leaves daemon exec/args/env/supervision unchanged. Use
|
|
113
|
+
`daemon.node` only for a JavaScript daemon: it replaces exec with the Node
|
|
114
|
+
launcher. For a Go daemon spawning Node children, keep the Go exec and use
|
|
115
|
+
`dependencies.packages`; call `/var/packages/Node.js_v22/target/usr/local/bin/node`.
|
|
116
|
+
Any dependency's target is `/var/packages/<name>/target`; no env vars or PATH
|
|
117
|
+
entries are injected, and the declaration grants no private-data access.
|
|
118
|
+
|
|
102
119
|
Read reference/portal.md before declaring or changing a DSM Application Portal alias (`add portal`); DSM owns the saved setting; DSM 7.2-64570+ supports schema-2 Portal without an executor, while DSM 7.1 requires executor registration. Read reference/files.md and reference/identity.md for capability boundaries. RootOps and delegate share one-time protected executor boot/control setup with digest approval. Existing native products can explicitly use the separate native runtimes in @synopackageland/sdk/native; do not mix its tasks with generated executor capabilities. App sessions alone do not grant NAS file authority.
|
|
103
120
|
|
|
121
|
+
Before choosing `daemon.backend` (the backend as a resident daemon behind `daemon.ingress`) for speed, read [reference/daemon-mode.md](reference/daemon-mode.md): measure the CGI first; the daemon pays off only for repeated, cacheable per-request work on a NAS with memory to spare.
|
|
122
|
+
|
|
104
123
|
Read [reference/lifecycle.md](reference/lifecycle.md) before editing `dsm-scripts/` (preinst, postinst, preupgrade, postupgrade, preuninst, postuninst, start-stop-status): the SDK runs them inside its generated scripts as the package account; never add root work or repeat SDK steps there.
|
|
105
124
|
|
|
106
125
|
Native Apps appear in Hyper Backup as applications by default. Read [reference/backup.md](reference/backup.md) before changing `backup` in dsm-package.yaml (opt out with `hyper_backup: false`, online mode, data version, hook). Hyper Backup runs the generated `scripts/backup/` as root; it is a fixed template that only switches to the package account, so never add or edit files there.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# When to run the backend as a daemon
|
|
2
|
+
|
|
3
|
+
A native App's backend normally runs as a CGI: DSM starts `ui/cgi/api.cgi`
|
|
4
|
+
for every request, and the process exits when the request ends. With
|
|
5
|
+
`daemon.backend: true` and `daemon.ingress` ([docs/daemon.md](../../docs/daemon.md)),
|
|
6
|
+
the same binary runs once as the package daemon and serves the pages through
|
|
7
|
+
DSM's nginx. The CGI stays for integrations, transfers and fallback.
|
|
8
|
+
|
|
9
|
+
Daemon mode is not the default recommendation. It removes work that repeats
|
|
10
|
+
on every request. It does not make a single expensive request cheaper, and it
|
|
11
|
+
costs memory for as long as the package runs. Measure first, then decide.
|
|
12
|
+
|
|
13
|
+
## Decision table
|
|
14
|
+
|
|
15
|
+
| Factor | Daemon helps | Stay with CGI |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| Fixed cost per request | High: each request loads or compiles many definitions, opens several stores or runs sign-in checks that could be remembered | Low: the handler reads a row or two and returns |
|
|
18
|
+
| Request rate | Pages send many calls; many users at once | A few calls per page; few users |
|
|
19
|
+
| Repeated, cacheable work | Most of the time goes to work whose result does not change between requests (compiled definitions, permission answers, zone data) | Each request does new work (one-off reports, large exports) |
|
|
20
|
+
| Long connections | Streams, server-sent events, long polling | None |
|
|
21
|
+
| Background work | A tick, outbox sending or polling that should run within seconds | A scheduled task every few minutes is enough |
|
|
22
|
+
| App size | Many Apps, large definitions | One small App |
|
|
23
|
+
| NAS memory | Enough room for the base process plus a bounded cache | Small models (2–4 GB) that already swap; every resident MB competes with DSM and other packages |
|
|
24
|
+
| Idle cost | Acceptable: the process stays up when nobody uses the App | Not acceptable: the App is opened rarely |
|
|
25
|
+
|
|
26
|
+
The base process is usually small: an idle Go daemon is typically 10–30 MB
|
|
27
|
+
RSS. Measure your own binary. The real variable is the cache, so give every
|
|
28
|
+
cache an upper bound and measure it filled.
|
|
29
|
+
|
|
30
|
+
## Factors and example numbers
|
|
31
|
+
|
|
32
|
+
These numbers come from Studio Office. Use them as an example of what
|
|
33
|
+
to measure, not as a budget.
|
|
34
|
+
|
|
35
|
+
- **CGI cost per request.** On a DS SA3400 with 25 Apps, every Studio call
|
|
36
|
+
spent 100–140 ms before the handler did any work: process start, the DSM
|
|
37
|
+
sign-in check (`authenticate.cgi`), the App Privileges check and schema
|
|
38
|
+
checks. Recompiling App definitions on each request took about 70% of
|
|
39
|
+
`studio.apps.list`. Measure this on the NAS: time an empty call (for
|
|
40
|
+
example `app.status`) and the slowest common page calls.
|
|
41
|
+
- **Resident memory.** Measured on macOS arm64 in a Go test process, not
|
|
42
|
+
on the NAS: 37 MB RSS with the routes built and the store opened, and
|
|
43
|
+
81 MB after 23 compiled template Apps were kept (about 1.1 MB of
|
|
44
|
+
heap per App).
|
|
45
|
+
The Node AI child has not been measured. Measure on the target NAS with your
|
|
46
|
+
real data before you choose cache sizes.
|
|
47
|
+
- **Cache bounds.** Bound each cache by entry count (LRU) and derive the bound
|
|
48
|
+
from the measured size per entry. Studio keeps 32 compiled releases, about
|
|
49
|
+
35 MB at the measured size. Cache only results that are pure functions of
|
|
50
|
+
their key, such as compiled definitions keyed by a hash of the stored plan,
|
|
51
|
+
and cache only successes.
|
|
52
|
+
- **Sign-in cache.** `dsm.CacheSessions` remembers verified sessions for a
|
|
53
|
+
TTL you choose (Studio: 10 minutes). The cost is staleness: a sign-out, a
|
|
54
|
+
disabled account or a lost administrator role takes effect up to the TTL
|
|
55
|
+
later. App Privileges keep their own 30-second check.
|
|
56
|
+
- **Crash and restart.** The SDK daemon controller restarts the daemon
|
|
57
|
+
(`daemon.restart`). Children of the daemon, such as a Node service, are the
|
|
58
|
+
backend's own job: start them, restart them with backoff and stop them on
|
|
59
|
+
SIGTERM. Keep the CGI as fallback, so pages still work when the daemon is
|
|
60
|
+
down. Retry only reads automatically, plus calls the daemon cannot have seen.
|
|
61
|
+
- **Upgrade.** DSM stops the old daemon and starts the new one. In-memory
|
|
62
|
+
caches start empty and fill on first use. Migrations still run at start.
|
|
63
|
+
The CGI is the same binary, so both paths always run the same version.
|
|
64
|
+
- **Security boundary.** The daemon serves the same handler as the CGI. Origin,
|
|
65
|
+
App entry, DSM session and route permission are checked on every request.
|
|
66
|
+
The ingress socket is mode 0666 because nginx runs as another account, so
|
|
67
|
+
a local process can connect directly and choose `X-Real-IP`. A valid DSM
|
|
68
|
+
session is still required, but IP-based App Privileges rules can be
|
|
69
|
+
bypassed this way. The route audit treats `api.ServeIngress` like
|
|
70
|
+
`cgi.Serve`.
|
|
71
|
+
|
|
72
|
+
## Decision steps
|
|
73
|
+
|
|
74
|
+
1. Measure the CGI. Time an empty authenticated call and the 3–5 calls a
|
|
75
|
+
common page makes, on the target NAS, a few times each. Count how many
|
|
76
|
+
calls one page sends.
|
|
77
|
+
2. Find where the time goes. Split fixed cost (process start, sign-in, entry
|
|
78
|
+
checks) from handler work. Then split handler work into repeated,
|
|
79
|
+
cacheable work and work that changes per request.
|
|
80
|
+
3. Optimize inside the CGI first, if that is enough:
|
|
81
|
+
- Send fewer requests: batch reads, and return all items in one page instead
|
|
82
|
+
of serial paging.
|
|
83
|
+
- Use disk caches keyed by content hash (for example compiled definitions
|
|
84
|
+
next to the store).
|
|
85
|
+
- Avoid loading what the call does not use.
|
|
86
|
+
4. Choose the daemon only when, after step 3, most of the remaining time is
|
|
87
|
+
fixed cost or cacheable repeated work, and the NAS has memory for the
|
|
88
|
+
measured base plus the bounded cache.
|
|
89
|
+
5. After switching, measure idle RSS and RSS with a full cache on the NAS, and
|
|
90
|
+
the same page calls as in step 1. Write the numbers into the App's docs.
|