@synopackageland/cli 0.25.0 → 0.25.2

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.2 (2026-10-22)
4
+
5
+ - Use templates 0.25.1 with the native Rust lockfile pinned to the public SDK 0.25.0 commit. SDK/App support and executor/lifecycle digests are unchanged.
6
+ - Update SDK download and Go build diagnostics for anonymous access to the public repository.
7
+
8
+ ## 0.25.1 (2026-10-22)
9
+
10
+ - Fix upgrades that first add a `daemon`: the generated `preupgrade` ran the old payload's missing `daemon/control` and DSM rolled back with error 4541. The hook now exits 0 when the outgoing payload has no `daemon/` (plain, executor and delegated-worker variants); inspect still accepts the released unguarded hook.
11
+ - Add `daemon.node` (`package: Node.js_v18|v20|v22`, optional `min_version`): the daemon's `exec` is a JavaScript entry run by the Package Center Node.js package through a generated launcher, with INFO `install_dep_packages`. The SPK bundles no Node. See docs/daemon.md for availability and dependency behaviour.
12
+ - Add `daemon.ingress` (`path`, optional `timeout`, default 3600 s): a same-origin path in DSM's nginx through web-config `nginx-static-config` `type: dsm`, proxied unbuffered to `/tmp/synopkgland-ingress-<package>.sock`, with nginx reloads in INFO. check requires os_min_ver 7.2-64570 or later (`STANDALONE_DAEMON_INGRESS_MIN_DSM`); inspect verifies the location, resource, INFO and daemon environment.
13
+
3
14
  ## 0.25.0 (2026-10-22)
4
15
 
5
16
  - Add the opt-in `console` declaration for native Apps with `identity_gateway`: a native UI URL launcher and an administrator-only DSM console, selected by Package Center Open. Validate both entries and refuse incompatible launch declarations.
@@ -322,6 +322,21 @@ export const ERROR_REGISTRY = {
322
322
  description: "The package daemon's Go supervisor could not be built.",
323
323
  suggestion: "Install Go 1.21 or later (builds pin go1.26.10 and need it offline) and check the compiler stderr; the NAS does not need a Go toolchain.",
324
324
  },
325
+ STANDALONE_DAEMON_NODE_INVALID: {
326
+ code: "STANDALONE_DAEMON_NODE_INVALID",
327
+ description: "daemon.node does not name a Package Center Node.js package and a version of its major.",
328
+ suggestion: "Set daemon.node.package to Node.js_v18, Node.js_v20 or Node.js_v22, and min_version (optional) to a version of that major such as 22.19.0.",
329
+ },
330
+ STANDALONE_DAEMON_INGRESS_INVALID: {
331
+ code: "STANDALONE_DAEMON_INGRESS_INVALID",
332
+ description: "daemon.ingress does not declare one same-origin path and a bounded proxy timeout.",
333
+ suggestion: "Set daemon.ingress.path to /<name>/ with lowercase letters, digits and -, not a DSM path, and timeout (optional) to 1–3600 seconds.",
334
+ },
335
+ STANDALONE_DAEMON_INGRESS_MIN_DSM: {
336
+ code: "STANDALONE_DAEMON_INGRESS_MIN_DSM",
337
+ description: "daemon.ingress uses DSM's web-config worker, which DSM 7.1 refuses in an unsigned package.",
338
+ suggestion: "Raise os_min_ver to 7.2-64570 or later, or remove daemon.ingress.",
339
+ },
325
340
  STANDALONE_DAEMON_PAYLOAD_INVALID: {
326
341
  code: "STANDALONE_DAEMON_PAYLOAD_INVALID",
327
342
  description: "The daemon payload is not a bounded, link-free directory inside the project.",
@@ -525,7 +540,7 @@ export const ERROR_REGISTRY = {
525
540
  STANDALONE_GO_BUILD_FAILED: {
526
541
  code: "STANDALONE_GO_BUILD_FAILED",
527
542
  description: "go build failed.",
528
- suggestion: "Run go build ./... in the backend directory and fix the errors. If github.com/synopackageland/syno-pkg-land-sdk is unreachable, make sure git can read github.com with credentials (verify with git ls-remote).",
543
+ suggestion: "Run go build ./... in the backend directory and fix the errors. If github.com/synopackageland/syno-pkg-land-sdk is unreachable, check network access to the configured Go proxy or the public GitHub repository.",
529
544
  },
530
545
  STANDALONE_PORTAL_INVALID: {
531
546
  code: "STANDALONE_PORTAL_INVALID",
@@ -890,7 +905,7 @@ export const ERROR_REGISTRY = {
890
905
  SDK_DOWNLOAD_FAILED: {
891
906
  code: "SDK_DOWNLOAD_FAILED",
892
907
  description: "go mod download cannot fetch the SDK version backend/go.mod requires.",
893
- suggestion: "The CLI fetches the private SDK with git: make sure git can read github.com with credentials (verify with git ls-remote), then confirm the version exists. Offline, run go mod download once on the same machine first.",
908
+ suggestion: "Confirm the SDK version exists in the public repository and check network access to the configured Go proxy or github.com. Offline, run go mod download for that version on the same machine first.",
894
909
  },
895
910
  TEMPLATES_MISSING: {
896
911
  code: "TEMPLATES_MISSING",
@@ -0,0 +1,21 @@
1
+ import type { StandaloneError } from "./manifest.js";
2
+ /** `daemon.ingress`: one path in DSM's own server blocks, proxied to the daemon. */
3
+ export interface DaemonIngress {
4
+ /** `/<name>/`, served on DSM's ports and custom domains, so same-origin with DSM. */
5
+ path: string;
6
+ /** nginx proxy_read_timeout and proxy_send_timeout in seconds. */
7
+ timeout: number;
8
+ }
9
+ export declare const DAEMON_INGRESS_CONFIG = "daemon/ingress-nginx.conf";
10
+ export declare function readDaemonIngress(value: unknown, fail: (error: StandaloneError) => void): DaemonIngress | undefined;
11
+ /** DSM before 7.2 rejects an unsigned web-config, the worker this path uses. */
12
+ export declare function daemonIngressMinDsmProblem(osMinVer: string | undefined): string | undefined;
13
+ export declare const daemonIngressSocket: (pkg: string) => string;
14
+ /** What the daemon learns: where to listen and which path prefix it serves. */
15
+ export declare function ingressEnv(pkg: string, ingress: DaemonIngress): Record<string, string>;
16
+ export declare function daemonIngressResource(): Record<string, unknown>;
17
+ /**
18
+ * Included in DSM's own server blocks. Streaming responses (SSE) pass
19
+ * unbuffered; nginx replaces any client X-Real-IP with the peer address.
20
+ */
21
+ export declare function renderDaemonIngressConfig(pkg: string, ingress: DaemonIngress): string;
@@ -0,0 +1,61 @@
1
+ import { isDsmAtLeast, PORTAL_MIN_DSM } from "../build-spk/dsm-version.js";
2
+ import { RESERVED_PORTAL_ALIASES } from "../portal/alias.js";
3
+ export const DAEMON_INGRESS_CONFIG = "daemon/ingress-nginx.conf";
4
+ const PATH = /^\/[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\/$/;
5
+ const reserved = (path) => RESERVED_PORTAL_ALIASES.includes(path.slice(1, -1));
6
+ export function readDaemonIngress(value, fail) {
7
+ if (value === undefined)
8
+ return undefined;
9
+ const v = value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
10
+ const timeout = v?.timeout ?? 3600;
11
+ if (!v ||
12
+ Object.keys(v).some((k) => k !== "path" && k !== "timeout") ||
13
+ typeof v.path !== "string" ||
14
+ !PATH.test(v.path) ||
15
+ reserved(v.path) ||
16
+ !Number.isInteger(timeout) ||
17
+ timeout < 1 ||
18
+ timeout > 3600) {
19
+ fail({
20
+ code: "STANDALONE_DAEMON_INGRESS_INVALID",
21
+ message: `daemon.ingress needs path "/<name>/" (lowercase letters, digits and -, not a DSM path such as /webapi/) and an optional timeout of 1–3600 seconds.`,
22
+ });
23
+ return undefined;
24
+ }
25
+ return { path: v.path, timeout: timeout };
26
+ }
27
+ /** DSM before 7.2 rejects an unsigned web-config, the worker this path uses. */
28
+ export function daemonIngressMinDsmProblem(osMinVer) {
29
+ if (osMinVer && isDsmAtLeast(osMinVer, PORTAL_MIN_DSM))
30
+ return undefined;
31
+ return `daemon.ingress needs os_min_ver ${PORTAL_MIN_DSM} or later; os_min_ver is "${osMinVer ?? ""}". DSM 7.1 rejects the unsigned web-config worker that adds the path to DSM's nginx. Raise os_min_ver or remove daemon.ingress.`;
32
+ }
33
+ export const daemonIngressSocket = (pkg) => `/tmp/synopkgland-ingress-${pkg}.sock`;
34
+ /** What the daemon learns: where to listen and which path prefix it serves. */
35
+ export function ingressEnv(pkg, ingress) {
36
+ return { SYNOPKGLAND_INGRESS_PATH: ingress.path, SYNOPKGLAND_INGRESS_SOCKET: daemonIngressSocket(pkg) };
37
+ }
38
+ export function daemonIngressResource() {
39
+ return { "nginx-static-config": { enable: [{ type: "dsm", relpath: DAEMON_INGRESS_CONFIG }] } };
40
+ }
41
+ /**
42
+ * Included in DSM's own server blocks. Streaming responses (SSE) pass
43
+ * unbuffered; nginx replaces any client X-Real-IP with the peer address.
44
+ */
45
+ export function renderDaemonIngressConfig(pkg, ingress) {
46
+ return `location ^~ ${ingress.path} {
47
+ proxy_pass http://unix:${daemonIngressSocket(pkg)}:;
48
+ proxy_http_version 1.1;
49
+ proxy_set_header Host $http_host;
50
+ proxy_set_header X-Real-IP $remote_addr;
51
+ proxy_set_header Connection "";
52
+ proxy_buffering off;
53
+ proxy_request_buffering off;
54
+ proxy_cache off;
55
+ gzip off;
56
+ proxy_read_timeout ${ingress.timeout}s;
57
+ proxy_send_timeout ${ingress.timeout}s;
58
+ client_max_body_size 8m;
59
+ }
60
+ `;
61
+ }
@@ -1,4 +1,6 @@
1
1
  import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
2
+ import { NODE_PACKAGES } from "./bridge-backend.js";
3
+ import { type DaemonIngress } from "./daemon-ingress.js";
2
4
  import type { CommandRunner } from "./build.js";
3
5
  import type { StandaloneError, StandaloneManifest } from "./manifest.js";
4
6
  export interface Daemon {
@@ -17,7 +19,26 @@ export interface Daemon {
17
19
  status: number;
18
20
  uninstall: number;
19
21
  };
22
+ /** `exec` is a JavaScript entry run by the node of a Package Center Node.js package. */
23
+ node?: DaemonNode;
24
+ /** A same-origin DSM path that nginx proxies to the daemon's Unix socket. */
25
+ ingress?: DaemonIngress;
20
26
  }
27
+ export interface DaemonNode {
28
+ package: (typeof NODE_PACKAGES)[number];
29
+ /** The lowest package version INFO install_dep_packages accepts. */
30
+ min_version?: string;
31
+ }
32
+ /** The CLI-generated launcher; the payload must not ship its own directory. */
33
+ export declare const NODE_LAUNCHER = "_synopkgland/node";
34
+ export declare const nodeBinary: (pkg: string) => string;
35
+ /** INFO install_dep_packages for a Node daemon. */
36
+ export declare function nodeDependency(node: DaemonNode): string;
37
+ /**
38
+ * Runs the dependency's node in place of the shell, so daemon/control
39
+ * supervises node itself. A removed dependency fails start with a clear line.
40
+ */
41
+ export declare function renderNodeLauncher(node: DaemonNode): string;
21
42
  /** A fixed argv or env value: only the package's own directories expand. */
22
43
  export declare const token: (v: unknown) => v is string;
23
44
  /** An application variable that cannot steer the loader, the shell or DSM. */
@@ -26,6 +47,11 @@ export declare const envKey: (k: string) => boolean;
26
47
  export declare const relativeExec: (v: unknown) => v is string;
27
48
  export declare function readDaemon(value: unknown, root: string, arches: NativeArchFamily[], fail: (error: StandaloneError) => void): Daemon | undefined;
28
49
  export declare function daemonConfig(manifest: StandaloneManifest): string;
50
+ /**
51
+ * The argv and environment daemon/control runs: a Node daemon execs the
52
+ * launcher with its entry first, and ingress names the socket to listen on.
53
+ */
54
+ export declare function runnableDaemon(pkg: string, daemon: Daemon): Daemon;
29
55
  export declare function buildDaemonControl(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
30
56
  export declare function copyDaemonPayload(manifest: StandaloneManifest, arch: NativeArchFamily, target: string, controller: string): void;
31
57
  /** Copies a bounded, link-free package-account payload tree. */
@@ -1,14 +1,37 @@
1
1
  import { operationBudgetSeconds, runtimeBudgetSdkSupported } from "../runtime/runtime-budgets.js";
2
2
  import { goChildEnv } from "../runtime/go-env.js";
3
3
  import { chmodSync, existsSync, copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, writeFileSync, } from "node:fs";
4
- import { isAbsolute, join, relative, resolve } from "node:path";
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";
7
7
  import { templateSdkVersion } from "../template-paths.js";
8
8
  import { checkCgiBinary } from "./binary.js";
9
+ import { NODE_PACKAGES } from "./bridge-backend.js";
10
+ import { readDaemonIngress, ingressEnv } from "./daemon-ingress.js";
9
11
  import { composeDaemon, composeNetwork } from "./compose.js";
10
12
  import { gatewayDaemon } from "./identity-gateway.js";
11
13
  import { StandaloneBuildError } from "./spk.js";
14
+ /** The CLI-generated launcher; the payload must not ship its own directory. */
15
+ export const NODE_LAUNCHER = "_synopkgland/node";
16
+ export const nodeBinary = (pkg) => `/var/packages/${pkg}/target/usr/local/bin/node`;
17
+ /** INFO install_dep_packages for a Node daemon. */
18
+ export function nodeDependency(node) {
19
+ return node.min_version ? `${node.package}>=${node.min_version}` : node.package;
20
+ }
21
+ /**
22
+ * Runs the dependency's node in place of the shell, so daemon/control
23
+ * supervises node itself. A removed dependency fails start with a clear line.
24
+ */
25
+ export function renderNodeLauncher(node) {
26
+ return `#!/bin/sh
27
+ node=${nodeBinary(node.package)}
28
+ if [ ! -x "$node" ]; then
29
+ echo "${node.package} is not installed or is incomplete; install it in Package Center." >&2
30
+ exit 127
31
+ fi
32
+ exec "$node" "$@"
33
+ `;
34
+ }
12
35
  const fields = new Set([
13
36
  "exec",
14
37
  "targets",
@@ -16,6 +39,8 @@ const fields = new Set([
16
39
  "env",
17
40
  "timeouts",
18
41
  "uninstall_args",
42
+ "node",
43
+ "ingress",
19
44
  ]);
20
45
  const record = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
21
46
  /** A fixed argv or env value: only the package's own directories expand. */
@@ -34,6 +59,8 @@ export const relativeExec = (v) => typeof v === "string" &&
34
59
  v.length <= 200 &&
35
60
  /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(v) &&
36
61
  !v.split("/").some((p) => p === "." || p === "..");
62
+ /** A Node entry such as `server.mjs` or `dist/main.js`, inside the payload. */
63
+ const relativeEntry = (v) => typeof v === "string" && relativeExec(`_/${v}`);
37
64
  export function readDaemon(value, root, arches, fail) {
38
65
  if (value === undefined)
39
66
  return undefined;
@@ -46,7 +73,7 @@ export function readDaemon(value, root, arches, fail) {
46
73
  };
47
74
  if (!record(value) || Object.keys(value).some((k) => !fields.has(k)))
48
75
  return invalid();
49
- if (!relativeExec(value.exec))
76
+ if (!(value.node === undefined ? relativeExec(value.exec) : relativeEntry(value.exec)))
50
77
  return invalid();
51
78
  if (!record(value.targets) ||
52
79
  Object.keys(value.targets).length !== arches.length)
@@ -83,6 +110,14 @@ export function readDaemon(value, root, arches, fail) {
83
110
  const timeouts = readLifecycleTimeouts(value.timeouts, fail);
84
111
  if (!timeouts)
85
112
  return undefined;
113
+ const node = readDaemonNode(value.node, fail);
114
+ if (value.node !== undefined && !node)
115
+ return undefined;
116
+ const ingress = readDaemonIngress(value.ingress, fail);
117
+ if (value.ingress !== undefined && !ingress)
118
+ return undefined;
119
+ if (ingress && Object.keys(env).some((key) => key in ingressEnv("", ingress)))
120
+ return invalid();
86
121
  return {
87
122
  exec: value.exec,
88
123
  targets,
@@ -92,8 +127,31 @@ export function readDaemon(value, root, arches, fail) {
92
127
  .sort()
93
128
  .map((key) => [key, env[key]])),
94
129
  timeouts: timeouts,
130
+ ...(node ? { node } : {}),
131
+ ...(ingress ? { ingress } : {}),
95
132
  };
96
133
  }
134
+ function readDaemonNode(value, fail) {
135
+ if (value === undefined)
136
+ return undefined;
137
+ const pkg = record(value) ? value.package : undefined;
138
+ const min = record(value) ? value.min_version : undefined;
139
+ const major = typeof pkg === "string" ? pkg.replace("Node.js_v", "") : "";
140
+ if (!record(value) ||
141
+ Object.keys(value).some((k) => k !== "package" && k !== "min_version") ||
142
+ !NODE_PACKAGES.includes(pkg) ||
143
+ (min !== undefined &&
144
+ (typeof min !== "string" ||
145
+ !/^[0-9]{1,3}(\.[0-9]{1,4}){0,2}$/.test(min) ||
146
+ min.split(".")[0] !== major))) {
147
+ fail({
148
+ code: "STANDALONE_DAEMON_NODE_INVALID",
149
+ message: `daemon.node needs package (${NODE_PACKAGES.join(", ")}) and an optional min_version of that major, such as "22.19.0".`,
150
+ });
151
+ return undefined;
152
+ }
153
+ return { package: pkg, ...(min ? { min_version: min } : {}) };
154
+ }
97
155
  function daemonSdkVersion(manifest) {
98
156
  return manifest.identityGateway?.sdk ??
99
157
  (manifest.backend.kind === "go" ? appSdkVersion(manifest.backend) : undefined) ??
@@ -104,7 +162,7 @@ export function daemonConfig(manifest) {
104
162
  ? composeDaemon(manifest)
105
163
  : manifest.identityGateway
106
164
  ? gatewayDaemon(manifest.daemon)
107
- : manifest.daemon;
165
+ : runnableDaemon(manifest.package, manifest.daemon);
108
166
  return (JSON.stringify({
109
167
  package: manifest.package,
110
168
  exec,
@@ -117,6 +175,20 @@ export function daemonConfig(manifest) {
117
175
  ...(manifest.compose ? { network: composeNetwork(manifest) } : {}),
118
176
  }) + "\n");
119
177
  }
178
+ /**
179
+ * The argv and environment daemon/control runs: a Node daemon execs the
180
+ * launcher with its entry first, and ingress names the socket to listen on.
181
+ */
182
+ export function runnableDaemon(pkg, daemon) {
183
+ const entry = daemon.node && [`\${SYNOPKG_PKGDEST}/daemon/payload/${daemon.exec}`];
184
+ const env = daemon.ingress ? { ...daemon.env, ...ingressEnv(pkg, daemon.ingress) } : daemon.env;
185
+ return {
186
+ ...daemon,
187
+ ...(entry ? { exec: NODE_LAUNCHER, args: [...entry, ...daemon.args] } : {}),
188
+ ...(entry && daemon.uninstall_args ? { uninstall_args: [...entry, ...daemon.uninstall_args] } : {}),
189
+ env: Object.fromEntries(Object.keys(env).sort().map((key) => [key, env[key]])),
190
+ };
191
+ }
120
192
  export async function buildDaemonControl(manifest, arch, work, run) {
121
193
  if (!manifest.daemon && !(manifest.compose && manifest.identityGateway))
122
194
  return undefined;
@@ -151,7 +223,18 @@ export function copyDaemonPayload(manifest, arch, target, controller) {
151
223
  copyPayloadTree(manifest.root, source, join(target, "payload"));
152
224
  else
153
225
  mkdirSync(join(target, "payload"), { recursive: true });
154
- if (!manifest.compose) {
226
+ if (!manifest.compose && manifest.daemon?.node) {
227
+ const exec = join(source, daemon.exec);
228
+ if (!existsSync(exec) || !lstatSync(exec).isFile())
229
+ throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `daemon.exec must name the Node entry file inside the payload: ${exec}`);
230
+ if (existsSync(join(source, NODE_LAUNCHER.split("/")[0])))
231
+ throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `The daemon payload must not contain ${NODE_LAUNCHER.split("/")[0]}/; the CLI writes its Node launcher there.`);
232
+ mkdirSync(join(target, "payload", dirname(NODE_LAUNCHER)), { recursive: true });
233
+ chmodSync(join(target, "payload", dirname(NODE_LAUNCHER)), 0o755);
234
+ writeFileSync(join(target, "payload", NODE_LAUNCHER), renderNodeLauncher(manifest.daemon.node));
235
+ chmodSync(join(target, "payload", NODE_LAUNCHER), 0o755);
236
+ }
237
+ else if (!manifest.compose) {
155
238
  const exec = join(source, daemon.exec);
156
239
  const problem = checkCgiBinary(manifest.root, exec, arch);
157
240
  if (problem)
@@ -21,7 +21,8 @@ import { familyOfArchToken, NATIVE_ARCH_FAMILIES, } from "../build-spk/dsm-arch.
21
21
  import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DSM, PORTAL_MIN_DSM_REASON, } from "../build-spk/dsm-version.js";
22
22
  import { ELF_MACHINE } from "./binary.js";
23
23
  import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
24
- import { daemonConfig, readDaemon, readLifecycleTimeouts } from "./daemon.js";
24
+ import { daemonConfig, readDaemon, readLifecycleTimeouts, renderNodeLauncher, nodeDependency, NODE_LAUNCHER } from "./daemon.js";
25
+ import { DAEMON_INGRESS_CONFIG, daemonIngressMinDsmProblem, readDaemonIngress, renderDaemonIngressConfig } from "./daemon-ingress.js";
25
26
  import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
26
27
  import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
27
28
  import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
@@ -155,12 +156,14 @@ export async function inspectSpk(spkPath, expectedKind) {
155
156
  const combined = metadata.compose === undefined
156
157
  ? undefined
157
158
  : await recoverCompose(dir, metadata.compose, findings);
158
- const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined));
159
+ const daemonNode = recoverDaemonNode(metadata.daemon_node, findings);
160
+ const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined), daemonNode);
159
161
  if (combined &&
160
162
  (!info.os_min_ver || !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)))
161
163
  findings.push("compose identity_gateway requires DSM >= 7.2.1");
162
164
  await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
163
165
  const nativeGateway = await recoverNativeGateway(dir, metadata.native_gateway, info, findings);
166
+ const daemonIngress = await recoverDaemonIngress(dir, metadata.daemon_ingress, info, paths, findings);
164
167
  const background = compiledKind && paths.has(BACKGROUND_WORKER_PAYLOAD);
165
168
  // Before any fixed-lifecycle comparison: those see the SDK steps alone.
166
169
  const appScripts = standalone || kind === "container"
@@ -179,7 +182,7 @@ export async function inspectSpk(spkPath, expectedKind) {
179
182
  !isDsmAtLeast(info.os_min_ver, PORTAL_MIN_DSM)) {
180
183
  findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${PORTAL_MIN_DSM} a portal needs; ${PORTAL_MIN_DSM_REASON}`);
181
184
  }
182
- checkConf(dir, findings, kind, background, sharedFolder, info.package, portal, combined?.compose, nativeGateway, info.os_min_ver);
185
+ checkConf(dir, findings, kind, background, sharedFolder, info.package, portal, combined?.compose, nativeGateway, info.os_min_ver, daemonIngress);
183
186
  checkScripts(dir, findings);
184
187
  checkUninstall(dir, findings);
185
188
  if (kind === "container")
@@ -207,7 +210,7 @@ export async function inspectSpk(spkPath, expectedKind) {
207
210
  if (standalone && portal)
208
211
  findings.push(...inspectPortalInfo(info, hasExecutor));
209
212
  const daemon = standalone
210
- ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined, nativeGateway, uninstallHook?.uninstall)
213
+ ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined, nativeGateway, uninstallHook?.uninstall, { ingress: daemonIngress, node: daemonNode })
211
214
  : undefined;
212
215
  if (background)
213
216
  checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go", metadata.lifecycleTimeouts, nativeGateway);
@@ -847,7 +850,7 @@ async function recoverCompose(dir, value, findings) {
847
850
  return undefined;
848
851
  }
849
852
  }
850
- async function checkDaemon(dir, info, paths, payload, findings, executor, background, combined, nativeGateway, uninstall) {
853
+ async function checkDaemon(dir, info, paths, payload, findings, executor, background, combined, nativeGateway, uninstall, extras = {}) {
851
854
  if (![...paths].some((path) => path.startsWith("daemon/")))
852
855
  return;
853
856
  if (info.precheckstartstop !== "yes")
@@ -861,7 +864,7 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
861
864
  await tar.x({
862
865
  file: join(dir, "package.tgz"),
863
866
  cwd: unpacked,
864
- filter: (path) => path.replace(/^\.\//, "") === "daemon/config.json",
867
+ filter: (path) => ["daemon/config.json", `daemon/payload/${NODE_LAUNCHER}`].includes(path.replace(/^\.\//, "")),
865
868
  });
866
869
  try {
867
870
  const config = JSON.parse(readFileSync(join(unpacked, "daemon/config.json"), "utf8"));
@@ -887,6 +890,14 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
887
890
  }, dir, ["x86_64"], () => { });
888
891
  if (!daemon)
889
892
  throw new Error("invalid config");
893
+ // The packaged config already carries the launcher argv; only the launcher itself is checked here.
894
+ if (extras.node &&
895
+ (daemon.exec !== NODE_LAUNCHER ||
896
+ !daemon.args[0]?.startsWith("${SYNOPKG_PKGDEST}/daemon/payload/") ||
897
+ readOptional(join(unpacked, "daemon/payload", NODE_LAUNCHER)) !== renderNodeLauncher(extras.node)))
898
+ throw new Error("Node launcher differs from its build declaration");
899
+ if (extras.ingress)
900
+ daemon.ingress = extras.ingress;
890
901
  if (config.operation_seconds !== undefined && (!Number.isInteger(config.operation_seconds) || config.operation_seconds < 1 || config.operation_seconds > 3600))
891
902
  throw new Error('invalid operation budget');
892
903
  if (combined &&
@@ -955,7 +966,7 @@ function archivedInfoTranslations(info) {
955
966
  }
956
967
  return translations;
957
968
  }
958
- function readInfo(path, findings, kind, compose = false) {
969
+ function readInfo(path, findings, kind, compose = false, daemonNode) {
959
970
  if (!existsSync(path)) {
960
971
  findings.push("INFO is missing");
961
972
  return {};
@@ -984,7 +995,10 @@ function readInfo(path, findings, kind, compose = false) {
984
995
  : kind === "native-node"
985
996
  ? /^Node\.js_v[0-9]{2}([><=].*)?$/
986
997
  : undefined;
987
- if (info.install_dep_packages &&
998
+ if (daemonNode && info.install_dep_packages !== nodeDependency(daemonNode))
999
+ findings.push(`daemon.node requires INFO install_dep_packages="${nodeDependency(daemonNode)}"`);
1000
+ else if (!daemonNode &&
1001
+ info.install_dep_packages &&
988
1002
  !(allowed &&
989
1003
  info.install_dep_packages.split(":").every((dep) => allowed.test(dep)))) {
990
1004
  findings.push(`INFO depends on other packages: ${info.install_dep_packages}`);
@@ -1088,7 +1102,44 @@ async function recoverNativeGateway(dir, value, info, findings) {
1088
1102
  return undefined;
1089
1103
  }
1090
1104
  }
1091
- function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, compose, nativeGateway, osMinVer) {
1105
+ function recoverDaemonNode(value, findings) {
1106
+ if (value === undefined)
1107
+ return undefined;
1108
+ const daemon = readDaemon({ exec: "x", targets: {}, node: value }, tmpdir(), [], () => { });
1109
+ if (!daemon?.node || !isDeepStrictEqual(daemon.node, value)) {
1110
+ findings.push("DAEMON_NODE_INVALID sdk-package.json daemon_node is not a Node.js package declaration build writes");
1111
+ return undefined;
1112
+ }
1113
+ return daemon.node;
1114
+ }
1115
+ async function recoverDaemonIngress(dir, value, info, paths, findings) {
1116
+ if (value === undefined)
1117
+ return undefined;
1118
+ try {
1119
+ const ingress = readDaemonIngress(value, () => { });
1120
+ if (!ingress || !isDeepStrictEqual(ingress, value))
1121
+ throw new Error("invalid ingress record");
1122
+ const minDsm = daemonIngressMinDsmProblem(info.os_min_ver);
1123
+ if (minDsm)
1124
+ throw new Error(minDsm);
1125
+ if (!paths.has("daemon/config.json"))
1126
+ throw new Error("ingress needs the package daemon");
1127
+ const root = join(dir, "daemon-ingress-inspection");
1128
+ mkdirSync(root);
1129
+ await tar.x({ file: join(dir, "package.tgz"), cwd: root, filter: (path) => path.replace(/^\.\//, "") === DAEMON_INGRESS_CONFIG });
1130
+ if (readOptional(join(root, DAEMON_INGRESS_CONFIG)) !== renderDaemonIngressConfig(info.package, ingress))
1131
+ throw new Error("ingress location differs from its fixed proxy");
1132
+ for (const key of ["instuninst_restart_services", "startstop_restart_services"])
1133
+ if (!(info[key] ?? "").split(/\s+/).includes("nginx.service"))
1134
+ throw new Error(`ingress requires INFO ${key}`);
1135
+ return ingress;
1136
+ }
1137
+ catch (e) {
1138
+ findings.push(`DAEMON_INGRESS_INVALID ${e.message}`);
1139
+ return undefined;
1140
+ }
1141
+ }
1142
+ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, compose, nativeGateway, osMinVer, daemonIngress) {
1092
1143
  const confDir = join(dir, "conf");
1093
1144
  const entries = existsSync(confDir) ? readdirSync(confDir) : [];
1094
1145
  const nativeResource = kind !== "container"
@@ -1101,6 +1152,7 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, c
1101
1152
  ? { jobs: { worker: "sha256-v1" } }
1102
1153
  : {},
1103
1154
  ...(sharedFolder ? { sharedFolder } : {}),
1155
+ ...(daemonIngress ? { daemon: { ingress: daemonIngress } } : {}),
1104
1156
  }, portal)
1105
1157
  : undefined;
1106
1158
  if (nativeResource !== undefined &&
@@ -42,7 +42,10 @@ function standaloneScriptVariants(name, manifest, expected) {
42
42
  const diagnostic = renderNativeGatewayDiagnostic(manifest, name);
43
43
  const wait = renderNativeGatewayPortWait(manifest, name);
44
44
  const diagnosticOnly = wait ? expected.replace(wait, '') : expected;
45
- return [expected, ...(wait ? [diagnosticOnly] : []), ...(diagnostic ? [diagnosticOnly.replace(diagnostic, '')] : [])];
45
+ const variants = [expected, ...(wait ? [diagnosticOnly] : []), ...(diagnostic ? [diagnosticOnly.replace(diagnostic, '')] : [])];
46
+ // Released packages stop the daemon in preupgrade without the first-daemon guard.
47
+ const unguarded = (v) => v.replace(daemonGuard(manifest.package), '').replace(delegatedDaemonGuard(manifest.package), '');
48
+ return name === 'preupgrade' ? [...new Set([...variants, ...variants.map(unguarded)])] : variants;
46
49
  }
47
50
  function renderStandaloneScriptInternal(name, manifest) {
48
51
  if (manifest.compose) {
@@ -212,11 +215,11 @@ function renderDelegatedDaemonScript(name, manifest) {
212
215
  const worker = (action) => `/usr/syno/bin/synosystemctl ${action} ${backgroundUnitName(pkg)}`;
213
216
  // Try every owner even on failure; never leave protected ingress open merely
214
217
  // because a producer could not stop. A partial teardown remains a failure.
215
- const stop = (daemonAction, executorAction, executorStop = executor(executorAction)) => `result=0\nstop_background_worker || result=1\n${daemon} ${daemonAction} ${paths} || result=1\n${executorStop} || result=1\nexit "$result"\n`;
218
+ const stop = (daemonAction, executorAction, executorStop = executor(executorAction), guard = "") => `result=0\nstop_background_worker || result=1\n${guard}${daemon} ${daemonAction} ${paths} || result=1\n${executorStop} || result=1\nexit "$result"\n`;
216
219
  const workerStop = renderBackgroundWorkerStop(pkg);
217
220
  if (name !== "start-stop-status") {
218
221
  if (name === "preupgrade")
219
- return `#!/bin/sh\n${renderExecutorUpgradeStop(pkg)}${workerStop}${stop("stop", "stop", UPGRADE_STOP)}`;
222
+ return `#!/bin/sh\n${renderExecutorUpgradeStop(pkg)}${workerStop}${stop("stop", "stop", UPGRADE_STOP, delegatedDaemonGuard(pkg))}`;
220
223
  const purge = manifest.uninstall
221
224
  ? stop(manifest.daemon?.uninstall_args ? "uninstall" : "stop", "purge $synopkgland_purge_args").replace(executor("purge $synopkgland_purge_args"), () => `synopkgland_uninstall_hook\n${executor("purge $synopkgland_purge_args")}`)
222
225
  : stop(manifest.daemon?.uninstall_args ? "uninstall" : "stop", "purge");
@@ -273,6 +276,17 @@ function renderExecutorUpgradeStop(pkg) {
273
276
  }
274
277
  `;
275
278
  }
279
+ /**
280
+ * preupgrade runs before DSM replaces the payload, so it sees the outgoing
281
+ * package. Only a payload without any daemon/ is an upgrade that introduces
282
+ * the daemon; a partial or symlinked daemon/ still stops or fails closed.
283
+ */
284
+ const daemonAbsent = (pkg) => {
285
+ const dir = `"\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}/daemon"`;
286
+ return `[ ! -e ${dir} ] && [ ! -L ${dir} ]`;
287
+ };
288
+ const daemonGuard = (pkg) => `if ${daemonAbsent(pkg)}; then exit 0; fi\n`;
289
+ const delegatedDaemonGuard = (pkg) => `${daemonAbsent(pkg)} || `;
276
290
  function renderDaemonScript(name, manifest) {
277
291
  const pkg = manifest.package;
278
292
  const control = `"/var/packages/${pkg}/target/daemon/control"`;
@@ -283,7 +297,7 @@ function renderDaemonScript(name, manifest) {
283
297
  const uninstall = name === "preuninst" && manifest.daemon?.uninstall_args;
284
298
  const upgrade = uninstall
285
299
  ? `if [ "\${SYNOPKG_PKG_STATUS:-}" = "UPGRADE" ]; then\n exec ${control} stop ${paths}\nfi\n`
286
- : "";
300
+ : name === "preupgrade" ? daemonGuard(pkg) : "";
287
301
  return `#!/bin/sh\n${upgrade}exec ${control} ${uninstall ? "uninstall" : "stop"} ${paths}\n`;
288
302
  }
289
303
  if (name !== "start-stop-status")
@@ -11,6 +11,7 @@ import { checkGlossary, loadCatalogs, readInfoTranslations, } from "./i18n.js";
11
11
  import { loadRootOps, readRootSettings, rootBinaryProblem, ROOT_OPS_DIR, } from "./root-ops.js";
12
12
  import { readComposeApp } from "./compose.js";
13
13
  import { readDaemon, readLifecycleTimeouts } from "./daemon.js";
14
+ import { daemonIngressMinDsmProblem } from "./daemon-ingress.js";
14
15
  import { readIdentityGateway, } from "./identity-gateway.js";
15
16
  import { readUninstall } from "./uninstall.js";
16
17
  import { readAppScripts } from "./app-scripts.js";
@@ -277,6 +278,13 @@ function readManifest(raw, c) {
277
278
  c.fail("COMPOSE_WEB_INVALID", "Compose upstream is derived from the SDK Compose ingress endpoint; remove identity_gateway.upstream.");
278
279
  // A failed Compose plan already explains the invalid upstream declaration.
279
280
  const identityGateway = raw.compose !== undefined && !compose ? undefined : readIdentityGateway(gatewayValue, daemon || (compose ? { targets: compose.targets } : undefined), (e) => c.fail(e.code, e.message), false, Boolean(compose?.plan.ingress));
281
+ if (daemon?.ingress) {
282
+ const problem = daemonIngressMinDsmProblem(osMinVer);
283
+ if (problem)
284
+ c.fail("STANDALONE_DAEMON_INGRESS_MIN_DSM", problem);
285
+ }
286
+ if ((daemon?.ingress || daemon?.node) && raw.identity_gateway !== undefined)
287
+ 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.");
280
288
  if (compose && identityGateway && compose.sdk !== identityGateway.sdk)
281
289
  c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose and identity_gateway must use the same SDK release.");
282
290
  if (compose &&
@@ -3,6 +3,7 @@ import type { Capabilities } from "./manifest.js";
3
3
  import { type SharedFolder } from "./shared-folder.js";
4
4
  import { type PortalPackage } from "../portal/package.js";
5
5
  import { type NativeGatewayPlan } from './native-gateway.js';
6
+ import { type DaemonIngress } from "./daemon-ingress.js";
6
7
  /**
7
8
  * `conf/resource` for a native package: only DSM's official `data-share`,
8
9
  * `systemd-user-unit` and `web-config` workers, in a fixed key order.
@@ -15,4 +16,7 @@ export declare function renderStandaloneResource(m: {
15
16
  capabilities: Capabilities;
16
17
  compose?: ComposeApp;
17
18
  sharedFolder?: SharedFolder;
19
+ daemon?: {
20
+ ingress?: DaemonIngress;
21
+ };
18
22
  }, portal?: PortalPackage): string | undefined;
@@ -2,6 +2,7 @@ import { renderDockerProjectResource } from "../build-spk/resource-worker.js";
2
2
  import { sharedFolderResource } from "./shared-folder.js";
3
3
  import { portalResource } from "../portal/package.js";
4
4
  import { nativeGatewayResource } from './native-gateway.js';
5
+ import { daemonIngressResource } from "./daemon-ingress.js";
5
6
  /**
6
7
  * `conf/resource` for a native package: only DSM's official `data-share`,
7
8
  * `systemd-user-unit` and `web-config` workers, in a fixed key order.
@@ -9,7 +10,10 @@ import { nativeGatewayResource } from './native-gateway.js';
9
10
  */
10
11
  export function renderStandaloneResource(m, portal) {
11
12
  const dataShare = m.sharedFolder && sharedFolderResource(m.package, m.sharedFolder);
12
- const webConfig = { ...(portal ? portalResource()['web-config'] : {}), ...nativeGatewayResource(m) };
13
+ const nginx = [nativeGatewayResource(m), m.daemon?.ingress && daemonIngressResource()]
14
+ .flatMap((r) => r?.["nginx-static-config"]?.enable ?? []);
15
+ const webConfig = { ...(portal ? portalResource()['web-config'] : {}),
16
+ ...(nginx.length ? { 'nginx-static-config': { enable: nginx } } : {}) };
13
17
  const resource = {
14
18
  ...(m.compose?.plan.devices?.gpu && !m.compose.plan.mounts_blocked ? { "video-driver": {} } : {}),
15
19
  ...(m.compose && !m.compose.plan.mounts_blocked
@@ -29,7 +29,8 @@ import { copyAppTree } from "./bridge-app.js";
29
29
  import { isBridgeBackend } from "./bridge-backend.js";
30
30
  import { hostScriptName, renderHostAdapterScript } from "./host-adapter.js";
31
31
  import { writeComposePayload } from "./compose.js";
32
- import { copyDaemonPayload } from "./daemon.js";
32
+ import { copyDaemonPayload, nodeDependency } from "./daemon.js";
33
+ import { DAEMON_INGRESS_CONFIG, renderDaemonIngressConfig } from "./daemon-ingress.js";
33
34
  import { GATEWAY_CGI_PATH, GATEWAY_CONFIG_PATH, GATEWAY_EXEC, identityGatewayConfig, identityGatewayDeclaration, } from "./identity-gateway.js";
34
35
  import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit, BRIDGE_WORKER_ARGS, } from "./background.js";
35
36
  import { renderStandaloneResource } from "./resource.js";
@@ -104,6 +105,8 @@ export async function assembleStandaloneSpk(options) {
104
105
  if (!options.daemonControl)
105
106
  throw new StandaloneBuildError("STANDALONE_DAEMON_ARTIFACT_REQUIRED", "daemon controller artifact required.");
106
107
  copyDaemonPayload(manifest, options.arch, join(staging, "package", "daemon"), options.daemonControl);
108
+ if (manifest.daemon?.ingress)
109
+ writeFile(join(staging, "package", DAEMON_INGRESS_CONFIG), renderDaemonIngressConfig(manifest.package, manifest.daemon.ingress));
107
110
  }
108
111
  if (manifest.identityGateway) {
109
112
  if (!options.identityGateway)
@@ -230,6 +233,8 @@ export async function assembleStandaloneSpk(options) {
230
233
  backend: manifest.backend.kind,
231
234
  ...(gatewayAdmissionPolicy(manifest.identityGateway, manifest.entry) ? { provider_only_oidc: gatewayAdmissionPolicy(manifest.identityGateway, manifest.entry) } : {}),
232
235
  ...(nativeGatewaySupported(manifest) ? { native_gateway: { sdk: manifest.identityGateway.sdk, listen: manifest.identityGateway.listen } } : {}),
236
+ ...(manifest.daemon?.ingress ? { daemon_ingress: manifest.daemon.ingress } : {}),
237
+ ...(manifest.daemon?.node ? { daemon_node: manifest.daemon.node } : {}),
233
238
  launch: resolvedSdkLaunch(manifest),
234
239
  capabilities: Object.keys(manifest.capabilities).sort(),
235
240
  ...(manifest.compose
@@ -360,6 +365,8 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
360
365
  const backend = manifest.backend;
361
366
  if (isBridgeBackend(backend) && backend.node)
362
367
  fields.push(["install_dep_packages", backend.node.package]);
368
+ if (manifest.daemon?.node)
369
+ fields.push(["install_dep_packages", nodeDependency(manifest.daemon.node)]);
363
370
  fields.push(...metadataInfoFields(manifest.metadata));
364
371
  // Background packages need prestart: it runs before DSM's systemd-user-unit acquire.
365
372
  if (manifest.daemon || manifest.compose || manifest.capabilities.jobs)
@@ -371,9 +378,11 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
371
378
  if (text?.description)
372
379
  fields.push([`description_${DSM_LANGS[locale]}`, text.description]);
373
380
  }
374
- if (nativeGatewaySupported(manifest))
381
+ // DSM reloads nginx on start and stop so the location follows the package state.
382
+ const nginx = nativeGatewaySupported(manifest) || Boolean(manifest.daemon?.ingress);
383
+ if (nginx)
375
384
  fields.push(['startstop_restart_services', 'nginx.service']);
376
- return portalInfoFields(fields, Boolean(manifest.portal) || nativeGatewaySupported(manifest)).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
385
+ return portalInfoFields(fields, Boolean(manifest.portal) || nginx).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
377
386
  }
378
387
  function copyUiTree(source, target) {
379
388
  for (const entry of ["app.js", "app.css"]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
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.25.0",
30
+ "@synopackageland/templates": "0.25.1",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",