@synopackageland/cli 0.25.2 → 0.27.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.0 (2026-10-23)
4
+
5
+ - Depend on templates `~0.27.0` with SDK/App 0.27.0 and UI 0.14.9. Templates patches can ship independently and resolve during npm installation/update; runtime uses the installed templates without network fetching. SDK minor upgrades still require a CLI release for executor mappings/digests and `SDK_UNSUPPORTED` checks. New scaffolds use browser same-origin DSM file operations and the idle-storage fixes.
6
+ - Resolve SDK 0.27.0 to the new executor/v0.27.0 and ship all six verified executor/lifecycle digests. Preserve the previous 0.26.0 inventory and exact-version refusal for unrecorded patches. Protected Apps must rebuild and follow normal administrator approval.
7
+ - Keep Go 1.26.10 and the existing runtime/toolchain requirements. Rust crate publication and NAS/App upgrade certification are outside this release.
8
+
9
+ ## 0.26.0 (2026-10-22)
10
+
11
+ - 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.
12
+ - 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.
13
+
14
+ - 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.
15
+
3
16
  ## 0.25.2 (2026-10-22)
4
17
 
5
18
  - 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.
package/README.md CHANGED
@@ -64,8 +64,18 @@ a failed fetch reports `SDK_DOWNLOAD_FAILED`.
64
64
  CLI-only patch releases can differ from the SDK patch. Compose import and SDK defaults
65
65
  read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
66
66
 
67
- `init` and `add` read their templates from `@synopackageland/templates`, an exact-pinned
68
- dependency. For development against local checkouts, set `SYNOPKGLAND_TEMPLATES_DIR` to a
67
+ `init` and `add` read their templates from the locally installed
68
+ `@synopackageland/templates`. CLI 0.27.0 depends on `~0.27.0`: npm installation or
69
+ update can select the latest templates patch in the 0.27 minor line. Templates
70
+ patches need only a templates release; update the npm installation to receive them.
71
+ The CLI never fetches templates at runtime, so offline use works after installation;
72
+ commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
73
+ The templates' SDK pin must stay in the CLI-supported minor line. An SDK minor
74
+ upgrade still needs a CLI release for its executor mapping/digests and
75
+ `SDK_UNSUPPORTED` checks. Runtime SDK defaults continue to come from the actual
76
+ installed templates, independently of the CLI patch version.
77
+
78
+ For development against local checkouts, set `SYNOPKGLAND_TEMPLATES_DIR` to a
69
79
  templates checkout and `SYNOPKGLAND_SDK_DIR` to an SDK checkout; SPKs built with
70
80
  `SYNOPKGLAND_SDK_DIR` warn `EXECUTOR_DIGEST_UNVERIFIED` and must not be published. A Vue project
71
81
  packs the local SDK into its frontend (`npm install --install-links`) without building it: run
@@ -81,7 +91,7 @@ This compares `OFF_GLOSSARY` and the Italian sentence rule, including regex sour
81
91
  flags. It supports declaration-only rules with read-only use in `glossaryProblems`;
82
92
  post-declaration mutations, aliases and other unsupported rule uses fail the check instead of
83
93
  comparing a partial snapshot. The command also fails on drift or missing source. It needs a checkout because templates
84
- 0.15.1 does not publish `scripts/test/i18n.test.mjs`. The npm gate runs this check when
94
+ does not publish `scripts/test/i18n.test.mjs`. The npm gate runs this check when
85
95
  `SYNOPKGLAND_TEMPLATES_DIR` is set; otherwise it explicitly reports that the drift check was skipped.
86
96
 
87
97
  ## Capabilities
@@ -278,6 +288,16 @@ sets it for every language. Omit `console` to keep one icon. check refuses
278
288
  `console` on Compose, without `identity_gateway`, or with `ui.launch` or
279
289
  `portal`. A cold DSM sign-in resumes through the url launcher.
280
290
 
291
+ A native App with `identity_gateway` whose only UI is the wrapped web app
292
+ declares `ui: false` instead of `ui.dir`. It gets no SDK window: the main menu
293
+ has one icon (`displayname`, a `type: "url"` launcher to `sso/open.cgi` that
294
+ opens a new tab), and Package Center's Open goes to the same entry through
295
+ INFO `adminurl`, as for a Compose App. The backend and ctl commands are
296
+ unchanged. check refuses `ui: false` without `identity_gateway`, with `console`
297
+ or `portal`, or with `capabilities.rootOps`, `delegate`, `webapi` or
298
+ `identity`, whose approval or sign-in is shown in the SDK window
299
+ (`STANDALONE_UI_NO_WINDOW_INVALID`). A missing `ui` is still an error.
300
+
281
301
  Compose uses schema-2 `dsm-package.yaml` and `ui.launch`; the default is new-page.
282
302
  Classless gateways use type url via the shared contract/app-launch resolver;
283
303
  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",
@@ -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",
@@ -9,5 +9,16 @@
9
9
  "armv7": "6d25fc972eba12e3ac68f8b6a95a45e9e5706ebe19ffc1ff774fc318acbeb90f"
10
10
  },
11
11
  "goToolchain": "go1.26.10"
12
+ },
13
+ "0.27.0": {
14
+ "x86_64": "35f760533c133f7ed99d62c11dc4696d06a56fd2d125d53291aa21743d8327cc",
15
+ "aarch64": "04dff10b52ca4c54e3e6a825c27bac876f296031d8f572f667e6902e18464e31",
16
+ "armv7": "8789b2663b683ae0a5a457b41ccfcff4d54be4c98dcd3af338622051ad24f943",
17
+ "lifecycle": {
18
+ "x86_64": "c6fbb377ecd5ec7ee5b96439f22091d9fa5965dfd616d510ca9445722f436721",
19
+ "aarch64": "a9ca1750d4d5cff4727dc29789e6eaf0a365cb0e674f1bb796971d82f2d4fa27",
20
+ "armv7": "6d25fc972eba12e3ac68f8b6a95a45e9e5706ebe19ffc1ff774fc318acbeb90f"
21
+ },
22
+ "goToolchain": "go1.26.10"
12
23
  }
13
24
  }
@@ -75,8 +75,8 @@ function goMod(backendDir) {
75
75
  return '';
76
76
  }
77
77
  }
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' };
78
+ /** Explicit executor release pins; older SDK versions can reuse an immutable 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', '0.26.0': '0.24.0', '0.27.0': '0.27.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]);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The scaffold templates come from the exactly pinned @synopackageland/templates
2
+ * The scaffold templates come from the locally installed @synopackageland/templates
3
3
  * package; SYNOPKGLAND_TEMPLATES_DIR points at a templates checkout instead.
4
4
  */
5
5
  export declare function templatesRoot(env?: NodeJS.ProcessEnv, from?: string): string;
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { dirname, join } from "node:path";
3
3
  import { sdkRequirement } from "./runtime/sdk-source.js";
4
4
  /**
5
- * The scaffold templates come from the exactly pinned @synopackageland/templates
5
+ * The scaffold templates come from the locally installed @synopackageland/templates
6
6
  * package; SYNOPKGLAND_TEMPLATES_DIR points at a templates checkout instead.
7
7
  */
8
8
  export function templatesRoot(env = process.env, from = import.meta.url) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.25.2",
3
+ "version": "0.27.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.1",
30
+ "@synopackageland/templates": "~0.27.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
@@ -5,6 +5,14 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
5
5
 
6
6
  # Independent Synology SPKs
7
7
 
8
+ CLI 0.27.x installs templates through `~0.27.0`. A templates patch needs only a
9
+ templates release; npm install/update selects a patch in that minor line. The CLI
10
+ uses the locally installed templates and reads their SDK pin for defaults; it
11
+ never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
12
+ reproducible offline-ready installations. An SDK minor upgrade still requires a
13
+ CLI release with executor mappings/digests and `SDK_UNSUPPORTED` validation; a
14
+ templates patch must keep its SDK pin in the CLI-supported minor line.
15
+
8
16
  Before releasing an executor App, read [reference/approval-upgrades.md](reference/approval-upgrades.md). Run build/inspect with the previous released SPK as `--baseline` and include the approval difference in release notes. Without a comparable baseline, retention is unknown.
9
17
 
10
18
  Use this guide to initialize, build and inspect own applications or upstream OSS as independent packages. 依已實作能力區分 DSM session 檔案、私有 jobs 與 App 登入;delegate 已實作並隨 CLI 提供固定操作的離線委託(預設 `files.moveFromStaging`);共用 delegate/rootOps executor 的完整裝置生命週期驗收仍待完成,不得宣稱已通過;支援邊界依 delegate guide/support matrix,禁止發明新的身分平台。
@@ -25,6 +33,8 @@ Read [reference/lifecycle.md](reference/lifecycle.md) before editing `dsm-script
25
33
 
26
34
  Read [reference/ui-vue.md](reference/ui-vue.md) when editing Vue components in the native-go Vue template; use the installed SDK contracts, keep DSM embedding boundaries, and verify the actual app.
27
35
 
36
+ Buttons follow DSM's policy ([reference/ui-vue.md](reference/ui-vue.md#buttons), the UI package's `docs/button-policy.md`): only the bar that closes a dialog, wizard or form draws capsules, with Cancel/Reset first and one primary (or one danger to confirm a deletion) last; toolbar, page, section and row actions are grey tool buttons, Delete included; links go to `Link`. Choose the variant, never the shape.
37
+
28
38
  Read [reference/ui-json.md](reference/ui-json.md) when generating or editing JSON UI; discover the installed catalog, validate the spec, then render and fix layout issues.
29
39
 
30
40
  ## UI copywriting
@@ -176,6 +186,12 @@ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生
176
186
  compose App、沒有 gateway 的 App、`ui.launch` 或 `portal` 都會被 check 拒絕。
177
187
  冷登入由 url 圖示經 `open.cgi` 續接,`oidc.browser` 需要支援此續接的 SDK。
178
188
 
189
+ 原生 App 搭配 `identity_gateway`、而且唯一的 UI 就是包裝的網頁時,宣告 `ui: false` 取代 `ui.dir`。
190
+ 沒有 SDK 視窗:主選單只有一個 `displayname` 圖示,是 `type: "url"`,經 `sso/open.cgi` 在新分頁開原生 UI;
191
+ 套件中心「開啟」經 INFO `adminurl` 開同一個入口,與 compose App 相同。backend 與 ctl 指令不受影響。
192
+ 沒有 `identity_gateway`、搭配 `console` 或 `portal`、或使用 `capabilities.rootOps`、`delegate`、`webapi`、`identity`
193
+ (這些的核准或登入畫面在 SDK 視窗裡)都會被 check 以 `STANDALONE_UI_NO_WINDOW_INVALID` 拒絕。省略 `ui` 仍是錯誤。
194
+
179
195
  Schema 2 Compose 使用 `dsm-package.yaml` 的 `compose.files`、`compose.web` 與
180
196
  `identity_gateway`;不使用 file/targets/address_env 或手寫 upstream。
181
197
  省略 `ui.launch` 時預設 `new-page`,使用 SDK standalone launcher,不帶 Application Portal。
@@ -232,7 +232,7 @@ declared availability readiness, LAN isolation/egress, protected grant lifecycle
232
232
  ACLs, two-user visibility and clean install/upgrade/reboot/stop/uninstall before
233
233
  calling the package supported on a NAS. No NAS is accessed by the build gate.
234
234
 
235
- The unified Compose contract uses SDK 0.17.3 and templates 0.17.5 with CLI 0.17.3. Use the published, matching versions together; the legacy packaging route and bridge publish mode are removed.
235
+ The unified Compose contract uses SDK 0.27.x with CLI 0.27.x and locally installed templates from `~0.27.0`. Templates patches can ship independently; npm install/update resolves them, and SDK defaults come from the installed templates. SDK minor upgrades still require a CLI release with executor mappings/digests and `SDK_UNSUPPORTED` checks. The CLI does not fetch templates at runtime. The legacy packaging route and bridge publish mode are removed.
236
236
 
237
237
  Primary override files, and each included override set, must share a directory. Cross-directory override bases are rejected before loader I/O. Included implicit .env files are confined, inventoried and preserved; symlinks are rejected.
238
238
 
@@ -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`。
@@ -61,6 +61,31 @@ Reuse the embedded theme's typography, spacing and public controls. Preserve
61
61
  loading, empty, error, disabled and permission states. Backend policies and DSM
62
62
  checks decide authority; component visibility cannot grant access.
63
63
 
64
+ ## Buttons
65
+
66
+ Follow DSM's button policy, in the installed
67
+ `node_modules/@synopackageland/ui/docs/button-policy.md`. Choose only the
68
+ `variant`; the container chooses the shape, so do not set `kind="action"`.
69
+
70
+ | Place | Use |
71
+ | --- | --- |
72
+ | Dialog, Sheet, Wizard footer; `FormActions`, `AppActionBar`, `Footbar`, Panel `#fbar` | capsule: Cancel or Reset `secondary`, then one `primary` last |
73
+ | Confirming a deletion | Dialog footer: Cancel, then one `danger` last |
74
+ | Toolbar, PageHeader, section or card body and `#footer`, beside a field | grey tool `Button` (Delete too) |
75
+ | List or grid row | `IconButton` with a `label` |
76
+ | Help, docs, external pages | `Link`, not a button |
77
+
78
+ - A capsule only ends something: it closes a dialog, applies a form or moves a
79
+ wizard. Disable this connection, Open or Check again stay tool buttons, even
80
+ in a `SettingsSection` `#footer`; put a section's Save / Cancel in `FormActions`.
81
+ - One main action per footer, placed last. Inside a page, `primary` only for
82
+ the one step that unblocks it (Sign in), at most one per section.
83
+ - Keep the default size; neighbouring buttons share one size. One refresh per
84
+ page, in the PageHeader.
85
+ - In development the UI warns in the console about a capsule outside a footer,
86
+ two main actions in a footer, or a main action that is not last. Clear these
87
+ warnings before handing over the app.
88
+
64
89
  ## Start fast
65
90
 
66
91
  Every api.cgi call pays DSM's session check (about 60–240 ms on a NAS), so
@@ -165,6 +190,12 @@ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生
165
190
  compose App、沒有 gateway 的 App、`ui.launch` 或 `portal` 都會被 check 拒絕。
166
191
  冷登入由 url 圖示經 `open.cgi` 續接,`oidc.browser` 需要支援此續接的 SDK。
167
192
 
193
+ 原生 App 搭配 `identity_gateway`、而且唯一的 UI 就是包裝的網頁時,宣告 `ui: false` 取代 `ui.dir`。
194
+ 沒有 SDK 視窗:主選單只有一個 `displayname` 圖示,是 `type: "url"`,經 `sso/open.cgi` 在新分頁開原生 UI;
195
+ 套件中心「開啟」經 INFO `adminurl` 開同一個入口,與 compose App 相同。backend 與 ctl 指令不受影響。
196
+ 沒有 `identity_gateway`、搭配 `console` 或 `portal`、或使用 `capabilities.rootOps`、`delegate`、`webapi`、`identity`
197
+ (這些的核准或登入畫面在 SDK 視窗裡)都會被 check 以 `STANDALONE_UI_NO_WINDOW_INVALID` 拒絕。省略 `ui` 仍是錯誤。
198
+
168
199
  Docker/web service App 設定 `integration.web.launch`,內建預設是 `new-page`;
169
200
  省略開啟方式時直接開新分頁,不宣告 Application Portal。SDK 原生 App
170
201
  省略時使用 `dsm-window`;非 UI SDK 原生 App 必須明確寫 `ui.launch: new-page`。