@synopackageland/cli 0.24.1 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.1 (2026-10-22)
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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.
8
+
9
+ ## 0.25.0 (2026-10-22)
10
+
11
+ - 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.
12
+ - Fix installation-settings inspection when native identity-gateway diagnostics precede the settings hook, retaining full lifecycle validation.
13
+ - Document punctuation rules for owned UI copy in the development skill.
14
+ - Support SDK/App 0.25.0 and templates 0.25.0, with UI 0.14.3 in Vue scaffolds. Reuse executor/v0.24.0 with unchanged protected executor/lifecycle payloads and Go 1.26.10. OIDC browser cold-login continuation requires SDK 0.25.0. No NAS acceptance is claimed.
15
+
3
16
  ## 0.24.1 (2026-10-22)
4
17
 
5
18
  - Build SDK/App 0.24.1 with the verified immutable executor/v0.24.0 release and its exact executor/lifecycle digests; retain SDK/App 0.24.0 support. SDK 0.24.1 adds AppShell brand navigation to the first page and supports UI 0.14.3.
package/README.md CHANGED
@@ -268,6 +268,16 @@ A full page does not need Application Portal. Omit `portal` unless an alias is
268
268
  explicitly wanted; only that opt-in adds `defaultAlias`, login styling, Portal
269
269
  panels and capabilities. No Portal is a healthy default for inspect/info/doctor.
270
270
 
271
+ A native App with `identity_gateway` can declare `console` to get two main-menu
272
+ icons: the native UI (`displayname`, a `type: "url"` launcher to `sso/open.cgi`
273
+ that opens a new tab) and an administrator-only DSM window running the SDK UI
274
+ from `ui.dir`, named "<displayname> for DSM" by default. Package Center's Open
275
+ (INFO `dsmapplaunchname`) starts the console, and Application Privileges lists
276
+ only the native icon. `console: {}` keeps the default name; `console: { title }`
277
+ sets it for every language. Omit `console` to keep one icon. check refuses
278
+ `console` on Compose, without `identity_gateway`, or with `ui.launch` or
279
+ `portal`. A cold DSM sign-in resumes through the url launcher.
280
+
271
281
  Compose uses schema-2 `dsm-package.yaml` and `ui.launch`; the default is new-page.
272
282
  Classless gateways use type url via the shared contract/app-launch resolver;
273
283
  AppWindow wrappers use standalone. Headless projects have no launcher.
@@ -18,6 +18,18 @@ export declare function uiTitleFields(app: AppTitle): {
18
18
  texts?: string;
19
19
  preloadTexts?: string[];
20
20
  };
21
+ /** The console's name: the declared title in every language, else "<displayname> for DSM". */
22
+ export declare function consoleTitle(displayname: string, console: {
23
+ title?: string;
24
+ }): string;
25
+ /** {@link uiTitleFields} for the console window, which shares the App's description. */
26
+ export declare function uiConsoleTitleFields(app: AppTitle, console: {
27
+ title?: string;
28
+ }): ReturnType<typeof uiTitleFields>;
21
29
  /** `ui/texts/<dsm lang>/strings` for English and every translated language; none without translations. */
22
- export declare function renderUiTexts(app: AppTitle): Map<string, string>;
23
- export declare function writeUiTexts(uiRoot: string, app: AppTitle): void;
30
+ export declare function renderUiTexts(app: AppTitle, console?: {
31
+ title?: string;
32
+ }): Map<string, string>;
33
+ export declare function writeUiTexts(uiRoot: string, app: AppTitle, console?: {
34
+ title?: string;
35
+ }): void;
@@ -4,6 +4,7 @@ import { DSM_LANGS, LOCALES } from "../standalone/i18n.js";
4
4
  const SECTION = "synopkgland";
5
5
  const TITLE = `${SECTION}:app_title`;
6
6
  const DESC = `${SECTION}:app_desc`;
7
+ const CONSOLE_TITLE = `${SECTION}:console_title`;
7
8
  /** `ui/` subdirectory with the strings; a `ui/config` entry's `texts` names it relative to `ui/`. */
8
9
  const TEXTS_DIR = "texts";
9
10
  function translated(app) {
@@ -21,8 +22,18 @@ export function uiTitleFields(app) {
21
22
  ? { title: TITLE, desc: DESC, texts: TEXTS_DIR, preloadTexts: [TITLE, DESC] }
22
23
  : { title: app.displayname, desc: app.description };
23
24
  }
25
+ /** The console's name: the declared title in every language, else "<displayname> for DSM". */
26
+ export function consoleTitle(displayname, console) {
27
+ return console.title ?? `${displayname} for DSM`;
28
+ }
29
+ /** {@link uiTitleFields} for the console window, which shares the App's description. */
30
+ export function uiConsoleTitleFields(app, console) {
31
+ return translated(app)
32
+ ? { title: CONSOLE_TITLE, desc: DESC, texts: TEXTS_DIR, preloadTexts: [CONSOLE_TITLE, DESC] }
33
+ : { title: consoleTitle(app.displayname, console), desc: app.description };
34
+ }
24
35
  /** `ui/texts/<dsm lang>/strings` for English and every translated language; none without translations. */
25
- export function renderUiTexts(app) {
36
+ export function renderUiTexts(app, console) {
26
37
  const files = new Map();
27
38
  if (!translated(app))
28
39
  return files;
@@ -32,12 +43,13 @@ export function renderUiTexts(app) {
32
43
  continue;
33
44
  const title = text?.displayname ?? app.displayname;
34
45
  const desc = text?.description ?? app.description;
35
- files.set(DSM_LANGS[locale], `[${SECTION}]\napp_title="${title}"\napp_desc="${desc}"\n`);
46
+ const consoleLine = console ? `console_title="${consoleTitle(title, console)}"\n` : "";
47
+ files.set(DSM_LANGS[locale], `[${SECTION}]\napp_title="${title}"\napp_desc="${desc}"\n${consoleLine}`);
36
48
  }
37
49
  return files;
38
50
  }
39
- export function writeUiTexts(uiRoot, app) {
40
- for (const [lang, strings] of renderUiTexts(app)) {
51
+ export function writeUiTexts(uiRoot, app, console) {
52
+ for (const [lang, strings] of renderUiTexts(app, console)) {
41
53
  mkdirSync(join(uiRoot, TEXTS_DIR, lang), { recursive: true });
42
54
  const path = join(uiRoot, TEXTS_DIR, lang, "strings");
43
55
  writeFileSync(path, strings, "utf8");
@@ -6,6 +6,9 @@ export declare const DEFAULT_WEB_LAUNCH: AppLaunchMode;
6
6
  export declare function isAppLaunchMode(value: unknown): value is AppLaunchMode;
7
7
  /** Both Compose routes use the same web default; a valid explicit choice wins. */
8
8
  export declare function resolveAppLaunch(value: unknown, webService: boolean): AppLaunchMode | undefined;
9
- export declare function resolvedSdkLaunch(manifest: Pick<StandaloneManifest, 'ui' | 'compose' | 'identityGateway'>): AppLaunchMode;
9
+ /** A console always opens the SDK UI in a DSM window. */
10
+ export declare function resolvedSdkLaunch(manifest: Pick<StandaloneManifest, 'ui' | 'compose' | 'identityGateway'> & {
11
+ console?: unknown;
12
+ }): AppLaunchMode;
10
13
  /** DSM rewrites standalone URLs to launchApp; only SDK wrappers provide that class. */
11
14
  export declare function dsmLauncherType(launch: AppLaunchMode, sdkWindow: boolean): 'standalone' | 'app' | 'url';
@@ -9,8 +9,9 @@ export function resolveAppLaunch(value, webService) {
9
9
  return value === undefined ? webService ? DEFAULT_WEB_LAUNCH : DEFAULT_NATIVE_LAUNCH
10
10
  : isAppLaunchMode(value) ? value : undefined;
11
11
  }
12
+ /** A console always opens the SDK UI in a DSM window. */
12
13
  export function resolvedSdkLaunch(manifest) {
13
- return resolveAppLaunch(manifest.ui.launch, Boolean(manifest.compose || manifest.identityGateway));
14
+ return resolveAppLaunch(manifest.console ? 'dsm-window' : manifest.ui.launch, Boolean(manifest.compose || manifest.identityGateway));
14
15
  }
15
16
  /** DSM rewrites standalone URLs to launchApp; only SDK wrappers provide that class. */
16
17
  export function dsmLauncherType(launch, sdkWindow) {
@@ -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.",
@@ -532,6 +547,26 @@ export const ERROR_REGISTRY = {
532
547
  description: "The portal format or alias in dsm-package.yaml is invalid.",
533
548
  suggestion: "Use portal: {} or portal: { alias: my-app }; alias is 1-64 lowercase alphanumerics or -, and not a DSM reserved path.",
534
549
  },
550
+ STANDALONE_CONSOLE_INVALID: {
551
+ code: "STANDALONE_CONSOLE_INVALID",
552
+ description: "The console format or title in dsm-package.yaml is invalid.",
553
+ suggestion: 'Use console: {} for "<displayname> for DSM", or console: { title: My App Admin } with 1-64 characters and no quotes, backslashes or control characters.',
554
+ },
555
+ STANDALONE_CONSOLE_REQUIRES_GATEWAY: {
556
+ code: "STANDALONE_CONSOLE_REQUIRES_GATEWAY",
557
+ description: "console is declared on a Compose App or an App without identity_gateway.",
558
+ suggestion: "Declare console only on a native App whose web UI the identity gateway serves; otherwise remove console and keep one icon.",
559
+ },
560
+ STANDALONE_CONSOLE_LAUNCH: {
561
+ code: "STANDALONE_CONSOLE_LAUNCH",
562
+ description: "console is declared together with ui.launch.",
563
+ suggestion: "Remove ui.launch: the console always opens in a DSM window and the native icon in a new tab.",
564
+ },
565
+ STANDALONE_CONSOLE_PORTAL: {
566
+ code: "STANDALONE_CONSOLE_PORTAL",
567
+ description: "console is declared together with portal.",
568
+ suggestion: "Declare either console or portal, not both.",
569
+ },
535
570
  STANDALONE_PORTAL_REQUIRES_EXECUTOR: {
536
571
  code: "STANDALONE_PORTAL_REQUIRES_EXECUTOR",
537
572
  description: "An App targeting DSM below 7.2-64570 declares portal without an executor.",
@@ -1,16 +1,5 @@
1
1
  {
2
- "0.24.0": {
3
- "x86_64": "de78684faac211c24a7cda8a4541ab1b138d37f6d580936fe354bb9bd0d78a0f",
4
- "aarch64": "8ef7c736203f6c111816b76f970b7204fa6f8d871ff3222fd7c0a27d0075b847",
5
- "armv7": "a38703119554b5f2a8bf4810ec2a6c8f1d793bfbb1ca96803eb1b39a26ffd96c",
6
- "lifecycle": {
7
- "x86_64": "c6fbb377ecd5ec7ee5b96439f22091d9fa5965dfd616d510ca9445722f436721",
8
- "aarch64": "a9ca1750d4d5cff4727dc29789e6eaf0a365cb0e674f1bb796971d82f2d4fa27",
9
- "armv7": "6d25fc972eba12e3ac68f8b6a95a45e9e5706ebe19ffc1ff774fc318acbeb90f"
10
- },
11
- "goToolchain": "go1.26.10"
12
- },
13
- "0.24.1": {
2
+ "0.25.0": {
14
3
  "x86_64": "de78684faac211c24a7cda8a4541ab1b138d37f6d580936fe354bb9bd0d78a0f",
15
4
  "aarch64": "8ef7c736203f6c111816b76f970b7204fa6f8d871ff3222fd7c0a27d0075b847",
16
5
  "armv7": "a38703119554b5f2a8bf4810ec2a6c8f1d793bfbb1ca96803eb1b39a26ffd96c",
@@ -76,7 +76,7 @@ function goMod(backendDir) {
76
76
  }
77
77
  }
78
78
  /** SDK releases that reuse an immutable executor release with identical payload bytes. */
79
- const EXECUTOR_RELEASES = { '0.21.0': '0.20.0', '0.21.1': '0.20.0', '0.22.0': '0.20.0', '0.23.0': '0.20.0', '0.23.1': '0.20.0', '0.23.2': '0.20.0', '0.24.1': '0.24.0' };
79
+ const EXECUTOR_RELEASES = { '0.21.0': '0.20.0', '0.21.1': '0.20.0', '0.22.0': '0.20.0', '0.23.0': '0.20.0', '0.23.1': '0.20.0', '0.23.2': '0.20.0', '0.24.1': '0.24.0', '0.25.0': '0.24.0' };
80
80
  /**
81
81
  * The module directories of one SDK version. SYNOPKGLAND_SDK_DIR names a
82
82
  * local sdk checkout (development and gates only); otherwise modules come from
@@ -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)
@@ -25,8 +25,13 @@ export declare function windowScriptName(manifest: Pick<StandaloneManifest, "bui
25
25
  /** The launcher fields an entry mode fixes; inspect checks a built SPK against them. */
26
26
  export declare function entryLauncherAccess(entry: EntryMode): Record<string, unknown>;
27
27
  export declare function appTitle(manifest: StandaloneManifest): AppTitle;
28
+ /** The DSM app class whose launch opens the SDK window: the console's when the App declares one. */
29
+ export declare function windowAppId(manifest: Pick<StandaloneManifest, "package" | "console">): string;
28
30
  /**
29
- * `ui/config`: registers the DSM main-menu app and its window class. A portal
31
+ * `ui/config`: registers the DSM main-menu app and its window class. With a
32
+ * console, `.Instance` is the gateway's url launcher (new tab) and `.Console`
33
+ * the administrator-only window; DSM lists only `.Instance` under Application
34
+ * Privileges. A portal
30
35
  * App's launcher also carries the Login Portal style and its icon as the
31
36
  * login page icon (DSM shows `loginIcon`, never `icon`, there).
32
37
  */
@@ -9,7 +9,7 @@ import { HOST_ADAPTER_RUNTIME } from "./host-adapter.js";
9
9
  import { permitsOrdinaryUsers } from "./api-permissions.js";
10
10
  import { portalRuntime } from "./portal-window.js";
11
11
  import { portalByExecutor } from "../build-spk/dsm-version.js";
12
- import { uiTitleFields } from "../build-spk/ui-texts.js";
12
+ import { consoleTitle, uiConsoleTitleFields, uiTitleFields } from "../build-spk/ui-texts.js";
13
13
  import { packageIcons, windowIconFile, windowIconTemplate, } from "../build-spk/dsm-icons.js";
14
14
  import { DEFAULT_WINDOW, FULL_PAGE_MIN_WIDTH } from "./window-size.js";
15
15
  import { requestTimeoutSeconds } from "./request-timeout.js";
@@ -74,7 +74,7 @@ function launcherAccess(manifest) {
74
74
  return manifest.entry
75
75
  ? entryLauncherAccess(manifest.entry)
76
76
  : {
77
- allUsers: manifest.compose
77
+ allUsers: manifest.compose || manifest.console
78
78
  ? manifest.identityGateway?.access !== "dsm-admins"
79
79
  : permitsOrdinaryUsers(manifest.apiPermissions),
80
80
  };
@@ -92,19 +92,35 @@ export function appTitle(manifest) {
92
92
  info: manifest.i18n?.info,
93
93
  };
94
94
  }
95
- /** The app name by locale, as `ui/texts` names the window: `createI18n(lang, ...).t("app_title")`. */
95
+ /**
96
+ * The window's name by locale, as `ui/texts` names it: `createI18n(lang,
97
+ * ...).t("app_title")`. A console window carries the console's name.
98
+ */
96
99
  function displayNameCatalogs(manifest) {
100
+ const name = (displayname) => manifest.console ? consoleTitle(displayname, manifest.console) : displayname;
97
101
  const catalogs = {
98
- en: { app_title: manifest.displayname },
102
+ en: { app_title: name(manifest.displayname) },
99
103
  };
100
104
  for (const [locale, text] of Object.entries(manifest.i18n?.info ?? {})) {
101
105
  if (text?.displayname)
102
- catalogs[locale] = { app_title: text.displayname };
106
+ catalogs[locale] = { app_title: name(text.displayname) };
103
107
  }
104
108
  return catalogs;
105
109
  }
110
+ /** The DSM app class whose launch opens the SDK window: the console's when the App declares one. */
111
+ export function windowAppId(manifest) {
112
+ return `SYNO.SDS.${manifest.package}.${manifest.console ? "Console" : "Instance"}`;
113
+ }
114
+ /** Whether the window itself resumes a cold DSM sign-in; with a console the url launcher does. */
115
+ function windowResumesOIDC(manifest) {
116
+ const oidc = manifest.identityGateway?.oidc;
117
+ return Boolean((oidc?.native_login || oidc?.browser) && !manifest.console);
118
+ }
106
119
  /**
107
- * `ui/config`: registers the DSM main-menu app and its window class. A portal
120
+ * `ui/config`: registers the DSM main-menu app and its window class. With a
121
+ * console, `.Instance` is the gateway's url launcher (new tab) and `.Console`
122
+ * the administrator-only window; DSM lists only `.Instance` under Application
123
+ * Privileges. A portal
108
124
  * App's launcher also carries the Login Portal style and its icon as the
109
125
  * login page icon (DSM shows `loginIcon`, never `icon`, there).
110
126
  */
@@ -127,6 +143,38 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), lo
127
143
  },
128
144
  }, null, 2) + "\n");
129
145
  }
146
+ if (manifest.console) {
147
+ const consoleFields = uiConsoleTitleFields(appTitle(manifest), manifest.console);
148
+ return `${JSON.stringify({
149
+ ".url": {
150
+ [`${ns}.Instance`]: {
151
+ type: "url",
152
+ ...titleFields,
153
+ icon,
154
+ ...launcherAccess(manifest),
155
+ url: gatewayEntryPath(manifest),
156
+ },
157
+ },
158
+ [windowScriptName(manifest)]: {
159
+ [`${ns}.Console`]: {
160
+ type: "app",
161
+ ...consoleFields,
162
+ icon,
163
+ appWindow: `${ns}.MainWindow`,
164
+ allowMultiInstance: false,
165
+ allUsers: false,
166
+ depend: [`${ns}.MainWindow`],
167
+ },
168
+ [`${ns}.MainWindow`]: {
169
+ type: "lib",
170
+ title: consoleFields.title,
171
+ texts: consoleFields.texts,
172
+ icon,
173
+ depend: [],
174
+ },
175
+ },
176
+ }, null, 2)}\n`;
177
+ }
130
178
  return `${JSON.stringify({
131
179
  [windowScriptName(manifest)]: {
132
180
  [`${ns}.Instance`]: {
@@ -379,7 +427,7 @@ ${ns}.prepareFullPage = function (title) {
379
427
  favicon.href = ${ns}.appIcon;
380
428
  };
381
429
 
382
- ${(manifest.identityGateway?.oidc?.native_login || manifest.identityGateway?.oidc?.browser) ? `${ns}.resumeOIDC = ${oidcResumeRuntime(pkg, Object.values(manifest.identityGateway.listen).filter((p) => typeof p === "number"))};` : ""}
430
+ ${windowResumesOIDC(manifest) ? `${ns}.resumeOIDC = ${oidcResumeRuntime(pkg, Object.values(manifest.identityGateway.listen).filter((p) => typeof p === "number"))};` : ""}
383
431
 
384
432
  // DSM reopens a window at the size it was last left at, even one saved on a
385
433
  // larger screen, and only moves a window that does not fit: keep it inside.
@@ -390,7 +438,7 @@ ${ns}.fitDesktop = function (size) {
390
438
  return size;
391
439
  };
392
440
 
393
- Ext.define(${JSON.stringify(`${ns}.Instance`)}, {
441
+ Ext.define(${JSON.stringify(windowAppId(manifest))}, {
394
442
  extend: "SYNO.SDS.AppInstance",
395
443
  appWindowName: ${JSON.stringify(`${ns}.MainWindow`)}
396
444
  });
@@ -408,7 +456,7 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
408
456
  },
409
457
  onOpen: function (param) {
410
458
  this.callParent(arguments);
411
- ${(manifest.identityGateway?.oidc?.native_login || manifest.identityGateway?.oidc?.browser) ? `if (${ns}.resumeOIDC(param)) return;` : ""}
459
+ ${windowResumesOIDC(manifest) ? `if (${ns}.resumeOIDC(param)) return;` : ""}
412
460
  var me = this;
413
461
  if (me.mounted) {
414
462
  me.reopenEvents.emit();
@@ -471,7 +519,7 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
471
519
  // with that launch's param. Before the App mounts, it mounts with the latest.
472
520
  onRequest: function (param) {
473
521
  this.callParent(arguments);
474
- ${(manifest.identityGateway?.oidc?.native_login || manifest.identityGateway?.oidc?.browser) ? `if (${ns}.resumeOIDC(param)) return;` : ""}
522
+ ${windowResumesOIDC(manifest) ? `if (${ns}.resumeOIDC(param)) return;` : ""}
475
523
  var params = ${ns}.launchParams(param);
476
524
  if (!this.mounted) {
477
525
  if (this.mountPending && Object.keys(params).length) this.launchParams = params;