@synopackageland/cli 0.13.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 (119) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +44 -1
  3. package/dist/build-spk/generator.d.ts +3 -2
  4. package/dist/build-spk/generator.js +6 -850
  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/app-launch.d.ts +11 -0
  14. package/dist/contract/app-launch.js +18 -0
  15. package/dist/contract/web-launch.d.ts +3 -13
  16. package/dist/contract/web-launch.js +14 -81
  17. package/dist/ctl/cli.d.ts +36 -0
  18. package/dist/ctl/cli.js +218 -0
  19. package/dist/ctl/descriptor.d.ts +81 -0
  20. package/dist/ctl/descriptor.js +93 -0
  21. package/dist/ctl/dsm-auth.d.ts +10 -0
  22. package/dist/ctl/dsm-auth.js +30 -0
  23. package/dist/ctl/errors.d.ts +17 -0
  24. package/dist/ctl/errors.js +32 -0
  25. package/dist/ctl/flows.d.ts +11 -0
  26. package/dist/ctl/flows.js +52 -0
  27. package/dist/ctl/input.d.ts +20 -0
  28. package/dist/ctl/input.js +128 -0
  29. package/dist/ctl/json.d.ts +2 -0
  30. package/dist/ctl/json.js +38 -0
  31. package/dist/ctl/redaction.d.ts +15 -0
  32. package/dist/ctl/redaction.js +198 -0
  33. package/dist/ctl/service.d.ts +38 -0
  34. package/dist/ctl/service.js +156 -0
  35. package/dist/ctl/store.d.ts +51 -0
  36. package/dist/ctl/store.js +93 -0
  37. package/dist/ctl/transfers.d.ts +14 -0
  38. package/dist/ctl/transfers.js +74 -0
  39. package/dist/ctl/transport.d.ts +11 -0
  40. package/dist/ctl/transport.js +79 -0
  41. package/dist/deploy/deploy.d.ts +4 -3
  42. package/dist/deploy/deploy.js +5 -210
  43. package/dist/deploy/plan.js +26 -7
  44. package/dist/dev.js +5 -4
  45. package/dist/error-registry.js +1020 -831
  46. package/dist/info.js +120 -28
  47. package/dist/init.js +188 -64
  48. package/dist/inspect.js +21 -6
  49. package/dist/runtime/executor-digests.json +1 -12
  50. package/dist/skill.js +51 -9
  51. package/dist/standalone/api-description-inspect.d.ts +4 -0
  52. package/dist/standalone/api-description-inspect.js +12 -0
  53. package/dist/standalone/api-description.d.ts +7 -0
  54. package/dist/standalone/api-description.js +73 -0
  55. package/dist/standalone/build.d.ts +3 -0
  56. package/dist/standalone/build.js +140 -33
  57. package/dist/standalone/compose-errors.d.ts +3 -0
  58. package/dist/standalone/compose-errors.js +243 -0
  59. package/dist/standalone/compose.d.ts +97 -10
  60. package/dist/standalone/compose.js +306 -75
  61. package/dist/standalone/daemon.js +111 -29
  62. package/dist/standalone/dsm-window.js +55 -9
  63. package/dist/standalone/identity-gateway.d.ts +9 -2
  64. package/dist/standalone/identity-gateway.js +282 -66
  65. package/dist/standalone/inspect-spk.d.ts +15 -0
  66. package/dist/standalone/inspect-spk.js +714 -128
  67. package/dist/standalone/lifecycle.js +124 -42
  68. package/dist/standalone/manifest.d.ts +10 -1
  69. package/dist/standalone/manifest.js +308 -87
  70. package/dist/standalone/resource.js +6 -2
  71. package/dist/standalone/spk.d.ts +4 -1
  72. package/dist/standalone/spk.js +139 -39
  73. package/dist/template-paths.d.ts +0 -2
  74. package/dist/template-paths.js +0 -5
  75. package/dist/test-command.d.ts +1 -1
  76. package/dist/test-command.js +1 -1
  77. package/dist/validate.d.ts +3 -3
  78. package/dist/validate.js +32 -140
  79. package/package.json +5 -3
  80. package/schema/synology-app.schema.json +1 -1
  81. package/schema/synopkgland-api-v1.schema.json +423 -0
  82. package/skills/SKILL.md +34 -1
  83. package/skills/reference/compose.md +349 -0
  84. package/skills/reference/create.md +26 -4
  85. package/skills/reference/ctl.md +41 -0
  86. package/skills/reference/deploy.md +1 -1
  87. package/skills/reference/files.md +4 -0
  88. package/skills/reference/identity-gateway.md +8 -138
  89. package/skills/reference/portal.md +31 -0
  90. package/skills/reference/ui-vue.md +31 -0
  91. package/skills/reference/wrap.md +10 -30
  92. package/assets/go/daemon/fixture_linux_test.go +0 -15
  93. package/assets/go/daemon/fixture_other_test.go +0 -7
  94. package/assets/go/daemon/go.mod +0 -5
  95. package/assets/go/daemon/main.go +0 -636
  96. package/assets/go/daemon/main_test.go +0 -315
  97. package/assets/go/daemon/network.go +0 -107
  98. package/assets/go/daemon/network_test.go +0 -86
  99. package/assets/go/daemon/process_linux.go +0 -55
  100. package/assets/go/daemon/process_linux_test.go +0 -161
  101. package/assets/go/daemon/process_other.go +0 -14
  102. package/assets/go/daemon/testdata/child.go +0 -44
  103. package/assets/go/gateway/auth.go +0 -101
  104. package/assets/go/gateway/auth_test.go +0 -216
  105. package/assets/go/gateway/cgi.go +0 -133
  106. package/assets/go/gateway/cgi_test.go +0 -156
  107. package/assets/go/gateway/csrf.go +0 -73
  108. package/assets/go/gateway/csrf_test.go +0 -155
  109. package/assets/go/gateway/directory.go +0 -108
  110. package/assets/go/gateway/directory_test.go +0 -202
  111. package/assets/go/gateway/gateway.go +0 -412
  112. package/assets/go/gateway/gateway_test.go +0 -676
  113. package/assets/go/gateway/go.mod +0 -5
  114. package/assets/go/gateway/groups.go +0 -66
  115. package/assets/go/gateway/main.go +0 -45
  116. package/dist/build-spk/gateway.d.ts +0 -31
  117. package/dist/build-spk/gateway.js +0 -60
  118. package/dist/compose/privilege.d.ts +0 -14
  119. package/dist/compose/privilege.js +0 -102
@@ -1,37 +1,51 @@
1
- import { readComposeApp } from "./compose.js";
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { inspectApiDescription } from "./api-description-inspect.js";
3
+ import { dsmLauncherType } from "../contract/app-launch.js";
4
+ import { isWebLaunchMode, resolvedWebLaunch, trustedAppEntryPath, usesAuthGateway, } from "../contract/web-launch.js";
5
+ import { loadContract } from "../contract/load.js";
6
+ import { readComposeApp, composeSource, dsmComposeProblem, COMPOSE_READINESS_LIMITATION, } from "./compose.js";
2
7
  import { createHash } from "node:crypto";
3
8
  import { inspectNativeLayout } from "./native-layout.js";
4
9
  import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
5
- import { inspectExecutor, inspectExecutorAnchor } from "../runtime/executor-inspect.js";
6
- 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";
7
12
  import { unwrapAppScripts } from "./app-scripts.js";
8
13
  import { tmpdir } from "node:os";
9
14
  import { join } from "node:path";
10
15
  import * as tar from "tar";
11
- import { familyOfArchToken, NATIVE_ARCH_FAMILIES } from "../build-spk/dsm-arch.js";
12
- 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";
13
18
  import { ELF_MACHINE } from "./binary.js";
14
19
  import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
15
20
  import { daemonConfig, readDaemon } from "./daemon.js";
16
21
  import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
17
22
  import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
18
23
  import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
19
- 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";
20
25
  import { renderStandaloneResource } from "./resource.js";
21
- import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER } from "./shared-folder.js";
22
- import { renderInstallWizardFiles, renderNativeInstallWizard } from "../build-spk/install-wizard.js";
23
- 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";
24
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";
25
34
  import { renderStandaloneScript } from "./lifecycle.js";
26
35
  import { listPortExposures, loadCompose } from "../compose/analyze.js";
27
- import { uninstallCleanup } from "../runtime/uninstall-cleanup.js";
28
- 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";
29
38
  import { isPortalAlias } from "../portal/alias.js";
30
39
  import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
31
40
  import { isDefaultLoginAsset } from "../build-spk/login-style.js";
32
41
  import { decodePng } from "../build-spk/png.js";
33
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. */
34
- 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
+ };
35
49
  /** Reads an SPK without installing it and reports how it would run on DSM. */
36
50
  export async function inspectStandaloneSpk(spkPath) {
37
51
  return inspectSpk(spkPath, "native-go");
@@ -49,21 +63,51 @@ export async function inspectSpk(spkPath, expectedKind) {
49
63
  mkdirSync(target);
50
64
  await tar.x({ file: join(dir, "package.tgz"), cwd: target });
51
65
  findings.push(...inspectNativeLayout(dir, target, info));
52
- 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
+ ])
53
73
  if (!info[key])
54
74
  findings.push(`Missing INFO ${key}`);
55
75
  const icon = inspectPackageIcons(dir, findings);
56
- return { kind: "native-layout", package: info.package, version: info.version, arch: info.arch,
57
- 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
+ };
58
87
  }
59
88
  const payload = await listPayload(join(dir, "package.tgz"), findings);
60
- const paths = new Set(payload.map(entry => entry.path));
89
+ const paths = new Set(payload.map((entry) => entry.path));
61
90
  let metadata = {};
62
91
  const metadataText = readOptional(join(dir, "sdk-package.json"));
63
92
  if (metadataText !== undefined) {
64
93
  try {
65
94
  const raw = JSON.parse(metadataText);
66
- if (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)))
67
111
  throw new Error();
68
112
  metadata = raw;
69
113
  }
@@ -71,27 +115,51 @@ export async function inspectSpk(spkPath, expectedKind) {
71
115
  findings.push("sdk-package.json does not match the schema 2 capability inventory.");
72
116
  }
73
117
  }
74
- 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);
75
126
  if (metadata.uninstall !== undefined && !uninstallHook)
76
127
  findings.push("UNINSTALL_RECORD_INVALID the uninstall in sdk-package.json is not the uninstall setting synopkgland build writes.");
77
128
  const binaryBackend = metadata.backend === "binary";
78
129
  // A strict standalone inspection names the language the CGI was built from.
79
130
  const compiled = COMPILED_KIND[metadata.backend ?? "go"] ?? "native-go";
80
- 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);
81
137
  const compiledKind = Object.values(COMPILED_KIND).includes(kind);
82
138
  const standalone = compiledKind || kind === "binary";
83
- 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);
84
142
  const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined));
85
- 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)))
86
145
  findings.push("compose identity_gateway requires DSM >= 7.2.1");
87
146
  await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
88
147
  const background = compiledKind && paths.has(BACKGROUND_WORKER_PAYLOAD);
89
148
  // Before any fixed-lifecycle comparison: those see the SDK steps alone.
90
- const appScripts = standalone || kind === "container" ? unwrapAppScripts(join(dir, "scripts"), findings) : [];
91
- 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;
92
155
  // A Compose App has no executor, so its SPK never carries a portal.
93
- const portal = standalone ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings) : undefined;
94
- 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)) {
95
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}`);
96
164
  }
97
165
  checkConf(dir, findings, kind, background, sharedFolder, info.package, portal, combined?.compose);
@@ -99,15 +167,36 @@ export async function inspectSpk(spkPath, expectedKind) {
99
167
  checkUninstall(dir, findings);
100
168
  if (kind === "container")
101
169
  await checkContainerExposure(dir, join(dir, "package.tgz"), findings);
102
- const bridge = compiledKind && kind !== "native-go" ? await checkBridgeApp(dir, metadata.backend, info, payload, findings) : undefined;
103
- 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");
104
190
  if (standalone && portal && !hasExecutor)
105
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");
106
- 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;
107
195
  if (background)
108
196
  checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go");
109
197
  let executor;
110
- 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")) {
111
200
  const executorDir = join(dir, "executor-inspection");
112
201
  mkdirSync(executorDir);
113
202
  await tar.x({ file: join(dir, "package.tgz"), cwd: executorDir });
@@ -116,37 +205,60 @@ export async function inspectSpk(spkPath, expectedKind) {
116
205
  executor = inspected.executor;
117
206
  if (executor)
118
207
  findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
119
- 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)) {
120
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`);
121
213
  }
122
- if (executor && executor.policy.purge !== uninstallHook?.uninstall.purge.root)
214
+ if (executor &&
215
+ executor.policy.purge !== uninstallHook?.uninstall.purge.root)
123
216
  findings.push(`UNINSTALL_ROOT_POLICY_MISMATCH the executor policy purge "${executor.policy.purge ?? ""}" differs from uninstall.purge.root "${uninstallHook?.uninstall.purge.root ?? ""}".`);
124
- if (payload.find(p => p.path === "executor/executor")?.mode !== "0755")
217
+ if (payload.find((p) => p.path === "executor/executor")?.mode !== "0755")
125
218
  findings.push("Executor must be executable.");
126
- if (payload.find(p => p.path === "executor/lifecycle")?.mode !== "0755")
219
+ if (payload.find((p) => p.path === "executor/lifecycle")?.mode !== "0755")
127
220
  findings.push("Executor lifecycle helper must be executable.");
128
- if (![...paths].some(path => path.startsWith("daemon/"))) {
129
- 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
+ };
130
230
  for (const hook of ["preuninst", "preupgrade", "start-stop-status"]) {
131
- if (readOptional(join(dir, "scripts", hook)) !== renderStandaloneScript(hook, lifecycleManifest))
231
+ if (readOptional(join(dir, "scripts", hook)) !==
232
+ renderStandaloneScript(hook, lifecycleManifest))
132
233
  findings.push(`Executor scripts/${hook} differs from fixed lifecycle.`);
133
234
  }
134
235
  }
135
- 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
+ }))) {
136
242
  if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
137
243
  findings.push(`Executor WIZARD_UIFILES/${name} is missing cleanup guidance.`);
138
244
  }
139
245
  }
140
246
  if (bridge)
141
247
  checkBridgeExecutor(bridge, executor, findings);
142
- if (background && daemon && (executor?.policy.lifetime !== 'until-revoked' || !metadata.capabilities?.includes('delegate'))) {
143
- 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.");
144
253
  }
145
254
  if (metadata.entry)
146
255
  await checkEntryLauncher(dir, info.package, metadata.entry, findings);
147
- const loginStyle = standalone ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings) : undefined;
256
+ const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings);
257
+ const loginStyle = standalone
258
+ ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings)
259
+ : undefined;
148
260
  if (standalone)
149
- 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);
150
262
  const icon = inspectPackageIcons(dir, findings);
151
263
  const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
152
264
  if (standalone && !cgiEntry) {
@@ -157,14 +269,32 @@ export async function inspectSpk(spkPath, expectedKind) {
157
269
  }
158
270
  return {
159
271
  kind,
272
+ ...(launch ? { launch } : {}),
160
273
  icon,
161
274
  ...(executor ? { executor } : {}),
162
- ...(portal ? { portal: { appId: portal.appId, alias: portal.alias } } : {}),
163
- ...(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
+ : {}),
164
286
  ...(metadata.entry ? { entry: metadata.entry } : {}),
165
287
  ...(appScripts.length ? { appScripts } : {}),
166
288
  ...(loginStyle ? { loginStyle } : {}),
167
- ...(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
+ : {}),
168
298
  package: info.package,
169
299
  version: info.version,
170
300
  arch: info.arch,
@@ -172,31 +302,119 @@ export async function inspectSpk(spkPath, expectedKind) {
172
302
  cgi: cgiEntry?.mode === "0755" ? CGI_PAYLOAD_PATH : undefined,
173
303
  payload,
174
304
  findings,
175
- 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
+ })),
176
309
  };
177
310
  }
178
311
  finally {
179
312
  rmSync(dir, { recursive: true, force: true });
180
313
  }
181
314
  }
315
+ const LAUNCHER_TYPE_MISMATCH = "LAUNCHER_TYPE_MISMATCH";
316
+ const LAUNCHER_PORTAL_MISMATCH = "LAUNCHER_PORTAL_MISMATCH";
317
+ /** Compare the actual launcher and Portal fields to the packaged declaration. */
318
+ async function checkLaunchConfiguration(dir, info, native, declared, alias, findings) {
319
+ const target = join(dir, "launch-inspection");
320
+ mkdirSync(target);
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
+ });
326
+ let launch = declared ?? "dsm-window";
327
+ let url;
328
+ if (!native) {
329
+ let contract;
330
+ try {
331
+ contract = loadContract(join(target, "synology-app.yaml")).contract;
332
+ }
333
+ catch {
334
+ findings.push(`${LAUNCHER_TYPE_MISMATCH} cannot read the packaged web launch declaration; rebuild with synopkgland build`);
335
+ return undefined;
336
+ }
337
+ const resolved = resolvedWebLaunch(contract);
338
+ if (!resolved) {
339
+ if (contract.adminport !== undefined)
340
+ findings.push(`${LAUNCHER_TYPE_MISMATCH} invalid packaged web launch mode; use dsm-window or new-page and rebuild`);
341
+ return undefined; // A headless service has no launcher.
342
+ }
343
+ launch = resolved;
344
+ url = trustedAppEntryPath(contract);
345
+ try {
346
+ const resource = JSON.parse(readFileSync(join(dir, "conf/resource"), "utf8"));
347
+ const portals = resource.webservice?.portals ?? [];
348
+ if (usesAuthGateway(contract)
349
+ ? portals.length > 0
350
+ : portals.some((p) => p.app !== (launch === "dsm-window" ? info.package : undefined))) {
351
+ findings.push(`${LAUNCHER_PORTAL_MISMATCH} Web Station entries differ from the declared access and launch mode; rebuild with synopkgland build`);
352
+ }
353
+ }
354
+ catch {
355
+ /* checkConf reports invalid resources */
356
+ }
357
+ }
358
+ const appId = native ? `SYNO.SDS.${info.package}.Instance` : info.package;
359
+ let launcher;
360
+ try {
361
+ const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
362
+ launcher = Object.values(config)
363
+ .map((entries) => entries[appId])
364
+ .find(Boolean);
365
+ }
366
+ catch {
367
+ /* report the missing or malformed launcher below */
368
+ }
369
+ const type = dsmLauncherType(launch, native);
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)) {
377
+ findings.push(`${LAUNCHER_TYPE_MISMATCH} ${launch} requires a ${type} launcher at ${appId} and matching INFO entry; rebuild with synopkgland build`);
378
+ }
379
+ if (launcher &&
380
+ (launcher.defaultAlias !== alias ||
381
+ (!alias &&
382
+ (launcher.loginStyle !== undefined ||
383
+ launcher.loginIcon !== undefined)))) {
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`);
385
+ }
386
+ return launch;
387
+ }
182
388
  /** The launcher in ui/config must carry exactly the access the declared entry mode fixes. */
183
389
  async function checkEntryLauncher(dir, pkg, entry, findings) {
184
390
  const target = join(dir, "entry-inspection");
185
391
  mkdirSync(target);
186
- 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
+ });
187
397
  let launcher;
188
398
  try {
189
399
  const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
190
400
  launcher = Object.values(config)[0]?.[`SYNO.SDS.${pkg}.Instance`];
191
401
  }
192
- catch { /* reported below */ }
402
+ catch {
403
+ /* reported below */
404
+ }
193
405
  const expected = entryLauncherAccess(entry);
194
406
  const fields = ["allUsers", "grantPrivilege", "advanceGrantPrivilege"];
195
- if (!launcher || fields.some(field => launcher[field] !== expected[field])) {
407
+ if (!launcher ||
408
+ fields.some((field) => launcher[field] !== expected[field])) {
196
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`);
197
410
  }
198
411
  }
199
- const LOGIN_STYLE_FILES = ["defaultLoginWallpaper", "defaultLoginWallpaperThumbnail", "loginPreviewThumbnailMaskPath", "titleImgPath"];
412
+ const LOGIN_STYLE_FILES = [
413
+ "defaultLoginWallpaper",
414
+ "defaultLoginWallpaperThumbnail",
415
+ "loginPreviewThumbnailMaskPath",
416
+ "titleImgPath",
417
+ ];
200
418
  /**
201
419
  * The launcher's loginStyle and the ui/ files it names, at 1x and 2x. A portal
202
420
  * App without one shows a blank Control Panel > Login Portal thumbnail.
@@ -204,13 +422,19 @@ const LOGIN_STYLE_FILES = ["defaultLoginWallpaper", "defaultLoginWallpaperThumbn
204
422
  async function inspectLoginStyle(dir, pkg, portal, findings) {
205
423
  const target = join(dir, "login-style-inspection");
206
424
  mkdirSync(target);
207
- 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
+ });
208
430
  let launcher;
209
431
  try {
210
432
  const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
211
433
  launcher = Object.values(config)[0]?.[`SYNO.SDS.${pkg}.Instance`];
212
434
  }
213
- catch { /* reported by the other checks */ }
435
+ catch {
436
+ /* reported by the other checks */
437
+ }
214
438
  const style = launcher?.loginStyle;
215
439
  if (typeof style !== "object" || style === null) {
216
440
  if (portal)
@@ -222,9 +446,12 @@ async function inspectLoginStyle(dir, pkg, portal, findings) {
222
446
  const path = fields[field];
223
447
  if (path === undefined)
224
448
  return undefined;
225
- const files = typeof path === "string" && !path.split("/").includes("..") && !path.startsWith("/")
226
- ? ["1x", "2x"].map(scale => join(target, "ui", path.replace("{0}", scale))) : [];
227
- 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))) {
228
455
  findings.push(`LOGIN_STYLE_FILE_MISSING ui/config loginStyle.${field} names ui/${String(path)}, which the payload does not carry`);
229
456
  return undefined;
230
457
  }
@@ -233,13 +460,22 @@ async function inspectLoginStyle(dir, pkg, portal, findings) {
233
460
  const [wallpaper] = LOGIN_STYLE_FILES.map(read);
234
461
  return {
235
462
  color: String(fields.mainColor ?? ""),
236
- wallpaper: wallpaper && isDefaultLoginAsset("wallpaper.jpg", wallpaper) ? "default" : "custom",
237
- 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",
238
471
  };
239
472
  }
240
473
  /** Package Center shows PACKAGE_ICON at 64 and 256 px; the package source refuses a release whose icon is over 256 KiB. */
241
474
  function inspectPackageIcons(dir, findings) {
242
- 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
+ ]) {
243
479
  const bytes = readOptionalBytes(join(dir, name));
244
480
  if (!bytes)
245
481
  continue;
@@ -261,7 +497,10 @@ function inspectPackageIcons(dir, findings) {
261
497
  return { source: "none" };
262
498
  return big.equals(defaultPackageIcons().packageIcon256)
263
499
  ? { source: "default" }
264
- : { source: "custom", sha256: createHash("sha256").update(big).digest("hex") };
500
+ : {
501
+ source: "custom",
502
+ sha256: createHash("sha256").update(big).digest("hex"),
503
+ };
265
504
  }
266
505
  function readOptionalBytes(path) {
267
506
  return existsSync(path) ? readFileSync(path) : undefined;
@@ -272,16 +511,18 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
272
511
  if (info.os_min_ver && !OS_MIN_VER.test(info.os_min_ver)) {
273
512
  findings.push(`INFO os_min_ver "${info.os_min_ver}" is not the DSM 7 <version>-<build> (for example 7.1-42661)`);
274
513
  }
275
- 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)) {
276
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`);
277
518
  }
278
519
  const tokens = (info.arch ?? "").split(" ").filter(Boolean);
279
520
  if (tokens.length === 0 || tokens.includes("noarch"))
280
521
  return;
281
- for (const token of tokens.filter(token => !familyOfArchToken(token))) {
522
+ for (const token of tokens.filter((token) => !familyOfArchToken(token))) {
282
523
  findings.push(`INFO arch contains an unknown platform "${token}"`);
283
524
  }
284
- const families = new Set(tokens.map(familyOfArchToken).filter(family => family !== undefined));
525
+ const families = new Set(tokens.map(familyOfArchToken).filter((family) => family !== undefined));
285
526
  if (families.size > 1) {
286
527
  findings.push(`INFO arch mixes several architectures (${[...families].join(", ")}); an SPK contains binaries for one architecture only`);
287
528
  }
@@ -291,31 +532,224 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
291
532
  const cgi = join(dir, "cgi-inspection");
292
533
  mkdirSync(cgi);
293
534
  // Only the header is read; the CGI is never run.
294
- 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
+ });
295
540
  const header = readFileSync(join(cgi, CGI_PAYLOAD_PATH)).subarray(0, 20);
296
- const machine = header.length === 20 && header.toString("latin1", 0, 4) === "\x7fELF" ? header.readUInt16LE(18) : undefined;
297
- 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);
298
545
  if (actual && actual !== declared) {
299
546
  findings.push(`INFO arch declares ${declared}, but ${CGI_PAYLOAD_PATH} is a ${actual} executable`);
300
547
  }
301
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
+ }
302
716
  async function recoverCompose(dir, value, findings) {
303
717
  try {
304
- if (!value || typeof value !== 'object' || Object.keys(value).join() !== 'services')
305
- throw new Error('invalid compose inventory');
306
- 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");
307
723
  mkdirSync(root);
308
- await tar.x({ file: join(dir, 'package.tgz'), cwd: root, filter: p => ['compose/docker-compose.yml', 'sso/gateway.json'].includes(p.replace(/^\.\//, '')) });
309
- 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
+ });
310
736
  if (!compose)
311
- throw new Error('invalid compose');
312
- 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"));
313
739
  const upstream = /^http:\/\/127\.0\.0\.1:(\d+)$/.exec(config.upstream)?.[1];
314
740
  const listen = config.listen;
315
- 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)))
316
- throw new Error('invalid gateway network');
317
- if (config.package !== readInfo(join(dir, 'INFO'), [], 'container').package)
318
- 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");
319
753
  return { compose, identityGateway: { listen } };
320
754
  }
321
755
  catch (e) {
@@ -324,37 +758,80 @@ async function recoverCompose(dir, value, findings) {
324
758
  }
325
759
  }
326
760
  async function checkDaemon(dir, info, paths, payload, findings, executor, background, combined) {
327
- if (![...paths].some(path => path.startsWith("daemon/")))
761
+ if (![...paths].some((path) => path.startsWith("daemon/")))
328
762
  return;
329
763
  if (info.precheckstartstop !== "yes")
330
- findings.push("daemon INFO must explicitly set precheckstartstop=\"yes\"");
331
- 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")
332
766
  findings.push("daemon/control is missing or not executable");
333
767
  const unpacked = join(dir, "daemon-inspection");
334
768
  // Extract only the bounded config; the payload executable is never run.
335
769
  const { mkdirSync } = await import("node:fs");
336
770
  mkdirSync(unpacked);
337
- 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
+ });
338
776
  try {
339
777
  const config = JSON.parse(readFileSync(join(unpacked, "daemon/config.json"), "utf8"));
340
- 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)))
341
788
  throw new Error("package/config mismatch");
342
- 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"], () => { });
343
797
  if (!daemon)
344
798
  throw new Error("invalid config");
345
- 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))
346
803
  throw new Error("compose gateway must run without a child");
347
- 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")
348
806
  throw new Error("missing executable");
349
- 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
+ };
350
816
  // A config from before timeouts.uninstall pairs with a control that rejects the key.
351
- 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);
352
820
  const { uninstall: _, ...legacyTimeouts } = daemon.timeouts;
353
- 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);
354
830
  if (readFileSync(join(unpacked, "daemon/config.json"), "utf8") !== canonical)
355
831
  throw new Error("non-canonical daemon config");
356
832
  for (const hook of ["start-stop-status", "preupgrade", "preuninst"]) {
357
- if (readFileSync(join(dir, "scripts", hook), "utf8") !== renderStandaloneScript(hook, manifest))
833
+ if (readFileSync(join(dir, "scripts", hook), "utf8") !==
834
+ renderStandaloneScript(hook, manifest))
358
835
  findings.push(`daemon scripts/${hook} does not match the fixed lifecycle`);
359
836
  }
360
837
  return daemon;
@@ -380,12 +857,21 @@ function readInfo(path, findings, kind, compose = false) {
380
857
  findings.push(`INFO is missing ${key}`);
381
858
  }
382
859
  }
383
- if (kind !== "container" && (info.thirdparty !== "yes" || info.dsmuidir !== "ui")) {
384
- 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>/');
385
863
  }
386
864
  // A container needs Container Manager (and Web Station for a portal); a Node App the Node.js it runs on.
387
- const allowed = compose ? /^ContainerManager$/ : kind === "container" ? /^(WebStation|ContainerManager)([><=].*)?$/ : kind === "native-node" ? /^Node\.js_v[0-9]{2}([><=].*)?$/ : undefined;
388
- 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)))) {
389
875
  findings.push(`INFO depends on other packages: ${info.install_dep_packages}`);
390
876
  }
391
877
  return info;
@@ -394,9 +880,15 @@ function readInfo(path, findings, kind, compose = false) {
394
880
  async function checkContainerExposure(dir, packageTgz, findings) {
395
881
  const extracted = join(dir, "payload");
396
882
  mkdirSync(extracted);
397
- 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
+ });
398
888
  const compose = loadCompose(join(extracted, "compose.yaml"))?.compose;
399
- 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
+ : []) {
400
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`);
401
893
  }
402
894
  try {
@@ -404,7 +896,9 @@ async function checkContainerExposure(dir, packageTgz, findings) {
404
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)");
405
897
  }
406
898
  }
407
- catch { /* checkConf already reports an unreadable conf/resource */ }
899
+ catch {
900
+ /* checkConf already reports an unreadable conf/resource */
901
+ }
408
902
  }
409
903
  function readOptional(path) {
410
904
  return existsSync(path) ? readFileSync(path, "utf8") : undefined;
@@ -412,12 +906,25 @@ function readOptional(path) {
412
906
  function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, compose) {
413
907
  const confDir = join(dir, "conf");
414
908
  const entries = existsSync(confDir) ? readdirSync(confDir) : [];
415
- const nativeResource = kind !== "container" ? renderStandaloneResource({ package: pkg ?? "", ...(compose ? { compose } : {}), capabilities: background ? { jobs: { worker: "sha256-v1" } } : {}, ...(sharedFolder ? { sharedFolder } : {}) }, portal) : undefined;
416
- 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) {
417
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)`);
418
922
  }
419
923
  for (const name of entries) {
420
- 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")) {
421
928
  findings.push(`conf/${name} registers an extra resource with DSM; a Standalone Package does not produce it`);
422
929
  }
423
930
  }
@@ -449,7 +956,7 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, c
449
956
  }
450
957
  const defaults = privilege.defaults;
451
958
  if (defaults?.["run-as"] !== "package") {
452
- findings.push("conf/privilege defaults.run-as must be \"package\"");
959
+ findings.push('conf/privilege defaults.run-as must be "package"');
453
960
  }
454
961
  for (const key of ["tool", "ctrl-script", "executable", "join-groupname"]) {
455
962
  if (key in privilege) {
@@ -462,17 +969,32 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
462
969
  const pkg = info.package;
463
970
  if (!pkg)
464
971
  return;
465
- const read = (path) => existsSync(join(dir, path)) ? readFileSync(join(dir, path), "utf8") : undefined;
466
- 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")
467
976
  findings.push(`${BACKGROUND_WORKER_PAYLOAD} is missing or not executable`);
468
977
  if (info.precheckstartstop !== "yes")
469
- findings.push("background INFO must explicitly set precheckstartstop=\"yes\" (prestart recovers the package user manager that lost its socket)");
470
- 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
+ : [];
471
982
  const unit = backgroundUnitName(pkg);
472
- 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 : [])) {
473
987
  findings.push(`${BACKGROUND_UNIT_DIR} must contain only the fixed ${unit} (runs as the package account, no User=, no root)`);
474
988
  }
475
- 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
+ }))
476
998
  findings.push("background scripts/start-stop-status does not match the fixed synosystemctl lifecycle");
477
999
  }
478
1000
  /**
@@ -483,42 +1005,77 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
483
1005
  */
484
1006
  function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false) {
485
1007
  const wizardDir = join(dir, "WIZARD_UIFILES");
486
- 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();
487
1011
  const postinst = readOptional(join(dir, "scripts/postinst"));
488
1012
  if (!folder && postinst?.includes(SHARED_FOLDER_MARKER))
489
1013
  findings.push("the install.sharedFolder declaration in scripts/postinst is invalid");
490
1014
  if (!pkg)
491
1015
  return;
492
1016
  const wizards = expectedWizardFiles(pkg, folder, selected, portal);
493
- 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";
494
1024
  // The default native wizard is optional; a declared shared folder or portal needs all of its files.
495
- 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)) {
496
1028
  if (readOptional(join(wizardDir, name)) !== wizards[name])
497
1029
  findings.push(`WIZARD_UIFILES/${name} does not match ${source}`);
498
1030
  }
499
- for (const name of present.filter(n => !(n in wizards)))
1031
+ for (const name of present.filter((n) => !(n in wizards)))
500
1032
  findings.push(`WIZARD_UIFILES/${name} is not a wizard file produced by ${source}`);
501
1033
  if (selected) {
502
- for (const hook of ['preinst', 'postinst']) {
503
- 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")))
504
1036
  findings.push(`scripts/${hook} missing install-selected draft handling`);
505
1037
  }
506
1038
  }
507
1039
  if (!folder)
508
1040
  return;
509
1041
  for (const hook of ["postinst", "postupgrade"]) {
510
- 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
+ })) {
511
1053
  findings.push(`scripts/${hook} does not match the fixed script declared by install.sharedFolder`);
512
1054
  }
513
1055
  }
514
1056
  }
515
1057
  function expectedWizardFiles(pkg, folder, selected = false, portal) {
516
- const steps = selected ? (t) => [...renderDelegateScopeSteps(t), ...(folder ? sharedFolderInstallSteps(pkg, folder)(t) : [])] : folder ? sharedFolderInstallSteps(pkg, folder) : renderNativeInstallWizard;
517
- const install = renderInstallWizardFiles(pkg, portal ? (t) => [...steps(t), renderPortalWizardStep(t, portal.alias)] : steps);
518
- 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
+ };
519
1074
  }
520
1075
  function portalManifest(portal) {
521
- return portal ? { displayname: portal.application, portal: { alias: portal.alias } } : {};
1076
+ return portal
1077
+ ? { displayname: portal.application, portal: { alias: portal.alias } }
1078
+ : {};
522
1079
  }
523
1080
  /**
524
1081
  * The portal declaration is recovered from the payload's etc/portal.json. Its
@@ -527,20 +1084,26 @@ function portalManifest(portal) {
527
1084
  */
528
1085
  async function recoverPortal(dir, info, appId, paths, payload, findings) {
529
1086
  const files = [PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH];
530
- if (!files.some(path => paths.has(path)))
1087
+ if (!files.some((path) => paths.has(path)))
531
1088
  return;
532
1089
  const unpacked = join(dir, "portal-inspection");
533
1090
  mkdirSync(unpacked);
534
- 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
+ });
535
1096
  try {
536
- 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"))
537
1098
  throw new Error("mode");
538
1099
  const json = readFileSync(join(unpacked, PORTAL_JSON_RELPATH), "utf8");
539
1100
  const alias = JSON.parse(json).alias;
540
1101
  if (!isPortalAlias(alias))
541
1102
  throw new Error("alias");
542
1103
  const portal = { appId, application: info.displayname ?? "", alias };
543
- 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())
544
1107
  throw new Error("content");
545
1108
  for (const hook of ["preinst", "postinst", "postupgrade"]) {
546
1109
  if (!readOptional(join(dir, "scripts", hook))?.startsWith(`#!/bin/sh\n${renderPortalLifecycle(hook, portal)}`))
@@ -576,7 +1139,10 @@ const UPGRADE_GUARD = /SYNOPKG_PKG_STATUS[^\n]*!=\s*"?UPGRADE"?/;
576
1139
  function uninstallBoxes(file, keys) {
577
1140
  try {
578
1141
  const steps = JSON.parse(readFileSync(file, "utf8"));
579
- 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));
580
1146
  }
581
1147
  catch {
582
1148
  return [];
@@ -593,7 +1159,16 @@ function uninstallWizardOptions(hook) {
593
1159
  if (!hook)
594
1160
  return {};
595
1161
  const { uninstall, catalogs } = hook;
596
- 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
+ };
597
1172
  }
598
1173
  function checkUninstall(dir, findings) {
599
1174
  const script = join(dir, "scripts", "postuninst");
@@ -601,20 +1176,31 @@ function checkUninstall(dir, findings) {
601
1176
  return;
602
1177
  const body = readFileSync(script, "utf8");
603
1178
  const wizardDir = join(dir, "WIZARD_UIFILES");
604
- const wizards = existsSync(wizardDir) ? readdirSync(wizardDir).filter(name => name === "uninstall_uifile" || name.startsWith("uninstall_uifile_")).sort() : [];
605
- const boxes = wizards.flatMap(name => uninstallBoxes(join(wizardDir, name), DATA_KEYS).map(box => ({ ...box, name })));
606
- 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);
607
1192
  const deletes = DELETE_COMMAND.test(body);
608
1193
  if (deletes) {
609
1194
  if (!UPGRADE_GUARD.test(body))
610
- findings.push("UNINSTALL_NO_UPGRADE_GUARD scripts/postuninst deletes data without the [ \"$SYNOPKG_PKG_STATUS\" != UPGRADE ] guard, so an upgrade deletes it by mistake");
611
- 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))
612
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");
613
- 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))) {
614
1200
  for (const path of line.match(/\/volume\w*\/(?!@app(data|home|conf|store)\/)[^\s"'$;]*/g) ?? [])
615
1201
  findings.push(`UNINSTALL_UNSAFE_DELETE scripts/postuninst deletes ${path}, which is not inside the package's own @app* directory`);
616
1202
  }
617
- if (!boxes.some(box => box.name === "uninstall_uifile"))
1203
+ if (!boxes.some((box) => box.name === "uninstall_uifile"))
618
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");
619
1205
  }
620
1206
  for (const box of boxes) {