@synopackageland/cli 0.14.1 → 0.15.1

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 (121) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +33 -18
  3. package/dist/build-spk/generator.d.ts +3 -2
  4. package/dist/build-spk/generator.js +6 -846
  5. package/dist/build-spk/resource-worker.d.ts +2 -14
  6. package/dist/build-spk/resource-worker.js +3 -166
  7. package/dist/cli.js +208 -56
  8. package/dist/compose/analyze.d.ts +3 -61
  9. package/dist/compose/analyze.js +35 -776
  10. package/dist/compose/env.d.ts +5 -3
  11. package/dist/compose/env.js +17 -25
  12. package/dist/compose/runner.js +23 -6
  13. package/dist/contract/unsupported-types.js +1 -1
  14. package/dist/contract/validate.js +2 -2
  15. package/dist/contract/web-launch.d.ts +1 -12
  16. package/dist/contract/web-launch.js +10 -74
  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/e2e/dsm.d.ts +2 -2
  46. package/dist/e2e/dsm.js +10 -6
  47. package/dist/error-registry.js +1020 -834
  48. package/dist/host/share-permission-lookup.d.ts +1 -1
  49. package/dist/host/share-permission.d.ts +1 -1
  50. package/dist/host/share-permission.js +1 -1
  51. package/dist/info.js +120 -28
  52. package/dist/init.js +188 -64
  53. package/dist/inspect.js +21 -6
  54. package/dist/runtime/executor-digests.json +5 -5
  55. package/dist/skill.js +51 -9
  56. package/dist/standalone/api-description-inspect.d.ts +4 -0
  57. package/dist/standalone/api-description-inspect.js +12 -0
  58. package/dist/standalone/api-description.d.ts +7 -0
  59. package/dist/standalone/api-description.js +73 -0
  60. package/dist/standalone/build.d.ts +3 -0
  61. package/dist/standalone/build.js +140 -33
  62. package/dist/standalone/compose-errors.d.ts +3 -0
  63. package/dist/standalone/compose-errors.js +243 -0
  64. package/dist/standalone/compose.d.ts +97 -10
  65. package/dist/standalone/compose.js +306 -75
  66. package/dist/standalone/daemon.js +111 -29
  67. package/dist/standalone/dsm-window.js +39 -8
  68. package/dist/standalone/identity-gateway.d.ts +9 -2
  69. package/dist/standalone/identity-gateway.js +282 -66
  70. package/dist/standalone/inspect-spk.d.ts +12 -0
  71. package/dist/standalone/inspect-spk.js +666 -136
  72. package/dist/standalone/lifecycle.js +124 -42
  73. package/dist/standalone/manifest.d.ts +8 -1
  74. package/dist/standalone/manifest.js +303 -86
  75. package/dist/standalone/resource.js +6 -2
  76. package/dist/standalone/spk.d.ts +4 -1
  77. package/dist/standalone/spk.js +138 -39
  78. package/dist/template-paths.d.ts +0 -2
  79. package/dist/template-paths.js +0 -5
  80. package/dist/test-command.d.ts +1 -1
  81. package/dist/test-command.js +1 -1
  82. package/dist/validate.d.ts +3 -3
  83. package/dist/validate.js +32 -140
  84. package/package.json +5 -3
  85. package/schema/synopkgland-api-v1.schema.json +423 -0
  86. package/skills/SKILL.md +4 -2
  87. package/skills/reference/compose.md +349 -0
  88. package/skills/reference/create.md +9 -18
  89. package/skills/reference/ctl.md +41 -0
  90. package/skills/reference/deploy.md +1 -1
  91. package/skills/reference/files.md +5 -1
  92. package/skills/reference/identity-gateway.md +8 -144
  93. package/skills/reference/wrap.md +10 -39
  94. package/assets/go/daemon/fixture_linux_test.go +0 -15
  95. package/assets/go/daemon/fixture_other_test.go +0 -7
  96. package/assets/go/daemon/go.mod +0 -5
  97. package/assets/go/daemon/main.go +0 -636
  98. package/assets/go/daemon/main_test.go +0 -315
  99. package/assets/go/daemon/network.go +0 -107
  100. package/assets/go/daemon/network_test.go +0 -86
  101. package/assets/go/daemon/process_linux.go +0 -55
  102. package/assets/go/daemon/process_linux_test.go +0 -161
  103. package/assets/go/daemon/process_other.go +0 -14
  104. package/assets/go/daemon/testdata/child.go +0 -44
  105. package/assets/go/gateway/auth.go +0 -101
  106. package/assets/go/gateway/auth_test.go +0 -216
  107. package/assets/go/gateway/cgi.go +0 -133
  108. package/assets/go/gateway/cgi_test.go +0 -156
  109. package/assets/go/gateway/csrf.go +0 -73
  110. package/assets/go/gateway/csrf_test.go +0 -155
  111. package/assets/go/gateway/directory.go +0 -108
  112. package/assets/go/gateway/directory_test.go +0 -202
  113. package/assets/go/gateway/gateway.go +0 -412
  114. package/assets/go/gateway/gateway_test.go +0 -676
  115. package/assets/go/gateway/go.mod +0 -5
  116. package/assets/go/gateway/groups.go +0 -66
  117. package/assets/go/gateway/main.go +0 -45
  118. package/dist/build-spk/gateway.d.ts +0 -31
  119. package/dist/build-spk/gateway.js +0 -60
  120. package/dist/compose/privilege.d.ts +0 -14
  121. package/dist/compose/privilege.js +0 -102
@@ -1,852 +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) !== undefined) {
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) !== undefined) {
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" || launch === "new-page") {
278
- add("dsmuidir", "ui");
279
- add("dsmappname", String(contract.package));
280
- }
281
- return `${lines.join("\n")}\n`;
282
- }
283
- function renderPrivilege() {
284
- return `${JSON.stringify({
285
- defaults: {
286
- "run-as": "package",
287
- },
288
- }, null, 2)}\n`;
289
- }
290
- function volumeSources(compose) {
291
- return volumeMounts(compose).map((mount) => mount.source);
292
- }
293
- function volumeMounts(compose) {
294
- const mounts = [];
295
- for (const service of Object.values(compose.services ?? {})) {
296
- const volumes = service.volumes;
297
- if (!volumes) {
298
- continue;
299
- }
300
- const entries = Array.isArray(volumes) ? volumes : [volumes];
301
- for (const entry of entries) {
302
- if (typeof entry === "string") {
303
- const parts = entry.split(":");
304
- const source = parts[0]?.trim() ?? "";
305
- if (source) {
306
- const modes = parts
307
- .slice(2)
308
- .flatMap((part) => part.split(","))
309
- .map((part) => part.trim());
310
- mounts.push({ source, readOnly: modes.includes("ro") });
311
- }
312
- }
313
- else if (entry && typeof entry === "object" && "source" in entry) {
314
- const record = entry;
315
- const source = String(record.source ?? "").trim();
316
- if (source) {
317
- mounts.push({ source, readOnly: record.read_only === true });
318
- }
319
- }
320
- }
321
- }
322
- return mounts;
323
- }
324
- // A Compose that mounts nothing reaches even less of the NAS than one that
325
- // mounts only PKGVAR, so it earns the same consent line. Returning false for
326
- // zero sources left the wizard step with no items at all, and DSM's Package
327
- // Center rejects that SPK with "Invalid file format".
328
- function composeUsesOnlyAppSpace(compose) {
329
- const sources = volumeSources(compose);
330
- return sources.every((source) => source.startsWith("${SYNOPKG_PKGVAR}") || source.startsWith("${SYNO_APP_SECRETS_DIR}"));
331
- }
332
- function injectHeadlessProbeVolume(doc) {
333
- const compose = (doc.toJSON() ?? {});
334
- const serviceName = Object.keys(compose.services ?? {})[0];
335
- if (!serviceName) {
336
- return;
337
- }
338
- const existingVolumes = compose.services?.[serviceName]?.volumes;
339
- const volumes = existingVolumes == null
340
- ? []
341
- : Array.isArray(existingVolumes)
342
- ? [...existingVolumes]
343
- : [existingVolumes];
344
- if (!volumes.some((volume) => volume === HEADLESS_PROBE_VOLUME)) {
345
- volumes.push(HEADLESS_PROBE_VOLUME);
346
- }
347
- doc.setIn(["services", serviceName, "volumes"], volumes);
348
- }
349
- function injectPosixAllSharesMarkers(composeContent, contract) {
350
- const names = collectInstallParameters(contract)
351
- .filter(({ param }) => param.type === "posix_all_shares")
352
- .map(({ name }) => name);
353
- if (names.length === 0) {
354
- return composeContent;
355
- }
356
- const declared = new Set(names);
357
- const lines = composeContent.split("\n");
358
- const output = [];
359
- for (const line of lines) {
360
- const match = line.match(/^(\s*)-\s*["']?\$\{([A-Za-z_][A-Za-z0-9_]*)\}:/);
361
- const name = match?.[2];
362
- if (match && name && declared.has(name)) {
363
- const marker = `# synopkgland:posix_all_shares ${name}`;
364
- if (output.at(-1)?.trim() !== marker) {
365
- output.push(`${match[1]}${marker}`);
366
- }
367
- }
368
- output.push(line);
369
- }
370
- return output.join("\n");
371
- }
372
- function renderInstallWizard(t, contract, compose) {
373
- const items = [];
374
- const params = collectInstallParameters(contract);
375
- const hasSharedFolder = params.some((item) => item.param.type === "shared_folder");
376
- const hasHomes = params.some((item) => item.param.type === "homes");
377
- const hasDsmUser = params.some((item) => item.param.type === "dsm_user");
378
- if (compose && scanComposePrivilege(compose).length > 0) {
379
- items.push({
380
- desc: t("consent.privilege"),
381
- });
382
- }
383
- if (hasSharedFolder) {
384
- items.push({
385
- desc: t("consent.sharedFolder"),
386
- });
387
- }
388
- if (hasHomes) {
389
- items.push({
390
- desc: t("consent.homes"),
391
- });
392
- }
393
- if (hasDsmUser) {
394
- items.push({
395
- desc: t("consent.dsmUser"),
396
- });
397
- }
398
- if (!hasSharedFolder && (!compose || composeUsesOnlyAppSpace(compose))) {
399
- items.push({
400
- desc: t("consent.appOwnSpace"),
401
- });
402
- }
403
- for (const { name, param } of params) {
404
- if (param.type === "secret" && param.generate === true) {
405
- continue;
406
- }
407
- if (param.type === "string") {
408
- items.push({
409
- type: "textfield",
410
- desc: t("param.install", { name }),
411
- subitems: [{
412
- key: name,
413
- desc: name,
414
- defaultValue: param.default ?? "",
415
- validator: { allowBlank: false },
416
- }],
417
- });
418
- continue;
419
- }
420
- if (param.type === "boolean") {
421
- items.push({
422
- type: "multiselect",
423
- desc: t("param.install", { name }),
424
- subitems: [{ key: name, desc: name, defaultValue: param.default ?? false }],
425
- });
426
- continue;
427
- }
428
- if (param.type === "secret") {
429
- items.push({
430
- type: "password",
431
- desc: t("param.install", { name }),
432
- subitems: [{ key: name, desc: name, validator: { allowBlank: false } }],
433
- });
434
- continue;
435
- }
436
- if (param.type === "shared_folder") {
437
- items.push(sharedFolderCombobox(t, name));
438
- continue;
439
- }
440
- if (param.type === "posix_all_shares") {
441
- // A47 (install side): DSM's declarative wizard only ever renders a
442
- // single-select combo here, so the former multiSelect control silently
443
- // mounted exactly one Share instead of every Share. Package Center can
444
- // therefore never carry the full Share list, and there is no honest
445
- // control to render -- so this branch produces only an informational
446
- // textblock. The full snapshot arrives exclusively via CLI
447
- // extra_values (`synopkgland deploy`), which the preinst materializer
448
- // expands. No subitem key is emitted, so the install uifile contains no
449
- // ALL_SHARES and no CHANGE_ALL_SHARES.
450
- items.push({
451
- type: "textblock",
452
- desc: t("consent.posixAllShares"),
453
- });
454
- continue;
455
- }
456
- if (param.type === "dsm_user") {
457
- items.push({
458
- type: "combobox",
459
- desc: t("param.runAs", { name }),
460
- subitems: [{
461
- key: name,
462
- desc: name,
463
- displayField: "name",
464
- valueField: "name",
465
- editable: false,
466
- forceSelection: true,
467
- autoSelect: false,
468
- mode: "remote",
469
- api_store: {
470
- api: "SYNO.Core.User",
471
- method: "list",
472
- version: 1,
473
- baseParams: { limit: -1, offset: 0 },
474
- root: "users",
475
- idProperty: "name",
476
- fields: ["name"],
477
- },
478
- validator: { allowBlank: false },
479
- }],
480
- });
481
- }
482
- }
483
- return [{
484
- step_title: t("install.step"),
485
- items,
486
- }];
487
- }
488
- function renderUpgradeWizard(t, contract, compose) {
489
- const installWizard = renderInstallWizard(t, contract, compose);
490
- const params = collectInstallParameters(contract);
491
- // Every type whose value must survive an upgrade the administrator did not
492
- // deliberately change. Two mechanisms are needed because DSM renders them
493
- // differently: a control that can be left blank just allows blank, while one
494
- // that always carries a value needs an explicit "change this" gate.
495
- // - dsm_user : blank in some layouts, but measured on DSM 7.4-90069 with
496
- // Shared Folder controls beside it to come up pre-filled with a
497
- // Shared Folder name, so it needs the same explicit gate
498
- // - string : blank once the new version's default is withheld (A44)
499
- // - shared_folder : DSM prefills the first store row
500
- // - boolean : a checkbox has no blank state at all
501
- // posix_all_shares is intentionally absent: it renders as a textblock with
502
- // no subitem, so there is nothing to blank or gate on the upgrade wizard.
503
- // Its value can only arrive via CLI extra_values, and the preinst
504
- // change-gate shell (`upgrade_change_enabled`, `CHANGE_<name>`) still
505
- // governs that path, reusing the canonical snapshot on an untouched upgrade.
506
- const upgradeReuseTypes = new Map(params
507
- .filter(({ param }) => param.type === "shared_folder" ||
508
- param.type === "dsm_user" ||
509
- param.type === "string" ||
510
- param.type === "boolean")
511
- .map(({ name, param }) => [name, param.type]));
512
- const needsChangeGate = (type) => type === "shared_folder" || type === "dsm_user" || type === "boolean";
513
- const upgradeItems = installWizard[0].items.flatMap((item) => {
514
- if (!Array.isArray(item.subitems)) {
515
- return [item];
516
- }
517
- const reuseKeys = [];
518
- const subitems = item.subitems.map((subitem) => {
519
- if (!subitem || typeof subitem !== "object") {
520
- return subitem;
521
- }
522
- const record = subitem;
523
- if (typeof record.key !== "string" || !upgradeReuseTypes.has(record.key)) {
524
- return subitem;
525
- }
526
- reuseKeys.push(record.key);
527
- const reused = {
528
- ...record,
529
- validator: { allowBlank: true },
530
- };
531
- // Withholding the default is what makes an untouched upgrade blank, and
532
- // blank is what lets preinst fall through to the canonical .env value.
533
- // Install keeps the default -- only this upgrade copy drops it.
534
- delete reused.defaultValue;
535
- return reused;
536
- });
537
- if (reuseKeys.length === 0) {
538
- return [item];
539
- }
540
- const control = {
541
- ...item,
542
- desc: t("upgrade.reuseDesc", { desc: String(item.desc ?? "") }),
543
- subitems,
544
- };
545
- const changeControls = reuseKeys
546
- .filter((key) => needsChangeGate(upgradeReuseTypes.get(key)))
547
- .map((key) => {
548
- const type = upgradeReuseTypes.get(key);
549
- const label = type === "shared_folder"
550
- ? t("label.sharedFolder")
551
- : type === "dsm_user"
552
- ? t("label.runAs")
553
- : key;
554
- return {
555
- type: "multiselect",
556
- desc: t("upgrade.changeDesc", { label }),
557
- subitems: [{
558
- key: `CHANGE_${key}`,
559
- desc: t("upgrade.changeSub", { label }),
560
- defaultValue: false,
561
- }],
562
- };
563
- });
564
- return [...changeControls, control];
565
- });
566
- const sharedFolderNames = params
567
- .filter((item) => item.param.type === "shared_folder")
568
- .map((item) => item.name);
569
- const hasSharedFolder = sharedFolderNames.length > 0;
570
- const hasDsmUser = params.some((item) => item.param.type === "dsm_user");
571
- const hasReadWriteSharedFolder = hasSharedFolder &&
572
- compose !== undefined &&
573
- volumeMounts(compose).some((mount) => !mount.readOnly &&
574
- sharedFolderNames.some((name) => mount.source === `\${${name}}` || mount.source.startsWith(`\${${name}}/`)));
575
- const upgradeConsent = [];
576
- const posixAllSharesNames = params
577
- .filter((item) => item.param.type === "posix_all_shares")
578
- .map((item) => item.name);
579
- if (posixAllSharesNames.length > 0) {
580
- upgradeConsent.push({
581
- desc: t("upgrade.posixNotice"),
582
- });
583
- }
584
- if (hasReadWriteSharedFolder) {
585
- upgradeConsent.push({
586
- desc: t("upgrade.sharedFolderRw"),
587
- });
588
- }
589
- if (hasDsmUser && hasSharedFolder) {
590
- upgradeConsent.push({
591
- desc: t("upgrade.dsmUser"),
592
- });
593
- }
594
- if (upgradeConsent.length === 0) {
595
- return [{
596
- ...installWizard[0],
597
- step_title: t("upgrade.step"),
598
- items: upgradeItems,
599
- }];
600
- }
601
- const firstControl = upgradeItems.findIndex((item) => item.subitems !== undefined);
602
- const insertAt = firstControl === -1 ? upgradeItems.length : firstControl;
603
- const items = [...upgradeItems];
604
- items.splice(insertAt, 0, ...upgradeConsent);
605
- return [{
606
- ...installWizard[0],
607
- step_title: t("upgrade.step"),
608
- items,
609
- }];
610
- }
611
- function writeWizards(staging, contract, compose, kind) {
612
- const install = renderInstallWizardFiles(String(contract.package), (t) => renderInstallWizard(t, contract, compose));
613
- writeWizardUiFiles(staging, {
614
- ...install.static,
615
- ...install.dynamic,
616
- ...renderWizardFiles(UPGRADE_UIFILE, (t) => renderUpgradeWizard(t, contract, compose)),
617
- ...renderUninstallWizardFiles(String(contract.package), kind),
618
- });
619
- }
620
- function renderDynamicPreinstMaterializer(contract) {
621
- const allParams = collectInstallParameters(contract);
622
- const scalarParams = allParams.filter(({ param }) => param.type === "string" || param.type === "boolean");
623
- const sharedFolderParams = allParams.filter(({ param }) => param.type === "shared_folder");
624
- const posixAllSharesParams = allParams.filter(({ param }) => param.type === "posix_all_shares");
625
- const dsmUserParam = allParams.find(({ param }) => param.type === "dsm_user");
626
- const lines = [
627
- "#!/bin/sh",
628
- "set -eu",
629
- "umask 077",
630
- "# D3/D5.4 package-only carrier; values are wizard answers, canonical values, or defaults.",
631
- "die() { echo \"dynamic route materialization failed: $1\" >&2; exit 1; }",
632
- 'T="${SYNOPKG_PKGINST_TEMP_DIR:?}"',
633
- 'STATUS="${SYNOPKG_PKG_STATUS:?}"',
634
- 'PROJECT="$T/compose"',
635
- 'RUNTIME_ENV="$PROJECT/.env"',
636
- 'CANONICAL_ENV="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}/.env"',
637
- 'PLATFORM_PKGVAR="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}"',
638
- 'PLATFORM_SECRETS="${SYNOPKG_PKGDEST_VOL:?}/@apphome/${SYNOPKG_PKGNAME:?}/secrets"',
639
- '[ -d "$PROJECT" ] || die "compose directory is missing"',
640
- '[ ! -L "$PROJECT" ] || die "compose directory is a symlink"',
641
- '[ "$(stat -c %u "$PROJECT")" = "$(id -u)" ] || die "compose directory owner is not the package user"',
642
- '[ ! -L "$RUNTIME_ENV" ] || die "runtime file is a symlink"',
643
- 'if [ -e "$RUNTIME_ENV" ] && [ ! -f "$RUNTIME_ENV" ]; then die "runtime path is not a regular file"; fi',
644
- "read_env_value() {",
645
- " file=\"$1\"",
646
- " key=\"$2\"",
647
- " 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\"",
648
- "}",
649
- "append_env() {",
650
- " key=\"$1\"",
651
- " value=\"$2\"",
652
- " type=\"${3:-string}\"",
653
- " if printf '%s' \"$value\" | LC_ALL=C grep -q '[[:cntrl:]]'; then",
654
- " die \"control character in environment value $key\"",
655
- " fi",
656
- " case \"$value\" in",
657
- " *[[:cntrl:]]*) die \"control character in environment value $key\" ;;",
658
- " esac",
659
- " if [ \"$type\" = \"boolean\" ]; then",
660
- " normalized=\"$(printf '%s' \"$value\" | LC_ALL=C tr '[:upper:]' '[:lower:]')\"",
661
- " case \"$normalized\" in",
662
- " true|false) value=\"$normalized\" ;;",
663
- " *) die \"invalid boolean value $key\" ;;",
664
- " esac",
665
- " fi",
666
- " case \"$value\" in",
667
- " *[!A-Za-z0-9_./:@-]*)",
668
- " escaped=\"$(printf '%s' \"$value\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\"",
669
- " printf '%s=\"%s\"\\n' \"$key\" \"$escaped\"",
670
- " ;;",
671
- " *) printf '%s=%s\\n' \"$key\" \"$value\" ;;",
672
- " esac",
673
- "}",
674
- ];
675
- // Emitted whenever anything reads a CHANGE_ gate. Boolean parameters (A44) use
676
- // it too, and a package with only booleans has no Share resolvers -- leaving it
677
- // behind that condition made `! upgrade_change_enabled ...` a missing command,
678
- // which is true, so the gate silently always reused the canonical value.
679
- const usesUpgradeChangeGate = sharedFolderParams.length > 0 ||
680
- posixAllSharesParams.length > 0 ||
681
- collectInstallParameters(contract).some(({ param }) => param.type === "boolean" || param.type === "dsm_user");
682
- if (usesUpgradeChangeGate) {
683
- 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", "}");
684
- }
685
- if (sharedFolderParams.length > 0 || posixAllSharesParams.length > 0) {
686
- 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\"", "}");
687
- }
688
- if (posixAllSharesParams.length > 0) {
689
- 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\"", "}");
690
- }
691
- if (dsmUserParam) {
692
- 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\"", "}");
693
- }
694
- 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", "{");
695
- const valueVars = [];
696
- for (const { name, param } of scalarParams) {
697
- const type = param.type;
698
- const valueVar = "D3_VALUE_" + name;
699
- valueVars.push({ name, valueVar, type });
700
- const fallback = param.default !== undefined
701
- ? String(param.default)
702
- : type === "boolean"
703
- ? "false"
704
- : "";
705
- // A44: on UPGRADE the wizard always submits something for these controls, so
706
- // "was it submitted" cannot mean "did the administrator choose it". A blank
707
- // string, or a boolean whose CHANGE_ gate is unchecked, means "keep what is
708
- // installed" -- otherwise the new version's default would silently replace a
709
- // value chosen at install time. INSTALL is unaffected: STATUS is not UPGRADE,
710
- // so the reuse branch is skipped and the default still applies.
711
- const reuseGuard = type === "boolean"
712
- ? `! upgrade_change_enabled "\${CHANGE_${name}:-}"`
713
- : `[ -z "\${${name}:-}" ]`;
714
- lines.push(` if [ "$STATUS" = "UPGRADE" ] && ${reuseGuard} && [ -f "$CANONICAL_ENV" ] && ` +
715
- `[ ! -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" ] && ' +
716
- `${valueVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", " " + valueVar + "=" + shellQuote(fallback), " fi");
717
- }
718
- const shareVars = [];
719
- for (const { name } of sharedFolderParams) {
720
- const rawVar = "D54_RAW_SHARE_" + name;
721
- const pathVar = "D54_RESOLVED_PATH_" + name;
722
- const changeVar = "CHANGE_" + name;
723
- shareVars.push({ name, pathVar });
724
- 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" ] && ' +
725
- `${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");
726
- }
727
- const posixVars = [];
728
- for (const { name } of posixAllSharesParams) {
729
- const rawVar = "D54_RAW_POSIX_" + name;
730
- const namesVar = "D54_POSIX_NAMES_" + name;
731
- const pathsVar = "D54_POSIX_PATHS_" + name;
732
- const countVar = "D54_POSIX_COUNT_" + name;
733
- const changeVar = "CHANGE_" + name;
734
- posixVars.push({ name, namesVar, pathsVar, countVar });
735
- 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" ] && ' +
736
- `${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"`);
737
- }
738
- if (dsmUserParam) {
739
- const name = dsmUserParam.name;
740
- const rawUserVar = "D54_RAW_USER_" + name;
741
- // Like the Shared Folder gate: on UPGRADE the submitted user only counts when
742
- // CHANGE_<name> is ticked, because the control is not reliably blank.
743
- 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" ] && ' +
744
- `${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"`);
745
- }
746
- lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
747
- for (const { name, valueVar, type } of valueVars) {
748
- lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
749
- }
750
- for (const { name, pathVar } of shareVars) {
751
- lines.push(` append_env "${name}" "$${pathVar}" "string"`);
752
- }
753
- for (const { name, namesVar, countVar } of posixVars) {
754
- lines.push(` append_env "${name}" "$${namesVar}" "string"`, ` append_env "${name}_COUNT" "$${countVar}" "string"`);
755
- }
756
- if (dsmUserParam) {
757
- const name = dsmUserParam.name;
758
- const rawUserVar = "D54_RAW_USER_" + name;
759
- 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`);
760
- }
761
- 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");
762
- return lines.join("\n") + "\n";
763
- }
764
- function renderDynamicCanonicalMirror(headless = false) {
765
- const lines = [
766
- "#!/bin/sh",
767
- "set -eu",
768
- "umask 077",
769
- "# D3 post-worker mirror; the runtime .env is the only source of truth here.",
770
- "die() { echo \"dynamic route canonical mirror failed: $1\" >&2; exit 1; }",
771
- 'PKGVAR="${SYNOPKG_PKGVAR:?}"',
772
- 'PKGHOME="${SYNOPKG_PKGHOME:?}"',
773
- 'PKGDEST="${SYNOPKG_PKGDEST:?}"',
774
- 'PROJECT="${PKGDEST}/compose"',
775
- 'RUNTIME_ENV="${PROJECT}/.env"',
776
- 'CANONICAL_ENV="${PKGVAR}/.env"',
777
- 'SECRETS="${PKGHOME}/secrets"',
778
- 'mkdir -p "$PKGVAR"',
779
- '[ -d "$PKGVAR" ] || die "PKGVAR is not a directory"',
780
- 'if [ -L "$SECRETS" ]; then die "secret root is a symlink"; fi',
781
- 'mkdir -p "$SECRETS"',
782
- '[ -d "$SECRETS" ] || die "secret root is not a directory"',
783
- 'chmod 700 "$SECRETS"',
784
- '[ "$(stat -c %a "$SECRETS")" = "700" ] || die "secret root mode is not 0700"',
785
- '[ -d "$PROJECT" ] || die "project directory is missing"',
786
- '[ ! -L "$PROJECT" ] || die "project directory is a symlink"',
787
- '[ ! -L "$RUNTIME_ENV" ] || die "runtime file is a symlink"',
788
- '[ -f "$RUNTIME_ENV" ] || die "runtime file is not a regular file"',
789
- '[ ! -L "$CANONICAL_ENV" ] || die "canonical file is a symlink"',
790
- 'if [ -e "$CANONICAL_ENV" ] && [ ! -f "$CANONICAL_ENV" ]; then die "canonical path is not a regular file"; fi',
791
- 'tmp="$(mktemp "$PKGVAR/.env.tmp.XXXXXX")"',
792
- 'chmod 600 "$tmp"',
793
- '[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"',
794
- 'trap \'rm -f "$tmp"\' EXIT HUP INT TERM',
795
- 'cp "$RUNTIME_ENV" "$tmp"',
796
- 'chmod 600 "$tmp"',
797
- 'mv "$tmp" "$CANONICAL_ENV"',
798
- 'trap - EXIT HUP INT TERM',
799
- '[ ! -L "$CANONICAL_ENV" ] || die "canonical file became a symlink"',
800
- '[ "$(stat -c %a "$CANONICAL_ENV")" = "600" ] || die "canonical env mode is not 0600"',
801
- ];
802
- if (headless) {
803
- 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"');
804
- }
805
- lines.push("exit 0");
806
- return lines.join("\n") + "\n";
807
- }
808
- function shellQuote(value) {
809
- return "'" + value.replace(/'/g, "'\\''") + "'";
810
- }
811
- function renderManagedScript(name, contract, isResourceWorker = false, resourceWorkerAdminport, isDynamicRoute = false, isHeadlessRoute = false) {
812
- if (isResourceWorker) {
813
- if (name === "start-stop-status") {
814
- return renderResourceWorkerStartStopStatus(String(contract.package), resourceWorkerAdminport, isHeadlessRoute);
815
- }
816
- if (isDynamicRoute && name === "preinst") {
817
- return renderDynamicPreinstMaterializer(contract);
818
- }
819
- if (isDynamicRoute && (name === "postinst" || name === "postupgrade")) {
820
- return renderDynamicCanonicalMirror(isHeadlessRoute);
821
- }
822
- if (isHeadlessRoute && (name === "postinst" || name === "postupgrade")) {
823
- return renderHeadlessResourceMarker();
824
- }
825
- if (name === "postuninst") {
826
- const projectRoot = isDynamicRoute ? 'PROJECT="${SYNOPKG_PKGDEST:-}/compose"\n' : "";
827
- const deleteRoots = [
828
- { variable: "PKGVAR", area: "appdata" },
829
- { variable: "PKGHOME", area: "apphome" },
830
- ...(isDynamicRoute ? [{ variable: "PROJECT", area: "appstore" }] : []),
831
- ];
832
- return `#!/bin/sh
833
- set -eu
834
- PKGVAR="\${SYNOPKG_PKGVAR:-}"
835
- PKGHOME="\${SYNOPKG_PKGHOME:-}"
836
- ${projectRoot}${renderDeleteAppDataBlock(String(contract.package), deleteRoots)}exit 0
837
- `;
838
- }
839
- return `#!/bin/sh\nexit 0\n`;
840
- }
841
- throw new Error("Unsupported container route");
842
- }
843
- async function createDeterministicSpk(stagingDir, outputPath) {
844
- const payloadDir = join(stagingDir, "package");
845
- if (existsSync(payloadDir) && statSync(payloadDir).isDirectory()) {
846
- await createReproducibleTar(payloadDir, join(stagingDir, "package.tgz"), { gzip: true });
847
- rmSync(payloadDir, { recursive: true, force: true });
848
- }
849
- 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"] };
850
10
  }
851
11
  export function walkFiles(root, prefix = "") {
852
12
  const files = [];