@synopackageland/cli 0.15.4 → 0.17.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +22 -8
  2. package/README.md +8 -1
  3. package/assets/init-compose.md +33 -0
  4. package/assets/init-upstream/build-source.sh +10 -0
  5. package/assets/init-upstream/update-image.mjs +26 -0
  6. package/assets/init-upstream/upgrade.sh +47 -0
  7. package/assets/init-upstream/version.mjs +12 -0
  8. package/dist/add.js +3 -3
  9. package/dist/build-spk/uninstall-wizard.d.ts +1 -1
  10. package/dist/build-spk/uninstall-wizard.js +1 -1
  11. package/dist/build-spk/wizard-messages.js +9 -9
  12. package/dist/cli.js +16 -2
  13. package/dist/contract/validate.js +2 -2
  14. package/dist/e2e/dsm.d.ts +2 -2
  15. package/dist/e2e/dsm.js +3 -3
  16. package/dist/error-registry.js +8 -4
  17. package/dist/host/share-permission.d.ts +1 -1
  18. package/dist/host/share-permission.js +1 -1
  19. package/dist/init-upstream.d.ts +7 -0
  20. package/dist/init-upstream.js +64 -0
  21. package/dist/init.d.ts +4 -0
  22. package/dist/init.js +37 -1
  23. package/dist/portal/alias.d.ts +2 -6
  24. package/dist/portal/alias.js +2 -6
  25. package/dist/portal/info.d.ts +4 -0
  26. package/dist/portal/info.js +32 -0
  27. package/dist/runtime/dsm-version.d.ts +1 -1
  28. package/dist/runtime/dsm-version.js +1 -1
  29. package/dist/runtime/executor-digests.json +4 -26
  30. package/dist/runtime/sdk-batch.d.ts +2 -0
  31. package/dist/runtime/sdk-batch.js +23 -0
  32. package/dist/runtime/uninstall-cleanup.js +1 -1
  33. package/dist/skill.js +1 -0
  34. package/dist/standalone/build.js +8 -1
  35. package/dist/standalone/compose-errors.js +15 -0
  36. package/dist/standalone/compose.d.ts +8 -0
  37. package/dist/standalone/compose.js +13 -3
  38. package/dist/standalone/dsm-window.js +25 -4
  39. package/dist/standalone/identity-gateway.js +1 -0
  40. package/dist/standalone/inspect-spk.js +17 -3
  41. package/dist/standalone/lifecycle.js +4 -1
  42. package/dist/standalone/manifest.js +7 -2
  43. package/dist/standalone/native-layout.js +6 -2
  44. package/dist/standalone/portal-window.js +1 -1
  45. package/dist/standalone/spk.d.ts +3 -0
  46. package/dist/standalone/spk.js +9 -2
  47. package/package.json +3 -3
  48. package/skills/SKILL.md +8 -14
  49. package/skills/reference/compose.md +25 -8
  50. package/skills/reference/create.md +4 -4
  51. package/skills/reference/portal.md +10 -6
  52. package/skills/reference/ui-vue.md +12 -0
  53. package/skills/reference/upstream-fork.md +165 -0
  54. package/skills/reference/wrap.md +3 -1
package/dist/init.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateForkRecipe, writeForkScaffold } from "./init-upstream.js";
1
2
  import { goChildEnv } from "./runtime/go-env.js";
2
3
  import { spawnSync } from "node:child_process";
3
4
  import { parse, parseDocument } from "yaml";
@@ -112,6 +113,30 @@ function isTemplateBuildArtifact(path) {
112
113
  return (name === "node_modules" || name === "dist" || name === ".go-results.json");
113
114
  }
114
115
  export function initFromTemplate(template, targetDir, options = {}) {
116
+ if (options.upstreamFork) {
117
+ if (template !== "compose" && template !== "native-go")
118
+ throw new Error("[INIT_OPTIONS_UNSUPPORTED] --upstream-fork supports compose or native-go.");
119
+ validateForkRecipe(template, options);
120
+ const packaging = join(targetDir, "synology");
121
+ if (existsSync(packaging))
122
+ throw new Error("[INIT_TARGET_NOT_EMPTY] synology/ already exists.");
123
+ initFromTemplate(template, packaging, {
124
+ ...options,
125
+ packageId: options.packageId ?? defaultPackageId(targetDir),
126
+ displayName: options.displayName ?? defaultDisplayName(targetDir),
127
+ upstreamFork: false,
128
+ upstreamImage: undefined,
129
+ });
130
+ writeForkScaffold(packaging, template, options);
131
+ return;
132
+ }
133
+ if (options.upstreamImage)
134
+ throw new Error("[INIT_OPTIONS_UNSUPPORTED] Image recipe options require --upstream-fork.");
135
+ const hasHealth = options.healthPath !== undefined || options.healthStatus !== undefined;
136
+ if (hasHealth && (template !== "compose" || typeof options.healthPath !== "string" ||
137
+ !/^\/(?!\/)[^\s#\\]*$/.test(options.healthPath) || !Number.isInteger(options.healthStatus) ||
138
+ options.healthStatus < 200 || options.healthStatus > 299))
139
+ throw new Error("[INIT_HEALTH_INVALID] Compose health needs both --health-path /actual-endpoint and --health-status 2xx; redirects are not followed.");
115
140
  if (!isTemplateName(template))
116
141
  throw new Error(`[INIT_TEMPLATE_UNKNOWN] Unknown template: ${template}`);
117
142
  const requested = capabilityNames(options.with ?? []);
@@ -261,6 +286,18 @@ export function initFromTemplate(template, targetDir, options = {}) {
261
286
  }
262
287
  if (template === "compose" && options.from)
263
288
  importCompose(options.from, targetDir);
289
+ if (template === "compose") {
290
+ const path = join(targetDir, "dsm-package.yaml");
291
+ const doc = parseDocument(readFileSync(path, "utf8"));
292
+ if (doc.hasIn(["compose", "web"])) {
293
+ doc.deleteIn(["compose", "web", "health"]);
294
+ if (hasHealth)
295
+ doc.setIn(["compose", "web", "health"], { path: options.healthPath, status: options.healthStatus });
296
+ }
297
+ writeFileSync(path, String(doc));
298
+ const guidance = readFileSync(new URL("../assets/init-compose.md", import.meta.url), "utf8");
299
+ writeFileSync(join(targetDir, "AGENTS.md"), guidance);
300
+ }
264
301
  if (template === "binary") {
265
302
  mkdirSync(join(targetDir, "prebuilt"), { recursive: true });
266
303
  for (const [arch, goarch] of [
@@ -356,7 +393,6 @@ function importCompose(from, targetDir) {
356
393
  if (candidates.length === 1)
357
394
  doc.setIn(["compose", "web"], {
358
395
  ...candidates[0],
359
- health: { path: "/", status: 200 },
360
396
  });
361
397
  else {
362
398
  doc.deleteIn(["compose", "web"]);
@@ -11,12 +11,8 @@ export declare const PORTAL_ALIAS_MAX = 64;
11
11
  * of Synology Apps, as observed on DSM 7.4-90069. DSM stays the authority.
12
12
  */
13
13
  export declare const RESERVED_PORTAL_ALIASES: readonly string[];
14
- /**
15
- * A portal needs the executor tier: DSM removes the alias files on uninstall
16
- * but leaves nginx serving the alias, and only the root executor purge can
17
- * reload it. A no-privilege App never runs anything as root.
18
- */
19
- export declare const PORTAL_REQUIRES_EXECUTOR = "portal needs the executor level (capabilities.rootOps or delegate): after uninstall DSM does not reload nginx, and a root executor must remove the sign-in alias. An App without an executor should remove portal and let an administrator configure a reverse proxy in DSM.";
14
+ /** DSM below 7.2 refuses unsigned web-config; only an executor can register the entry there. */
15
+ export declare const PORTAL_REQUIRES_EXECUTOR = "portal before DSM 7.2-64570 needs an executor (capabilities.rootOps, delegate or webapi) to register its entry: DSM 7.1 rejects the unsigned web-config worker. Raise os_min_ver to 7.2-64570 or later, add an executor capability, or remove portal. DSM reloads nginx through INFO; a no-privilege App never runs root code.";
20
16
  export interface PortalDeclaration {
21
17
  alias: string;
22
18
  }
@@ -15,12 +15,8 @@ export const RESERVED_PORTAL_ALIASES = [
15
15
  "dsfile", "indexdb", "mobile", "oauth", "scripts", "sharing", "ssdp", "synosdsjslib", "synohdpack", "webapi", "webman",
16
16
  "calendar", "chatplus", "contacts", "drive", "file", "photo", "sso", "synologymeet",
17
17
  ];
18
- /**
19
- * A portal needs the executor tier: DSM removes the alias files on uninstall
20
- * but leaves nginx serving the alias, and only the root executor purge can
21
- * reload it. A no-privilege App never runs anything as root.
22
- */
23
- export const PORTAL_REQUIRES_EXECUTOR = "portal needs the executor level (capabilities.rootOps or delegate): after uninstall DSM does not reload nginx, and a root executor must remove the sign-in alias. An App without an executor should remove portal and let an administrator configure a reverse proxy in DSM.";
18
+ /** DSM below 7.2 refuses unsigned web-config; only an executor can register the entry there. */
19
+ export const PORTAL_REQUIRES_EXECUTOR = "portal before DSM 7.2-64570 needs an executor (capabilities.rootOps, delegate or webapi) to register its entry: DSM 7.1 rejects the unsigned web-config worker. Raise os_min_ver to 7.2-64570 or later, add an executor capability, or remove portal. DSM reloads nginx through INFO; a no-privilege App never runs root code.";
24
20
  export function isPortalAlias(value) {
25
21
  return typeof value === "string" && PORTAL_ALIAS_PATTERN.test(value) && !RESERVED_PORTAL_ALIASES.includes(value);
26
22
  }
@@ -0,0 +1,4 @@
1
+ /** DSM reloads these services after install, upgrade and uninstall; hooks stay unprivileged. */
2
+ export declare function portalInfoFields(fields: Array<[string, string]>, portal: boolean): Array<[string, string]>;
3
+ /** Check a resource-worker Portal. The legacy app.d registration still needs an executor. */
4
+ export declare function inspectPortalInfo(info: Record<string, string>, hasExecutor: boolean): string[];
@@ -0,0 +1,32 @@
1
+ import { portalByExecutor } from "../build-spk/dsm-version.js";
2
+ const RELOAD_SERVICES = "instuninst_restart_services";
3
+ const NGINX = "nginx.service";
4
+ /** DSM reloads these services after install, upgrade and uninstall; hooks stay unprivileged. */
5
+ export function portalInfoFields(fields, portal) {
6
+ if (!portal)
7
+ return fields;
8
+ const services = [...new Set([...fields.filter(([key]) => key === RELOAD_SERVICES)
9
+ .flatMap(([, value]) => value.split(/\s+/).filter(Boolean)), NGINX])].join(" ");
10
+ const result = [];
11
+ let written = false;
12
+ for (const [key, value] of fields) {
13
+ if (key !== RELOAD_SERVICES)
14
+ result.push([key, value]);
15
+ else if (!written) {
16
+ result.push([key, services]);
17
+ written = true;
18
+ }
19
+ }
20
+ if (!written)
21
+ result.push([RELOAD_SERVICES, services]);
22
+ return result;
23
+ }
24
+ /** Check a resource-worker Portal. The legacy app.d registration still needs an executor. */
25
+ export function inspectPortalInfo(info, hasExecutor) {
26
+ const findings = [];
27
+ if (!(info[RELOAD_SERVICES] ?? "").split(/\s+/).includes(NGINX))
28
+ findings.push(`PORTAL_RELOAD_MISSING INFO ${RELOAD_SERVICES} must include ${NGINX} so DSM reloads nginx after uninstall`);
29
+ if (info.os_min_ver && portalByExecutor(info.os_min_ver) && !hasExecutor)
30
+ findings.push("PORTAL_REQUIRES_EXECUTOR before DSM 7.2-64570 the portal entry must be registered by an executor; raise os_min_ver or declare an executor capability");
31
+ return findings;
32
+ }
@@ -1,6 +1,6 @@
1
1
  /** Self-contained: the same function is embedded in the DSM window. True only
2
2
  * when DSM refused a request because this method version is unavailable, the
3
- * one refusal that may be retried with an older version. DSM 7.1.1 (t-0234)
3
+ * one refusal that may be retried with an older version. DSM 7.1.1
4
4
  * returns 104 for a version above maxVersion and 103 for a method absent at an
5
5
  * existing version. Accepts the numeric code and the window's dsm_ form. */
6
6
  export declare function versionUnsupported(code: unknown): boolean;
@@ -1,6 +1,6 @@
1
1
  /** Self-contained: the same function is embedded in the DSM window. True only
2
2
  * when DSM refused a request because this method version is unavailable, the
3
- * one refusal that may be retried with an older version. DSM 7.1.1 (t-0234)
3
+ * one refusal that may be retried with an older version. DSM 7.1.1
4
4
  * returns 104 for a version above maxVersion and 103 for a method absent at an
5
5
  * existing version. Accepts the numeric code and the window's dsm_ form. */
6
6
  export function versionUnsupported(code) {
@@ -1,30 +1,8 @@
1
1
  {
2
- "0.15.0": {
3
- "x86_64": "efe409287770f6b70e7f8465e8767eeb2413b9ab8775020cf4dffb1555d7bf1f",
4
- "aarch64": "d68c9889ed2391e918b9c278679ea0a01cd04425528c11090700a3b6341fc543",
5
- "armv7": "9149a05efb9f2d7d86a400707099b3d657382b1c0dbad6588be8c500e90445cc",
6
- "lifecycle": {
7
- "x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
8
- "aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
9
- "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
10
- },
11
- "goToolchain": "go1.26.9"
12
- },
13
- "0.15.1": {
14
- "x86_64": "4f47fb43f6e60dbad064afb0bbec96dd009fa046670e2c4b590b128a855cb404",
15
- "aarch64": "c903b09ad657a838f0c62b6688e65e9094572b22b969770fca92fcc3640a5364",
16
- "armv7": "8178d90de7ea720530bb9496e50ed118086b0e57382d84675aadc68f482d219f",
17
- "lifecycle": {
18
- "x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
19
- "aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
20
- "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
21
- },
22
- "goToolchain": "go1.26.9"
23
- },
24
- "0.15.2": {
25
- "x86_64": "4f47fb43f6e60dbad064afb0bbec96dd009fa046670e2c4b590b128a855cb404",
26
- "aarch64": "c903b09ad657a838f0c62b6688e65e9094572b22b969770fca92fcc3640a5364",
27
- "armv7": "8178d90de7ea720530bb9496e50ed118086b0e57382d84675aadc68f482d219f",
2
+ "0.17.0": {
3
+ "x86_64": "68f372684997258adc61312f2c353e1a286a6b5aaa556f716b4e6ba6f33893d0",
4
+ "aarch64": "f6950301e201a4ebc14577a4554fa1b671f90d0f354d1e8809f4d1dafb57c723",
5
+ "armv7": "e77c9b6ae22ecfa85d9834af3350192e445987265a7ca6c02ca9b01d88a1b4f7",
28
6
  "lifecycle": {
29
7
  "x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
30
8
  "aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
@@ -0,0 +1,2 @@
1
+ /** The SDK's plain browser factory, embedded without imposing a TS build on Go Apps. */
2
+ export declare function appSdkBatchRuntime(backend: object, env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,23 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { appSdkVersion, sdkBelowFloor, sdkSource } from './sdk-source.js';
4
+ /** The SDK's plain browser factory, embedded without imposing a TS build on Go Apps. */
5
+ export function appSdkBatchRuntime(backend, env = process.env) {
6
+ if (backend.kind === 'binary')
7
+ return '';
8
+ const version = appSdkVersion(backend);
9
+ if (!version || sdkBelowFloor(version))
10
+ return '';
11
+ const sdk = sdkSource(version, env);
12
+ let source;
13
+ try {
14
+ source = readFileSync(join(sdk.goDir, 'ts/src/batch.js'), 'utf8');
15
+ }
16
+ catch (error) {
17
+ // Older supported SDKs retain their desktop-only carrier behavior.
18
+ if (error.code === 'ENOENT')
19
+ return '';
20
+ throw error;
21
+ }
22
+ return source.replace(/^export function createBatch\(/m, 'function createBatch(');
23
+ }
@@ -16,7 +16,7 @@ export function uninstallCleanup(pkg, { executor = false, portal = false, portal
16
16
  executor
17
17
  ? { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: AUTOMATIC_FAILED, action: 'The root executor removes these rules automatically through the Application Privileges API on uninstall. Administrator: remove surviving rules in Control Panel > Application Privileges; DSM would apply them again to a later install under the same name.' }
18
18
  : { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: 'After uninstall, if this package will not be reinstalled.', action: 'DSM keeps these rules in /etc/synoappprivilege.db and applies them again to a later install under the same name. Administrator: remove them in Control Panel > Application Privileges.' },
19
- ...(executor && portal ? [{ target: `${pkg} Login Portal alias`, condition: AUTOMATIC_FAILED, action: 'DSM removes the alias configuration without reloading its web server; the root executor then reloads the web server automatically. Administrator: if the alias still opens a DSM page, save an application alias in Control Panel > Login Portal > Applications to reload it, or restart the NAS.' }] : []),
19
+ ...(portal ? [{ target: `${pkg} Login Portal alias`, condition: 'Only if automatic cleanup failed and the alias still opens a DSM page after the asynchronous reload.', action: 'DSM removes the alias configuration and reloads the web server automatically through INFO instuninst_restart_services. Administrator: if the alias still opens a DSM page, save an application alias in Control Panel > Login Portal > Applications to reload it, or restart the NAS.' }] : []),
20
20
  ...(executor && portalEntry ? [{ target: `${pkg} Application Portal entry`, condition: 'After uninstall, if the executor was not running or was never approved, so it could not remove the entry.', action: `Administrator, as root: ${portalEntryCleanupCommands(pkg).join('; ')}` }] : []),
21
21
  { target: `${pkg} package account/group`, condition: 'After uninstall, only if this package will not be reinstalled and the identity is unused.', action: 'Administrator: review DSM internal account/group and associated ACLs before cleanup. Package scripts do not delete system identities.' },
22
22
  ];
package/dist/skill.js CHANGED
@@ -14,6 +14,7 @@ const SKILL_CATALOG = [
14
14
  file: "reference/compose.md",
15
15
  when: "Upstream Compose with the SDK planner, fixed identity and support boundary",
16
16
  },
17
+ { id: "upstream-fork", file: "reference/upstream-fork.md", when: "Package an upstream source fork without changing upstream files" },
17
18
  {
18
19
  id: "wrap",
19
20
  file: "reference/wrap.md",
@@ -1,3 +1,4 @@
1
+ import { PORTAL_REQUIRES_EXECUTOR } from "../portal/alias.js";
1
2
  import { buildApiDescription } from "./api-description.js";
2
3
  import { goChildEnv } from "../runtime/go-env.js";
3
4
  import { spawn } from "node:child_process";
@@ -10,7 +11,7 @@ import { resolveMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM,
10
11
  import { checkCgiBinary } from "./binary.js";
11
12
  import { loadStandaloneManifest, } from "./manifest.js";
12
13
  import { assembleStandaloneSpk, StandaloneBuildError } from "./spk.js";
13
- import { buildComposeControl, lockComposeImages, dsmComposeProblem, } from "./compose.js";
14
+ import { buildComposeControl, buildComposeStorageInit, lockComposeImages, dsmComposeProblem, } from "./compose.js";
14
15
  import { buildDaemonControl } from "./daemon.js";
15
16
  import { buildIdentityGateway } from "./identity-gateway.js";
16
17
  import { buildExecutor } from "../runtime/executor-build.js";
@@ -113,6 +114,10 @@ export async function buildStandalone(options) {
113
114
  !isDsmAtLeast(manifest.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
114
115
  throw new StandaloneBuildError("STANDALONE_PORTAL_MIN_DSM", `[STANDALONE_PORTAL_MIN_DSM] ${portalMinDsmMessage(manifest.os_min_ver, "portal")}`);
115
116
  }
117
+ if (manifest.portal && portalByExecutor(manifest.os_min_ver) &&
118
+ !manifest.capabilities.rootOps && !manifest.capabilities.delegate && !manifest.capabilities.webapi) {
119
+ throw new StandaloneBuildError("STANDALONE_PORTAL_REQUIRES_EXECUTOR", PORTAL_REQUIRES_EXECUTOR);
120
+ }
116
121
  if (manifest.compose && !isDsmAtLeast(manifest.os_min_ver, "7.2.1-69057"))
117
122
  throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose identity_gateway requires DSM >= 7.2.1.");
118
123
  const composeProblem = manifest.compose && dsmComposeProblem(manifest.compose);
@@ -148,6 +153,7 @@ export async function buildStandalone(options) {
148
153
  const daemonControl = await buildDaemonControl(manifest, arch, archWork, run);
149
154
  const identityGateway = await buildIdentityGateway(manifest, arch, archWork, run);
150
155
  const composeControl = await buildComposeControl(manifest, arch, archWork, run);
156
+ const composeStorageInit = await buildComposeStorageInit(manifest, arch, archWork, run);
151
157
  // A portal needs the executor, so the executor's SDK carries the panel text.
152
158
  const portalText = manifest.portal && executor
153
159
  ? portalCatalogs(sdkSource(executor.sdk).goDir)
@@ -160,6 +166,7 @@ export async function buildStandalone(options) {
160
166
  daemonControl,
161
167
  identityGateway,
162
168
  composeControl,
169
+ composeStorageInit,
163
170
  backgroundWorker,
164
171
  executor,
165
172
  app,
@@ -1,5 +1,20 @@
1
1
  /** SDK planner errors accepted at this versioned tool boundary. */
2
2
  export const COMPOSE_ERRORS = {
3
+ COMPOSE_STORAGE_INIT_INVALID: {
4
+ code: "COMPOSE_STORAGE_INIT_INVALID",
5
+ description: "The container storage initialization declaration is invalid.",
6
+ suggestion: "Declare private subdirectories on writable mounts and explicit upstream entrypoint/command argv; keep the service nonroot. The SDK initializer is supplied by the SPK.",
7
+ },
8
+ COMPOSE_STORAGE_NOT_WRITABLE: {
9
+ code: "COMPOSE_STORAGE_NOT_WRITABLE",
10
+ description: "The package UID cannot initialize or write its private container data.",
11
+ suggestion: "Use a writable mount base with a package-owned private subtree; inspect ownership, permissions, read-only storage, quota and free space. Do not add root or chown hooks.",
12
+ },
13
+ COMPOSE_STORAGE_COMMAND_INVALID: {
14
+ code: "COMPOSE_STORAGE_COMMAND_INVALID",
15
+ description: "The application command after storage preflight cannot be executed.",
16
+ suggestion: "Declare the actual upstream executable and arguments. Keep the upstream official image unchanged; the SPK supplies the SDK initializer.",
17
+ },
3
18
  COMPOSE_SUBNET_STATE_INVALID: {
4
19
  code: "COMPOSE_SUBNET_STATE_INVALID",
5
20
  description: "The private ingress history is invalid or has foreign ownership.",
@@ -23,6 +23,12 @@ export interface ComposePlan {
23
23
  mounts_blocked: boolean;
24
24
  acceptance_blockers: string[];
25
25
  services: Array<{
26
+ storage_init?: {
27
+ directories: Array<{
28
+ base: string;
29
+ path: string;
30
+ }>;
31
+ };
26
32
  service: string;
27
33
  user: string;
28
34
  container_root: boolean;
@@ -96,6 +102,8 @@ export declare function writeComposePayload(c: ComposeApp, root: string, target:
96
102
  export declare function renderComposeInstall(pkg: string): string;
97
103
  /** The same SDK entry point supplies the package-account install materializer. */
98
104
  export declare function buildComposeControl(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
105
+ /** The SPK supplies this read-only binary; upstream registry images stay unchanged. */
106
+ export declare function buildComposeStorageInit(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
99
107
  /** Only the build resolves registry metadata; check and inspect remain offline. */
100
108
  export declare function lockComposeImages(m: StandaloneManifest, arch: NativeArchFamily, resolveImages: boolean, imageProofs?: unknown): StandaloneManifest;
101
109
  export declare function composeInputs(root: string, file: string, envFile?: string): ComposePlan["inputs"];
@@ -194,6 +194,7 @@ export function writeComposePayload(c, root, target, gateway) {
194
194
  export function renderComposeInstall(pkg) {
195
195
  return `#!/bin/sh
196
196
  set -eu
197
+ "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" preflight || exit $?
197
198
  exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose" --package '${pkg}'
198
199
  `;
199
200
  }
@@ -201,10 +202,19 @@ exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNO
201
202
  export async function buildComposeControl(m, arch, work, run) {
202
203
  if (!m.compose)
203
204
  return;
205
+ return buildComposeTool(m, arch, work, run, "plan");
206
+ }
207
+ /** The SPK supplies this read-only binary; upstream registry images stay unchanged. */
208
+ export async function buildComposeStorageInit(m, arch, work, run) {
209
+ if (!m.compose?.plan.services.some((s) => s.storage_init))
210
+ return;
211
+ return buildComposeTool(m, arch, work, run, "storage-init");
212
+ }
213
+ async function buildComposeTool(m, arch, work, run, tool) {
204
214
  const { pinnedGoBuildEnv, pinnedGoProblem } = await import("../build-spk/go-toolchain.js");
205
215
  const { checkCgiBinary } = await import("./binary.js");
206
216
  const { StandaloneBuildError } = await import("./spk.js");
207
- const out = join(work, `compose-control-${arch}`);
217
+ const out = join(work, `compose-${tool === "plan" ? "control" : tool}-${arch}`);
208
218
  const source = sdkSource(m.compose.sdk);
209
219
  const result = await run("go", [
210
220
  "build",
@@ -214,13 +224,13 @@ export async function buildComposeControl(m, arch, work, run) {
214
224
  "-s -w -buildid=",
215
225
  "-o",
216
226
  out,
217
- "./compose/cmd/plan",
227
+ `./compose/cmd/${tool}`,
218
228
  ], {
219
229
  cwd: source.goDir,
220
230
  env: goChildEnv({ ...process.env, ...pinnedGoBuildEnv(arch) }),
221
231
  });
222
232
  if (result.status !== 0 || result.missing)
223
- throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", `Cannot build the SDK Compose control: ${result.stderr}`);
233
+ throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", `Cannot build the SDK Compose ${tool}: ${result.stderr}`);
224
234
  const error = checkCgiBinary(work, out, arch);
225
235
  if (error)
226
236
  throw new StandaloneBuildError(error.code, error.message);
@@ -12,6 +12,7 @@ import { packageIcons, windowIconFile, windowIconTemplate, } from "../build-spk/
12
12
  import { DEFAULT_WINDOW, FULL_PAGE_MIN_WIDTH } from "./window-size.js";
13
13
  import { requestTimeoutSeconds } from "./request-timeout.js";
14
14
  import { appSdkTimeouts } from "../runtime/sdk-timeouts.js";
15
+ import { appSdkBatchRuntime } from "../runtime/sdk-batch.js";
15
16
  import { appSdkSessionRuntime } from "../runtime/sdk-session.js";
16
17
  /** Same path the generated wrapper POSTs to, and the payload location of the CGI. */
17
18
  export const CGI_PAYLOAD_PATH = "ui/cgi/api.cgi";
@@ -171,6 +172,7 @@ export function renderWindowScript(manifest, assetVersion, icons = packageIcons(
171
172
  const requestTimeout = requestTimeoutSeconds(manifest.backend) * 1000;
172
173
  const clientMargin = appSdkTimeouts(manifest.backend).browserMarginMs;
173
174
  const sessionRuntime = appSdkSessionRuntime(manifest.backend);
175
+ const batchRuntime = appSdkBatchRuntime(manifest.backend);
174
176
  if (manifest.portal && !portalText)
175
177
  throw new Error("A portal App's window needs the SDK's portal panel text.");
176
178
  return `/* Generated by synopkgland build. Edit your UI in the project's ui folder. */
@@ -186,13 +188,17 @@ ${sessionRuntime}
186
188
  fetch: function (url, options) { return fetch(url, options); }
187
189
  });
188
190
  })();
191
+ // DSM loads this script when the App launches. A page it gave no SynoToken
192
+ // (Portal, standalone) asks for one now, while app.js downloads, so the
193
+ // first call does not wait a round trip for it after the App mounts.
194
+ if (${ns}.session && !${ns}.session.token()) ${ns}.session.ready();
189
195
  `
190
196
  : ""}
191
197
 
192
198
  // The SDK's clientTimeout: past the backend's request deadline
193
199
  // (ctx.requestTimeout, from backend.timeout_seconds), so its request_timeout
194
200
  // reply arrives first. options.signal aborts the fetch, for a page the user leaves.
195
- ${ns}.callOnce = function (method, params, options, requestTimeout) {
201
+ ${ns}.callOnce = function (method, params, options, requestTimeout, envelope) {
196
202
  var signal = options && options.signal;
197
203
  var canceled = { code: "request_canceled", message: "The request was canceled" };
198
204
  if (signal && signal.aborted) {
@@ -224,7 +230,7 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
224
230
  method: "POST",
225
231
  credentials: "same-origin",
226
232
  headers: headers,
227
- body: JSON.stringify({ method: method, params: params || {} }),
233
+ body: JSON.stringify(envelope || { method: method, params: params || {} }),
228
234
  signal: controller.signal
229
235
  }).then(function (response) {
230
236
  return response.json().catch(function () {
@@ -254,9 +260,21 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
254
260
  });
255
261
  };
256
262
 
263
+ ${batchRuntime ? `
264
+ // A batch is one request and is never replayed after a partial result.
265
+ ${ns}.batch = (function () {
266
+ ${batchRuntime}
267
+ return function (calls, options, requestTimeout) {
268
+ return createBatch(function (envelope, callOptions) {
269
+ return ${ns}.callOnce(null, null, callOptions, requestTimeout, envelope);
270
+ })(calls, options);
271
+ };
272
+ })();
273
+ ` : ""}
274
+
257
275
  // APP_STOPPED, APP_STARTING and EXECUTOR_STARTING mean the executor op was not
258
276
  // sent. A stopped package waits for the user to start it in Package Center, so
259
- // APP_STOPPED rejects at once (t-0529). A start ends by itself: a call repeats
277
+ // APP_STOPPED rejects at once. A start ends by itself: a call repeats
260
278
  // half a second, then doubling, for 4 s of waiting in all, so a short start
261
279
  // shows the page nothing and the SDK's starting copy follows a long one within
262
280
  // its own 4 s recheck; executor.control, which the window's approval starts the
@@ -264,7 +282,7 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
264
282
  // deadline; past the wait it rejects with the last refusal, and leaving the
265
283
  // page during a wait rejects request_canceled at once. The SDK's status reads
266
284
  // are not repeated: its panels show the stopped or starting package and check
267
- // again on their own (t-0503 R1).
285
+ // again on their own.
268
286
  ${ns}.retryCodes = ["APP_STARTING", "EXECUTOR_STARTING"];
269
287
  ${ns}.statusReads = ["delegate.status", "authorization.status", "executor.status"];
270
288
  ${ns}.call = function (method, params, options, requestTimeout) {
@@ -402,6 +420,7 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
402
420
  try { if (typeof _S === "function") lang = _S("lang"); } catch (error) {}
403
421
  var ctx = { requestTimeout: ${requestTimeout} };
404
422
  var call = function (method, params, options) { return ${ns}.call(method, params, options, ctx.requestTimeout); };
423
+ var batch = ${batchRuntime ? `function (calls, options) { return ${ns}.batch(calls, options, ctx.requestTimeout); }` : "undefined"};
405
424
  ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
406
425
  if (me.mountToken !== mountToken) return;
407
426
  me.mountPending = false;
@@ -415,6 +434,7 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
415
434
  me.mounted = app.mount(root, Ext.apply(ctx, {
416
435
  displayName: ${ns}.createI18n(lang, ${JSON.stringify(displayNameCatalogs(manifest))}).t("app_title"),
417
436
  call: call,
437
+ batch: batch,
418
438
  files: {endpoint: ${JSON.stringify(cgiUrl(pkg))}, token: function () { return SYNO.SDS.Session && SYNO.SDS.Session.SynoToken; }},
419
439
  reopen: me.reopenEvents.subscribe,
420
440
  launchParams: me.launchParams,
@@ -495,6 +515,7 @@ function renderPortalAccess(ns, manifest) {
495
515
  isAdmin: function () { return ${ns}.sessionUser().isAdmin; },
496
516
  send: ${ns}.webApi(me),
497
517
  call: call,
518
+ batch: batch,
498
519
  launch: function (app, params) { SYNO.SDS.AppLaunch(app, params); },
499
520
  location: window.location,
500
521
  locale: ${ns}.createI18n(lang, ${JSON.stringify(Object.fromEntries(Object.keys(DSM_LANGS).map((locale) => [locale, {}])))}).locale
@@ -313,6 +313,7 @@ export function identityGatewayConfig(m) {
313
313
  access: g.access ?? "dsm-users",
314
314
  ...(g.groups ? { groups: g.groups } : {}),
315
315
  ...(m.entry ? { entry: m.entry } : {}),
316
+ ...(m.compose ? { container_app: true } : {}),
316
317
  ...(m.compose?.plan.mounts_blocked ? { compose_blocked: true } : {}),
317
318
  ...(g.max_upload_bytes ? { max_upload_bytes: g.max_upload_bytes } : {}),
318
319
  ...(g.response_header_timeout_seconds
@@ -1,3 +1,4 @@
1
+ import { inspectPortalInfo } from "../portal/info.js";
1
2
  import { isDeepStrictEqual } from "node:util";
2
3
  import { inspectApiDescription } from "./api-description-inspect.js";
3
4
  import { dsmLauncherType } from "../contract/app-launch.js";
@@ -152,7 +153,7 @@ export async function inspectSpk(spkPath, expectedKind) {
152
153
  const sharedFolder = standalone
153
154
  ? recoverSharedFolder(readOptional(join(dir, "scripts/postinst")))
154
155
  : undefined;
155
- // A Compose App has no executor, so its SPK never carries a portal.
156
+ // Resource-worker portals use DSM-owned INFO reload, with no executor requirement.
156
157
  const portal = standalone
157
158
  ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings)
158
159
  : undefined;
@@ -187,8 +188,8 @@ export async function inspectSpk(spkPath, expectedKind) {
187
188
  ? await checkBridgeApp(dir, metadata.backend, info, payload, findings)
188
189
  : undefined;
189
190
  const hasExecutor = (metadata.capabilities ?? []).some((name) => name === "rootOps" || name === "delegate" || name === "webapi") || paths.has("executor/executor");
190
- if (standalone && portal && !hasExecutor)
191
- findings.push("PORTAL_REQUIRES_EXECUTOR portal needs the executor level (rootOps or delegate): after uninstall only a root purge can stop nginx serving the alias");
191
+ if (standalone && portal)
192
+ findings.push(...inspectPortalInfo(info, hasExecutor));
192
193
  const daemon = standalone
193
194
  ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined)
194
195
  : undefined;
@@ -205,6 +206,8 @@ export async function inspectSpk(spkPath, expectedKind) {
205
206
  executor = inspected.executor;
206
207
  if (executor)
207
208
  findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
209
+ if (executor?.policy.portal && !portal)
210
+ findings.push(...inspectPortalInfo(info, true));
208
211
  if (executor?.policy.portal &&
209
212
  info.os_min_ver &&
210
213
  OS_MIN_VER.test(info.os_min_ver) &&
@@ -668,6 +671,17 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
668
671
  throw new Error(materializer.message);
669
672
  if (payload.find((p) => p.path === "compose/control")?.mode !== "0755")
670
673
  throw new Error("Compose materializer must be executable");
674
+ const initializer = join(root, "compose/storage-init");
675
+ if (c.plan.services.some((s) => s.storage_init)) {
676
+ const error = checkCgiBinary(root, initializer, arch);
677
+ if (error)
678
+ throw new Error(error.message);
679
+ if (payload.find((p) => p.path === "compose/storage-init")?.mode !== "0755")
680
+ throw new Error("Compose storage initializer must be executable");
681
+ }
682
+ else if (existsSync(initializer)) {
683
+ throw new Error("unexpected Compose storage initializer");
684
+ }
671
685
  const binaries = [
672
686
  "daemon/control",
673
687
  "daemon/payload/_sso/gateway",
@@ -21,12 +21,15 @@ export function renderStandaloneScript(name, manifest) {
21
21
  : renderStandaloneScript(name, { ...native, appScripts: undefined });
22
22
  const control = `"/var/packages/${manifest.package}/target/daemon/control"`;
23
23
  const paths = `"\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${manifest.package}/var}"`;
24
+ const preflight = name === "start-stop-status"
25
+ ? `case "$1" in prestart|start|restart)\n "\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}/compose/control" preflight || exit $? ;;\nesac\n`
26
+ : "";
24
27
  const remember = name === "postinst" &&
25
28
  manifest.compose.plan.ingress &&
26
29
  !manifest.compose.plan.mounts_blocked
27
30
  ? `"/var/packages/${manifest.package}/target/compose/control" remember-ingress --project "/var/packages/${manifest.package}/target/compose" --package '${manifest.package}' || exit $?\n`
28
31
  : "";
29
- const ledger = remember +
32
+ const ledger = preflight + remember +
30
33
  (manifest.identityGateway && !manifest.compose.plan.mounts_blocked
31
34
  ? name === "start-stop-status"
32
35
  ? `if [ "$1" = restart ]; then\n "$0" stop || exit $?\n exec "$0" start\nfi\nif [ "$1" = start ]; then\n ${control} acquire ${paths} || exit $?\nelif [ "$1" = stop ]; then\n ${control} release ${paths} || exit $?\nfi\n`
@@ -234,6 +234,10 @@ function readManifest(raw, c) {
234
234
  const daemon = arches &&
235
235
  readDaemon(raw.daemon, c.root, arches, (e) => c.fail(e.code, e.message));
236
236
  const gatewayRaw = isRecord(raw.identity_gateway) ? raw.identity_gateway : {};
237
+ if (raw.compose !== undefined && isRecord(raw.ctl) && raw.ctl.enabled === true)
238
+ c.fail("CTL_UNSUPPORTED_BACKEND", "Compose does not support automatic ctl descriptions; disable ctl.");
239
+ if (isRecord(raw.compose) && isRecord(raw.compose.web) && !isRecord(raw.compose.web.health))
240
+ c.fail("COMPOSE_HEALTH_REQUIRED", "Select compose.web.health.path and expected 2xx status for the actual App. Readiness does not follow redirects; check is static and does not probe HTTP.");
237
241
  const compose = arches &&
238
242
  readComposeApp(raw.compose, c.root, arches, (e) => c.fail(e.code, e.message), {
239
243
  package: packageId,
@@ -260,7 +264,8 @@ function readManifest(raw, c) {
260
264
  : raw.identity_gateway;
261
265
  if (compose?.web && gatewayRaw.upstream !== undefined)
262
266
  c.fail("COMPOSE_WEB_INVALID", "Compose upstream is derived from the SDK Compose ingress endpoint; remove identity_gateway.upstream.");
263
- const identityGateway = readIdentityGateway(gatewayValue, daemon || (compose ? { targets: compose.targets } : undefined), (e) => c.fail(e.code, e.message), false, Boolean(compose?.plan.ingress));
267
+ // A failed Compose plan already explains the invalid upstream declaration.
268
+ const identityGateway = raw.compose !== undefined && !compose ? undefined : readIdentityGateway(gatewayValue, daemon || (compose ? { targets: compose.targets } : undefined), (e) => c.fail(e.code, e.message), false, Boolean(compose?.plan.ingress));
264
269
  if (compose && identityGateway && compose.sdk !== identityGateway.sdk)
265
270
  c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose and identity_gateway must use the same SDK release.");
266
271
  if (compose &&
@@ -369,7 +374,7 @@ function readManifest(raw, c) {
369
374
  ? undefined
370
375
  : (readPortalDeclaration(raw.portal, packageId) ??
371
376
  c.fail("STANDALONE_PORTAL_INVALID", "portal must be {} or { alias: <1-64 lowercase alphanumerics or -, not a DSM reserved path> }."));
372
- if (portal &&
377
+ if (portal && osMinVer && portalByExecutor(osMinVer) &&
373
378
  !capabilities.rootOps &&
374
379
  !capabilities.delegate &&
375
380
  !capabilities.webapi) {
@@ -1,3 +1,4 @@
1
+ import { portalInfoFields } from "../portal/info.js";
1
2
  import { validateMetadata, metadataInfoFields } from "../package-metadata.js";
2
3
  /** Existing native products: the CLI owns archives/INFO, the project owns its layout. */
3
4
  import { createHash } from 'node:crypto';
@@ -329,7 +330,7 @@ export async function buildNativeLayout(options) {
329
330
  await createReproducibleTar(target, join(stage, 'package.tgz'), { gzip: true });
330
331
  rmSync(target, { recursive: true });
331
332
  const fields = { package: m.package, version, arch: layoutInfoArch(arch), os_min_ver: m.os_min_ver, displayname: m.displayname, description: m.description, maintainer: m.maintainer, dsmuidir: 'ui', dsmappname: `SYNO.SDS.${m.package}.Instance`, startable: 'yes', thirdparty: 'yes', checksum: createHash('md5').update(readFileSync(join(stage, 'package.tgz'))).digest('hex') };
332
- const info = [...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)];
333
+ const info = portalInfoFields([...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)], Boolean(m.portal));
333
334
  if (new Set(info.map(([k]) => k)).size !== info.length)
334
335
  fail('INFO anchor collides with an INFO field');
335
336
  writeFileSync(join(stage, 'INFO'), info.map(([k, v]) => `${k}="${v.replace(/([\\"$`])/g, '\\$1')}"\n`).join(''));
@@ -370,8 +371,11 @@ export function inspectNativeLayout(dir, payload, info) {
370
371
  fail('package checksum mismatch');
371
372
  checkInfoAnchors(readFileSync(join(dir, 'INFO'), 'utf8'), inv.anchors === undefined ? {} : anchors(inv.anchors), actual);
372
373
  const declared = inv.portal === undefined ? undefined : portal(inv.portal, info.package);
373
- if (declared)
374
+ if (declared) {
374
375
  checkPortal(declared, info.package, info.displayname ?? '', info.os_min_ver);
376
+ if (!(info.instuninst_restart_services ?? '').split(/\s+/).includes('nginx.service'))
377
+ fail('Portal INFO instuninst_restart_services must include nginx.service');
378
+ }
375
379
  checkInfoPortal(readFileSync(join(dir, 'INFO'), 'utf8'), declared);
376
380
  return [];
377
381
  }
@@ -204,7 +204,7 @@ export const portalRuntime = (catalogs) => `function createPortalAccess(options)
204
204
  };
205
205
  }
206
206
  // The window reads the status when it opens, so a settings page shows the
207
- // portal section as it is (t-0520).
207
+ // portal section as it is.
208
208
  status();
209
209
  return access;
210
210
  }`;