@synopackageland/cli 0.25.1 → 0.26.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.0 (2026-10-22)
4
+
5
+ - Support SDK/App 0.26.x and pin templates 0.26.0, retaining UI 0.14.3 and executor/v0.24.0 with unchanged protected payload digests.
6
+ - Add optional bounded `daemon.restart` crash recovery for native Apps; require SDK 0.26.0 or later, validate the policy and record it for inspection. Initial startup and intentional stops retain their existing behavior.
7
+
8
+ - Add `ui: false` for native Apps with `identity_gateway` and no SDK window. The main menu gets one url launcher to `sso/open.cgi` (new tab) and Package Center's Open goes to the same entry through INFO `adminurl`, as for Compose Apps. check refuses it without a gateway, with `console` or `portal`, or with capabilities whose approval or sign-in needs the SDK window (`STANDALONE_UI_NO_WINDOW_INVALID`); inspect verifies the single launcher and `adminurl`. Apps with `ui.dir` build unchanged.
9
+
10
+ ## 0.25.2 (2026-10-22)
11
+
12
+ - 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.
13
+ - Update SDK download and Go build diagnostics for anonymous access to the public repository.
14
+
3
15
  ## 0.25.1 (2026-10-22)
4
16
 
5
17
  - 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.
package/README.md CHANGED
@@ -278,6 +278,16 @@ sets it for every language. Omit `console` to keep one icon. check refuses
278
278
  `console` on Compose, without `identity_gateway`, or with `ui.launch` or
279
279
  `portal`. A cold DSM sign-in resumes through the url launcher.
280
280
 
281
+ A native App with `identity_gateway` whose only UI is the wrapped web app
282
+ declares `ui: false` instead of `ui.dir`. It gets no SDK window: the main menu
283
+ has one icon (`displayname`, a `type: "url"` launcher to `sso/open.cgi` that
284
+ opens a new tab), and Package Center's Open goes to the same entry through
285
+ INFO `adminurl`, as for a Compose App. The backend and ctl commands are
286
+ unchanged. check refuses `ui: false` without `identity_gateway`, with `console`
287
+ or `portal`, or with `capabilities.rootOps`, `delegate`, `webapi` or
288
+ `identity`, whose approval or sign-in is shown in the SDK window
289
+ (`STANDALONE_UI_NO_WINDOW_INVALID`). A missing `ui` is still an error.
290
+
281
291
  Compose uses schema-2 `dsm-package.yaml` and `ui.launch`; the default is new-page.
282
292
  Classless gateways use type url via the shared contract/app-launch resolver;
283
293
  AppWindow wrappers use standalone. Headless projects have no launcher.
@@ -282,6 +282,16 @@ export const ERROR_REGISTRY = {
282
282
  description: "The package daemon declaration does not match the fixed argv, environment and lifecycle contract.",
283
283
  suggestion: "Check daemon.exec, the per-architecture targets, args, env, uninstall_args and integer-second timeouts; remove shell, user and custom conf.",
284
284
  },
285
+ STANDALONE_DAEMON_RESTART_INVALID: {
286
+ code: "STANDALONE_DAEMON_RESTART_INVALID",
287
+ description: "daemon.restart does not match the bounded native crash-recovery policy.",
288
+ suggestion: "Use mode off or on-failure, integer delay/attempt/window limits, and max_seconds at least initial_seconds; omit or set limits to zero for SDK defaults and remove unknown fields.",
289
+ },
290
+ STANDALONE_DAEMON_RESTART_SDK_REQUIRED: {
291
+ code: "STANDALONE_DAEMON_RESTART_SDK_REQUIRED",
292
+ description: "The selected controller SDK version or source does not support daemon.restart.",
293
+ suggestion: "Upgrade the selected controller SDK to 0.26.0 or later and use matching sources, including a local SDK override; update the App SDK requirement, identity_gateway.sdk or the templates that supply the default.",
294
+ },
285
295
  UNINSTALL_INVALID: {
286
296
  code: "UNINSTALL_INVALID",
287
297
  description: "The uninstall declaration does not match the fixed exec, argv, environment and timeout contract.",
@@ -485,7 +495,12 @@ export const ERROR_REGISTRY = {
485
495
  STANDALONE_UI_INVALID: {
486
496
  code: "STANDALONE_UI_INVALID",
487
497
  description: "The ui configuration is invalid.",
488
- suggestion: "Provide ui.dir.",
498
+ suggestion: "Provide ui.dir, or ui: false for a native identity_gateway App without an SDK window.",
499
+ },
500
+ STANDALONE_UI_NO_WINDOW_INVALID: {
501
+ code: "STANDALONE_UI_NO_WINDOW_INVALID",
502
+ description: "ui: false is declared where the App needs an SDK window.",
503
+ suggestion: "ui: false needs identity_gateway and no console, portal, capabilities.rootOps, delegate, webapi or identity; otherwise declare ui.dir.",
489
504
  },
490
505
  STANDALONE_UI_BUILD_INVALID: {
491
506
  code: "STANDALONE_UI_BUILD_INVALID",
@@ -540,7 +555,7 @@ export const ERROR_REGISTRY = {
540
555
  STANDALONE_GO_BUILD_FAILED: {
541
556
  code: "STANDALONE_GO_BUILD_FAILED",
542
557
  description: "go build failed.",
543
- 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).",
558
+ 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.",
544
559
  },
545
560
  STANDALONE_PORTAL_INVALID: {
546
561
  code: "STANDALONE_PORTAL_INVALID",
@@ -905,7 +920,7 @@ export const ERROR_REGISTRY = {
905
920
  SDK_DOWNLOAD_FAILED: {
906
921
  code: "SDK_DOWNLOAD_FAILED",
907
922
  description: "go mod download cannot fetch the SDK version backend/go.mod requires.",
908
- 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.",
923
+ 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.",
909
924
  },
910
925
  TEMPLATES_MISSING: {
911
926
  code: "TEMPLATES_MISSING",
@@ -1,5 +1,5 @@
1
1
  {
2
- "0.25.0": {
2
+ "0.26.0": {
3
3
  "x86_64": "de78684faac211c24a7cda8a4541ab1b138d37f6d580936fe354bb9bd0d78a0f",
4
4
  "aarch64": "8ef7c736203f6c111816b76f970b7204fa6f8d871ff3222fd7c0a27d0075b847",
5
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', '0.25.0': '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', '0.26.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,15 @@
1
+ import type { StandaloneError } from "./manifest.js";
2
+ export declare const DAEMON_RESTART_MIN_SDK = "0.26.0";
3
+ export interface DaemonRestart {
4
+ mode: "off" | "on-failure";
5
+ initial_seconds: number;
6
+ max_seconds: number;
7
+ max_attempts: number;
8
+ window_seconds: number;
9
+ }
10
+ /** Normalize exactly the SDK's zero/omitted defaults; omission itself stays off. */
11
+ export declare function readDaemonRestart(value: unknown, fail: (error: StandaloneError) => void): DaemonRestart | undefined;
12
+ export declare function daemonRestartSdkSupported(sdk: string): boolean;
13
+ export declare function daemonRestartSdkProblem(sdk: string): string | undefined;
14
+ /** Development overrides must contain the decoder as well as the restart code. */
15
+ export declare function requireDaemonRestartSource(dir: string, sdk: string, policy?: DaemonRestart): void;
@@ -0,0 +1,55 @@
1
+ import { Ajv } from "ajv";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { StandaloneBuildError } from "./spk.js";
5
+ export const DAEMON_RESTART_MIN_SDK = "0.26.0";
6
+ let validator;
7
+ function restartValidator() {
8
+ return validator ??= new Ajv().compile(JSON.parse(readFileSync(new URL("../../schema/daemon-restart.v1.schema.json", import.meta.url), "utf8")));
9
+ }
10
+ /** Normalize exactly the SDK's zero/omitted defaults; omission itself stays off. */
11
+ export function readDaemonRestart(value, fail) {
12
+ if (value === undefined)
13
+ return undefined;
14
+ if (restartValidator()(value)) {
15
+ const raw = value;
16
+ const policy = {
17
+ mode: raw.mode,
18
+ initial_seconds: raw.initial_seconds || 1,
19
+ max_seconds: raw.max_seconds || 30,
20
+ max_attempts: raw.max_attempts || 5,
21
+ window_seconds: raw.window_seconds || 300,
22
+ };
23
+ if (policy.max_seconds >= policy.initial_seconds)
24
+ return policy;
25
+ }
26
+ fail({
27
+ code: "STANDALONE_DAEMON_RESTART_INVALID",
28
+ message: "daemon.restart requires mode off or on-failure; delays 1–3600s (max >= initial), attempts 1–100 and window 1–86400s. Omitted or zero limits use SDK defaults; unknown fields are rejected.",
29
+ });
30
+ return undefined;
31
+ }
32
+ export function daemonRestartSdkSupported(sdk) {
33
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(sdk);
34
+ return Boolean(match && (Number(match[1]) > 0 || Number(match[2]) >= 26));
35
+ }
36
+ export function daemonRestartSdkProblem(sdk) {
37
+ if (!daemonRestartSdkSupported(sdk))
38
+ return `daemon.restart requires SDK ${DAEMON_RESTART_MIN_SDK} or later; selected SDK ${sdk}. Upgrade the App's SDK pin, identity_gateway.sdk or the templates supplying the default controller SDK before rebuilding.`;
39
+ return undefined;
40
+ }
41
+ /** Development overrides must contain the decoder as well as the restart code. */
42
+ export function requireDaemonRestartSource(dir, sdk, policy) {
43
+ if (!policy)
44
+ return;
45
+ let main = "", restart = "";
46
+ try {
47
+ main = readFileSync(join(dir, "daemonctl/main.go"), "utf8");
48
+ restart = readFileSync(join(dir, "daemonctl/restart.go"), "utf8");
49
+ }
50
+ catch { /* Fail below before compiling or packaging a mismatched runtime. */ }
51
+ const problem = daemonRestartSdkProblem(sdk);
52
+ if (problem || !/\bRestart\s+\*restartPolicy\s+`json:"restart,omitempty"`/.test(main) ||
53
+ !/^func normalizeRestart\(/m.test(restart) || !/^type restartBudget struct\s*\{/m.test(restart))
54
+ throw new StandaloneBuildError("STANDALONE_DAEMON_RESTART_SDK_REQUIRED", problem ?? `Selected SDK ${sdk} source does not support daemon.restart; use matching SDK ${DAEMON_RESTART_MIN_SDK} or later sources, including for a local development override.`);
55
+ }
@@ -1,9 +1,12 @@
1
1
  import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
2
2
  import { NODE_PACKAGES } from "./bridge-backend.js";
3
3
  import { type DaemonIngress } from "./daemon-ingress.js";
4
+ import { type DaemonRestart } from "./daemon-restart.js";
4
5
  import type { CommandRunner } from "./build.js";
5
6
  import type { StandaloneError, StandaloneManifest } from "./manifest.js";
6
7
  export interface Daemon {
8
+ /** Optional bounded recovery; omitted means off. SDK 0.26.0 or later. */
9
+ restart?: DaemonRestart;
7
10
  /** Fixed foreground executable relative to daemon/payload. */
8
11
  exec: string;
9
12
  /** Architecture-specific project directories, copied without links. */
@@ -46,6 +49,7 @@ export declare const envKey: (k: string) => boolean;
46
49
  /** A relative path to a fixed executable, without `.` or `..` parts. */
47
50
  export declare const relativeExec: (v: unknown) => v is string;
48
51
  export declare function readDaemon(value: unknown, root: string, arches: NativeArchFamily[], fail: (error: StandaloneError) => void): Daemon | undefined;
52
+ export declare function daemonSdkVersion(manifest: Pick<StandaloneManifest, "backend" | "identityGateway">): string;
49
53
  export declare function daemonConfig(manifest: StandaloneManifest): string;
50
54
  /**
51
55
  * The argv and environment daemon/control runs: a Node daemon execs the
@@ -8,6 +8,7 @@ import { templateSdkVersion } from "../template-paths.js";
8
8
  import { checkCgiBinary } from "./binary.js";
9
9
  import { NODE_PACKAGES } from "./bridge-backend.js";
10
10
  import { readDaemonIngress, ingressEnv } from "./daemon-ingress.js";
11
+ import { readDaemonRestart, requireDaemonRestartSource } from "./daemon-restart.js";
11
12
  import { composeDaemon, composeNetwork } from "./compose.js";
12
13
  import { gatewayDaemon } from "./identity-gateway.js";
13
14
  import { StandaloneBuildError } from "./spk.js";
@@ -41,6 +42,7 @@ const fields = new Set([
41
42
  "uninstall_args",
42
43
  "node",
43
44
  "ingress",
45
+ "restart",
44
46
  ]);
45
47
  const record = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
46
48
  /** A fixed argv or env value: only the package's own directories expand. */
@@ -110,6 +112,9 @@ export function readDaemon(value, root, arches, fail) {
110
112
  const timeouts = readLifecycleTimeouts(value.timeouts, fail);
111
113
  if (!timeouts)
112
114
  return undefined;
115
+ const restart = readDaemonRestart(value.restart, fail);
116
+ if (value.restart !== undefined && !restart)
117
+ return undefined;
113
118
  const node = readDaemonNode(value.node, fail);
114
119
  if (value.node !== undefined && !node)
115
120
  return undefined;
@@ -129,6 +134,7 @@ export function readDaemon(value, root, arches, fail) {
129
134
  timeouts: timeouts,
130
135
  ...(node ? { node } : {}),
131
136
  ...(ingress ? { ingress } : {}),
137
+ ...(restart ? { restart } : {}),
132
138
  };
133
139
  }
134
140
  function readDaemonNode(value, fail) {
@@ -152,13 +158,13 @@ function readDaemonNode(value, fail) {
152
158
  }
153
159
  return { package: pkg, ...(min ? { min_version: min } : {}) };
154
160
  }
155
- function daemonSdkVersion(manifest) {
161
+ export function daemonSdkVersion(manifest) {
156
162
  return manifest.identityGateway?.sdk ??
157
163
  (manifest.backend.kind === "go" ? appSdkVersion(manifest.backend) : undefined) ??
158
164
  templateSdkVersion();
159
165
  }
160
166
  export function daemonConfig(manifest) {
161
- const { exec, args, uninstall_args, env, timeouts } = manifest.compose
167
+ const { exec, args, uninstall_args, env, timeouts, restart } = manifest.compose
162
168
  ? composeDaemon(manifest)
163
169
  : manifest.identityGateway
164
170
  ? gatewayDaemon(manifest.daemon)
@@ -168,6 +174,7 @@ export function daemonConfig(manifest) {
168
174
  exec,
169
175
  args,
170
176
  ...(uninstall_args ? { uninstall_args } : {}),
177
+ ...(restart ? { restart } : {}),
171
178
  env,
172
179
  timeouts,
173
180
  ...(runtimeBudgetSdkSupported(daemonSdkVersion(manifest))
@@ -193,6 +200,9 @@ export async function buildDaemonControl(manifest, arch, work, run) {
193
200
  if (!manifest.daemon && !(manifest.compose && manifest.identityGateway))
194
201
  return undefined;
195
202
  const out = join(work, `daemon-control-${arch}`);
203
+ const sdk = daemonSdkVersion(manifest);
204
+ const source = runtimeBudgetSource(sdkSource(sdk).goDir, manifest);
205
+ requireDaemonRestartSource(source, sdk, manifest.daemon?.restart);
196
206
  const result = await run("go", [
197
207
  "build",
198
208
  "-trimpath",
@@ -203,7 +213,7 @@ export async function buildDaemonControl(manifest, arch, work, run) {
203
213
  out,
204
214
  "./daemonctl",
205
215
  ], {
206
- cwd: runtimeBudgetSource(sdkSource(daemonSdkVersion(manifest)).goDir, manifest),
216
+ cwd: source,
207
217
  env: goChildEnv({ ...process.env, ...pinnedGoBuildEnv(arch) }),
208
218
  });
209
219
  if (result.missing || result.status !== 0)
@@ -25,6 +25,8 @@ 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
+ /** Whether the App ships the SDK window; Compose and `ui: false` Apps open the gateway instead. */
29
+ export declare function hasSdkWindow(manifest: Pick<StandaloneManifest, "compose" | "sdkWindow">): boolean;
28
30
  /** The DSM app class whose launch opens the SDK window: the console's when the App declares one. */
29
31
  export declare function windowAppId(manifest: Pick<StandaloneManifest, "package" | "console">): string;
30
32
  /**
@@ -74,7 +74,7 @@ function launcherAccess(manifest) {
74
74
  return manifest.entry
75
75
  ? entryLauncherAccess(manifest.entry)
76
76
  : {
77
- allUsers: manifest.compose || manifest.console
77
+ allUsers: manifest.compose || manifest.console || !hasSdkWindow(manifest)
78
78
  ? manifest.identityGateway?.access !== "dsm-admins"
79
79
  : permitsOrdinaryUsers(manifest.apiPermissions),
80
80
  };
@@ -107,6 +107,10 @@ function displayNameCatalogs(manifest) {
107
107
  }
108
108
  return catalogs;
109
109
  }
110
+ /** Whether the App ships the SDK window; Compose and `ui: false` Apps open the gateway instead. */
111
+ export function hasSdkWindow(manifest) {
112
+ return !manifest.compose && manifest.sdkWindow !== false;
113
+ }
110
114
  /** The DSM app class whose launch opens the SDK window: the console's when the App declares one. */
111
115
  export function windowAppId(manifest) {
112
116
  return `SYNO.SDS.${manifest.package}.${manifest.console ? "Console" : "Instance"}`;
@@ -128,7 +132,7 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), lo
128
132
  const icon = windowIconTemplate(icons);
129
133
  const ns = `SYNO.SDS.${manifest.package}`;
130
134
  const titleFields = uiTitleFields(appTitle(manifest));
131
- if (manifest.compose) {
135
+ if (!hasSdkWindow(manifest)) {
132
136
  if (!manifest.identityGateway)
133
137
  return "{}\n";
134
138
  return (JSON.stringify({
@@ -23,6 +23,7 @@ import { ELF_MACHINE } from "./binary.js";
23
23
  import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
24
24
  import { daemonConfig, readDaemon, readLifecycleTimeouts, renderNodeLauncher, nodeDependency, NODE_LAUNCHER } from "./daemon.js";
25
25
  import { DAEMON_INGRESS_CONFIG, daemonIngressMinDsmProblem, readDaemonIngress, renderDaemonIngressConfig } from "./daemon-ingress.js";
26
+ import { daemonRestartSdkProblem, readDaemonRestart } from "./daemon-restart.js";
26
27
  import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
27
28
  import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
28
29
  import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
@@ -114,7 +115,9 @@ export async function inspectSpk(spkPath, expectedKind) {
114
115
  new Set(raw.capabilities).size !== raw.capabilities.length ||
115
116
  (raw.entry !== undefined &&
116
117
  !["admin-only", "app-privilege"].includes(raw.entry)) ||
117
- (raw.console !== undefined && raw.console !== true))
118
+ (raw.console !== undefined && raw.console !== true) ||
119
+ (raw.window !== undefined &&
120
+ (raw.window !== false || raw.console !== undefined)))
118
121
  throw new Error();
119
122
  if (raw.lifecycleTimeouts !== undefined && (!readLifecycleTimeouts(raw.lifecycleTimeouts, () => { }) || Object.keys(raw.lifecycleTimeouts).length !== 4))
120
123
  throw new Error();
@@ -126,6 +129,9 @@ export async function inspectSpk(spkPath, expectedKind) {
126
129
  findings.push("sdk-package.json does not match the schema 2 capability inventory.");
127
130
  }
128
131
  }
132
+ const daemonRestart = recoverDaemonRestart(metadata.daemon_restart, metadata.daemon_sdk, findings);
133
+ if (metadata.daemon_restart !== undefined && (metadata.compose !== undefined || !paths.has("daemon/control")))
134
+ findings.push("DAEMON_RESTART_INVALID daemon restart requires a native daemon controller, without Compose supervision");
129
135
  const identityGateway = await inspectProviderOnlyAdmission(dir, paths, metadata.provider_only_oidc, findings);
130
136
  const installSettings = await inspectInstallSettings(dir, payload, metadata, findings);
131
137
  if (metadata.compose !== undefined) {
@@ -210,7 +216,7 @@ export async function inspectSpk(spkPath, expectedKind) {
210
216
  if (standalone && portal)
211
217
  findings.push(...inspectPortalInfo(info, hasExecutor));
212
218
  const daemon = standalone
213
- ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined, nativeGateway, uninstallHook?.uninstall, { ingress: daemonIngress, node: daemonNode })
219
+ ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined, nativeGateway, uninstallHook?.uninstall, { ingress: daemonIngress, node: daemonNode, restart: daemonRestart })
214
220
  : undefined;
215
221
  if (background)
216
222
  checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go", metadata.lifecycleTimeouts, nativeGateway);
@@ -276,7 +282,7 @@ export async function inspectSpk(spkPath, expectedKind) {
276
282
  }
277
283
  if (metadata.entry)
278
284
  await checkEntryLauncher(dir, info.package, metadata.entry, findings);
279
- const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings, metadata.console === true);
285
+ const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings, metadata.console === true, metadata.window === false);
280
286
  const loginStyle = standalone
281
287
  ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings)
282
288
  : undefined;
@@ -340,7 +346,7 @@ export async function inspectSpk(spkPath, expectedKind) {
340
346
  const LAUNCHER_TYPE_MISMATCH = "LAUNCHER_TYPE_MISMATCH";
341
347
  const LAUNCHER_PORTAL_MISMATCH = "LAUNCHER_PORTAL_MISMATCH";
342
348
  /** Compare the actual launcher and Portal fields to the packaged declaration. */
343
- async function checkLaunchConfiguration(dir, info, native, declared, alias, findings, console = false) {
349
+ async function checkLaunchConfiguration(dir, info, native, declared, alias, findings, console = false, windowless = false) {
344
350
  const target = join(dir, "launch-inspection");
345
351
  mkdirSync(target);
346
352
  await tar.x({
@@ -395,6 +401,10 @@ async function checkLaunchConfiguration(dir, info, native, declared, alias, find
395
401
  checkConsoleLaunchers(target, info, native, findings);
396
402
  return launch;
397
403
  }
404
+ if (windowless) {
405
+ checkGatewayOnlyLauncher(target, info, native, findings);
406
+ return launch;
407
+ }
398
408
  const type = dsmLauncherType(launch, native);
399
409
  if (!launcher ||
400
410
  launcher.type !== type ||
@@ -443,6 +453,36 @@ function checkConsoleLaunchers(target, info, native, findings) {
443
453
  findings.push(`${LAUNCHER_TYPE_MISMATCH} console requires a url launcher at ${ns}.Instance to sso/open.cgi, an administrator-only app launcher at ${ns}.Console and INFO dsmapplaunchname=${ns}.Console; rebuild with synopkgland build`);
444
454
  }
445
455
  }
456
+ /**
457
+ * A `ui: false` App's ui/config: one url launcher at `.Instance` to the
458
+ * gateway, which INFO's adminurl also opens for Package Center's Open, and
459
+ * no SDK window.
460
+ */
461
+ function checkGatewayOnlyLauncher(target, info, native, findings) {
462
+ const ns = `SYNO.SDS.${info.package}`;
463
+ const entry = `/webman/3rdparty/${info.package}/sso/open.cgi`;
464
+ let config = {};
465
+ try {
466
+ config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
467
+ }
468
+ catch {
469
+ /* reported below as a missing launcher */
470
+ }
471
+ const sections = Object.keys(config);
472
+ const instance = config[".url"]?.[`${ns}.Instance`];
473
+ if (!native ||
474
+ sections.length !== 1 ||
475
+ Object.keys(config[".url"] ?? {}).length !== 1 ||
476
+ instance?.type !== "url" ||
477
+ instance.url !== entry ||
478
+ instance.appWindow !== undefined ||
479
+ info.dsmuidir !== "ui" ||
480
+ info.dsmappname !== `${ns}.Instance` ||
481
+ info.dsmapplaunchname !== undefined ||
482
+ info.adminurl !== entry) {
483
+ findings.push(`${LAUNCHER_TYPE_MISMATCH} ui: false requires only a url launcher at ${ns}.Instance to sso/open.cgi and INFO adminurl to the same entry; rebuild with synopkgland build`);
484
+ }
485
+ }
446
486
  /** The launcher in ui/config must carry exactly the access the declared entry mode fixes. */
447
487
  async function checkEntryLauncher(dir, pkg, entry, findings) {
448
488
  const target = join(dir, "entry-inspection");
@@ -877,15 +917,19 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
877
917
  "timeouts",
878
918
  "operation_seconds",
879
919
  "uninstall_args",
920
+ "restart",
880
921
  ...(combined ? ["network"] : []),
881
922
  ].includes(key)))
882
923
  throw new Error("package/config mismatch");
924
+ if (!isDeepStrictEqual(config.restart, extras.restart))
925
+ throw new Error("daemon restart differs from its build declaration");
883
926
  const daemon = readDaemon({
884
927
  exec: config.exec,
885
928
  args: config.args,
886
929
  uninstall_args: config.uninstall_args,
887
930
  env: config.env,
888
931
  timeouts: config.timeouts,
932
+ restart: config.restart,
889
933
  targets: { x86_64: "payload" },
890
934
  }, dir, ["x86_64"], () => { });
891
935
  if (!daemon)
@@ -947,8 +991,18 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
947
991
  return daemon;
948
992
  }
949
993
  catch {
950
- findings.push("daemon/config.json or the payload does not match the fixed daemon contract");
994
+ findings.push("daemon/config.json (including restart) or the payload does not match the fixed daemon contract");
995
+ }
996
+ }
997
+ function recoverDaemonRestart(value, sdk, findings) {
998
+ if (value === undefined && sdk === undefined)
999
+ return undefined;
1000
+ const policy = readDaemonRestart(value, () => { });
1001
+ if (!policy || !isDeepStrictEqual(policy, value) || typeof sdk !== "string" || daemonRestartSdkProblem(sdk)) {
1002
+ findings.push("DAEMON_RESTART_INVALID sdk-package.json must record a normalized daemon restart policy and SDK 0.26.0 or later");
1003
+ return undefined;
951
1004
  }
1005
+ return policy;
952
1006
  }
953
1007
  /** INFO retains the localized title inputs used by build's ui/config renderer. */
954
1008
  function archivedInfoTranslations(info) {
@@ -100,6 +100,12 @@ export interface StandaloneManifest {
100
100
  entry?: EntryMode;
101
101
  /** `portal`: registers the DSM Application Portal entry; an administrator activates it. */
102
102
  portal?: PortalDeclaration;
103
+ /**
104
+ * `ui: false`: a native identity_gateway App with no SDK window. Its one
105
+ * main-menu icon and Package Center's Open both go to the gateway's web UI,
106
+ * as for a Compose App. Absent when the App has a `ui` directory.
107
+ */
108
+ sdkWindow?: false;
103
109
  /**
104
110
  * `console`: the main menu gets two icons, the gateway's native UI in a new
105
111
  * tab and the SDK UI in an administrator-only DSM window titled `title`
@@ -10,7 +10,8 @@ import { isNativeArchFamily, } from "../build-spk/dsm-arch.js";
10
10
  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
- import { readDaemon, readLifecycleTimeouts } from "./daemon.js";
13
+ import { daemonSdkVersion, readDaemon, readLifecycleTimeouts } from "./daemon.js";
14
+ import { daemonRestartSdkProblem } from "./daemon-restart.js";
14
15
  import { daemonIngressMinDsmProblem } from "./daemon-ingress.js";
15
16
  import { readIdentityGateway, } from "./identity-gateway.js";
16
17
  import { readUninstall } from "./uninstall.js";
@@ -283,6 +284,11 @@ function readManifest(raw, c) {
283
284
  if (problem)
284
285
  c.fail("STANDALONE_DAEMON_INGRESS_MIN_DSM", problem);
285
286
  }
287
+ if (daemon?.restart && backend) {
288
+ const problem = daemonRestartSdkProblem(daemonSdkVersion({ backend, identityGateway }));
289
+ if (problem)
290
+ c.fail("STANDALONE_DAEMON_RESTART_SDK_REQUIRED", problem);
291
+ }
286
292
  if ((daemon?.ingress || daemon?.node) && raw.identity_gateway !== undefined)
287
293
  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.");
288
294
  if (compose && identityGateway && compose.sdk !== identityGateway.sdk)
@@ -338,7 +344,10 @@ function readManifest(raw, c) {
338
344
  }
339
345
  permissions.policy = floored.policy;
340
346
  }
341
- const ui = raw.compose !== undefined
347
+ const noWindow = raw.compose === undefined && raw.ui === false;
348
+ if (noWindow)
349
+ readNoWindow(raw, capabilities, c);
350
+ const ui = raw.compose !== undefined || noWindow
342
351
  ? {
343
352
  dir: c.root,
344
353
  ...(isRecord(raw.ui) && raw.ui.launch !== undefined
@@ -347,8 +356,8 @@ function readManifest(raw, c) {
347
356
  }
348
357
  : readUi(raw.ui, c);
349
358
  if (raw.compose !== undefined &&
350
- isRecord(raw.ui) &&
351
- Object.keys(raw.ui).some((k) => k !== "launch"))
359
+ raw.ui !== undefined &&
360
+ (!isRecord(raw.ui) || Object.keys(raw.ui).some((k) => k !== "launch")))
352
361
  c.fail("STANDALONE_UI_INVALID", "Compose uses the SDK gateway; ui accepts only launch.");
353
362
  if (raw.compose !== undefined &&
354
363
  ui?.launch !== undefined &&
@@ -466,6 +475,7 @@ function readManifest(raw, c) {
466
475
  ...(entry ? { entry } : {}),
467
476
  ...(portal ? { portal } : {}),
468
477
  ...(dsmConsole ? { console: dsmConsole } : {}),
478
+ ...(noWindow ? { sdkWindow: false } : {}),
469
479
  ...(icon ? { icon } : {}),
470
480
  ...(loginStyle ? { loginStyle } : {}),
471
481
  ...(appScripts ? { appScripts } : {}),
@@ -539,6 +549,24 @@ function readConsole(raw, launch, c) {
539
549
  c.fail("STANDALONE_CONSOLE_PORTAL", "console and portal both claim the App's SDK UI; declare one of them.");
540
550
  return value.title === undefined ? {} : { title: value.title };
541
551
  }
552
+ /**
553
+ * `ui: false`: the gateway's web UI is the App's only UI, so the App needs
554
+ * identity_gateway, and nothing may need the SDK window: a console or portal
555
+ * is that window, and root, delegate and WebAPI approvals and the standalone
556
+ * sign-in page are shown in it.
557
+ */
558
+ function readNoWindow(raw, capabilities, c) {
559
+ const fail = (why) => c.fail("STANDALONE_UI_NO_WINDOW_INVALID", `ui: false ${why}`);
560
+ if (raw.identity_gateway === undefined)
561
+ fail("needs identity_gateway: its web UI is what the main-menu icon and Package Center's Open start.");
562
+ if (raw.console !== undefined)
563
+ fail("leaves no SDK window for the console; remove console, or declare ui.dir.");
564
+ if (raw.portal !== undefined)
565
+ fail("leaves no SDK window for the portal; remove portal, or declare ui.dir.");
566
+ for (const name of ["rootOps", "delegate", "webapi", "identity"])
567
+ if (capabilities[name])
568
+ fail(`cannot use capabilities.${name}: its approval or sign-in is shown in the SDK window; declare ui.dir.`);
569
+ }
542
570
  /** `login_style`: a portal's colour, wallpaper (with its thumbnail) and title image, each optional. */
543
571
  function readLoginStyle(value, portal, c) {
544
572
  const invalid = (message) => c.fail("STANDALONE_LOGIN_STYLE_INVALID", message);
@@ -736,7 +764,7 @@ function goReadsTimeout(backend, c) {
736
764
  }
737
765
  function readUi(value, c) {
738
766
  if (!isRecord(value)) {
739
- return c.fail("STANDALONE_UI_INVALID", "ui must contain dir.");
767
+ return c.fail("STANDALONE_UI_INVALID", "ui must contain dir; a native identity_gateway App without an SDK window declares ui: false.");
740
768
  }
741
769
  if (value.launch !== undefined && !isWebLaunchMode(value.launch))
742
770
  return c.fail("STANDALONE_UI_LAUNCH_INVALID", "ui.launch must be dsm-window or new-page. Application Portal is a separate opt-in.");
@@ -21,7 +21,7 @@ import { renderStandaloneScript } from "./lifecycle.js";
21
21
  import { APP_SCRIPTS_DIR, APP_SCRIPTS_SPK_DIR } from "./app-scripts.js";
22
22
  import { codedMessage } from "../errors.js";
23
23
  import { DSM_LANGS, LOCALES } from "./i18n.js";
24
- import { appTitle, CGI_PAYLOAD_PATH, renderUiConfig, renderWindowScript, windowScriptName, } from "./dsm-window.js";
24
+ import { appTitle, CGI_PAYLOAD_PATH, hasSdkWindow, renderUiConfig, renderWindowScript, windowScriptName, } from "./dsm-window.js";
25
25
  import { writeUiTexts } from "../build-spk/ui-texts.js";
26
26
  import { loginStyleAssets } from "../build-spk/login-style.js";
27
27
  import { copyStandaloneNotices, copyNoticeTree } from "../build-spk/notices.js";
@@ -29,7 +29,7 @@ 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, nodeDependency } from "./daemon.js";
32
+ import { copyDaemonPayload, daemonSdkVersion, nodeDependency } from "./daemon.js";
33
33
  import { DAEMON_INGRESS_CONFIG, renderDaemonIngressConfig } from "./daemon-ingress.js";
34
34
  import { GATEWAY_CGI_PATH, GATEWAY_CONFIG_PATH, GATEWAY_EXEC, identityGatewayConfig, identityGatewayDeclaration, } from "./identity-gateway.js";
35
35
  import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit, BRIDGE_WORKER_ARGS, } from "./background.js";
@@ -77,7 +77,7 @@ export async function assembleStandaloneSpk(options) {
77
77
  const icons = packageIcons(manifest.icon);
78
78
  const uiRoot = join(staging, "package", "ui");
79
79
  const appDir = join(uiRoot, "app");
80
- if (!manifest.compose)
80
+ if (hasSdkWindow(manifest))
81
81
  copyUiTree(manifest.ui.dir, appDir);
82
82
  else
83
83
  mkdirSync(appDir, { recursive: true });
@@ -88,7 +88,7 @@ export async function assembleStandaloneSpk(options) {
88
88
  writeFile(join(uiRoot, "config"), renderUiConfig(manifest, icons, login?.fields));
89
89
  for (const [path, bytes] of login?.files ?? [])
90
90
  writeFile(join(uiRoot, path), bytes);
91
- if (!manifest.compose)
91
+ if (hasSdkWindow(manifest))
92
92
  writeFile(join(uiRoot, windowScriptName(manifest)), renderWindowScript(manifest, assetVersion, icons, options.portalText));
93
93
  writeFile(join(uiRoot, hostScriptName(manifest.build)), renderHostAdapterScript(manifest.package));
94
94
  writeDsmWindowIcons(uiRoot, icons);
@@ -235,6 +235,7 @@ export async function assembleStandaloneSpk(options) {
235
235
  ...(nativeGatewaySupported(manifest) ? { native_gateway: { sdk: manifest.identityGateway.sdk, listen: manifest.identityGateway.listen } } : {}),
236
236
  ...(manifest.daemon?.ingress ? { daemon_ingress: manifest.daemon.ingress } : {}),
237
237
  ...(manifest.daemon?.node ? { daemon_node: manifest.daemon.node } : {}),
238
+ ...(manifest.daemon?.restart ? { daemon_restart: manifest.daemon.restart, daemon_sdk: daemonSdkVersion(manifest) } : {}),
238
239
  launch: resolvedSdkLaunch(manifest),
239
240
  capabilities: Object.keys(manifest.capabilities).sort(),
240
241
  ...(manifest.compose
@@ -248,6 +249,7 @@ export async function assembleStandaloneSpk(options) {
248
249
  : {}),
249
250
  ...(manifest.entry ? { entry: manifest.entry } : {}),
250
251
  ...(manifest.console ? { console: true } : {}),
252
+ ...(manifest.sdkWindow === false ? { window: false } : {}),
251
253
  ...(manifest.uninstall
252
254
  ? {
253
255
  uninstall: uninstallRecord(manifest.uninstall, manifest.i18n?.catalogs),
@@ -353,15 +355,14 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
353
355
  // Package Center writes INFO as root; root tasks accept only this executor.
354
356
  if (executorDigest)
355
357
  fields.push([EXECUTOR_ANCHOR, executorDigest]);
356
- if (manifest.compose) {
357
- // DSM dependency status can fail while the official docker-project worker is available.
358
- // Let that worker check availability; never start or repair Container Manager here.
359
- if (manifest.identityGateway)
360
- fields.push([
361
- "adminurl",
362
- gatewayEntryPath(manifest),
363
- ]);
364
- }
358
+ // Package Center's Open goes to the gateway entry when there is no SDK window.
359
+ // A Compose App's entry lets the docker-project worker check availability;
360
+ // never start or repair Container Manager here.
361
+ if (!hasSdkWindow(manifest) && manifest.identityGateway)
362
+ fields.push([
363
+ "adminurl",
364
+ gatewayEntryPath(manifest),
365
+ ]);
365
366
  const backend = manifest.backend;
366
367
  if (isBridgeBackend(backend) && backend.node)
367
368
  fields.push(["install_dep_packages", backend.node.package]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.25.1",
3
+ "version": "0.26.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.25.0",
30
+ "@synopackageland/templates": "0.26.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://synopkgland.com/schemas/daemon-restart/1",
4
+ "title": "Native daemon restart policy",
5
+ "description": "The daemon.restart declaration in schema-2 dsm-package.yaml. Requires SDK 0.26.0 or later. Omitted or zero numeric limits use SDK defaults; normalized max_seconds must be at least initial_seconds.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["mode"],
9
+ "properties": {
10
+ "mode": { "type": "string", "enum": ["off", "on-failure"] },
11
+ "initial_seconds": { "type": "integer", "minimum": 0, "maximum": 3600, "default": 1 },
12
+ "max_seconds": { "type": "integer", "minimum": 0, "maximum": 3600, "default": 30 },
13
+ "max_attempts": { "type": "integer", "minimum": 0, "maximum": 100, "default": 5 },
14
+ "window_seconds": { "type": "integer", "minimum": 0, "maximum": 86400, "default": 300 }
15
+ }
16
+ }
package/skills/SKILL.md CHANGED
@@ -176,6 +176,12 @@ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生
176
176
  compose App、沒有 gateway 的 App、`ui.launch` 或 `portal` 都會被 check 拒絕。
177
177
  冷登入由 url 圖示經 `open.cgi` 續接,`oidc.browser` 需要支援此續接的 SDK。
178
178
 
179
+ 原生 App 搭配 `identity_gateway`、而且唯一的 UI 就是包裝的網頁時,宣告 `ui: false` 取代 `ui.dir`。
180
+ 沒有 SDK 視窗:主選單只有一個 `displayname` 圖示,是 `type: "url"`,經 `sso/open.cgi` 在新分頁開原生 UI;
181
+ 套件中心「開啟」經 INFO `adminurl` 開同一個入口,與 compose App 相同。backend 與 ctl 指令不受影響。
182
+ 沒有 `identity_gateway`、搭配 `console` 或 `portal`、或使用 `capabilities.rootOps`、`delegate`、`webapi`、`identity`
183
+ (這些的核准或登入畫面在 SDK 視窗裡)都會被 check 以 `STANDALONE_UI_NO_WINDOW_INVALID` 拒絕。省略 `ui` 仍是錯誤。
184
+
179
185
  Schema 2 Compose 使用 `dsm-package.yaml` 的 `compose.files`、`compose.web` 與
180
186
  `identity_gateway`;不使用 file/targets/address_env 或手寫 upstream。
181
187
  省略 `ui.launch` 時預設 `new-page`,使用 SDK standalone launcher,不帶 Application Portal。
@@ -66,6 +66,12 @@ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生
66
66
  compose App、沒有 gateway 的 App、`ui.launch` 或 `portal` 都會被 check 拒絕。
67
67
  冷登入由 url 圖示經 `open.cgi` 續接,`oidc.browser` 需要支援此續接的 SDK。
68
68
 
69
+ 原生 App 搭配 `identity_gateway`、而且唯一的 UI 就是包裝的網頁時,宣告 `ui: false` 取代 `ui.dir`。
70
+ 沒有 SDK 視窗:主選單只有一個 `displayname` 圖示,是 `type: "url"`,經 `sso/open.cgi` 在新分頁開原生 UI;
71
+ 套件中心「開啟」經 INFO `adminurl` 開同一個入口,與 compose App 相同。backend 與 ctl 指令不受影響。
72
+ 沒有 `identity_gateway`、搭配 `console` 或 `portal`、或使用 `capabilities.rootOps`、`delegate`、`webapi`、`identity`
73
+ (這些的核准或登入畫面在 SDK 視窗裡)都會被 check 以 `STANDALONE_UI_NO_WINDOW_INVALID` 拒絕。省略 `ui` 仍是錯誤。
74
+
69
75
  Docker/web service App 設定 `integration.web.launch`,內建預設是 `new-page`;
70
76
  省略開啟方式時直接開新分頁,不宣告 Application Portal。SDK 原生 App
71
77
  省略時使用 `dsm-window`;非 UI SDK 原生 App 必須明確寫 `ui.launch: new-page`。
@@ -165,6 +165,12 @@ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生
165
165
  compose App、沒有 gateway 的 App、`ui.launch` 或 `portal` 都會被 check 拒絕。
166
166
  冷登入由 url 圖示經 `open.cgi` 續接,`oidc.browser` 需要支援此續接的 SDK。
167
167
 
168
+ 原生 App 搭配 `identity_gateway`、而且唯一的 UI 就是包裝的網頁時,宣告 `ui: false` 取代 `ui.dir`。
169
+ 沒有 SDK 視窗:主選單只有一個 `displayname` 圖示,是 `type: "url"`,經 `sso/open.cgi` 在新分頁開原生 UI;
170
+ 套件中心「開啟」經 INFO `adminurl` 開同一個入口,與 compose App 相同。backend 與 ctl 指令不受影響。
171
+ 沒有 `identity_gateway`、搭配 `console` 或 `portal`、或使用 `capabilities.rootOps`、`delegate`、`webapi`、`identity`
172
+ (這些的核准或登入畫面在 SDK 視窗裡)都會被 check 以 `STANDALONE_UI_NO_WINDOW_INVALID` 拒絕。省略 `ui` 仍是錯誤。
173
+
168
174
  Docker/web service App 設定 `integration.web.launch`,內建預設是 `new-page`;
169
175
  省略開啟方式時直接開新分頁,不宣告 Application Portal。SDK 原生 App
170
176
  省略時使用 `dsm-window`;非 UI SDK 原生 App 必須明確寫 `ui.launch: new-page`。