@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,856 +1,12 @@
1
- import { metadataInfoFields } from "../package-metadata.js";
2
- import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { createHash } from "node:crypto";
6
5
  import * as tar from "tar";
7
- import { parseDocument, Scalar } from "yaml";
8
- import { loadContract, collectInstallParameters } from "../contract/load.js";
9
- import { renderDsmWindowConfig, resolvedWebLaunch, trustedAppEntryPath, usesAuthGateway, } from "../contract/web-launch.js";
10
- import { buildGatewayBinary, gatewayConfig, writeGatewayPayload } from "./gateway.js";
11
- import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
12
- import { scanComposePrivilege } from "../compose/privilege.js";
13
- import { validateProject } from "../validate.js";
14
- import { packageIcons, writeDsmWindowIcons, writePackageCenterIcons } from "./dsm-icons.js";
15
- import { contractIconPath } from "../contract/icon.js";
16
- import { infoArchValue, isContainerArchFamily } from "./dsm-arch.js";
17
- import { assertContainerMinDsm, resolveMinDsm } from "./dsm-version.js";
18
- import { createReproducibleTar } from "./reproducible-archive.js";
19
- import { copyProjectNotices } from "./notices.js";
20
- import { renderDeleteAppDataBlock } from "./delete-app-data.js";
21
- import { renderUninstallWizardFiles } from "./uninstall-wizard.js";
22
- import { APP_SCRIPTS_DIR, APP_SCRIPTS_SPK_DIR, composeAppScript, readAppScripts } from "../standalone/app-scripts.js";
23
- import { renderInstallWizardFiles } from "./install-wizard.js";
24
- import { renderWizardFiles, writeWizardUiFiles } from "./wizard-i18n.js";
25
- import { sharedFolderCombobox } from "./share-combobox.js";
26
- import { writeUiTexts } from "./ui-texts.js";
27
- import { DSM_LANGS, LOCALES } from "../standalone/i18n.js";
28
- import { contractAppTitle } from "../contract/i18n.js";
29
- import { renderHeadlessResourceMarker, renderResourceConfig, renderResourceWorkerStartStopStatus, } from "./resource-worker.js";
30
- const FILE_MODE = 0o644;
31
- const SCRIPT_MODE = 0o755;
32
- const INSTALL_UIFILE = "install_uifile";
33
- const UPGRADE_UIFILE = "upgrade_uifile";
34
- const UNINSTALL_UIFILE = "uninstall_uifile";
35
- const HEADLESS_PROBE_VOLUME = "./.synopkgland:/run/synopkgland:ro";
36
- const HEADLESS_PROBE_README = "Reserved bind target for package-user headless status probing.\n";
37
- const SCRIPT_NAMES = [
38
- "preinst",
39
- "postinst",
40
- "preuninst",
41
- "postuninst",
42
- "preupgrade",
43
- "postupgrade",
44
- "start-stop-status",
45
- ];
46
- export async function buildSpk(options) {
47
- if (options.archFamily && !isContainerArchFamily(options.archFamily)) {
48
- return { ok: false, errors: ["CONTAINER_ARCH_UNSUPPORTED"] };
49
- }
50
- const validation = validateProject(options.project);
51
- if (!validation.ok) {
52
- return {
53
- ok: false,
54
- errors: validation.errors.map((e) => e.code),
55
- };
56
- }
57
- const parsed = loadContract(options.project.contractPath);
58
- const contract = { ...parsed.contract };
59
- if (options.packageIdentity) {
60
- contract.package = options.packageIdentity;
61
- }
62
- if (options.displayname) {
63
- contract.displayname = options.displayname;
64
- }
65
- if (options.spkVersion) {
66
- contract.version = options.spkVersion;
67
- }
68
- if (options.minDsm !== undefined) {
69
- contract.os_min_ver = resolveMinDsm(options.minDsm);
70
- }
71
- if (options.minDsm !== undefined) {
72
- assertContainerMinDsm(String(contract.os_min_ver));
73
- }
74
- const parsedCompose = loadCompose(options.project.composePath);
75
- const route = parsedCompose?.compose
76
- ? classifyComposeRoute(contract, parsedCompose)
77
- : undefined;
78
- if (route && route.errors.length > 0) {
79
- return { ok: false, errors: route.errors.map((error) => error.code) };
80
- }
81
- if (!route || route.route === "reject") {
82
- return { ok: false, errors: ["COMPOSE_ROUTE_UNSUPPORTED"] };
83
- }
84
- const rwEligibility = route.eligibility;
85
- const gateway = rwEligibility?.adminport !== undefined && usesAuthGateway(contract)
86
- ? gatewayConfig(contract, rwEligibility.adminport)
87
- : undefined;
88
- if (gateway && !options.archFamily) {
89
- return { ok: false, errors: ["GATEWAY_ARCH_FAMILY_REQUIRED"] };
90
- }
91
- const isDynamicRoute = route?.route === "dynamic";
92
- const isResourceWorkerRoute = route?.route === "static-zero-config" ||
93
- isDynamicRoute;
94
- const icons = packageIcons(contractIconPath(contract, options.project.root));
95
- const staging = mkdtempSync(join(tmpdir(), "syno-spk-"));
96
- const gatewayWork = mkdtempSync(join(tmpdir(), "syno-gateway-"));
97
- try {
98
- let gatewayBinary;
99
- if (gateway) {
100
- const built = await buildGatewayBinary({
101
- packageId: String(contract.package),
102
- arch: options.archFamily,
103
- config: gateway,
104
- work: gatewayWork,
105
- run: options.gatewayRun,
106
- });
107
- if ("error" in built) {
108
- console.error(built.detail);
109
- return { ok: false, errors: [built.error] };
110
- }
111
- gatewayBinary = built.binary;
112
- }
113
- writeFileSync(join(staging, "INFO"), renderInfo(contract, options.archFamily, isResourceWorkerRoute), "utf8");
114
- writePackageCenterIcons(staging, icons);
115
- mkdirSync(join(staging, "conf"), { recursive: true });
116
- writeFileSync(join(staging, "conf", "privilege"), renderPrivilege(), "utf8");
117
- writeFileSync(join(staging, "privilege"), renderPrivilege(), "utf8");
118
- if (isResourceWorkerRoute) {
119
- const resourceConfig = renderResourceConfig(contract, rwEligibility, parsedCompose.compose);
120
- writeFileSync(join(staging, "conf", "resource"), resourceConfig, "utf8");
121
- chmodSync(join(staging, "conf", "resource"), FILE_MODE);
122
- }
123
- writeWizards(staging, contract, parsedCompose?.compose, "container");
124
- mkdirSync(join(staging, "scripts"), { recursive: true });
125
- // validateProject already refused a bad scripts/ entry.
126
- const appScripts = readAppScripts(options.project.root, () => { }) ?? [];
127
- for (const name of SCRIPT_NAMES) {
128
- const scriptPath = join(staging, "scripts", name);
129
- const script = renderManagedScript(name, contract, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute, rwEligibility?.headless === true);
130
- writeFileSync(scriptPath, appScripts.includes(name) ? composeAppScript(name, script) : script, "utf8");
131
- chmodSync(scriptPath, SCRIPT_MODE);
132
- }
133
- for (const name of appScripts) {
134
- mkdirSync(join(staging, APP_SCRIPTS_SPK_DIR), { recursive: true });
135
- copyFileSync(join(options.project.root, APP_SCRIPTS_DIR, name), join(staging, APP_SCRIPTS_SPK_DIR, name));
136
- chmodSync(join(staging, APP_SCRIPTS_SPK_DIR, name), SCRIPT_MODE);
137
- }
138
- mkdirSync(join(staging, "package"), { recursive: true });
139
- copyFileSync(options.project.composePath, join(staging, "package", "compose.yaml"));
140
- copyFileSync(options.project.contractPath, join(staging, "package", "synology-app.yaml"));
141
- if (isResourceWorkerRoute) {
142
- const resourceProjectDir = join(staging, "package", "compose");
143
- mkdirSync(resourceProjectDir, { recursive: true });
144
- const dsmUserParam = collectInstallParameters(contract).find((p) => p.param.type === "dsm_user");
145
- let composeContent;
146
- if (dsmUserParam || rwEligibility?.headless === true) {
147
- const rawYaml = readFileSync(options.project.composePath, "utf8");
148
- const doc = parseDocument(rawYaml);
149
- const services = doc.get("services");
150
- if (dsmUserParam && services && typeof services === "object" && typeof services.toJSON === "function") {
151
- const serviceNames = Object.keys(services.toJSON());
152
- for (const sName of serviceNames) {
153
- const userVal = new Scalar(`\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}`);
154
- userVal.type = Scalar.QUOTE_DOUBLE;
155
- doc.setIn(["services", sName, "user"], userVal);
156
- }
157
- }
158
- if (rwEligibility?.headless === true) {
159
- injectHeadlessProbeVolume(doc);
160
- }
161
- composeContent = doc.toString();
162
- if (dsmUserParam) {
163
- const escaped = `\\$\\{${dsmUserParam.name}_UID\\}:\\$\\{${dsmUserParam.name}_GID\\}`;
164
- composeContent = composeContent.replace(new RegExp(`^([ \\t]*)user:\\s*"${escaped}"`, "gm"), `$1user: "\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}"\n$1# synopkgland:group_add ${dsmUserParam.name}`);
165
- }
166
- }
167
- else {
168
- composeContent = readFileSync(options.project.composePath, "utf8");
169
- }
170
- composeContent = injectPosixAllSharesMarkers(composeContent, contract);
171
- writeFileSync(join(resourceProjectDir, "docker-compose.yml"), composeContent, "utf8");
172
- chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
173
- if (rwEligibility?.headless === true) {
174
- const probePayloadDir = join(resourceProjectDir, ".synopkgland");
175
- mkdirSync(probePayloadDir, { recursive: true });
176
- writeFileSync(join(probePayloadDir, "README"), HEADLESS_PROBE_README, "utf8");
177
- chmodSync(join(probePayloadDir, "README"), FILE_MODE);
178
- }
179
- }
180
- const htmlDir = join(options.project.root, "html");
181
- if (existsSync(htmlDir)) {
182
- cpSync(htmlDir, join(staging, "package", "html"), { recursive: true });
183
- if (isResourceWorkerRoute) {
184
- cpSync(htmlDir, join(staging, "package", "compose", "html"), { recursive: true });
185
- }
186
- }
187
- if (contract.adminport !== undefined && resolvedWebLaunch(contract) === "dsm-window") {
188
- const uiDir = join(staging, "package", "ui");
189
- mkdirSync(uiDir, { recursive: true });
190
- writeFileSync(join(uiDir, "config"), renderDsmWindowConfig(contract, icons), "utf8");
191
- writeDsmWindowIcons(uiDir, icons);
192
- writeUiTexts(uiDir, contractAppTitle(contract));
193
- }
194
- if (gateway && gatewayBinary) {
195
- writeGatewayPayload(join(staging, "package"), gatewayBinary, gateway);
196
- }
197
- chmodSync(join(staging, "INFO"), FILE_MODE);
198
- chmodSync(join(staging, "privilege"), FILE_MODE);
199
- chmodSync(join(staging, "package", "compose.yaml"), FILE_MODE);
200
- chmodSync(join(staging, "package", "synology-app.yaml"), FILE_MODE);
201
- if (contract.adminport !== undefined && resolvedWebLaunch(contract) === "dsm-window") {
202
- chmodSync(join(staging, "package", "ui", "config"), FILE_MODE);
203
- }
204
- const outputPath = options.outputPath ?? join(options.project.root, `${String(contract.package)}.spk`);
205
- copyProjectNotices(options.project.root, join(staging, 'package'));
206
- await createDeterministicSpk(staging, outputPath);
207
- return { ok: true, outputPath, errors: [] };
208
- }
209
- finally {
210
- rmSync(staging, { recursive: true, force: true });
211
- rmSync(gatewayWork, { recursive: true, force: true });
212
- }
213
- }
214
- function quoteInfo(value) {
215
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
216
- }
217
- function spkVersion(version) {
218
- return /-\d+$/.test(version) ? version : `${version}-0001`;
219
- }
220
- function renderInfo(contract, archFamily, isResourceWorker = false) {
221
- const lines = [];
222
- const add = (key, value) => {
223
- lines.push(`${key}=${quoteInfo(String(value))}`);
224
- };
225
- add("package", String(contract.package));
226
- add("version", spkVersion(String(contract.version)));
227
- add("displayname", String(contract.displayname));
228
- add("description", String(contract.description));
229
- add("maintainer", String(contract.maintainer));
230
- const info = contractAppTitle(contract).info;
231
- for (const locale of LOCALES) {
232
- const text = info?.[locale];
233
- if (text?.displayname)
234
- add(`displayname_${DSM_LANGS[locale]}`, text.displayname);
235
- if (text?.description)
236
- add(`description_${DSM_LANGS[locale]}`, text.description);
237
- }
238
- for (const [key, value] of metadataInfoFields(contract.metadata))
239
- add(key, value);
240
- add("arch", archFamily === undefined ? "noarch" : infoArchValue(archFamily));
241
- add("os_min_ver", String(contract.os_min_ver));
242
- const viaGateway = isResourceWorker && usesAuthGateway(contract);
243
- if (isResourceWorker) {
244
- // DSM reports Container Manager as "not ready" through the package
245
- // dependency gate even while pkgctl-ContainerManager is running. The
246
- // docker-project worker itself remains the authoritative live prerequisite
247
- // (ContainerManager >= 1432); keep only the reliable Web Station package
248
- // dependency here so a valid resource-worker SPK can reach acquisition.
249
- // A gateway package has no Web Station portal, so it does not depend on it.
250
- if (contract.adminport !== undefined && !viaGateway) {
251
- add("install_dep_packages", "WebStation");
252
- }
253
- }
254
- add("startable", "yes");
255
- const launch = resolvedWebLaunch(contract);
256
- if (contract.adminprotocol && !viaGateway) {
257
- add("adminprotocol", String(contract.adminprotocol));
258
- }
259
- if (launch === "new-page" || launch === "dsm-window") {
260
- // Package Center Open uses INFO.adminurl as a same-host path. Do not emit
261
- // adminport — that would open the Compose published port (ADR-0053/0062).
262
- add("adminurl", trustedAppEntryPath(contract));
263
- }
264
- else {
265
- if (contract.adminport !== undefined) {
266
- add("adminport", Number(contract.adminport));
267
- // DSM checkport=yes treats INFO.adminport as exclusive. Compose also binds
268
- // that host port, so a matching adminport yields synopkg 283 — including
269
- // when the same App's container already holds the port. ADR-0053: the
270
- // published port is the Web Station upstream, not Package Center Open.
271
- add("checkport", "no");
272
- }
273
- if (contract.adminurl) {
274
- add("adminurl", String(contract.adminurl));
275
- }
276
- }
277
- if (launch === "dsm-window") {
278
- add("dsmuidir", "ui");
279
- add("dsmappname", String(contract.package));
280
- }
281
- else if (viaGateway) {
282
- // The gateway CGI is served from ui/ even when the App opens in a new page.
283
- add("dsmuidir", "ui");
284
- }
285
- return `${lines.join("\n")}\n`;
286
- }
287
- function renderPrivilege() {
288
- return `${JSON.stringify({
289
- defaults: {
290
- "run-as": "package",
291
- },
292
- }, null, 2)}\n`;
293
- }
294
- function volumeSources(compose) {
295
- return volumeMounts(compose).map((mount) => mount.source);
296
- }
297
- function volumeMounts(compose) {
298
- const mounts = [];
299
- for (const service of Object.values(compose.services ?? {})) {
300
- const volumes = service.volumes;
301
- if (!volumes) {
302
- continue;
303
- }
304
- const entries = Array.isArray(volumes) ? volumes : [volumes];
305
- for (const entry of entries) {
306
- if (typeof entry === "string") {
307
- const parts = entry.split(":");
308
- const source = parts[0]?.trim() ?? "";
309
- if (source) {
310
- const modes = parts
311
- .slice(2)
312
- .flatMap((part) => part.split(","))
313
- .map((part) => part.trim());
314
- mounts.push({ source, readOnly: modes.includes("ro") });
315
- }
316
- }
317
- else if (entry && typeof entry === "object" && "source" in entry) {
318
- const record = entry;
319
- const source = String(record.source ?? "").trim();
320
- if (source) {
321
- mounts.push({ source, readOnly: record.read_only === true });
322
- }
323
- }
324
- }
325
- }
326
- return mounts;
327
- }
328
- // A Compose that mounts nothing reaches even less of the NAS than one that
329
- // mounts only PKGVAR, so it earns the same consent line. Returning false for
330
- // zero sources left the wizard step with no items at all, and DSM's Package
331
- // Center rejects that SPK with "Invalid file format".
332
- function composeUsesOnlyAppSpace(compose) {
333
- const sources = volumeSources(compose);
334
- return sources.every((source) => source.startsWith("${SYNOPKG_PKGVAR}") || source.startsWith("${SYNO_APP_SECRETS_DIR}"));
335
- }
336
- function injectHeadlessProbeVolume(doc) {
337
- const compose = (doc.toJSON() ?? {});
338
- const serviceName = Object.keys(compose.services ?? {})[0];
339
- if (!serviceName) {
340
- return;
341
- }
342
- const existingVolumes = compose.services?.[serviceName]?.volumes;
343
- const volumes = existingVolumes == null
344
- ? []
345
- : Array.isArray(existingVolumes)
346
- ? [...existingVolumes]
347
- : [existingVolumes];
348
- if (!volumes.some((volume) => volume === HEADLESS_PROBE_VOLUME)) {
349
- volumes.push(HEADLESS_PROBE_VOLUME);
350
- }
351
- doc.setIn(["services", serviceName, "volumes"], volumes);
352
- }
353
- function injectPosixAllSharesMarkers(composeContent, contract) {
354
- const names = collectInstallParameters(contract)
355
- .filter(({ param }) => param.type === "posix_all_shares")
356
- .map(({ name }) => name);
357
- if (names.length === 0) {
358
- return composeContent;
359
- }
360
- const declared = new Set(names);
361
- const lines = composeContent.split("\n");
362
- const output = [];
363
- for (const line of lines) {
364
- const match = line.match(/^(\s*)-\s*["']?\$\{([A-Za-z_][A-Za-z0-9_]*)\}:/);
365
- const name = match?.[2];
366
- if (match && name && declared.has(name)) {
367
- const marker = `# synopkgland:posix_all_shares ${name}`;
368
- if (output.at(-1)?.trim() !== marker) {
369
- output.push(`${match[1]}${marker}`);
370
- }
371
- }
372
- output.push(line);
373
- }
374
- return output.join("\n");
375
- }
376
- function renderInstallWizard(t, contract, compose) {
377
- const items = [];
378
- const params = collectInstallParameters(contract);
379
- const hasSharedFolder = params.some((item) => item.param.type === "shared_folder");
380
- const hasHomes = params.some((item) => item.param.type === "homes");
381
- const hasDsmUser = params.some((item) => item.param.type === "dsm_user");
382
- if (compose && scanComposePrivilege(compose).length > 0) {
383
- items.push({
384
- desc: t("consent.privilege"),
385
- });
386
- }
387
- if (hasSharedFolder) {
388
- items.push({
389
- desc: t("consent.sharedFolder"),
390
- });
391
- }
392
- if (hasHomes) {
393
- items.push({
394
- desc: t("consent.homes"),
395
- });
396
- }
397
- if (hasDsmUser) {
398
- items.push({
399
- desc: t("consent.dsmUser"),
400
- });
401
- }
402
- if (!hasSharedFolder && (!compose || composeUsesOnlyAppSpace(compose))) {
403
- items.push({
404
- desc: t("consent.appOwnSpace"),
405
- });
406
- }
407
- for (const { name, param } of params) {
408
- if (param.type === "secret" && param.generate === true) {
409
- continue;
410
- }
411
- if (param.type === "string") {
412
- items.push({
413
- type: "textfield",
414
- desc: t("param.install", { name }),
415
- subitems: [{
416
- key: name,
417
- desc: name,
418
- defaultValue: param.default ?? "",
419
- validator: { allowBlank: false },
420
- }],
421
- });
422
- continue;
423
- }
424
- if (param.type === "boolean") {
425
- items.push({
426
- type: "multiselect",
427
- desc: t("param.install", { name }),
428
- subitems: [{ key: name, desc: name, defaultValue: param.default ?? false }],
429
- });
430
- continue;
431
- }
432
- if (param.type === "secret") {
433
- items.push({
434
- type: "password",
435
- desc: t("param.install", { name }),
436
- subitems: [{ key: name, desc: name, validator: { allowBlank: false } }],
437
- });
438
- continue;
439
- }
440
- if (param.type === "shared_folder") {
441
- items.push(sharedFolderCombobox(t, name));
442
- continue;
443
- }
444
- if (param.type === "posix_all_shares") {
445
- // A47 (install side): DSM's declarative wizard only ever renders a
446
- // single-select combo here, so the former multiSelect control silently
447
- // mounted exactly one Share instead of every Share. Package Center can
448
- // therefore never carry the full Share list, and there is no honest
449
- // control to render -- so this branch produces only an informational
450
- // textblock. The full snapshot arrives exclusively via CLI
451
- // extra_values (`synopkgland deploy`), which the preinst materializer
452
- // expands. No subitem key is emitted, so the install uifile contains no
453
- // ALL_SHARES and no CHANGE_ALL_SHARES.
454
- items.push({
455
- type: "textblock",
456
- desc: t("consent.posixAllShares"),
457
- });
458
- continue;
459
- }
460
- if (param.type === "dsm_user") {
461
- items.push({
462
- type: "combobox",
463
- desc: t("param.runAs", { name }),
464
- subitems: [{
465
- key: name,
466
- desc: name,
467
- displayField: "name",
468
- valueField: "name",
469
- editable: false,
470
- forceSelection: true,
471
- autoSelect: false,
472
- mode: "remote",
473
- api_store: {
474
- api: "SYNO.Core.User",
475
- method: "list",
476
- version: 1,
477
- baseParams: { limit: -1, offset: 0 },
478
- root: "users",
479
- idProperty: "name",
480
- fields: ["name"],
481
- },
482
- validator: { allowBlank: false },
483
- }],
484
- });
485
- }
486
- }
487
- return [{
488
- step_title: t("install.step"),
489
- items,
490
- }];
491
- }
492
- function renderUpgradeWizard(t, contract, compose) {
493
- const installWizard = renderInstallWizard(t, contract, compose);
494
- const params = collectInstallParameters(contract);
495
- // Every type whose value must survive an upgrade the administrator did not
496
- // deliberately change. Two mechanisms are needed because DSM renders them
497
- // differently: a control that can be left blank just allows blank, while one
498
- // that always carries a value needs an explicit "change this" gate.
499
- // - dsm_user : blank in some layouts, but measured on DSM 7.4-90069 with
500
- // Shared Folder controls beside it to come up pre-filled with a
501
- // Shared Folder name, so it needs the same explicit gate
502
- // - string : blank once the new version's default is withheld (A44)
503
- // - shared_folder : DSM prefills the first store row
504
- // - boolean : a checkbox has no blank state at all
505
- // posix_all_shares is intentionally absent: it renders as a textblock with
506
- // no subitem, so there is nothing to blank or gate on the upgrade wizard.
507
- // Its value can only arrive via CLI extra_values, and the preinst
508
- // change-gate shell (`upgrade_change_enabled`, `CHANGE_<name>`) still
509
- // governs that path, reusing the canonical snapshot on an untouched upgrade.
510
- const upgradeReuseTypes = new Map(params
511
- .filter(({ param }) => param.type === "shared_folder" ||
512
- param.type === "dsm_user" ||
513
- param.type === "string" ||
514
- param.type === "boolean")
515
- .map(({ name, param }) => [name, param.type]));
516
- const needsChangeGate = (type) => type === "shared_folder" || type === "dsm_user" || type === "boolean";
517
- const upgradeItems = installWizard[0].items.flatMap((item) => {
518
- if (!Array.isArray(item.subitems)) {
519
- return [item];
520
- }
521
- const reuseKeys = [];
522
- const subitems = item.subitems.map((subitem) => {
523
- if (!subitem || typeof subitem !== "object") {
524
- return subitem;
525
- }
526
- const record = subitem;
527
- if (typeof record.key !== "string" || !upgradeReuseTypes.has(record.key)) {
528
- return subitem;
529
- }
530
- reuseKeys.push(record.key);
531
- const reused = {
532
- ...record,
533
- validator: { allowBlank: true },
534
- };
535
- // Withholding the default is what makes an untouched upgrade blank, and
536
- // blank is what lets preinst fall through to the canonical .env value.
537
- // Install keeps the default -- only this upgrade copy drops it.
538
- delete reused.defaultValue;
539
- return reused;
540
- });
541
- if (reuseKeys.length === 0) {
542
- return [item];
543
- }
544
- const control = {
545
- ...item,
546
- desc: t("upgrade.reuseDesc", { desc: String(item.desc ?? "") }),
547
- subitems,
548
- };
549
- const changeControls = reuseKeys
550
- .filter((key) => needsChangeGate(upgradeReuseTypes.get(key)))
551
- .map((key) => {
552
- const type = upgradeReuseTypes.get(key);
553
- const label = type === "shared_folder"
554
- ? t("label.sharedFolder")
555
- : type === "dsm_user"
556
- ? t("label.runAs")
557
- : key;
558
- return {
559
- type: "multiselect",
560
- desc: t("upgrade.changeDesc", { label }),
561
- subitems: [{
562
- key: `CHANGE_${key}`,
563
- desc: t("upgrade.changeSub", { label }),
564
- defaultValue: false,
565
- }],
566
- };
567
- });
568
- return [...changeControls, control];
569
- });
570
- const sharedFolderNames = params
571
- .filter((item) => item.param.type === "shared_folder")
572
- .map((item) => item.name);
573
- const hasSharedFolder = sharedFolderNames.length > 0;
574
- const hasDsmUser = params.some((item) => item.param.type === "dsm_user");
575
- const hasReadWriteSharedFolder = hasSharedFolder &&
576
- compose !== undefined &&
577
- volumeMounts(compose).some((mount) => !mount.readOnly &&
578
- sharedFolderNames.some((name) => mount.source === `\${${name}}` || mount.source.startsWith(`\${${name}}/`)));
579
- const upgradeConsent = [];
580
- const posixAllSharesNames = params
581
- .filter((item) => item.param.type === "posix_all_shares")
582
- .map((item) => item.name);
583
- if (posixAllSharesNames.length > 0) {
584
- upgradeConsent.push({
585
- desc: t("upgrade.posixNotice"),
586
- });
587
- }
588
- if (hasReadWriteSharedFolder) {
589
- upgradeConsent.push({
590
- desc: t("upgrade.sharedFolderRw"),
591
- });
592
- }
593
- if (hasDsmUser && hasSharedFolder) {
594
- upgradeConsent.push({
595
- desc: t("upgrade.dsmUser"),
596
- });
597
- }
598
- if (upgradeConsent.length === 0) {
599
- return [{
600
- ...installWizard[0],
601
- step_title: t("upgrade.step"),
602
- items: upgradeItems,
603
- }];
604
- }
605
- const firstControl = upgradeItems.findIndex((item) => item.subitems !== undefined);
606
- const insertAt = firstControl === -1 ? upgradeItems.length : firstControl;
607
- const items = [...upgradeItems];
608
- items.splice(insertAt, 0, ...upgradeConsent);
609
- return [{
610
- ...installWizard[0],
611
- step_title: t("upgrade.step"),
612
- items,
613
- }];
614
- }
615
- function writeWizards(staging, contract, compose, kind) {
616
- const install = renderInstallWizardFiles(String(contract.package), (t) => renderInstallWizard(t, contract, compose));
617
- writeWizardUiFiles(staging, {
618
- ...install.static,
619
- ...install.dynamic,
620
- ...renderWizardFiles(UPGRADE_UIFILE, (t) => renderUpgradeWizard(t, contract, compose)),
621
- ...renderUninstallWizardFiles(String(contract.package), kind),
622
- });
623
- }
624
- function renderDynamicPreinstMaterializer(contract) {
625
- const allParams = collectInstallParameters(contract);
626
- const scalarParams = allParams.filter(({ param }) => param.type === "string" || param.type === "boolean");
627
- const sharedFolderParams = allParams.filter(({ param }) => param.type === "shared_folder");
628
- const posixAllSharesParams = allParams.filter(({ param }) => param.type === "posix_all_shares");
629
- const dsmUserParam = allParams.find(({ param }) => param.type === "dsm_user");
630
- const lines = [
631
- "#!/bin/sh",
632
- "set -eu",
633
- "umask 077",
634
- "# D3/D5.4 package-only carrier; values are wizard answers, canonical values, or defaults.",
635
- "die() { echo \"dynamic route materialization failed: $1\" >&2; exit 1; }",
636
- 'T="${SYNOPKG_PKGINST_TEMP_DIR:?}"',
637
- 'STATUS="${SYNOPKG_PKG_STATUS:?}"',
638
- 'PROJECT="$T/compose"',
639
- 'RUNTIME_ENV="$PROJECT/.env"',
640
- 'CANONICAL_ENV="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}/.env"',
641
- 'PLATFORM_PKGVAR="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}"',
642
- 'PLATFORM_SECRETS="${SYNOPKG_PKGDEST_VOL:?}/@apphome/${SYNOPKG_PKGNAME:?}/secrets"',
643
- '[ -d "$PROJECT" ] || die "compose directory is missing"',
644
- '[ ! -L "$PROJECT" ] || die "compose directory is a symlink"',
645
- '[ "$(stat -c %u "$PROJECT")" = "$(id -u)" ] || die "compose directory owner is not the package user"',
646
- '[ ! -L "$RUNTIME_ENV" ] || die "runtime file is a symlink"',
647
- 'if [ -e "$RUNTIME_ENV" ] && [ ! -f "$RUNTIME_ENV" ]; then die "runtime path is not a regular file"; fi',
648
- "read_env_value() {",
649
- " file=\"$1\"",
650
- " key=\"$2\"",
651
- " awk -F= -v key=\"$key\" '$1 == key { value=substr($0, index($0,\"=\")+1); if (value ~ /^\".*\"$/) value=substr(value,2,length(value)-2); print value; found=1; exit } END { if (!found) exit 1 }' \"$file\"",
652
- "}",
653
- "append_env() {",
654
- " key=\"$1\"",
655
- " value=\"$2\"",
656
- " type=\"${3:-string}\"",
657
- " if printf '%s' \"$value\" | LC_ALL=C grep -q '[[:cntrl:]]'; then",
658
- " die \"control character in environment value $key\"",
659
- " fi",
660
- " case \"$value\" in",
661
- " *[[:cntrl:]]*) die \"control character in environment value $key\" ;;",
662
- " esac",
663
- " if [ \"$type\" = \"boolean\" ]; then",
664
- " normalized=\"$(printf '%s' \"$value\" | LC_ALL=C tr '[:upper:]' '[:lower:]')\"",
665
- " case \"$normalized\" in",
666
- " true|false) value=\"$normalized\" ;;",
667
- " *) die \"invalid boolean value $key\" ;;",
668
- " esac",
669
- " fi",
670
- " case \"$value\" in",
671
- " *[!A-Za-z0-9_./:@-]*)",
672
- " escaped=\"$(printf '%s' \"$value\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\"",
673
- " printf '%s=\"%s\"\\n' \"$key\" \"$escaped\"",
674
- " ;;",
675
- " *) printf '%s=%s\\n' \"$key\" \"$value\" ;;",
676
- " esac",
677
- "}",
678
- ];
679
- // Emitted whenever anything reads a CHANGE_ gate. Boolean parameters (A44) use
680
- // it too, and a package with only booleans has no Share resolvers -- leaving it
681
- // behind that condition made `! upgrade_change_enabled ...` a missing command,
682
- // which is true, so the gate silently always reused the canonical value.
683
- const usesUpgradeChangeGate = sharedFolderParams.length > 0 ||
684
- posixAllSharesParams.length > 0 ||
685
- collectInstallParameters(contract).some(({ param }) => param.type === "boolean" || param.type === "dsm_user");
686
- if (usesUpgradeChangeGate) {
687
- lines.push("upgrade_change_enabled() {", " normalized=\"$(printf '%s' \"$1\" | LC_ALL=C tr '[:upper:]' '[:lower:]')\"", " case \"$normalized\" in", " true|1|yes|on) return 0 ;;", " *) return 1 ;;", " esac", "}");
688
- }
689
- if (sharedFolderParams.length > 0 || posixAllSharesParams.length > 0) {
690
- lines.push("resolve_shared_folder() {", " share_name=\"$1\"", " case \"$share_name\" in", " */*|*..*) die \"invalid share name $share_name\" ;;", " '') die \"share name is empty\" ;;", " esac", " mounts_file=\"${D54_MOUNTS_FILE:-/proc/mounts}\"", " if [ ! -r \"$mounts_file\" ] && [ -r \"/etc/mtab\" ]; then", " mounts_file=\"/etc/mtab\"", " fi", " if [ ! -r \"$mounts_file\" ]; then", " die \"cannot read mount points from $mounts_file\"", " fi", " vols=\"$(awk '$2 ~ /^\\/volume[0-9]+$/ {print $2}' \"$mounts_file\" | sort -u)\"", ' if [ -z "$vols" ]; then', " vols=\"$(awk '{print $2}' \"$mounts_file\" | sort -u)\"", " fi", " matched=\"\"", " count=0", " for v in $vols; do", " if [ -d \"$v/$share_name\" ]; then", " matched=\"$v/$share_name\"", " count=$((count + 1))", " fi", " done", " if [ \"$count\" -eq 0 ]; then", " die \"share $share_name not found on any volume\"", " elif [ \"$count\" -gt 1 ]; then", " die \"share $share_name matched multiple volumes\"", " fi", " printf '%s' \"$matched\"", "}");
691
- }
692
- if (posixAllSharesParams.length > 0) {
693
- lines.push("normalize_posix_all_shares() {", " value=\"$1\"", " case \"$value\" in", " \\[*\\]) value=\"$(printf '%s' \"$value\" | sed 's/^[[:space:]]*\\[//; s/\\][[:space:]]*$//; s/\"//g')\" ;;", " esac", " printf '%s' \"$value\" | awk -F, '{ for (i = 1; i <= NF; i++) { gsub(/^[ \\t]+|[ \\t]+$/, \"\", $i); if ($i != \"\") print $i } }'", "}", "resolve_posix_all_shares() {", " raw_list=\"$1\"", " normalized=\"$(normalize_posix_all_shares \"$raw_list\")\"", " [ -n \"$normalized\" ] || die \"POSIX_ALL_SHARES_EMPTY\"", " names=\"\"", " paths=\"\"", " count=0", " while IFS= read -r share_name; do", " case \"$share_name\" in", " */*|*..*|@*|'') die \"invalid Share name $share_name\" ;;", " *[[:cntrl:]]*) die \"invalid Share name $share_name\" ;;", " esac", " share_path=\"$(resolve_shared_folder \"$share_name\")\"", " names=\"${names}${names:+,}${share_name}\"", " paths=\"${paths}${paths:+,}${share_path}\"", " count=$((count + 1))", " done <<EOF", "$normalized", "EOF", " [ \"$count\" -gt 0 ] || die \"POSIX_ALL_SHARES_EMPTY\"", " printf '%s\\n%s\\n%s' \"$names\" \"$paths\" \"$count\"", "}", "rewrite_posix_all_shares() {", " p_name=\"$1\"", " share_names=\"$2\"", " share_paths=\"$3\"", " compose_file=\"$4\"", " [ -f \"$compose_file\" ] || die \"compose file missing at $compose_file\"", " [ ! -L \"$compose_file\" ] || die \"compose file is a symlink\"", " tmp_compose=\"$(mktemp \"${compose_file}.tmp.XXXXXX\")\"", " awk -v param=\"$p_name\" -v share_names=\"$share_names\" -v share_paths=\"$share_paths\" '", " BEGIN {", " name_count = split(share_names, names, \",\")", " path_count = split(share_paths, paths, \",\")", " marker = \"# synopkgland:posix_all_shares \" param", " pending = 0", " single_quote = sprintf(\"%c\", 39)", " }", " {", " if (index($0, marker) > 0 && $0 ~ /^[ \\t]*# synopkgland:posix_all_shares /) {", " print $0", " pending = 1", " next", " }", " if (!pending) { print $0; next }", " pending = 0", " line = $0", " indent = line", " sub(/[^ \\t].*$/, \"\", indent)", " entry = line", " sub(/^[ \\t]*-[ \\t]*/, \"\", entry)", " if (entry ~ /^\".*\"$/) entry = substr(entry, 2, length(entry) - 2)", " if (length(entry) >= 2 && substr(entry, 1, 1) == single_quote && substr(entry, length(entry), 1) == single_quote) entry = substr(entry, 2, length(entry) - 2)", " split(entry, fields, \":\")", " if (fields[1] != \"${\" param \"}\" || fields[2] == \"\") { print $0; next }", " mode = \"\"", " if (length(fields) >= 3) mode = \":\" fields[3]", " for (i = 1; i <= name_count && i <= path_count; i++) {", " print indent \"- \" paths[i] \":\" fields[2] \"/\" names[i] mode", " }", " }", " ' \"$compose_file\" > \"$tmp_compose\" || die \"failed to expand POSIX all-shares in $compose_file\"", " mv \"$tmp_compose\" \"$compose_file\" || die \"failed to replace $compose_file\"", "}");
694
- }
695
- if (dsmUserParam) {
696
- lines.push("resolve_dsm_user() {", " user_name=\"$1\"", " case \"$user_name\" in", " */*|*..*|*[[:cntrl:]]*) die \"invalid user name $user_name\" ;;", " '') die \"user name is empty\" ;;", " esac", " u_uid=\"$(id -u -- \"$user_name\" 2>/dev/null || true)\"", " case \"$u_uid\" in", " ''|*[!0-9]*) die \"resolved uid for $user_name is not a valid integer\" ;;", " 0) die \"resolved uid for $user_name cannot be 0 (root)\" ;;", " esac", " u_gid=\"$(id -g -- \"$user_name\" 2>/dev/null || true)\"", " case \"$u_gid\" in", " ''|*[!0-9]*) die \"resolved gid for $user_name is not a valid integer\" ;;", " esac", " # id -G returns all group ids including primary", " raw_groups=\"$(id -G -- \"$user_name\" 2>/dev/null || true)\"", " # filter out primary gid, deduplicate, keep numeric only", " supp_groups=\"\"", " for g in $raw_groups; do", " case \"$g\" in", " ''|*[!0-9]*) continue ;;", " esac", " if [ \"$g\" != \"$u_gid\" ]; then", " already=0", " for sg in $supp_groups; do", " if [ \"$sg\" = \"$g\" ]; then", " already=1", " break", " fi", " done", " if [ \"$already\" -eq 0 ]; then", " supp_groups=\"${supp_groups}${supp_groups:+ }$g\"", " fi", " fi", " done", " printf '%s %s %s' \"$u_uid\" \"$u_gid\" \"$supp_groups\"", "}", "rewrite_group_add() {", " p_name=\"$1\"", " g_list=\"$2\"", " compose_file=\"$3\"", " [ -f \"$compose_file\" ] || die \"compose file missing at $compose_file\"", " [ ! -L \"$compose_file\" ] || die \"compose file is a symlink\"", " tmp_compose=\"$(mktemp \"${compose_file}.tmp.XXXXXX\")\"", " awk -v param=\"$p_name\" -v g_list=\"$g_list\" '", " BEGIN {", " n = split(g_list, g_arr, \" \")", " }", " {", " regex = \"^[ \\t]*# synopkgland:group_add \" param \"$\"", " if ($0 ~ regex) {", " match($0, /^[ \\t]*/)", " indent = substr($0, RSTART, RLENGTH)", " if (n > 0) {", " print indent \"group_add:\"", " for (i = 1; i <= n; i++) {", " print indent \" - ${\" param \"_GROUP_\" i \"}\"", " }", " }", " } else {", " print $0", " }", " }", " ' \"$compose_file\" > \"$tmp_compose\" || die \"failed to rewrite group_add in $compose_file\"", " mv \"$tmp_compose\" \"$compose_file\" || die \"failed to replace $compose_file\"", "}");
697
- }
698
- lines.push('tmp="$(mktemp "$PROJECT/.env.tmp.XXXXXX")"', 'chmod 600 "$tmp"', '[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"', '[ "$(stat -c %u "$tmp")" = "$(id -u)" ] || die "temporary env owner is not the package user"', "trap 'rm -f \"$tmp\"' EXIT HUP INT TERM", "{");
699
- const valueVars = [];
700
- for (const { name, param } of scalarParams) {
701
- const type = param.type;
702
- const valueVar = "D3_VALUE_" + name;
703
- valueVars.push({ name, valueVar, type });
704
- const fallback = param.default !== undefined
705
- ? String(param.default)
706
- : type === "boolean"
707
- ? "false"
708
- : "";
709
- // A44: on UPGRADE the wizard always submits something for these controls, so
710
- // "was it submitted" cannot mean "did the administrator choose it". A blank
711
- // string, or a boolean whose CHANGE_ gate is unchecked, means "keep what is
712
- // installed" -- otherwise the new version's default would silently replace a
713
- // value chosen at install time. INSTALL is unaffected: STATUS is not UPGRADE,
714
- // so the reuse branch is skipped and the default still applies.
715
- const reuseGuard = type === "boolean"
716
- ? `! upgrade_change_enabled "\${CHANGE_${name}:-}"`
717
- : `[ -z "\${${name}:-}" ]`;
718
- lines.push(` if [ "$STATUS" = "UPGRADE" ] && ${reuseGuard} && [ -f "$CANONICAL_ENV" ] && ` +
719
- `[ ! -L "$CANONICAL_ENV" ] && ${valueVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", ` elif [ "\${${name}+x}" = x ]; then`, ` ${valueVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
720
- `${valueVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", " " + valueVar + "=" + shellQuote(fallback), " fi");
721
- }
722
- const shareVars = [];
723
- for (const { name } of sharedFolderParams) {
724
- const rawVar = "D54_RAW_SHARE_" + name;
725
- const pathVar = "D54_RESOLVED_PATH_" + name;
726
- const changeVar = "CHANGE_" + name;
727
- shareVars.push({ name, pathVar });
728
- lines.push(` if [ "$STATUS" = "UPGRADE" ] && upgrade_change_enabled "\${${changeVar}:-}"; then`, ` if [ "\${${name}+x}" = x ] && [ -n "\${${name}}" ]; then`, ` ${rawVar}="\${${name}}"`, " else", ` die "missing required shared folder parameter ${name}"`, " fi", ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
729
- `${rawVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", ' elif [ "$STATUS" = "UPGRADE" ]; then', ` die "missing canonical shared folder parameter ${name}"`, ` elif [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, " else", ` die "missing required shared folder parameter ${name}"`, " fi", ` case "$${rawVar}" in`, " /*) " + pathVar + "=\"$" + rawVar + "\" ;;", " *) " + pathVar + "=\"$(resolve_shared_folder \"$" + rawVar + "\")\" ;;", " esac");
730
- }
731
- const posixVars = [];
732
- for (const { name } of posixAllSharesParams) {
733
- const rawVar = "D54_RAW_POSIX_" + name;
734
- const namesVar = "D54_POSIX_NAMES_" + name;
735
- const pathsVar = "D54_POSIX_PATHS_" + name;
736
- const countVar = "D54_POSIX_COUNT_" + name;
737
- const changeVar = "CHANGE_" + name;
738
- posixVars.push({ name, namesVar, pathsVar, countVar });
739
- lines.push(` if [ "$STATUS" = "UPGRADE" ] && upgrade_change_enabled "\${${changeVar}:-}"; then`, ` if [ "\${${name}+x}" = x ] && [ -n "\${${name}}" ]; then`, ` ${rawVar}="\${${name}}"`, " else", ` die "missing required POSIX all-shares parameter ${name}"`, " fi", ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
740
- `${rawVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", ' elif [ "$STATUS" = "UPGRADE" ]; then', ` die "missing canonical POSIX all-shares parameter ${name}"`, ` elif [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, " else", ` die "missing required POSIX all-shares parameter ${name}"`, " fi", ` posix_info="$(resolve_posix_all_shares "$${rawVar}")"`, ` ${namesVar}="$(printf '%s\\n' "$posix_info" | sed -n '1p')"`, ` ${pathsVar}="$(printf '%s\\n' "$posix_info" | sed -n '2p')"`, ` ${countVar}="$(printf '%s\\n' "$posix_info" | sed -n '3p')"`, ` rewrite_posix_all_shares "${name}" "$${namesVar}" "$${pathsVar}" "$PROJECT/docker-compose.yml"`);
741
- }
742
- if (dsmUserParam) {
743
- const name = dsmUserParam.name;
744
- const rawUserVar = "D54_RAW_USER_" + name;
745
- // Like the Shared Folder gate: on UPGRADE the submitted user only counts when
746
- // CHANGE_<name> is ticked, because the control is not reliably blank.
747
- lines.push(` if [ "$STATUS" = "UPGRADE" ] && upgrade_change_enabled "\${CHANGE_${name}:-}"; then`, ` if [ "\${${name}+x}" = x ] && [ -n "\${${name}}" ]; then`, ` ${rawUserVar}="\${${name}}"`, " else", ` die "missing required dsm user parameter ${name}"`, " fi", ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
748
- `${rawUserVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", ` elif [ "\${${name}+x}" = x ]; then`, ` ${rawUserVar}="\${${name}}"`, " else", ` die "missing required dsm user parameter ${name}"`, " fi", ` resolved_user_info="$(resolve_dsm_user "$${rawUserVar}")"`, " set -- $resolved_user_info", ` ${name}_UID="$1"`, ` ${name}_GID="$2"`, " shift 2 || true", ` ${name}_GROUPS="$*"`, ` rewrite_group_add "${name}" "$${name}_GROUPS" "$PROJECT/docker-compose.yml"`);
749
- }
750
- lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
751
- for (const { name, valueVar, type } of valueVars) {
752
- lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
753
- }
754
- for (const { name, pathVar } of shareVars) {
755
- lines.push(` append_env "${name}" "$${pathVar}" "string"`);
756
- }
757
- for (const { name, namesVar, countVar } of posixVars) {
758
- lines.push(` append_env "${name}" "$${namesVar}" "string"`, ` append_env "${name}_COUNT" "$${countVar}" "string"`);
759
- }
760
- if (dsmUserParam) {
761
- const name = dsmUserParam.name;
762
- const rawUserVar = "D54_RAW_USER_" + name;
763
- lines.push(` append_env "${name}" "$${rawUserVar}" "string"`, ` append_env "${name}_UID" "$${name}_UID" "string"`, ` append_env "${name}_GID" "$${name}_GID" "string"`, ` idx=1`, ` for g in $${name}_GROUPS; do`, ` append_env "${name}_GROUP_\${idx}" "$g" "string"`, ` idx=$((idx + 1))`, ` done`);
764
- }
765
- lines.push(" } > \"$tmp\"", " mv \"$tmp\" \"$RUNTIME_ENV\"", " trap - EXIT HUP INT TERM", " [ ! -L \"$RUNTIME_ENV\" ] || die \"runtime file became a symlink\"", " [ \"$(stat -c %a \"$RUNTIME_ENV\")\" = \"600\" ] || die \"runtime env mode is not 0600\"", "exit 0");
766
- return lines.join("\n") + "\n";
767
- }
768
- function renderDynamicCanonicalMirror(headless = false) {
769
- const lines = [
770
- "#!/bin/sh",
771
- "set -eu",
772
- "umask 077",
773
- "# D3 post-worker mirror; the runtime .env is the only source of truth here.",
774
- "die() { echo \"dynamic route canonical mirror failed: $1\" >&2; exit 1; }",
775
- 'PKGVAR="${SYNOPKG_PKGVAR:?}"',
776
- 'PKGHOME="${SYNOPKG_PKGHOME:?}"',
777
- 'PKGDEST="${SYNOPKG_PKGDEST:?}"',
778
- 'PROJECT="${PKGDEST}/compose"',
779
- 'RUNTIME_ENV="${PROJECT}/.env"',
780
- 'CANONICAL_ENV="${PKGVAR}/.env"',
781
- 'SECRETS="${PKGHOME}/secrets"',
782
- 'mkdir -p "$PKGVAR"',
783
- '[ -d "$PKGVAR" ] || die "PKGVAR is not a directory"',
784
- 'if [ -L "$SECRETS" ]; then die "secret root is a symlink"; fi',
785
- 'mkdir -p "$SECRETS"',
786
- '[ -d "$SECRETS" ] || die "secret root is not a directory"',
787
- 'chmod 700 "$SECRETS"',
788
- '[ "$(stat -c %a "$SECRETS")" = "700" ] || die "secret root mode is not 0700"',
789
- '[ -d "$PROJECT" ] || die "project directory is missing"',
790
- '[ ! -L "$PROJECT" ] || die "project directory is a symlink"',
791
- '[ ! -L "$RUNTIME_ENV" ] || die "runtime file is a symlink"',
792
- '[ -f "$RUNTIME_ENV" ] || die "runtime file is not a regular file"',
793
- '[ ! -L "$CANONICAL_ENV" ] || die "canonical file is a symlink"',
794
- 'if [ -e "$CANONICAL_ENV" ] && [ ! -f "$CANONICAL_ENV" ]; then die "canonical path is not a regular file"; fi',
795
- 'tmp="$(mktemp "$PKGVAR/.env.tmp.XXXXXX")"',
796
- 'chmod 600 "$tmp"',
797
- '[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"',
798
- 'trap \'rm -f "$tmp"\' EXIT HUP INT TERM',
799
- 'cp "$RUNTIME_ENV" "$tmp"',
800
- 'chmod 600 "$tmp"',
801
- 'mv "$tmp" "$CANONICAL_ENV"',
802
- 'trap - EXIT HUP INT TERM',
803
- '[ ! -L "$CANONICAL_ENV" ] || die "canonical file became a symlink"',
804
- '[ "$(stat -c %a "$CANONICAL_ENV")" = "600" ] || die "canonical env mode is not 0600"',
805
- ];
806
- if (headless) {
807
- lines.push('MARKER="${PKGVAR}/.docker-project-managed"', '[ ! -L "$MARKER" ] || die "docker-project marker is a symlink"', 'marker_tmp="$(mktemp "$PKGVAR/.docker-project-managed.tmp.XXXXXX")"', 'printf \'%s\\n\' managed > "$marker_tmp"', 'chmod 600 "$marker_tmp"', 'mv "$marker_tmp" "$MARKER"');
808
- }
809
- lines.push("exit 0");
810
- return lines.join("\n") + "\n";
811
- }
812
- function shellQuote(value) {
813
- return "'" + value.replace(/'/g, "'\\''") + "'";
814
- }
815
- function renderManagedScript(name, contract, isResourceWorker = false, resourceWorkerAdminport, isDynamicRoute = false, isHeadlessRoute = false) {
816
- if (isResourceWorker) {
817
- if (name === "start-stop-status") {
818
- return renderResourceWorkerStartStopStatus(String(contract.package), resourceWorkerAdminport, isHeadlessRoute);
819
- }
820
- if (isDynamicRoute && name === "preinst") {
821
- return renderDynamicPreinstMaterializer(contract);
822
- }
823
- if (isDynamicRoute && (name === "postinst" || name === "postupgrade")) {
824
- return renderDynamicCanonicalMirror(isHeadlessRoute);
825
- }
826
- if (isHeadlessRoute && (name === "postinst" || name === "postupgrade")) {
827
- return renderHeadlessResourceMarker();
828
- }
829
- if (name === "postuninst") {
830
- const projectRoot = isDynamicRoute ? 'PROJECT="${SYNOPKG_PKGDEST:-}/compose"\n' : "";
831
- const deleteRoots = [
832
- { variable: "PKGVAR", area: "appdata" },
833
- { variable: "PKGHOME", area: "apphome" },
834
- ...(isDynamicRoute ? [{ variable: "PROJECT", area: "appstore" }] : []),
835
- ];
836
- return `#!/bin/sh
837
- set -eu
838
- PKGVAR="\${SYNOPKG_PKGVAR:-}"
839
- PKGHOME="\${SYNOPKG_PKGHOME:-}"
840
- ${projectRoot}${renderDeleteAppDataBlock(String(contract.package), deleteRoots)}exit 0
841
- `;
842
- }
843
- return `#!/bin/sh\nexit 0\n`;
844
- }
845
- throw new Error("Unsupported container route");
846
- }
847
- async function createDeterministicSpk(stagingDir, outputPath) {
848
- const payloadDir = join(stagingDir, "package");
849
- if (existsSync(payloadDir) && statSync(payloadDir).isDirectory()) {
850
- await createReproducibleTar(payloadDir, join(stagingDir, "package.tgz"), { gzip: true });
851
- rmSync(payloadDir, { recursive: true, force: true });
852
- }
853
- await createReproducibleTar(stagingDir, outputPath);
6
+ const INSTALL_UIFILE = "install_uifile", UPGRADE_UIFILE = "upgrade_uifile", UNINSTALL_UIFILE = "uninstall_uifile";
7
+ /** Legacy project detection supplies a migration error, never a second builder. */
8
+ export async function buildSpk(_options) {
9
+ return { ok: false, errors: ["COMPOSE_LEGACY_MANIFEST_REMOVED"] };
854
10
  }
855
11
  export function walkFiles(root, prefix = "") {
856
12
  const files = [];