@synopackageland/cli 0.14.1 → 0.15.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 (114) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +26 -16
  3. package/dist/build-spk/generator.d.ts +3 -2
  4. package/dist/build-spk/generator.js +6 -846
  5. package/dist/build-spk/resource-worker.d.ts +2 -14
  6. package/dist/build-spk/resource-worker.js +3 -166
  7. package/dist/cli.js +208 -56
  8. package/dist/compose/analyze.d.ts +3 -61
  9. package/dist/compose/analyze.js +35 -776
  10. package/dist/compose/env.d.ts +5 -3
  11. package/dist/compose/env.js +17 -25
  12. package/dist/compose/runner.js +23 -6
  13. package/dist/contract/web-launch.d.ts +1 -12
  14. package/dist/contract/web-launch.js +10 -74
  15. package/dist/ctl/cli.d.ts +36 -0
  16. package/dist/ctl/cli.js +218 -0
  17. package/dist/ctl/descriptor.d.ts +81 -0
  18. package/dist/ctl/descriptor.js +93 -0
  19. package/dist/ctl/dsm-auth.d.ts +10 -0
  20. package/dist/ctl/dsm-auth.js +30 -0
  21. package/dist/ctl/errors.d.ts +17 -0
  22. package/dist/ctl/errors.js +32 -0
  23. package/dist/ctl/flows.d.ts +11 -0
  24. package/dist/ctl/flows.js +52 -0
  25. package/dist/ctl/input.d.ts +20 -0
  26. package/dist/ctl/input.js +128 -0
  27. package/dist/ctl/json.d.ts +2 -0
  28. package/dist/ctl/json.js +38 -0
  29. package/dist/ctl/redaction.d.ts +15 -0
  30. package/dist/ctl/redaction.js +198 -0
  31. package/dist/ctl/service.d.ts +38 -0
  32. package/dist/ctl/service.js +156 -0
  33. package/dist/ctl/store.d.ts +51 -0
  34. package/dist/ctl/store.js +93 -0
  35. package/dist/ctl/transfers.d.ts +14 -0
  36. package/dist/ctl/transfers.js +74 -0
  37. package/dist/ctl/transport.d.ts +11 -0
  38. package/dist/ctl/transport.js +79 -0
  39. package/dist/deploy/deploy.d.ts +4 -3
  40. package/dist/deploy/deploy.js +5 -210
  41. package/dist/deploy/plan.js +26 -7
  42. package/dist/dev.js +5 -4
  43. package/dist/error-registry.js +1020 -834
  44. package/dist/info.js +120 -28
  45. package/dist/init.js +188 -64
  46. package/dist/inspect.js +21 -6
  47. package/dist/runtime/executor-digests.json +1 -12
  48. package/dist/skill.js +51 -9
  49. package/dist/standalone/api-description-inspect.d.ts +4 -0
  50. package/dist/standalone/api-description-inspect.js +12 -0
  51. package/dist/standalone/api-description.d.ts +7 -0
  52. package/dist/standalone/api-description.js +73 -0
  53. package/dist/standalone/build.d.ts +3 -0
  54. package/dist/standalone/build.js +140 -33
  55. package/dist/standalone/compose-errors.d.ts +3 -0
  56. package/dist/standalone/compose-errors.js +243 -0
  57. package/dist/standalone/compose.d.ts +97 -10
  58. package/dist/standalone/compose.js +306 -75
  59. package/dist/standalone/daemon.js +111 -29
  60. package/dist/standalone/dsm-window.js +39 -8
  61. package/dist/standalone/identity-gateway.d.ts +9 -2
  62. package/dist/standalone/identity-gateway.js +282 -66
  63. package/dist/standalone/inspect-spk.d.ts +12 -0
  64. package/dist/standalone/inspect-spk.js +666 -136
  65. package/dist/standalone/lifecycle.js +124 -42
  66. package/dist/standalone/manifest.d.ts +8 -1
  67. package/dist/standalone/manifest.js +303 -86
  68. package/dist/standalone/resource.js +6 -2
  69. package/dist/standalone/spk.d.ts +4 -1
  70. package/dist/standalone/spk.js +138 -39
  71. package/dist/template-paths.d.ts +0 -2
  72. package/dist/template-paths.js +0 -5
  73. package/dist/test-command.d.ts +1 -1
  74. package/dist/test-command.js +1 -1
  75. package/dist/validate.d.ts +3 -3
  76. package/dist/validate.js +32 -140
  77. package/package.json +5 -3
  78. package/schema/synopkgland-api-v1.schema.json +423 -0
  79. package/skills/SKILL.md +3 -1
  80. package/skills/reference/compose.md +349 -0
  81. package/skills/reference/create.md +8 -17
  82. package/skills/reference/ctl.md +41 -0
  83. package/skills/reference/deploy.md +1 -1
  84. package/skills/reference/files.md +4 -0
  85. package/skills/reference/identity-gateway.md +8 -144
  86. package/skills/reference/wrap.md +10 -39
  87. package/assets/go/daemon/fixture_linux_test.go +0 -15
  88. package/assets/go/daemon/fixture_other_test.go +0 -7
  89. package/assets/go/daemon/go.mod +0 -5
  90. package/assets/go/daemon/main.go +0 -636
  91. package/assets/go/daemon/main_test.go +0 -315
  92. package/assets/go/daemon/network.go +0 -107
  93. package/assets/go/daemon/network_test.go +0 -86
  94. package/assets/go/daemon/process_linux.go +0 -55
  95. package/assets/go/daemon/process_linux_test.go +0 -161
  96. package/assets/go/daemon/process_other.go +0 -14
  97. package/assets/go/daemon/testdata/child.go +0 -44
  98. package/assets/go/gateway/auth.go +0 -101
  99. package/assets/go/gateway/auth_test.go +0 -216
  100. package/assets/go/gateway/cgi.go +0 -133
  101. package/assets/go/gateway/cgi_test.go +0 -156
  102. package/assets/go/gateway/csrf.go +0 -73
  103. package/assets/go/gateway/csrf_test.go +0 -155
  104. package/assets/go/gateway/directory.go +0 -108
  105. package/assets/go/gateway/directory_test.go +0 -202
  106. package/assets/go/gateway/gateway.go +0 -412
  107. package/assets/go/gateway/gateway_test.go +0 -676
  108. package/assets/go/gateway/go.mod +0 -5
  109. package/assets/go/gateway/groups.go +0 -66
  110. package/assets/go/gateway/main.go +0 -45
  111. package/dist/build-spk/gateway.d.ts +0 -31
  112. package/dist/build-spk/gateway.js +0 -60
  113. package/dist/compose/privilege.d.ts +0 -14
  114. package/dist/compose/privilege.js +0 -102
@@ -1,40 +1,51 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { inspectApiDescription } from "./api-description-inspect.js";
1
3
  import { dsmLauncherType } from "../contract/app-launch.js";
2
- import { isWebLaunchMode, resolvedWebLaunch, trustedAppEntryPath, usesAuthGateway } from "../contract/web-launch.js";
4
+ import { isWebLaunchMode, resolvedWebLaunch, trustedAppEntryPath, usesAuthGateway, } from "../contract/web-launch.js";
3
5
  import { loadContract } from "../contract/load.js";
4
- import { readComposeApp } from "./compose.js";
6
+ import { readComposeApp, composeSource, dsmComposeProblem, COMPOSE_READINESS_LIMITATION, } from "./compose.js";
5
7
  import { createHash } from "node:crypto";
6
8
  import { inspectNativeLayout } from "./native-layout.js";
7
9
  import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
8
- import { inspectExecutor, inspectExecutorAnchor } from "../runtime/executor-inspect.js";
9
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
10
+ import { inspectExecutor, inspectExecutorAnchor, } from "../runtime/executor-inspect.js";
11
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, } from "node:fs";
10
12
  import { unwrapAppScripts } from "./app-scripts.js";
11
13
  import { tmpdir } from "node:os";
12
14
  import { join } from "node:path";
13
15
  import * as tar from "tar";
14
- import { familyOfArchToken, NATIVE_ARCH_FAMILIES } from "../build-spk/dsm-arch.js";
15
- import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DSM, PORTAL_MIN_DSM_REASON } from "../build-spk/dsm-version.js";
16
+ import { familyOfArchToken, NATIVE_ARCH_FAMILIES, } from "../build-spk/dsm-arch.js";
17
+ import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DSM, PORTAL_MIN_DSM_REASON, } from "../build-spk/dsm-version.js";
16
18
  import { ELF_MACHINE } from "./binary.js";
17
19
  import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
18
20
  import { daemonConfig, readDaemon } from "./daemon.js";
19
21
  import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
20
22
  import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
21
23
  import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
22
- import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, BRIDGE_WORKER_ARGS, backgroundUnitName, renderBackgroundUnit } from "./background.js";
24
+ import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, BRIDGE_WORKER_ARGS, backgroundUnitName, renderBackgroundUnit, } from "./background.js";
23
25
  import { renderStandaloneResource } from "./resource.js";
24
- import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER } from "./shared-folder.js";
25
- import { renderInstallWizardFiles, renderNativeInstallWizard } from "../build-spk/install-wizard.js";
26
- import { renderDelegateScopeSteps, renderDelegateInstallDraft } from "../build-spk/delegate-scope.js";
26
+ import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER, } from "./shared-folder.js";
27
+ import { renderInstallWizardFiles, renderNativeInstallWizard, } from "../build-spk/install-wizard.js";
28
+ import { renderDelegateScopeSteps, renderDelegateInstallDraft, } from "../build-spk/delegate-scope.js";
27
29
  import { renderUninstallWizardFiles } from "../build-spk/uninstall-wizard.js";
30
+ import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
31
+ import { renderUiConfig } from "./dsm-window.js";
32
+ import { identityGatewayConfig, readIdentityGateway, } from "./identity-gateway.js";
33
+ import { checkCgiBinary } from "./binary.js";
28
34
  import { renderStandaloneScript } from "./lifecycle.js";
29
35
  import { listPortExposures, loadCompose } from "../compose/analyze.js";
30
- import { uninstallCleanup } from "../runtime/uninstall-cleanup.js";
31
- import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep } from "../portal/package.js";
36
+ import { uninstallCleanup, } from "../runtime/uninstall-cleanup.js";
37
+ import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep, } from "../portal/package.js";
32
38
  import { isPortalAlias } from "../portal/alias.js";
33
39
  import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
34
40
  import { isDefaultLoginAsset } from "../build-spk/login-style.js";
35
41
  import { decodePng } from "../build-spk/png.js";
36
42
  /** The SPK kind of each sdk-package.json backend a CGI is built for: Go itself, or the runtime of an App in another language. */
37
- const COMPILED_KIND = { go: "native-go", python: "native-python", node: "native-node", rust: "native-rust" };
43
+ const COMPILED_KIND = {
44
+ go: "native-go",
45
+ python: "native-python",
46
+ node: "native-node",
47
+ rust: "native-rust",
48
+ };
38
49
  /** Reads an SPK without installing it and reports how it would run on DSM. */
39
50
  export async function inspectStandaloneSpk(spkPath) {
40
51
  return inspectSpk(spkPath, "native-go");
@@ -52,21 +63,51 @@ export async function inspectSpk(spkPath, expectedKind) {
52
63
  mkdirSync(target);
53
64
  await tar.x({ file: join(dir, "package.tgz"), cwd: target });
54
65
  findings.push(...inspectNativeLayout(dir, target, info));
55
- for (const key of ["package", "version", "arch", "os_min_ver", "checksum"])
66
+ for (const key of [
67
+ "package",
68
+ "version",
69
+ "arch",
70
+ "os_min_ver",
71
+ "checksum",
72
+ ])
56
73
  if (!info[key])
57
74
  findings.push(`Missing INFO ${key}`);
58
75
  const icon = inspectPackageIcons(dir, findings);
59
- return { kind: "native-layout", package: info.package, version: info.version, arch: info.arch,
60
- signed: existsSync(join(dir, "syno_signature.asc")), payload, findings, capabilities: [], icon };
76
+ return {
77
+ kind: "native-layout",
78
+ package: info.package,
79
+ version: info.version,
80
+ arch: info.arch,
81
+ signed: existsSync(join(dir, "syno_signature.asc")),
82
+ payload,
83
+ findings,
84
+ capabilities: [],
85
+ icon,
86
+ };
61
87
  }
62
88
  const payload = await listPayload(join(dir, "package.tgz"), findings);
63
- const paths = new Set(payload.map(entry => entry.path));
89
+ const paths = new Set(payload.map((entry) => entry.path));
64
90
  let metadata = {};
65
91
  const metadataText = readOptional(join(dir, "sdk-package.json"));
66
92
  if (metadataText !== undefined) {
67
93
  try {
68
94
  const raw = JSON.parse(metadataText);
69
- if ((raw.launch !== undefined && !isWebLaunchMode(raw.launch)) || raw.schema !== 2 || !["go", "python", "node", "rust", "binary"].includes(raw.backend) || !Array.isArray(raw.capabilities) || raw.capabilities.some((name) => !["files", "jobs", "identity", "rootOps", "delegate", "webapi"].includes(String(name))) || new Set(raw.capabilities).size !== raw.capabilities.length || (raw.entry !== undefined && !["admin-only", "app-privilege"].includes(raw.entry)))
95
+ if ((raw.launch !== undefined && !isWebLaunchMode(raw.launch)) ||
96
+ raw.schema !== 2 ||
97
+ !["go", "python", "node", "rust", "binary"].includes(raw.backend) ||
98
+ !Array.isArray(raw.capabilities) ||
99
+ raw.capabilities.some((name) => ![
100
+ "files",
101
+ "jobs",
102
+ "identity",
103
+ "rootOps",
104
+ "delegate",
105
+ "webapi",
106
+ "composeMounts",
107
+ ].includes(String(name))) ||
108
+ new Set(raw.capabilities).size !== raw.capabilities.length ||
109
+ (raw.entry !== undefined &&
110
+ !["admin-only", "app-privilege"].includes(raw.entry)))
70
111
  throw new Error();
71
112
  metadata = raw;
72
113
  }
@@ -74,27 +115,51 @@ export async function inspectSpk(spkPath, expectedKind) {
74
115
  findings.push("sdk-package.json does not match the schema 2 capability inventory.");
75
116
  }
76
117
  }
77
- const uninstallHook = metadata.uninstall === undefined ? undefined : readUninstallRecord(metadata.uninstall, metadata.capabilities?.includes("rootOps") === true);
118
+ if (metadata.compose !== undefined) {
119
+ const result = await inspectUnifiedCompose(dir, payload, metadata, findings);
120
+ result.icon = inspectPackageIcons(dir, findings);
121
+ return result;
122
+ }
123
+ const uninstallHook = metadata.uninstall === undefined
124
+ ? undefined
125
+ : readUninstallRecord(metadata.uninstall, metadata.capabilities?.includes("rootOps") === true);
78
126
  if (metadata.uninstall !== undefined && !uninstallHook)
79
127
  findings.push("UNINSTALL_RECORD_INVALID the uninstall in sdk-package.json is not the uninstall setting synopkgland build writes.");
80
128
  const binaryBackend = metadata.backend === "binary";
81
129
  // A strict standalone inspection names the language the CGI was built from.
82
130
  const compiled = COMPILED_KIND[metadata.backend ?? "go"] ?? "native-go";
83
- const kind = (expectedKind === "native-go" ? compiled : expectedKind) ?? (paths.has("compose.yaml") ? "container" : binaryBackend ? "binary" : compiled);
131
+ const kind = (expectedKind === "native-go" ? compiled : expectedKind) ??
132
+ (paths.has("compose.yaml")
133
+ ? "container"
134
+ : binaryBackend
135
+ ? "binary"
136
+ : compiled);
84
137
  const compiledKind = Object.values(COMPILED_KIND).includes(kind);
85
138
  const standalone = compiledKind || kind === "binary";
86
- const combined = metadata.compose === undefined ? undefined : await recoverCompose(dir, metadata.compose, findings);
139
+ const combined = metadata.compose === undefined
140
+ ? undefined
141
+ : await recoverCompose(dir, metadata.compose, findings);
87
142
  const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined));
88
- if (combined && (!info.os_min_ver || !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)))
143
+ if (combined &&
144
+ (!info.os_min_ver || !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)))
89
145
  findings.push("compose identity_gateway requires DSM >= 7.2.1");
90
146
  await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
91
147
  const background = compiledKind && paths.has(BACKGROUND_WORKER_PAYLOAD);
92
148
  // Before any fixed-lifecycle comparison: those see the SDK steps alone.
93
- const appScripts = standalone || kind === "container" ? unwrapAppScripts(join(dir, "scripts"), findings) : [];
94
- const sharedFolder = standalone ? recoverSharedFolder(readOptional(join(dir, "scripts/postinst"))) : undefined;
149
+ const appScripts = standalone || kind === "container"
150
+ ? unwrapAppScripts(join(dir, "scripts"), findings)
151
+ : [];
152
+ const sharedFolder = standalone
153
+ ? recoverSharedFolder(readOptional(join(dir, "scripts/postinst")))
154
+ : undefined;
95
155
  // A Compose App has no executor, so its SPK never carries a portal.
96
- const portal = standalone ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings) : undefined;
97
- if (portal && info.os_min_ver && OS_MIN_VER.test(info.os_min_ver) && !isDsmAtLeast(info.os_min_ver, PORTAL_MIN_DSM)) {
156
+ const portal = standalone
157
+ ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings)
158
+ : undefined;
159
+ if (portal &&
160
+ info.os_min_ver &&
161
+ OS_MIN_VER.test(info.os_min_ver) &&
162
+ !isDsmAtLeast(info.os_min_ver, PORTAL_MIN_DSM)) {
98
163
  findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${PORTAL_MIN_DSM} a portal needs; ${PORTAL_MIN_DSM_REASON}`);
99
164
  }
100
165
  checkConf(dir, findings, kind, background, sharedFolder, info.package, portal, combined?.compose);
@@ -102,15 +167,36 @@ export async function inspectSpk(spkPath, expectedKind) {
102
167
  checkUninstall(dir, findings);
103
168
  if (kind === "container")
104
169
  await checkContainerExposure(dir, join(dir, "package.tgz"), findings);
105
- const bridge = compiledKind && kind !== "native-go" ? await checkBridgeApp(dir, metadata.backend, info, payload, findings) : undefined;
106
- const hasExecutor = (metadata.capabilities ?? []).some(name => name === "rootOps" || name === "delegate" || name === "webapi") || paths.has("executor/executor");
170
+ if (metadata.ctl || paths.has("synopkgland-api.json")) {
171
+ if (!metadata.ctl ||
172
+ !paths.has("synopkgland-api.json") ||
173
+ [...paths].some((path) => path.startsWith("ui/") && path.endsWith("synopkgland-api.json")))
174
+ findings.push("CTL_DESCRIPTOR_LAYOUT_INVALID API description must be private and match its build record.");
175
+ else {
176
+ const apiDir = join(dir, "api-description-inspection");
177
+ mkdirSync(apiDir);
178
+ await tar.x({
179
+ file: join(dir, "package.tgz"),
180
+ cwd: apiDir,
181
+ filter: (path) => path.replace(/^\.\//, "") === "synopkgland-api.json",
182
+ });
183
+ findings.push(...inspectApiDescription(readFileSync(join(apiDir, "synopkgland-api.json"), "utf8"), { id: info.package, version: info.version }, metadata.ctl.hash, metadata.ctl.registry));
184
+ }
185
+ }
186
+ const bridge = compiledKind && kind !== "native-go"
187
+ ? await checkBridgeApp(dir, metadata.backend, info, payload, findings)
188
+ : undefined;
189
+ const hasExecutor = (metadata.capabilities ?? []).some((name) => name === "rootOps" || name === "delegate" || name === "webapi") || paths.has("executor/executor");
107
190
  if (standalone && portal && !hasExecutor)
108
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");
109
- const daemon = standalone ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined) : undefined;
192
+ const daemon = standalone
193
+ ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined)
194
+ : undefined;
110
195
  if (background)
111
196
  checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go");
112
197
  let executor;
113
- if ((metadata.capabilities ?? []).some(name => name === "rootOps" || name === "delegate" || name === "webapi") || paths.has("executor/executor")) {
198
+ if ((metadata.capabilities ?? []).some((name) => name === "rootOps" || name === "delegate" || name === "webapi") ||
199
+ paths.has("executor/executor")) {
114
200
  const executorDir = join(dir, "executor-inspection");
115
201
  mkdirSync(executorDir);
116
202
  await tar.x({ file: join(dir, "package.tgz"), cwd: executorDir });
@@ -119,38 +205,60 @@ export async function inspectSpk(spkPath, expectedKind) {
119
205
  executor = inspected.executor;
120
206
  if (executor)
121
207
  findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
122
- if (executor?.policy.portal && info.os_min_ver && OS_MIN_VER.test(info.os_min_ver) && !isDsmAtLeast(info.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
208
+ if (executor?.policy.portal &&
209
+ info.os_min_ver &&
210
+ OS_MIN_VER.test(info.os_min_ver) &&
211
+ !isDsmAtLeast(info.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
123
212
  findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${PORTAL_EXECUTOR_MIN_DSM} the executor needs to register a portal`);
124
213
  }
125
- if (executor && executor.policy.purge !== uninstallHook?.uninstall.purge.root)
214
+ if (executor &&
215
+ executor.policy.purge !== uninstallHook?.uninstall.purge.root)
126
216
  findings.push(`UNINSTALL_ROOT_POLICY_MISMATCH the executor policy purge "${executor.policy.purge ?? ""}" differs from uninstall.purge.root "${uninstallHook?.uninstall.purge.root ?? ""}".`);
127
- if (payload.find(p => p.path === "executor/executor")?.mode !== "0755")
217
+ if (payload.find((p) => p.path === "executor/executor")?.mode !== "0755")
128
218
  findings.push("Executor must be executable.");
129
- if (payload.find(p => p.path === "executor/lifecycle")?.mode !== "0755")
219
+ if (payload.find((p) => p.path === "executor/lifecycle")?.mode !== "0755")
130
220
  findings.push("Executor lifecycle helper must be executable.");
131
- if (![...paths].some(path => path.startsWith("daemon/"))) {
132
- const lifecycleManifest = { package: info.package, capabilities: { rootOps: true, ...(background ? { jobs: { worker: "sha256-v1" } } : {}) }, ...(uninstallHook ? { uninstall: uninstallHook.uninstall } : {}) };
221
+ if (![...paths].some((path) => path.startsWith("daemon/"))) {
222
+ const lifecycleManifest = {
223
+ package: info.package,
224
+ capabilities: {
225
+ rootOps: true,
226
+ ...(background ? { jobs: { worker: "sha256-v1" } } : {}),
227
+ },
228
+ ...(uninstallHook ? { uninstall: uninstallHook.uninstall } : {}),
229
+ };
133
230
  for (const hook of ["preuninst", "preupgrade", "start-stop-status"]) {
134
- if (readOptional(join(dir, "scripts", hook)) !== renderStandaloneScript(hook, lifecycleManifest))
231
+ if (readOptional(join(dir, "scripts", hook)) !==
232
+ renderStandaloneScript(hook, lifecycleManifest))
135
233
  findings.push(`Executor scripts/${hook} differs from fixed lifecycle.`);
136
234
  }
137
235
  }
138
- for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", { executor: true, portal: Boolean(portal), portalEntry: Boolean(executor?.policy.portal), ...uninstallWizardOptions(uninstallHook) }))) {
236
+ for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", {
237
+ executor: true,
238
+ portal: Boolean(portal),
239
+ portalEntry: Boolean(executor?.policy.portal),
240
+ ...uninstallWizardOptions(uninstallHook),
241
+ }))) {
139
242
  if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
140
243
  findings.push(`Executor WIZARD_UIFILES/${name} is missing cleanup guidance.`);
141
244
  }
142
245
  }
143
246
  if (bridge)
144
247
  checkBridgeExecutor(bridge, executor, findings);
145
- if (background && daemon && (executor?.policy.lifetime !== 'until-revoked' || !metadata.capabilities?.includes('delegate'))) {
146
- findings.push('daemon/background combination requires an explicitly pinned until-revoked delegate policy.');
248
+ if (background &&
249
+ daemon &&
250
+ (executor?.policy.lifetime !== "until-revoked" ||
251
+ !metadata.capabilities?.includes("delegate"))) {
252
+ findings.push("daemon/background combination requires an explicitly pinned until-revoked delegate policy.");
147
253
  }
148
254
  if (metadata.entry)
149
255
  await checkEntryLauncher(dir, info.package, metadata.entry, findings);
150
256
  const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings);
151
- const loginStyle = standalone ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings) : undefined;
257
+ const loginStyle = standalone
258
+ ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings)
259
+ : undefined;
152
260
  if (standalone)
153
- checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === 'install-selected', portal, hasExecutor);
261
+ checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === "install-selected", portal, hasExecutor);
154
262
  const icon = inspectPackageIcons(dir, findings);
155
263
  const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
156
264
  if (standalone && !cgiEntry) {
@@ -164,12 +272,29 @@ export async function inspectSpk(spkPath, expectedKind) {
164
272
  ...(launch ? { launch } : {}),
165
273
  icon,
166
274
  ...(executor ? { executor } : {}),
167
- ...(portal ? { portal: { appId: portal.appId, alias: portal.alias } } : {}),
168
- ...(executor?.policy.portal ? { portal: { appId: executor.policy.app, alias: executor.policy.portal.alias } } : {}),
275
+ ...(portal
276
+ ? { portal: { appId: portal.appId, alias: portal.alias } }
277
+ : {}),
278
+ ...(executor?.policy.portal
279
+ ? {
280
+ portal: {
281
+ appId: executor.policy.app,
282
+ alias: executor.policy.portal.alias,
283
+ },
284
+ }
285
+ : {}),
169
286
  ...(metadata.entry ? { entry: metadata.entry } : {}),
170
287
  ...(appScripts.length ? { appScripts } : {}),
171
288
  ...(loginStyle ? { loginStyle } : {}),
172
- ...(standalone ? { uninstallCleanup: uninstallCleanup(info.package, { executor: hasExecutor, portal: Boolean(portal), portalEntry: Boolean(executor?.policy.portal) }) } : {}),
289
+ ...(standalone
290
+ ? {
291
+ uninstallCleanup: uninstallCleanup(info.package, {
292
+ executor: hasExecutor,
293
+ portal: Boolean(portal),
294
+ portalEntry: Boolean(executor?.policy.portal),
295
+ }),
296
+ }
297
+ : {}),
173
298
  package: info.package,
174
299
  version: info.version,
175
300
  arch: info.arch,
@@ -177,7 +302,10 @@ export async function inspectSpk(spkPath, expectedKind) {
177
302
  cgi: cgiEntry?.mode === "0755" ? CGI_PAYLOAD_PATH : undefined,
178
303
  payload,
179
304
  findings,
180
- capabilities: (metadata.capabilities ?? []).map(name => ({ name, requiresAdminApproval: name === "rootOps" || name === "delegate" || name === "webapi" })),
305
+ capabilities: (metadata.capabilities ?? []).map((name) => ({
306
+ name,
307
+ requiresAdminApproval: name === "rootOps" || name === "delegate" || name === "webapi",
308
+ })),
181
309
  };
182
310
  }
183
311
  finally {
@@ -190,7 +318,11 @@ const LAUNCHER_PORTAL_MISMATCH = "LAUNCHER_PORTAL_MISMATCH";
190
318
  async function checkLaunchConfiguration(dir, info, native, declared, alias, findings) {
191
319
  const target = join(dir, "launch-inspection");
192
320
  mkdirSync(target);
193
- await tar.x({ file: join(dir, "package.tgz"), cwd: target, filter: path => ["ui/config", "synology-app.yaml"].includes(path.replace(/^\.\//, "")) });
321
+ await tar.x({
322
+ file: join(dir, "package.tgz"),
323
+ cwd: target,
324
+ filter: (path) => ["ui/config", "synology-app.yaml"].includes(path.replace(/^\.\//, "")),
325
+ });
194
326
  let launch = declared ?? "dsm-window";
195
327
  let url;
196
328
  if (!native) {
@@ -213,24 +345,42 @@ async function checkLaunchConfiguration(dir, info, native, declared, alias, find
213
345
  try {
214
346
  const resource = JSON.parse(readFileSync(join(dir, "conf/resource"), "utf8"));
215
347
  const portals = resource.webservice?.portals ?? [];
216
- if (usesAuthGateway(contract) ? portals.length > 0 : portals.some((p) => p.app !== (launch === "dsm-window" ? info.package : undefined))) {
348
+ if (usesAuthGateway(contract)
349
+ ? portals.length > 0
350
+ : portals.some((p) => p.app !== (launch === "dsm-window" ? info.package : undefined))) {
217
351
  findings.push(`${LAUNCHER_PORTAL_MISMATCH} Web Station entries differ from the declared access and launch mode; rebuild with synopkgland build`);
218
352
  }
219
353
  }
220
- catch { /* checkConf reports invalid resources */ }
354
+ catch {
355
+ /* checkConf reports invalid resources */
356
+ }
221
357
  }
222
358
  const appId = native ? `SYNO.SDS.${info.package}.Instance` : info.package;
223
359
  let launcher;
224
360
  try {
225
361
  const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
226
- launcher = Object.values(config).map((entries) => entries[appId]).find(Boolean);
362
+ launcher = Object.values(config)
363
+ .map((entries) => entries[appId])
364
+ .find(Boolean);
365
+ }
366
+ catch {
367
+ /* report the missing or malformed launcher below */
227
368
  }
228
- catch { /* report the missing or malformed launcher below */ }
229
369
  const type = dsmLauncherType(launch, native);
230
- if (!launcher || launcher.type !== type || info.dsmuidir !== "ui" || info.dsmappname !== appId || (native ? launcher.appWindow !== `SYNO.SDS.${info.package}.MainWindow` : launcher.url !== url || info.adminurl !== url)) {
370
+ if (!launcher ||
371
+ launcher.type !== type ||
372
+ info.dsmuidir !== "ui" ||
373
+ info.dsmappname !== appId ||
374
+ (native
375
+ ? launcher.appWindow !== `SYNO.SDS.${info.package}.MainWindow`
376
+ : launcher.url !== url || info.adminurl !== url)) {
231
377
  findings.push(`${LAUNCHER_TYPE_MISMATCH} ${launch} requires a ${type} launcher at ${appId} and matching INFO entry; rebuild with synopkgland build`);
232
378
  }
233
- if (launcher && (launcher.defaultAlias !== alias || (!alias && (launcher.loginStyle !== undefined || launcher.loginIcon !== undefined)))) {
379
+ if (launcher &&
380
+ (launcher.defaultAlias !== alias ||
381
+ (!alias &&
382
+ (launcher.loginStyle !== undefined ||
383
+ launcher.loginIcon !== undefined)))) {
234
384
  findings.push(`${LAUNCHER_PORTAL_MISMATCH} defaultAlias and login fields require an explicit matching portal declaration; remove them or opt in to portal and rebuild`);
235
385
  }
236
386
  return launch;
@@ -239,20 +389,32 @@ async function checkLaunchConfiguration(dir, info, native, declared, alias, find
239
389
  async function checkEntryLauncher(dir, pkg, entry, findings) {
240
390
  const target = join(dir, "entry-inspection");
241
391
  mkdirSync(target);
242
- await tar.x({ file: join(dir, "package.tgz"), cwd: target, filter: path => path.replace(/^\.\//, "") === "ui/config" });
392
+ await tar.x({
393
+ file: join(dir, "package.tgz"),
394
+ cwd: target,
395
+ filter: (path) => path.replace(/^\.\//, "") === "ui/config",
396
+ });
243
397
  let launcher;
244
398
  try {
245
399
  const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
246
400
  launcher = Object.values(config)[0]?.[`SYNO.SDS.${pkg}.Instance`];
247
401
  }
248
- catch { /* reported below */ }
402
+ catch {
403
+ /* reported below */
404
+ }
249
405
  const expected = entryLauncherAccess(entry);
250
406
  const fields = ["allUsers", "grantPrivilege", "advanceGrantPrivilege"];
251
- if (!launcher || fields.some(field => launcher[field] !== expected[field])) {
407
+ if (!launcher ||
408
+ fields.some((field) => launcher[field] !== expected[field])) {
252
409
  findings.push(`ENTRY_LAUNCHER_MISMATCH sdk-package.json declares entry: ${entry}, but the ui/config launcher is not ${JSON.stringify(expected)}; regenerate it with synopkgland build`);
253
410
  }
254
411
  }
255
- const LOGIN_STYLE_FILES = ["defaultLoginWallpaper", "defaultLoginWallpaperThumbnail", "loginPreviewThumbnailMaskPath", "titleImgPath"];
412
+ const LOGIN_STYLE_FILES = [
413
+ "defaultLoginWallpaper",
414
+ "defaultLoginWallpaperThumbnail",
415
+ "loginPreviewThumbnailMaskPath",
416
+ "titleImgPath",
417
+ ];
256
418
  /**
257
419
  * The launcher's loginStyle and the ui/ files it names, at 1x and 2x. A portal
258
420
  * App without one shows a blank Control Panel > Login Portal thumbnail.
@@ -260,13 +422,19 @@ const LOGIN_STYLE_FILES = ["defaultLoginWallpaper", "defaultLoginWallpaperThumbn
260
422
  async function inspectLoginStyle(dir, pkg, portal, findings) {
261
423
  const target = join(dir, "login-style-inspection");
262
424
  mkdirSync(target);
263
- await tar.x({ file: join(dir, "package.tgz"), cwd: target, filter: path => path.replace(/^\.\//, "").startsWith("ui/") });
425
+ await tar.x({
426
+ file: join(dir, "package.tgz"),
427
+ cwd: target,
428
+ filter: (path) => path.replace(/^\.\//, "").startsWith("ui/"),
429
+ });
264
430
  let launcher;
265
431
  try {
266
432
  const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
267
433
  launcher = Object.values(config)[0]?.[`SYNO.SDS.${pkg}.Instance`];
268
434
  }
269
- catch { /* reported by the other checks */ }
435
+ catch {
436
+ /* reported by the other checks */
437
+ }
270
438
  const style = launcher?.loginStyle;
271
439
  if (typeof style !== "object" || style === null) {
272
440
  if (portal)
@@ -278,9 +446,12 @@ async function inspectLoginStyle(dir, pkg, portal, findings) {
278
446
  const path = fields[field];
279
447
  if (path === undefined)
280
448
  return undefined;
281
- const files = typeof path === "string" && !path.split("/").includes("..") && !path.startsWith("/")
282
- ? ["1x", "2x"].map(scale => join(target, "ui", path.replace("{0}", scale))) : [];
283
- if (!files.length || !files.every(file => existsSync(file))) {
449
+ const files = typeof path === "string" &&
450
+ !path.split("/").includes("..") &&
451
+ !path.startsWith("/")
452
+ ? ["1x", "2x"].map((scale) => join(target, "ui", path.replace("{0}", scale)))
453
+ : [];
454
+ if (!files.length || !files.every((file) => existsSync(file))) {
284
455
  findings.push(`LOGIN_STYLE_FILE_MISSING ui/config loginStyle.${field} names ui/${String(path)}, which the payload does not carry`);
285
456
  return undefined;
286
457
  }
@@ -289,13 +460,22 @@ async function inspectLoginStyle(dir, pkg, portal, findings) {
289
460
  const [wallpaper] = LOGIN_STYLE_FILES.map(read);
290
461
  return {
291
462
  color: String(fields.mainColor ?? ""),
292
- wallpaper: wallpaper && isDefaultLoginAsset("wallpaper.jpg", wallpaper) ? "default" : "custom",
293
- title: fields.titleImgPath !== undefined ? "custom" : launcher.loginIcon !== undefined ? "app-icon" : "none",
463
+ wallpaper: wallpaper && isDefaultLoginAsset("wallpaper.jpg", wallpaper)
464
+ ? "default"
465
+ : "custom",
466
+ title: fields.titleImgPath !== undefined
467
+ ? "custom"
468
+ : launcher.loginIcon !== undefined
469
+ ? "app-icon"
470
+ : "none",
294
471
  };
295
472
  }
296
473
  /** Package Center shows PACKAGE_ICON at 64 and 256 px; the package source refuses a release whose icon is over 256 KiB. */
297
474
  function inspectPackageIcons(dir, findings) {
298
- for (const [name, size] of [["PACKAGE_ICON.PNG", 64], ["PACKAGE_ICON_256.PNG", 256]]) {
475
+ for (const [name, size] of [
476
+ ["PACKAGE_ICON.PNG", 64],
477
+ ["PACKAGE_ICON_256.PNG", 256],
478
+ ]) {
299
479
  const bytes = readOptionalBytes(join(dir, name));
300
480
  if (!bytes)
301
481
  continue;
@@ -317,7 +497,10 @@ function inspectPackageIcons(dir, findings) {
317
497
  return { source: "none" };
318
498
  return big.equals(defaultPackageIcons().packageIcon256)
319
499
  ? { source: "default" }
320
- : { source: "custom", sha256: createHash("sha256").update(big).digest("hex") };
500
+ : {
501
+ source: "custom",
502
+ sha256: createHash("sha256").update(big).digest("hex"),
503
+ };
321
504
  }
322
505
  function readOptionalBytes(path) {
323
506
  return existsSync(path) ? readFileSync(path) : undefined;
@@ -328,16 +511,18 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
328
511
  if (info.os_min_ver && !OS_MIN_VER.test(info.os_min_ver)) {
329
512
  findings.push(`INFO os_min_ver "${info.os_min_ver}" is not the DSM 7 <version>-<build> (for example 7.1-42661)`);
330
513
  }
331
- else if (kind === "container" && info.os_min_ver && !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)) {
514
+ else if (kind === "container" &&
515
+ info.os_min_ver &&
516
+ !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)) {
332
517
  findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${CONTAINER_MIN_DSM} a container App needs; docker-project/Container Manager starts at DSM 7.2.1, so on an earlier DSM it installs but cannot start`);
333
518
  }
334
519
  const tokens = (info.arch ?? "").split(" ").filter(Boolean);
335
520
  if (tokens.length === 0 || tokens.includes("noarch"))
336
521
  return;
337
- for (const token of tokens.filter(token => !familyOfArchToken(token))) {
522
+ for (const token of tokens.filter((token) => !familyOfArchToken(token))) {
338
523
  findings.push(`INFO arch contains an unknown platform "${token}"`);
339
524
  }
340
- const families = new Set(tokens.map(familyOfArchToken).filter(family => family !== undefined));
525
+ const families = new Set(tokens.map(familyOfArchToken).filter((family) => family !== undefined));
341
526
  if (families.size > 1) {
342
527
  findings.push(`INFO arch mixes several architectures (${[...families].join(", ")}); an SPK contains binaries for one architecture only`);
343
528
  }
@@ -347,31 +532,224 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
347
532
  const cgi = join(dir, "cgi-inspection");
348
533
  mkdirSync(cgi);
349
534
  // Only the header is read; the CGI is never run.
350
- await tar.x({ file: join(dir, "package.tgz"), cwd: cgi, filter: path => path.replace(/^\.\//, "") === CGI_PAYLOAD_PATH });
535
+ await tar.x({
536
+ file: join(dir, "package.tgz"),
537
+ cwd: cgi,
538
+ filter: (path) => path.replace(/^\.\//, "") === CGI_PAYLOAD_PATH,
539
+ });
351
540
  const header = readFileSync(join(cgi, CGI_PAYLOAD_PATH)).subarray(0, 20);
352
- const machine = header.length === 20 && header.toString("latin1", 0, 4) === "\x7fELF" ? header.readUInt16LE(18) : undefined;
353
- const actual = NATIVE_ARCH_FAMILIES.find(family => ELF_MACHINE[family] === machine);
541
+ const machine = header.length === 20 && header.toString("latin1", 0, 4) === "\x7fELF"
542
+ ? header.readUInt16LE(18)
543
+ : undefined;
544
+ const actual = NATIVE_ARCH_FAMILIES.find((family) => ELF_MACHINE[family] === machine);
354
545
  if (actual && actual !== declared) {
355
546
  findings.push(`INFO arch declares ${declared}, but ${CGI_PAYLOAD_PATH} is a ${actual} executable`);
356
547
  }
357
548
  }
549
+ /** Replay the SDK planner over archived source bytes, not merely a mutable lock. */
550
+ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
551
+ const info = readInfo(join(dir, "INFO"), findings, "binary", true);
552
+ const base = {
553
+ kind: "container",
554
+ package: info.package,
555
+ version: info.version,
556
+ arch: info.arch,
557
+ signed: existsSync(join(dir, "syno_signature.asc")),
558
+ payload,
559
+ findings,
560
+ launch: metadata.launch,
561
+ capabilities: (metadata.capabilities ?? []).map((name) => ({
562
+ name,
563
+ requiresAdminApproval: true,
564
+ })),
565
+ };
566
+ const root = join(dir, "unified-compose-inspection");
567
+ mkdirSync(root);
568
+ await tar.x({ file: join(dir, "package.tgz"), cwd: root });
569
+ try {
570
+ if (metadata.capabilities?.some((c) => c !== "composeMounts"))
571
+ throw new Error("Compose cannot claim an undeclared executor capability");
572
+ const declaration = metadata.compose;
573
+ const saved = JSON.parse(readFileSync(join(root, "compose/request.json"), "utf8"));
574
+ if (!isDeepStrictEqual(saved, declaration))
575
+ throw new Error("Compose request inventory differs from the payload");
576
+ const gateway = readOptional(join(root, "sso/gateway.json"));
577
+ const cfg = gateway ? JSON.parse(gateway) : undefined;
578
+ const arch = info.arch.split(/\s+/).map(familyOfArchToken).find(Boolean) ?? "x86_64";
579
+ const { helpers: _, ...raw } = saved.declaration;
580
+ const c = readComposeApp(raw, join(root, "compose/upstream"), [arch], (e) => {
581
+ throw new Error(`[${e.code}] ${e.message}`);
582
+ }, {
583
+ package: info.package,
584
+ sdk: saved.sdk,
585
+ mode: cfg?.mode ?? "none",
586
+ composeMounts: metadata.capabilities?.includes("composeMounts"),
587
+ architecture: arch,
588
+ imageProofs: JSON.parse(readFileSync(join(root, "compose/images.json"), "utf8")),
589
+ });
590
+ if (!c)
591
+ throw new Error("missing Compose plan");
592
+ if (c.plan.images.some((i) => !i.pinned || !c.plan.image_proofs?.[i.source]))
593
+ throw new Error("COMPOSE_IMAGE_LOCK_INVALID every service needs verified immutable image metadata");
594
+ if (composeSource(c) !==
595
+ readFileSync(join(root, "compose/docker-compose.yml"), "utf8"))
596
+ throw new Error("worker Compose differs from the SDK plan");
597
+ if (c.plan.override !==
598
+ readFileSync(join(root, "compose/generated.override.yaml"), "utf8"))
599
+ throw new Error("generated override differs from the SDK plan");
600
+ if (JSON.stringify(c.plan.lock) !==
601
+ JSON.stringify(JSON.parse(readFileSync(join(root, "compose/compose.lock.json"), "utf8"))))
602
+ throw new Error("source/image/policy lock differs from the SDK plan");
603
+ c.targets = {};
604
+ const g = saved.gateway
605
+ ? readIdentityGateway(saved.gateway, { targets: { [arch]: join(root, "daemon/payload") } }, (e) => {
606
+ throw new Error(e.message);
607
+ }, true)
608
+ : undefined;
609
+ // The complete gateway declaration is retained separately so native argv
610
+ // adapters are inspected against their actual packaged payload.
611
+ const manifest = {
612
+ root,
613
+ package: info.package,
614
+ version: info.version,
615
+ spkVersion: info.version,
616
+ displayname: info.displayname,
617
+ description: info.description,
618
+ maintainer: info.maintainer,
619
+ os_min_ver: info.os_min_ver,
620
+ build: Number(info.version?.split("-").pop()),
621
+ arches: [arch],
622
+ backend: { kind: "binary", files: {} },
623
+ ui: { dir: root, launch: metadata.launch },
624
+ capabilities: {
625
+ ...(metadata.capabilities?.includes("composeMounts")
626
+ ? { composeMounts: true }
627
+ : {}),
628
+ },
629
+ compose: c,
630
+ ...(g ? { identityGateway: g } : {}),
631
+ ...(metadata.entry ? { entry: metadata.entry } : {}),
632
+ };
633
+ if (Boolean(c.web) !== Boolean(g))
634
+ throw new Error("Web ingress must have a complete identity gateway");
635
+ if (gateway && gateway !== identityGatewayConfig(manifest))
636
+ throw new Error("gateway configuration differs from the declared package contract");
637
+ if (readOptional(join(dir, "conf/privilege")) !== renderNativePrivilege())
638
+ throw new Error("Compose hooks must run as the package account");
639
+ const resource = renderStandaloneResource(manifest);
640
+ if (readOptional(join(dir, "conf/resource")) !== resource)
641
+ throw new Error("unexpected worker resource; unapproved Shares must never auto-acquire");
642
+ if (readdirSync(join(dir, "conf")).some((n) => !["privilege", ...(resource ? ["resource"] : [])].includes(n)))
643
+ throw new Error("unexpected Compose resource");
644
+ if (readOptional(join(root, "ui/config")) !== renderUiConfig(manifest))
645
+ throw new Error("LAUNCHER_TYPE_MISMATCH classless Compose must use the shared url launcher");
646
+ if (g &&
647
+ (cfg.package !== info.package ||
648
+ info.adminurl !== `/webman/3rdparty/${info.package}/sso/open.cgi`))
649
+ throw new Error("gateway entry mismatch");
650
+ const appScripts = unwrapAppScripts(join(dir, "scripts"), findings);
651
+ manifest.appScripts = undefined;
652
+ for (const hook of [
653
+ "preinst",
654
+ "postinst",
655
+ "preuninst",
656
+ "postuninst",
657
+ "preupgrade",
658
+ "postupgrade",
659
+ "start-stop-status",
660
+ ])
661
+ if (readOptional(join(dir, "scripts", hook)) !==
662
+ renderStandaloneScript(hook, manifest))
663
+ throw new Error(`unexpected scripts/${hook}`);
664
+ if (appScripts.length)
665
+ base.appScripts = appScripts;
666
+ const materializer = checkCgiBinary(root, join(root, "compose/control"), arch);
667
+ if (materializer)
668
+ throw new Error(materializer.message);
669
+ if (payload.find((p) => p.path === "compose/control")?.mode !== "0755")
670
+ throw new Error("Compose materializer must be executable");
671
+ const binaries = [
672
+ "daemon/control",
673
+ "daemon/payload/_sso/gateway",
674
+ "ui/sso/authorize.cgi",
675
+ "ui/sso/open.cgi",
676
+ ];
677
+ for (const path of g ? binaries : []) {
678
+ const problem = checkCgiBinary(root, join(root, path), arch);
679
+ if (problem)
680
+ throw new Error(problem.message);
681
+ if (payload.find((p) => p.path === path)?.mode !== "0755")
682
+ throw new Error(`${path} must be executable`);
683
+ }
684
+ if (g &&
685
+ readOptional(join(root, "daemon/config.json")) !== daemonConfig(manifest))
686
+ throw new Error("daemon configuration differs from the SDK profile");
687
+ if (!isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM))
688
+ throw new Error("Compose requires DSM >= 7.2.1");
689
+ const storageProblem = dsmComposeProblem(c);
690
+ if (storageProblem)
691
+ throw new Error(`[${storageProblem.code}] ${storageProblem.message}`);
692
+ base.compose = {
693
+ ...(c.plan.ingress ? { ingress: c.plan.ingress } : {}),
694
+ images: c.plan.images,
695
+ services: c.plan.services,
696
+ mountsBlocked: c.plan.mounts_blocked,
697
+ acceptanceBlockers: c.plan.acceptance_blockers,
698
+ readiness: {
699
+ profile: c.plan.mounts_blocked
700
+ ? "disabled"
701
+ : !c.web
702
+ ? "none"
703
+ : c.plan.services.find((s) => s.service === c.web?.service)
704
+ ?.network === "host"
705
+ ? "host-uid-listener"
706
+ : "health-fixed-ip-lifecycle",
707
+ limitation: COMPOSE_READINESS_LIMITATION,
708
+ },
709
+ };
710
+ }
711
+ catch (e) {
712
+ findings.push(`COMPOSE_ARTIFACT_INVALID ${e.message}`);
713
+ }
714
+ return base;
715
+ }
358
716
  async function recoverCompose(dir, value, findings) {
359
717
  try {
360
- if (!value || typeof value !== 'object' || Object.keys(value).join() !== 'services')
361
- throw new Error('invalid compose inventory');
362
- const root = join(dir, 'compose-inspection');
718
+ if (!value ||
719
+ typeof value !== "object" ||
720
+ Object.keys(value).join() !== "services")
721
+ throw new Error("invalid compose inventory");
722
+ const root = join(dir, "compose-inspection");
363
723
  mkdirSync(root);
364
- await tar.x({ file: join(dir, 'package.tgz'), cwd: root, filter: p => ['compose/docker-compose.yml', 'sso/gateway.json'].includes(p.replace(/^\.\//, '')) });
365
- const compose = readComposeApp({ file: 'compose/docker-compose.yml', targets: { x86_64: 'daemon/payload' }, services: value.services }, root, ['x86_64'], e => { throw new Error(e.message); });
724
+ await tar.x({
725
+ file: join(dir, "package.tgz"),
726
+ cwd: root,
727
+ filter: (p) => ["compose/docker-compose.yml", "sso/gateway.json"].includes(p.replace(/^\.\//, "")),
728
+ });
729
+ const compose = readComposeApp({
730
+ file: "compose/docker-compose.yml",
731
+ targets: { x86_64: "daemon/payload" },
732
+ services: value.services,
733
+ }, root, ["x86_64"], (e) => {
734
+ throw new Error(e.message);
735
+ });
366
736
  if (!compose)
367
- throw new Error('invalid compose');
368
- const config = JSON.parse(readFileSync(join(root, 'sso/gateway.json'), 'utf8'));
737
+ throw new Error("invalid compose");
738
+ const config = JSON.parse(readFileSync(join(root, "sso/gateway.json"), "utf8"));
369
739
  const upstream = /^http:\/\/127\.0\.0\.1:(\d+)$/.exec(config.upstream)?.[1];
370
740
  const listen = config.listen;
371
- if (!upstream || !Object.values(compose.services).some(s => s.port === Number(upstream)) || !listen || !Object.keys(listen).length || Object.entries(listen).some(([key, port]) => !['http', 'https'].includes(key) || !Number.isInteger(port) || Number(port) < 1024 || Number(port) > 65535 || Object.values(compose.services).some(s => s.port === port)))
372
- throw new Error('invalid gateway network');
373
- if (config.package !== readInfo(join(dir, 'INFO'), [], 'container').package)
374
- throw new Error('gateway package mismatch');
741
+ if (!upstream ||
742
+ !Object.values(compose.services).some((s) => s.port === Number(upstream)) ||
743
+ !listen ||
744
+ !Object.keys(listen).length ||
745
+ Object.entries(listen).some(([key, port]) => !["http", "https"].includes(key) ||
746
+ !Number.isInteger(port) ||
747
+ Number(port) < 1024 ||
748
+ Number(port) > 65535 ||
749
+ Object.values(compose.services).some((s) => s.port === port)))
750
+ throw new Error("invalid gateway network");
751
+ if (config.package !== readInfo(join(dir, "INFO"), [], "container").package)
752
+ throw new Error("gateway package mismatch");
375
753
  return { compose, identityGateway: { listen } };
376
754
  }
377
755
  catch (e) {
@@ -380,37 +758,80 @@ async function recoverCompose(dir, value, findings) {
380
758
  }
381
759
  }
382
760
  async function checkDaemon(dir, info, paths, payload, findings, executor, background, combined) {
383
- if (![...paths].some(path => path.startsWith("daemon/")))
761
+ if (![...paths].some((path) => path.startsWith("daemon/")))
384
762
  return;
385
763
  if (info.precheckstartstop !== "yes")
386
- findings.push("daemon INFO must explicitly set precheckstartstop=\"yes\"");
387
- if (payload.find(p => p.path === "daemon/control")?.mode !== "0755")
764
+ findings.push('daemon INFO must explicitly set precheckstartstop="yes"');
765
+ if (payload.find((p) => p.path === "daemon/control")?.mode !== "0755")
388
766
  findings.push("daemon/control is missing or not executable");
389
767
  const unpacked = join(dir, "daemon-inspection");
390
768
  // Extract only the bounded config; the payload executable is never run.
391
769
  const { mkdirSync } = await import("node:fs");
392
770
  mkdirSync(unpacked);
393
- await tar.x({ file: join(dir, "package.tgz"), cwd: unpacked, filter: path => path.replace(/^\.\//, "") === "daemon/config.json" });
771
+ await tar.x({
772
+ file: join(dir, "package.tgz"),
773
+ cwd: unpacked,
774
+ filter: (path) => path.replace(/^\.\//, "") === "daemon/config.json",
775
+ });
394
776
  try {
395
777
  const config = JSON.parse(readFileSync(join(unpacked, "daemon/config.json"), "utf8"));
396
- if (config.package !== info.package || Object.keys(config).some(key => !["package", "exec", "args", "env", "timeouts", "uninstall_args", ...(combined ? ["network"] : [])].includes(key)))
778
+ if (config.package !== info.package ||
779
+ Object.keys(config).some((key) => ![
780
+ "package",
781
+ "exec",
782
+ "args",
783
+ "env",
784
+ "timeouts",
785
+ "uninstall_args",
786
+ ...(combined ? ["network"] : []),
787
+ ].includes(key)))
397
788
  throw new Error("package/config mismatch");
398
- const daemon = readDaemon({ exec: config.exec, args: config.args, uninstall_args: config.uninstall_args, env: config.env, timeouts: config.timeouts, targets: { x86_64: "payload" } }, dir, ["x86_64"], () => { });
789
+ const daemon = readDaemon({
790
+ exec: config.exec,
791
+ args: config.args,
792
+ uninstall_args: config.uninstall_args,
793
+ env: config.env,
794
+ timeouts: config.timeouts,
795
+ targets: { x86_64: "payload" },
796
+ }, dir, ["x86_64"], () => { });
399
797
  if (!daemon)
400
798
  throw new Error("invalid config");
401
- if (combined && (daemon.exec !== "_sso/gateway" || JSON.stringify(daemon.args) !== '["run"]' || daemon.uninstall_args))
799
+ if (combined &&
800
+ (daemon.exec !== "_sso/gateway" ||
801
+ JSON.stringify(daemon.args) !== '["run"]' ||
802
+ daemon.uninstall_args))
402
803
  throw new Error("compose gateway must run without a child");
403
- if (payload.find(p => p.path === `daemon/payload/${daemon.exec}`)?.mode !== "0755")
804
+ if (payload.find((p) => p.path === `daemon/payload/${daemon.exec}`)?.mode !==
805
+ "0755")
404
806
  throw new Error("missing executable");
405
- const manifest = { package: info.package, daemon, ...combined, capabilities: { ...(executor ? { rootOps: true } : {}), ...(background ? { jobs: { worker: 'sha256-v1' } } : {}) } };
807
+ const manifest = {
808
+ package: info.package,
809
+ daemon,
810
+ ...combined,
811
+ capabilities: {
812
+ ...(executor ? { rootOps: true } : {}),
813
+ ...(background ? { jobs: { worker: "sha256-v1" } } : {}),
814
+ },
815
+ };
406
816
  // A config from before timeouts.uninstall pairs with a control that rejects the key.
407
- const legacy = typeof config.timeouts === "object" && config.timeouts !== null && !("uninstall" in config.timeouts);
817
+ const legacy = typeof config.timeouts === "object" &&
818
+ config.timeouts !== null &&
819
+ !("uninstall" in config.timeouts);
408
820
  const { uninstall: _, ...legacyTimeouts } = daemon.timeouts;
409
- const canonical = daemonConfig(legacy ? { ...manifest, daemon: { ...daemon, timeouts: legacyTimeouts } } : manifest);
821
+ const canonical = daemonConfig(legacy
822
+ ? {
823
+ ...manifest,
824
+ daemon: {
825
+ ...daemon,
826
+ timeouts: legacyTimeouts,
827
+ },
828
+ }
829
+ : manifest);
410
830
  if (readFileSync(join(unpacked, "daemon/config.json"), "utf8") !== canonical)
411
831
  throw new Error("non-canonical daemon config");
412
832
  for (const hook of ["start-stop-status", "preupgrade", "preuninst"]) {
413
- if (readFileSync(join(dir, "scripts", hook), "utf8") !== renderStandaloneScript(hook, manifest))
833
+ if (readFileSync(join(dir, "scripts", hook), "utf8") !==
834
+ renderStandaloneScript(hook, manifest))
414
835
  findings.push(`daemon scripts/${hook} does not match the fixed lifecycle`);
415
836
  }
416
837
  return daemon;
@@ -436,12 +857,21 @@ function readInfo(path, findings, kind, compose = false) {
436
857
  findings.push(`INFO is missing ${key}`);
437
858
  }
438
859
  }
439
- if (kind !== "container" && (info.thirdparty !== "yes" || info.dsmuidir !== "ui")) {
440
- findings.push("INFO must have thirdparty=\"yes\" and dsmuidir=\"ui\" for DSM to serve /webman/3rdparty/<package>/");
860
+ if (kind !== "container" &&
861
+ (info.thirdparty !== "yes" || info.dsmuidir !== "ui")) {
862
+ findings.push('INFO must have thirdparty="yes" and dsmuidir="ui" for DSM to serve /webman/3rdparty/<package>/');
441
863
  }
442
864
  // A container needs Container Manager (and Web Station for a portal); a Node App the Node.js it runs on.
443
- const allowed = compose ? /^ContainerManager$/ : kind === "container" ? /^(WebStation|ContainerManager)([><=].*)?$/ : kind === "native-node" ? /^Node\.js_v[0-9]{2}([><=].*)?$/ : undefined;
444
- if (info.install_dep_packages && !(allowed && info.install_dep_packages.split(":").every(dep => allowed.test(dep)))) {
865
+ const allowed = compose
866
+ ? undefined
867
+ : kind === "container"
868
+ ? /^(WebStation|ContainerManager)([><=].*)?$/
869
+ : kind === "native-node"
870
+ ? /^Node\.js_v[0-9]{2}([><=].*)?$/
871
+ : undefined;
872
+ if (info.install_dep_packages &&
873
+ !(allowed &&
874
+ info.install_dep_packages.split(":").every((dep) => allowed.test(dep)))) {
445
875
  findings.push(`INFO depends on other packages: ${info.install_dep_packages}`);
446
876
  }
447
877
  return info;
@@ -450,9 +880,15 @@ function readInfo(path, findings, kind, compose = false) {
450
880
  async function checkContainerExposure(dir, packageTgz, findings) {
451
881
  const extracted = join(dir, "payload");
452
882
  mkdirSync(extracted);
453
- await tar.x({ file: packageTgz, cwd: extracted, filter: path => path.replace(/^\.\//, "") === "compose.yaml" });
883
+ await tar.x({
884
+ file: packageTgz,
885
+ cwd: extracted,
886
+ filter: (path) => path.replace(/^\.\//, "") === "compose.yaml",
887
+ });
454
888
  const compose = loadCompose(join(extracted, "compose.yaml"))?.compose;
455
- for (const exposure of compose ? listPortExposures(compose).filter(e => !e.loopbackOnly) : []) {
889
+ for (const exposure of compose
890
+ ? listPortExposures(compose).filter((e) => !e.loopbackOnly)
891
+ : []) {
456
892
  findings.push(`CONTAINER_PORT_EXPOSED ${exposure.service}: ${exposure.entry} binds to every network interface, so it is reachable without DSM verification; bind it to 127.0.0.1`);
457
893
  }
458
894
  try {
@@ -460,7 +896,9 @@ async function checkContainerExposure(dir, packageTgz, findings) {
460
896
  findings.push("CONTAINER_ANONYMOUS_PORTAL conf/resource registers a Web Station portal that anyone can open without signing in to DSM (integration.web.access: public)");
461
897
  }
462
898
  }
463
- catch { /* checkConf already reports an unreadable conf/resource */ }
899
+ catch {
900
+ /* checkConf already reports an unreadable conf/resource */
901
+ }
464
902
  }
465
903
  function readOptional(path) {
466
904
  return existsSync(path) ? readFileSync(path, "utf8") : undefined;
@@ -468,12 +906,25 @@ function readOptional(path) {
468
906
  function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, compose) {
469
907
  const confDir = join(dir, "conf");
470
908
  const entries = existsSync(confDir) ? readdirSync(confDir) : [];
471
- const nativeResource = kind !== "container" ? renderStandaloneResource({ package: pkg ?? "", ...(compose ? { compose } : {}), capabilities: background ? { jobs: { worker: "sha256-v1" } } : {}, ...(sharedFolder ? { sharedFolder } : {}) }, portal) : undefined;
472
- if (nativeResource !== undefined && readOptional(join(confDir, "resource")) !== nativeResource) {
909
+ const nativeResource = kind !== "container"
910
+ ? renderStandaloneResource({
911
+ package: pkg ?? "",
912
+ ...(compose ? { compose } : {}),
913
+ capabilities: background
914
+ ? { jobs: { worker: "sha256-v1" } }
915
+ : {},
916
+ ...(sharedFolder ? { sharedFolder } : {}),
917
+ }, portal)
918
+ : undefined;
919
+ if (nativeResource !== undefined &&
920
+ readOptional(join(confDir, "resource")) !== nativeResource) {
473
921
  findings.push(`conf/resource must be ${nativeResource} (only the official data-share, systemd-user-unit and web-config, and consistent with the install.sharedFolder and portal declarations)`);
474
922
  }
475
923
  for (const name of entries) {
476
- if (name !== "privilege" && !(kind === "container" && name === "resource") && !(nativeResource !== undefined && name === "resource") && !(background && name === "systemd")) {
924
+ if (name !== "privilege" &&
925
+ !(kind === "container" && name === "resource") &&
926
+ !(nativeResource !== undefined && name === "resource") &&
927
+ !(background && name === "systemd")) {
477
928
  findings.push(`conf/${name} registers an extra resource with DSM; a Standalone Package does not produce it`);
478
929
  }
479
930
  }
@@ -505,7 +956,7 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, c
505
956
  }
506
957
  const defaults = privilege.defaults;
507
958
  if (defaults?.["run-as"] !== "package") {
508
- findings.push("conf/privilege defaults.run-as must be \"package\"");
959
+ findings.push('conf/privilege defaults.run-as must be "package"');
509
960
  }
510
961
  for (const key of ["tool", "ctrl-script", "executable", "join-groupname"]) {
511
962
  if (key in privilege) {
@@ -518,17 +969,32 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
518
969
  const pkg = info.package;
519
970
  if (!pkg)
520
971
  return;
521
- const read = (path) => existsSync(join(dir, path)) ? readFileSync(join(dir, path), "utf8") : undefined;
522
- if (payload.find(p => p.path === BACKGROUND_WORKER_PAYLOAD)?.mode !== "0755")
972
+ const read = (path) => existsSync(join(dir, path))
973
+ ? readFileSync(join(dir, path), "utf8")
974
+ : undefined;
975
+ if (payload.find((p) => p.path === BACKGROUND_WORKER_PAYLOAD)?.mode !== "0755")
523
976
  findings.push(`${BACKGROUND_WORKER_PAYLOAD} is missing or not executable`);
524
977
  if (info.precheckstartstop !== "yes")
525
- findings.push("background INFO must explicitly set precheckstartstop=\"yes\" (prestart recovers the package user manager that lost its socket)");
526
- const units = existsSync(join(dir, BACKGROUND_UNIT_DIR)) ? readdirSync(join(dir, BACKGROUND_UNIT_DIR)) : [];
978
+ findings.push('background INFO must explicitly set precheckstartstop="yes" (prestart recovers the package user manager that lost its socket)');
979
+ const units = existsSync(join(dir, BACKGROUND_UNIT_DIR))
980
+ ? readdirSync(join(dir, BACKGROUND_UNIT_DIR))
981
+ : [];
527
982
  const unit = backgroundUnitName(pkg);
528
- if (units.length !== 1 || units[0] !== unit || read(`${BACKGROUND_UNIT_DIR}/${unit}`) !== renderBackgroundUnit(pkg, bridge ? BRIDGE_WORKER_ARGS : [])) {
983
+ if (units.length !== 1 ||
984
+ units[0] !== unit ||
985
+ read(`${BACKGROUND_UNIT_DIR}/${unit}`) !==
986
+ renderBackgroundUnit(pkg, bridge ? BRIDGE_WORKER_ARGS : [])) {
529
987
  findings.push(`${BACKGROUND_UNIT_DIR} must contain only the fixed ${unit} (runs as the package account, no User=, no root)`);
530
988
  }
531
- if (read("scripts/start-stop-status") !== renderStandaloneScript("start-stop-status", { package: pkg, ...(daemon ? { daemon } : {}), capabilities: { jobs: { worker: "sha256-v1" }, ...(executor ? { rootOps: true } : {}) } }))
989
+ if (read("scripts/start-stop-status") !==
990
+ renderStandaloneScript("start-stop-status", {
991
+ package: pkg,
992
+ ...(daemon ? { daemon } : {}),
993
+ capabilities: {
994
+ jobs: { worker: "sha256-v1" },
995
+ ...(executor ? { rootOps: true } : {}),
996
+ },
997
+ }))
532
998
  findings.push("background scripts/start-stop-status does not match the fixed synosystemctl lifecycle");
533
999
  }
534
1000
  /**
@@ -539,42 +1005,77 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
539
1005
  */
540
1006
  function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false) {
541
1007
  const wizardDir = join(dir, "WIZARD_UIFILES");
542
- const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : []).filter(name => /^(install|upgrade)_uifile(_|\.|$)/.test(name)).sort();
1008
+ const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : [])
1009
+ .filter((name) => /^(install|upgrade)_uifile(_|\.|$)/.test(name))
1010
+ .sort();
543
1011
  const postinst = readOptional(join(dir, "scripts/postinst"));
544
1012
  if (!folder && postinst?.includes(SHARED_FOLDER_MARKER))
545
1013
  findings.push("the install.sharedFolder declaration in scripts/postinst is invalid");
546
1014
  if (!pkg)
547
1015
  return;
548
1016
  const wizards = expectedWizardFiles(pkg, folder, selected, portal);
549
- const source = selected ? "the install-selected policy" : folder ? "the install.sharedFolder declaration" : portal ? "the portal declaration" : "the native-go default install wizard";
1017
+ const source = selected
1018
+ ? "the install-selected policy"
1019
+ : folder
1020
+ ? "the install.sharedFolder declaration"
1021
+ : portal
1022
+ ? "the portal declaration"
1023
+ : "the native-go default install wizard";
550
1024
  // The default native wizard is optional; a declared shared folder or portal needs all of its files.
551
- for (const name of folder || selected || portal ? Object.keys(wizards) : present.filter(n => n in wizards)) {
1025
+ for (const name of folder || selected || portal
1026
+ ? Object.keys(wizards)
1027
+ : present.filter((n) => n in wizards)) {
552
1028
  if (readOptional(join(wizardDir, name)) !== wizards[name])
553
1029
  findings.push(`WIZARD_UIFILES/${name} does not match ${source}`);
554
1030
  }
555
- for (const name of present.filter(n => !(n in wizards)))
1031
+ for (const name of present.filter((n) => !(n in wizards)))
556
1032
  findings.push(`WIZARD_UIFILES/${name} is not a wizard file produced by ${source}`);
557
1033
  if (selected) {
558
- for (const hook of ['preinst', 'postinst']) {
559
- if (!readOptional(join(dir, 'scripts', hook))?.includes(renderDelegateInstallDraft(pkg, hook === 'preinst')))
1034
+ for (const hook of ["preinst", "postinst"]) {
1035
+ if (!readOptional(join(dir, "scripts", hook))?.includes(renderDelegateInstallDraft(pkg, hook === "preinst")))
560
1036
  findings.push(`scripts/${hook} missing install-selected draft handling`);
561
1037
  }
562
1038
  }
563
1039
  if (!folder)
564
1040
  return;
565
1041
  for (const hook of ["postinst", "postupgrade"]) {
566
- if (readOptional(join(dir, "scripts", hook)) !== renderStandaloneScript(hook, { package: pkg, sharedFolder: folder, ...(selected ? { capabilities: { delegate: { scope: "install-selected" } } } : executor ? { capabilities: { rootOps: true } } : {}), ...portalManifest(portal) })) {
1042
+ if (readOptional(join(dir, "scripts", hook)) !==
1043
+ renderStandaloneScript(hook, {
1044
+ package: pkg,
1045
+ sharedFolder: folder,
1046
+ ...(selected
1047
+ ? { capabilities: { delegate: { scope: "install-selected" } } }
1048
+ : executor
1049
+ ? { capabilities: { rootOps: true } }
1050
+ : {}),
1051
+ ...portalManifest(portal),
1052
+ })) {
567
1053
  findings.push(`scripts/${hook} does not match the fixed script declared by install.sharedFolder`);
568
1054
  }
569
1055
  }
570
1056
  }
571
1057
  function expectedWizardFiles(pkg, folder, selected = false, portal) {
572
- const steps = selected ? (t) => [...renderDelegateScopeSteps(t), ...(folder ? sharedFolderInstallSteps(pkg, folder)(t) : [])] : folder ? sharedFolderInstallSteps(pkg, folder) : renderNativeInstallWizard;
573
- const install = renderInstallWizardFiles(pkg, portal ? (t) => [...steps(t), renderPortalWizardStep(t, portal.alias)] : steps);
574
- return { ...install.static, ...install.dynamic, ...(folder ? renderSharedFolderUpgradeWizardFiles(pkg, folder) : {}) };
1058
+ const steps = selected
1059
+ ? (t) => [
1060
+ ...renderDelegateScopeSteps(t),
1061
+ ...(folder ? sharedFolderInstallSteps(pkg, folder)(t) : []),
1062
+ ]
1063
+ : folder
1064
+ ? sharedFolderInstallSteps(pkg, folder)
1065
+ : renderNativeInstallWizard;
1066
+ const install = renderInstallWizardFiles(pkg, portal
1067
+ ? (t) => [...steps(t), renderPortalWizardStep(t, portal.alias)]
1068
+ : steps);
1069
+ return {
1070
+ ...install.static,
1071
+ ...install.dynamic,
1072
+ ...(folder ? renderSharedFolderUpgradeWizardFiles(pkg, folder) : {}),
1073
+ };
575
1074
  }
576
1075
  function portalManifest(portal) {
577
- return portal ? { displayname: portal.application, portal: { alias: portal.alias } } : {};
1076
+ return portal
1077
+ ? { displayname: portal.application, portal: { alias: portal.alias } }
1078
+ : {};
578
1079
  }
579
1080
  /**
580
1081
  * The portal declaration is recovered from the payload's etc/portal.json. Its
@@ -583,20 +1084,26 @@ function portalManifest(portal) {
583
1084
  */
584
1085
  async function recoverPortal(dir, info, appId, paths, payload, findings) {
585
1086
  const files = [PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH];
586
- if (!files.some(path => paths.has(path)))
1087
+ if (!files.some((path) => paths.has(path)))
587
1088
  return;
588
1089
  const unpacked = join(dir, "portal-inspection");
589
1090
  mkdirSync(unpacked);
590
- await tar.x({ file: join(dir, "package.tgz"), cwd: unpacked, filter: path => files.includes(path.replace(/^\.\//, "")) });
1091
+ await tar.x({
1092
+ file: join(dir, "package.tgz"),
1093
+ cwd: unpacked,
1094
+ filter: (path) => files.includes(path.replace(/^\.\//, "")),
1095
+ });
591
1096
  try {
592
- if (files.some(path => payload.find(p => p.path === path)?.mode !== "0644"))
1097
+ if (files.some((path) => payload.find((p) => p.path === path)?.mode !== "0644"))
593
1098
  throw new Error("mode");
594
1099
  const json = readFileSync(join(unpacked, PORTAL_JSON_RELPATH), "utf8");
595
1100
  const alias = JSON.parse(json).alias;
596
1101
  if (!isPortalAlias(alias))
597
1102
  throw new Error("alias");
598
1103
  const portal = { appId, application: info.displayname ?? "", alias };
599
- if (json !== renderPortalJson(portal) || readFileSync(join(unpacked, PORTAL_MUSTACHE_RELPATH), "utf8") !== renderPortalMustache())
1104
+ if (json !== renderPortalJson(portal) ||
1105
+ readFileSync(join(unpacked, PORTAL_MUSTACHE_RELPATH), "utf8") !==
1106
+ renderPortalMustache())
600
1107
  throw new Error("content");
601
1108
  for (const hook of ["preinst", "postinst", "postupgrade"]) {
602
1109
  if (!readOptional(join(dir, "scripts", hook))?.startsWith(`#!/bin/sh\n${renderPortalLifecycle(hook, portal)}`))
@@ -632,7 +1139,10 @@ const UPGRADE_GUARD = /SYNOPKG_PKG_STATUS[^\n]*!=\s*"?UPGRADE"?/;
632
1139
  function uninstallBoxes(file, keys) {
633
1140
  try {
634
1141
  const steps = JSON.parse(readFileSync(file, "utf8"));
635
- return steps.flatMap(step => step.items ?? []).flatMap(item => item.subitems ?? []).filter(box => keys.includes(box.key));
1142
+ return steps
1143
+ .flatMap((step) => step.items ?? [])
1144
+ .flatMap((item) => item.subitems ?? [])
1145
+ .filter((box) => keys.includes(box.key));
636
1146
  }
637
1147
  catch {
638
1148
  return [];
@@ -649,7 +1159,16 @@ function uninstallWizardOptions(hook) {
649
1159
  if (!hook)
650
1160
  return {};
651
1161
  const { uninstall, catalogs } = hook;
652
- return { rootPurge: Boolean(uninstall.purge.root), ...(uninstall.state ? { state: { items: (locale) => stateItemTexts(uninstall, catalogs, locale) } } : {}) };
1162
+ return {
1163
+ rootPurge: Boolean(uninstall.purge.root),
1164
+ ...(uninstall.state
1165
+ ? {
1166
+ state: {
1167
+ items: (locale) => stateItemTexts(uninstall, catalogs, locale),
1168
+ },
1169
+ }
1170
+ : {}),
1171
+ };
653
1172
  }
654
1173
  function checkUninstall(dir, findings) {
655
1174
  const script = join(dir, "scripts", "postuninst");
@@ -657,20 +1176,31 @@ function checkUninstall(dir, findings) {
657
1176
  return;
658
1177
  const body = readFileSync(script, "utf8");
659
1178
  const wizardDir = join(dir, "WIZARD_UIFILES");
660
- const wizards = existsSync(wizardDir) ? readdirSync(wizardDir).filter(name => name === "uninstall_uifile" || name.startsWith("uninstall_uifile_")).sort() : [];
661
- const boxes = wizards.flatMap(name => uninstallBoxes(join(wizardDir, name), DATA_KEYS).map(box => ({ ...box, name })));
662
- checkStateBoxes(dir, wizards.flatMap(name => uninstallBoxes(join(wizardDir, name), [DELETE_STATE_KEY]).map(box => ({ ...box, name }))), findings);
1179
+ const wizards = existsSync(wizardDir)
1180
+ ? readdirSync(wizardDir)
1181
+ .filter((name) => name === "uninstall_uifile" || name.startsWith("uninstall_uifile_"))
1182
+ .sort()
1183
+ : [];
1184
+ const boxes = wizards.flatMap((name) => uninstallBoxes(join(wizardDir, name), DATA_KEYS).map((box) => ({
1185
+ ...box,
1186
+ name,
1187
+ })));
1188
+ checkStateBoxes(dir, wizards.flatMap((name) => uninstallBoxes(join(wizardDir, name), [DELETE_STATE_KEY]).map((box) => ({
1189
+ ...box,
1190
+ name,
1191
+ }))), findings);
663
1192
  const deletes = DELETE_COMMAND.test(body);
664
1193
  if (deletes) {
665
1194
  if (!UPGRADE_GUARD.test(body))
666
- findings.push("UNINSTALL_NO_UPGRADE_GUARD scripts/postuninst deletes data without the [ \"$SYNOPKG_PKG_STATUS\" != UPGRADE ] guard, so an upgrade deletes it by mistake");
667
- if (!/is_package_area\(\)\s*\{/.test(body) || !/if ! is_package_area "\$real"/.test(body))
1195
+ findings.push('UNINSTALL_NO_UPGRADE_GUARD scripts/postuninst deletes data without the [ "$SYNOPKG_PKG_STATUS" != UPGRADE ] guard, so an upgrade deletes it by mistake');
1196
+ if (!/is_package_area\(\)\s*\{/.test(body) ||
1197
+ !/if ! is_package_area "\$real"/.test(body))
668
1198
  findings.push("UNINSTALL_UNSAFE_DELETE scripts/postuninst does not verify the deleted paths are under /volumeN/@appdata|@apphome|@appconf|@appstore/<package>; an empty or redirected variable deletes the wrong data");
669
- for (const line of body.split("\n").filter(l => DELETE_COMMAND.test(l))) {
1199
+ for (const line of body.split("\n").filter((l) => DELETE_COMMAND.test(l))) {
670
1200
  for (const path of line.match(/\/volume\w*\/(?!@app(data|home|conf|store)\/)[^\s"'$;]*/g) ?? [])
671
1201
  findings.push(`UNINSTALL_UNSAFE_DELETE scripts/postuninst deletes ${path}, which is not inside the package's own @app* directory`);
672
1202
  }
673
- if (!boxes.some(box => box.name === "uninstall_uifile"))
1203
+ if (!boxes.some((box) => box.name === "uninstall_uifile"))
674
1204
  findings.push("UNINSTALL_WIZARD_MISSING postuninst deletes data, but WIZARD_UIFILES/uninstall_uifile has no pkgwizard_remove_additional checkbox, so the user cannot choose");
675
1205
  }
676
1206
  for (const box of boxes) {