@synopackageland/cli 0.3.9 → 0.4.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 (286) hide show
  1. package/README.md +20 -9
  2. package/dist/bin.js +1 -2
  3. package/dist/build-spk/generator.d.ts +0 -3
  4. package/dist/build-spk/generator.js +135 -287
  5. package/dist/build-spk/native-lifecycle.js +0 -3
  6. package/dist/build-spk/native-payload.js +0 -1
  7. package/dist/cli.js +171 -151
  8. package/dist/compose/analyze.d.ts +1 -2
  9. package/dist/compose/analyze.js +5 -48
  10. package/dist/contract/unsupported-types.d.ts +10 -0
  11. package/dist/contract/unsupported-types.js +49 -0
  12. package/dist/contract/validate.js +25 -2
  13. package/dist/contract/web-launch.js +0 -2
  14. package/dist/deploy/deploy.d.ts +1 -4
  15. package/dist/deploy/deploy.js +57 -156
  16. package/dist/deploy/homes-values.js +1 -1
  17. package/dist/deploy/plan.d.ts +18 -0
  18. package/dist/deploy/plan.js +54 -0
  19. package/dist/deploy/port-preflight.d.ts +21 -0
  20. package/dist/deploy/port-preflight.js +42 -0
  21. package/dist/deploy/spk-version.d.ts +2 -14
  22. package/dist/deploy/spk-version.js +25 -111
  23. package/dist/dev.d.ts +1 -3
  24. package/dist/dev.js +14 -28
  25. package/dist/error-registry.js +810 -857
  26. package/dist/host/dsm-client.js +23 -263
  27. package/dist/host/dsm-port-profile.d.ts +47 -0
  28. package/dist/host/dsm-port-profile.js +206 -0
  29. package/dist/host/port-info-lookup.d.ts +9 -0
  30. package/dist/host/port-info-lookup.js +87 -0
  31. package/dist/host/port-info.d.ts +33 -0
  32. package/dist/host/port-info.js +66 -0
  33. package/dist/host/types.d.ts +6 -78
  34. package/dist/index.d.ts +0 -9
  35. package/dist/index.js +0 -6
  36. package/dist/info.d.ts +0 -1
  37. package/dist/info.js +3 -10
  38. package/dist/init.d.ts +11 -1
  39. package/dist/init.js +112 -4
  40. package/dist/log.js +2 -3
  41. package/dist/native/validate.js +0 -1
  42. package/dist/standalone/binary.d.ts +9 -0
  43. package/dist/standalone/binary.js +67 -0
  44. package/dist/standalone/build.d.ts +25 -0
  45. package/dist/standalone/build.js +108 -0
  46. package/dist/standalone/dsm-window.d.ts +26 -0
  47. package/dist/standalone/dsm-window.js +251 -0
  48. package/dist/standalone/helper.d.ts +31 -0
  49. package/dist/standalone/helper.js +104 -0
  50. package/dist/standalone/i18n.d.ts +28 -0
  51. package/dist/standalone/i18n.js +161 -0
  52. package/dist/standalone/inspect-spk.d.ts +21 -0
  53. package/dist/standalone/inspect-spk.js +160 -0
  54. package/dist/standalone/manifest.d.ts +71 -0
  55. package/dist/standalone/manifest.js +276 -0
  56. package/dist/standalone/root-messages.d.ts +7 -0
  57. package/dist/standalone/root-messages.js +250 -0
  58. package/dist/standalone/root-ops.d.ts +42 -0
  59. package/dist/standalone/root-ops.js +101 -0
  60. package/dist/standalone/root-window.d.ts +35 -0
  61. package/dist/standalone/root-window.js +290 -0
  62. package/dist/standalone/spk.d.ts +22 -0
  63. package/dist/standalone/spk.js +205 -0
  64. package/dist/templates/binary/synology-app.yaml +7 -0
  65. package/dist/templates/hello-files/compose.yaml +0 -1
  66. package/dist/templates/hello-web/compose.yaml +13 -10
  67. package/dist/templates/hello-web/html/app.js +1 -23
  68. package/dist/templates/hello-web/html/index.html +1 -3
  69. package/dist/templates/hello-web-page/compose.yaml +13 -10
  70. package/dist/templates/hello-web-page/html/app.js +1 -23
  71. package/dist/templates/hello-web-page/html/index.html +1 -3
  72. package/dist/templates/native-go/README.md +268 -0
  73. package/dist/templates/native-go/backend/cmd/app/main.go +49 -0
  74. package/dist/templates/native-go/backend/cmd/helper/main.go +38 -0
  75. package/dist/templates/native-go/backend/go.mod +3 -0
  76. package/dist/templates/native-go/backend/internal/api/errors.go +33 -0
  77. package/dist/templates/native-go/backend/internal/api/registry.go +94 -0
  78. package/dist/templates/native-go/backend/internal/api/registry_test.go +329 -0
  79. package/dist/templates/native-go/backend/internal/api/server.go +155 -0
  80. package/dist/templates/native-go/backend/internal/app/root_none.go +9 -0
  81. package/dist/templates/native-go/backend/internal/app/routes.go +127 -0
  82. package/dist/templates/native-go/backend/internal/app/routes_test.go +167 -0
  83. package/dist/templates/native-go/backend/internal/dsm/auth.go +118 -0
  84. package/dist/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
  85. package/dist/templates/native-go/backend/internal/helper/control.go +219 -0
  86. package/dist/templates/native-go/backend/internal/helper/control_test.go +242 -0
  87. package/dist/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
  88. package/dist/templates/native-go/backend/internal/helper/peer_other.go +7 -0
  89. package/dist/templates/native-go/backend/internal/helper/process_linux.go +167 -0
  90. package/dist/templates/native-go/backend/internal/helper/process_other.go +5 -0
  91. package/dist/templates/native-go/backend/internal/helper/server_linux.go +83 -0
  92. package/dist/templates/native-go/backend/internal/helper/status.go +271 -0
  93. package/dist/templates/native-go/backend/internal/helper/status_test.go +205 -0
  94. package/dist/templates/native-go/backend/internal/helper/supervision.go +84 -0
  95. package/dist/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
  96. package/dist/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
  97. package/dist/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
  98. package/dist/templates/native-go/dsm-package.yaml +58 -0
  99. package/dist/templates/native-go/gitignore +1 -0
  100. package/dist/templates/native-go/i18n/glossary.json +662 -0
  101. package/dist/templates/native-go/i18n/messages/de.json +28 -0
  102. package/dist/templates/native-go/i18n/messages/en.json +28 -0
  103. package/dist/templates/native-go/i18n/messages/es.json +28 -0
  104. package/dist/templates/native-go/i18n/messages/fr.json +28 -0
  105. package/dist/templates/native-go/i18n/messages/it.json +28 -0
  106. package/dist/templates/native-go/i18n/messages/ja.json +28 -0
  107. package/dist/templates/native-go/i18n/messages/ko.json +28 -0
  108. package/dist/templates/native-go/i18n/messages/zh-CN.json +28 -0
  109. package/dist/templates/native-go/i18n/messages/zh-TW.json +28 -0
  110. package/dist/templates/native-go/ui/app.css +35 -0
  111. package/dist/templates/native-go/ui/app.js +94 -0
  112. package/dist/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
  113. package/dist/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
  114. package/dist/templates/native-go-better-auth/node/app.mjs +72 -0
  115. package/dist/templates/native-go-better-auth/node/build.mjs +2 -0
  116. package/dist/templates/native-go-better-auth/node/package-lock.json +862 -0
  117. package/dist/templates/native-go-better-auth/node/package.json +1 -0
  118. package/dist/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
  119. package/dist/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
  120. package/dist/templates/native-go-integration/backend/internal/app/personal.go +203 -0
  121. package/dist/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
  122. package/dist/templates/native-go-root/backend/internal/app/root.go +107 -0
  123. package/dist/templates/native-go-root/backend/internal/app/root_test.go +124 -0
  124. package/dist/templates/native-go-root/root/whoami.sh +6 -0
  125. package/dist/templates/native-go-vue/README.md +110 -0
  126. package/dist/templates/native-go-vue/frontend/DESIGN.md +78 -0
  127. package/dist/templates/native-go-vue/frontend/package-lock.json +2507 -0
  128. package/dist/templates/native-go-vue/frontend/package.json +6 -0
  129. package/dist/templates/native-go-vue/frontend/src/App.vue +132 -0
  130. package/dist/templates/native-go-vue/frontend/src/context.ts +17 -0
  131. package/dist/templates/native-go-vue/frontend/src/main.ts +26 -0
  132. package/dist/templates/native-go-vue/frontend/src/style.css +14 -0
  133. package/dist/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
  134. package/dist/templates/native-go-vue/frontend/tsconfig.json +1 -0
  135. package/dist/templates/native-go-vue/frontend/vite.config.ts +8 -0
  136. package/dist/templates/native-go-vue/gitignore +2 -0
  137. package/dist/types.d.ts +1 -1
  138. package/dist/validate.js +11 -7
  139. package/package.json +3 -7
  140. package/schema/synology-app.schema.json +36 -37
  141. package/skills/SKILL.md +4 -48
  142. package/skills/reference/create.md +11 -83
  143. package/skills/reference/deploy.md +7 -78
  144. package/skills/reference/diagnose.md +4 -101
  145. package/skills/reference/files.md +5 -41
  146. package/skills/reference/identity.md +3 -29
  147. package/skills/reference/wrap.md +0 -2
  148. package/templates/binary/synology-app.yaml +7 -0
  149. package/templates/hello-files/compose.yaml +0 -1
  150. package/templates/hello-web/compose.yaml +13 -10
  151. package/templates/hello-web/html/app.js +1 -23
  152. package/templates/hello-web/html/index.html +1 -3
  153. package/templates/hello-web-page/compose.yaml +13 -10
  154. package/templates/hello-web-page/html/app.js +1 -23
  155. package/templates/hello-web-page/html/index.html +1 -3
  156. package/templates/native-go/README.md +268 -0
  157. package/templates/native-go/backend/cmd/app/main.go +49 -0
  158. package/templates/native-go/backend/cmd/helper/main.go +38 -0
  159. package/templates/native-go/backend/go.mod +3 -0
  160. package/templates/native-go/backend/internal/api/errors.go +33 -0
  161. package/templates/native-go/backend/internal/api/registry.go +94 -0
  162. package/templates/native-go/backend/internal/api/registry_test.go +329 -0
  163. package/templates/native-go/backend/internal/api/server.go +155 -0
  164. package/templates/native-go/backend/internal/app/root_none.go +9 -0
  165. package/templates/native-go/backend/internal/app/routes.go +127 -0
  166. package/templates/native-go/backend/internal/app/routes_test.go +167 -0
  167. package/templates/native-go/backend/internal/dsm/auth.go +118 -0
  168. package/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
  169. package/templates/native-go/backend/internal/helper/control.go +219 -0
  170. package/templates/native-go/backend/internal/helper/control_test.go +242 -0
  171. package/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
  172. package/templates/native-go/backend/internal/helper/peer_other.go +7 -0
  173. package/templates/native-go/backend/internal/helper/process_linux.go +167 -0
  174. package/templates/native-go/backend/internal/helper/process_other.go +5 -0
  175. package/templates/native-go/backend/internal/helper/server_linux.go +83 -0
  176. package/templates/native-go/backend/internal/helper/status.go +271 -0
  177. package/templates/native-go/backend/internal/helper/status_test.go +205 -0
  178. package/templates/native-go/backend/internal/helper/supervision.go +84 -0
  179. package/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
  180. package/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
  181. package/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
  182. package/templates/native-go/dsm-package.yaml +58 -0
  183. package/templates/native-go/gitignore +1 -0
  184. package/templates/native-go/i18n/glossary.json +662 -0
  185. package/templates/native-go/i18n/messages/de.json +28 -0
  186. package/templates/native-go/i18n/messages/en.json +28 -0
  187. package/templates/native-go/i18n/messages/es.json +28 -0
  188. package/templates/native-go/i18n/messages/fr.json +28 -0
  189. package/templates/native-go/i18n/messages/it.json +28 -0
  190. package/templates/native-go/i18n/messages/ja.json +28 -0
  191. package/templates/native-go/i18n/messages/ko.json +28 -0
  192. package/templates/native-go/i18n/messages/zh-CN.json +28 -0
  193. package/templates/native-go/i18n/messages/zh-TW.json +28 -0
  194. package/templates/native-go/ui/app.css +35 -0
  195. package/templates/native-go/ui/app.js +94 -0
  196. package/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
  197. package/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
  198. package/templates/native-go-better-auth/node/app.mjs +72 -0
  199. package/templates/native-go-better-auth/node/build.mjs +2 -0
  200. package/templates/native-go-better-auth/node/package-lock.json +862 -0
  201. package/templates/native-go-better-auth/node/package.json +1 -0
  202. package/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
  203. package/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
  204. package/templates/native-go-integration/backend/internal/app/personal.go +203 -0
  205. package/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
  206. package/templates/native-go-root/backend/internal/app/root.go +107 -0
  207. package/templates/native-go-root/backend/internal/app/root_test.go +124 -0
  208. package/templates/native-go-root/root/whoami.sh +6 -0
  209. package/templates/native-go-vue/README.md +110 -0
  210. package/templates/native-go-vue/frontend/DESIGN.md +78 -0
  211. package/templates/native-go-vue/frontend/package-lock.json +2507 -0
  212. package/templates/native-go-vue/frontend/package.json +6 -0
  213. package/templates/native-go-vue/frontend/src/App.vue +132 -0
  214. package/templates/native-go-vue/frontend/src/context.ts +17 -0
  215. package/templates/native-go-vue/frontend/src/main.ts +26 -0
  216. package/templates/native-go-vue/frontend/src/style.css +14 -0
  217. package/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
  218. package/templates/native-go-vue/frontend/tsconfig.json +1 -0
  219. package/templates/native-go-vue/frontend/vite.config.ts +8 -0
  220. package/templates/native-go-vue/gitignore +2 -0
  221. package/dist/attestation/app-package.d.ts +0 -86
  222. package/dist/attestation/app-package.js +0 -206
  223. package/dist/attestation/install-on-host.d.ts +0 -14
  224. package/dist/attestation/install-on-host.js +0 -52
  225. package/dist/attestation/keyring.d.ts +0 -21
  226. package/dist/attestation/keyring.js +0 -168
  227. package/dist/attestation/platform-service.d.ts +0 -70
  228. package/dist/attestation/platform-service.js +0 -250
  229. package/dist/build-spk/broker-glue.d.ts +0 -16
  230. package/dist/build-spk/broker-glue.js +0 -40
  231. package/dist/deploy/catalog-publish.d.ts +0 -3
  232. package/dist/deploy/catalog-publish.js +0 -7
  233. package/dist/doctor.d.ts +0 -22
  234. package/dist/doctor.js +0 -721
  235. package/dist/error-collector.d.ts +0 -2
  236. package/dist/error-collector.js +0 -46
  237. package/dist/host/broker-paths.d.ts +0 -1
  238. package/dist/host/broker-paths.js +0 -1
  239. package/dist/host/broker-upgrade-preflight.d.ts +0 -61
  240. package/dist/host/broker-upgrade-preflight.js +0 -156
  241. package/dist/host/digests.d.ts +0 -5
  242. package/dist/host/digests.js +0 -63
  243. package/dist/host/fake-nas.d.ts +0 -41
  244. package/dist/host/fake-nas.js +0 -174
  245. package/dist/mock-broker/bootstrap.d.ts +0 -2
  246. package/dist/mock-broker/bootstrap.js +0 -8
  247. package/dist/mock-broker/files.d.ts +0 -60
  248. package/dist/mock-broker/files.js +0 -408
  249. package/dist/mock-broker/oidc.d.ts +0 -31
  250. package/dist/mock-broker/oidc.js +0 -161
  251. package/dist/mock-broker/path.d.ts +0 -9
  252. package/dist/mock-broker/path.js +0 -50
  253. package/dist/mock-broker/server.d.ts +0 -19
  254. package/dist/mock-broker/server.js +0 -350
  255. package/dist/publish/build-ledger.d.ts +0 -22
  256. package/dist/publish/build-ledger.js +0 -26
  257. package/dist/publish/community-path.d.ts +0 -6
  258. package/dist/publish/community-path.js +0 -41
  259. package/dist/publish/curated-release.d.ts +0 -78
  260. package/dist/publish/curated-release.js +0 -883
  261. package/dist/publish/file-build-ledger.d.ts +0 -18
  262. package/dist/publish/file-build-ledger.js +0 -268
  263. package/dist/publish/github-pr.d.ts +0 -25
  264. package/dist/publish/github-pr.js +0 -85
  265. package/dist/publish/github-repo.d.ts +0 -8
  266. package/dist/publish/github-repo.js +0 -21
  267. package/dist/publish/platform-service-release.d.ts +0 -60
  268. package/dist/publish/platform-service-release.js +0 -333
  269. package/dist/publish/preflight.d.ts +0 -24
  270. package/dist/publish/preflight.js +0 -138
  271. package/dist/publish/publish.d.ts +0 -27
  272. package/dist/publish/publish.js +0 -136
  273. package/dist/publish/sync-definition.d.ts +0 -4
  274. package/dist/publish/sync-definition.js +0 -48
  275. package/dist/release-bin.d.ts +0 -2
  276. package/dist/release-bin.js +0 -23
  277. package/dist/templates/hello-web/html/sdk.js +0 -68
  278. package/dist/templates/hello-web-page/html/sdk.js +0 -68
  279. package/dist/templates/native/synology-app.yaml +0 -7
  280. package/dist/test-host.d.ts +0 -15
  281. package/dist/test-host.js +0 -37
  282. package/templates/hello-web/html/sdk.js +0 -68
  283. package/templates/hello-web-page/html/sdk.js +0 -68
  284. package/templates/native/synology-app.yaml +0 -7
  285. /package/dist/templates/{native → binary}/native.yaml +0 -0
  286. /package/templates/{native → binary}/native.yaml +0 -0
@@ -13,31 +13,17 @@ import { writeDsmWindowIcons, writePackageCenterIcons } from "./dsm-icons.js";
13
13
  import { infoArchValue } from "./dsm-arch.js";
14
14
  import { createReproducibleTar } from "./reproducible-archive.js";
15
15
  import { renderDeleteAppDataBlock } from "./delete-app-data.js";
16
- import { BROKER_INSTALLER_VARS, BROKER_SOCKET_REQUIRED_SCRIPT, renderBrokerStatusProbe, } from "./broker-glue.js";
17
16
  import { loadNative } from "../native/load.js";
18
17
  import { materializeNativePayload } from "./native-payload.js";
19
- import { renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuninst, } from "./native-lifecycle.js";
18
+ import { renderNativePostinst, renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuninst, renderNativePreuninst, } from "./native-lifecycle.js";
20
19
  import { renderHeadlessResourceMarker, renderResourceConfig, renderResourceWorkerStartStopStatus, } from "./resource-worker.js";
21
20
  const FILE_MODE = 0o644;
22
21
  const SCRIPT_MODE = 0o755;
23
- function hasInterpolation(value) {
24
- if (typeof value === "string") {
25
- return /\$\{([^}]+)\}/.test(value);
26
- }
27
- if (Array.isArray(value)) {
28
- return value.some(hasInterpolation);
29
- }
30
- if (value && typeof value === "object") {
31
- return Object.values(value).some(hasInterpolation);
32
- }
33
- return false;
34
- }
35
22
  const INSTALL_UIFILE = "install_uifile";
36
23
  const UPGRADE_UIFILE = "upgrade_uifile";
37
24
  const UNINSTALL_UIFILE = "uninstall_uifile";
38
25
  const HEADLESS_PROBE_VOLUME = "./.synopkgland:/run/synopkgland:ro";
39
26
  const HEADLESS_PROBE_README = "Reserved bind target for package-user headless status probing.\n";
40
- export const EMBEDDED_ATTESTATION_RELATIVE_PATH = ".synopkgland/attestation.jwt";
41
27
  const SCRIPT_NAMES = [
42
28
  "preinst",
43
29
  "postinst",
@@ -48,9 +34,6 @@ const SCRIPT_NAMES = [
48
34
  "start-stop-status",
49
35
  ];
50
36
  export async function buildSpk(options) {
51
- if (options.embeddedAttestationToken !== undefined && options.embeddedAttestationToken.length === 0) {
52
- return { ok: false, errors: ["EMBEDDED_ATTESTATION_TOKEN_EMPTY"] };
53
- }
54
37
  const validation = validateProject(options.project);
55
38
  if (!validation.ok) {
56
39
  return {
@@ -123,12 +106,6 @@ export async function buildSpk(options) {
123
106
  }
124
107
  copyFileSync(options.project.contractPath, join(packageDir, "synology-app.yaml"));
125
108
  copyFileSync(nativePath, join(packageDir, "native.yaml"));
126
- if (options.embeddedAttestationToken !== undefined) {
127
- const attestationPath = join(packageDir, EMBEDDED_ATTESTATION_RELATIVE_PATH);
128
- mkdirSync(join(packageDir, ".synopkgland"), { recursive: true });
129
- writeFileSync(attestationPath, options.embeddedAttestationToken, "utf8");
130
- chmodSync(attestationPath, FILE_MODE);
131
- }
132
109
  chmodSync(join(staging, "INFO"), FILE_MODE);
133
110
  chmodSync(join(staging, "privilege"), FILE_MODE);
134
111
  chmodSync(join(staging, "WIZARD_UIFILES", INSTALL_UIFILE), FILE_MODE);
@@ -152,7 +129,10 @@ export async function buildSpk(options) {
152
129
  if (route && route.errors.length > 0) {
153
130
  return { ok: false, errors: route.errors.map((error) => error.code) };
154
131
  }
155
- const rwEligibility = route?.eligibility;
132
+ if (!route || route.route === "reject") {
133
+ return { ok: false, errors: ["COMPOSE_ROUTE_UNSUPPORTED"] };
134
+ }
135
+ const rwEligibility = route.eligibility;
156
136
  const isDynamicRoute = route?.route === "dynamic";
157
137
  const isResourceWorkerRoute = route?.route === "static-zero-config" ||
158
138
  isDynamicRoute;
@@ -186,7 +166,7 @@ export async function buildSpk(options) {
186
166
  mkdirSync(resourceProjectDir, { recursive: true });
187
167
  const dsmUserParam = collectInstallParameters(contract).find((p) => p.param.type === "dsm_user");
188
168
  let composeContent;
189
- if (dsmUserParam || rwEligibility?.headless === true || hasOidcCompat(contract)) {
169
+ if (dsmUserParam || rwEligibility?.headless === true) {
190
170
  const rawYaml = readFileSync(options.project.composePath, "utf8");
191
171
  const doc = parseDocument(rawYaml);
192
172
  const services = doc.get("services");
@@ -198,28 +178,6 @@ export async function buildSpk(options) {
198
178
  doc.setIn(["services", sName, "user"], userVal);
199
179
  }
200
180
  }
201
- if (hasOidcCompat(contract) && services && typeof services === "object" && typeof services.toJSON === "function") {
202
- const rawServices = services.toJSON();
203
- for (const [sName, sValue] of Object.entries(rawServices)) {
204
- if (sValue && typeof sValue === "object" && hasInterpolation(sValue)) {
205
- const str = JSON.stringify(sValue);
206
- if (str.includes("SYNOPKGLAND_OIDC_ISSUER")) {
207
- const serviceDoc = doc.getIn(["services", sName]);
208
- const existingExtraHosts = serviceDoc?.get?.("extra_hosts");
209
- const hostGatewayMapping = "${SYNOPKGLAND_OIDC_ISSUER_HOST}:host-gateway";
210
- if (!existingExtraHosts) {
211
- doc.setIn(["services", sName, "extra_hosts"], [hostGatewayMapping]);
212
- }
213
- else if (Array.isArray(existingExtraHosts.toJSON?.())) {
214
- const arr = existingExtraHosts.toJSON();
215
- if (!arr.includes(hostGatewayMapping)) {
216
- doc.setIn(["services", sName, "extra_hosts"], [...arr, hostGatewayMapping]);
217
- }
218
- }
219
- }
220
- }
221
- }
222
- }
223
181
  if (rwEligibility?.headless === true) {
224
182
  injectHeadlessProbeVolume(doc);
225
183
  }
@@ -241,11 +199,6 @@ export async function buildSpk(options) {
241
199
  writeFileSync(join(probePayloadDir, "README"), HEADLESS_PROBE_README, "utf8");
242
200
  chmodSync(join(probePayloadDir, "README"), FILE_MODE);
243
201
  }
244
- const routeMarkerDir = join(staging, "package", ".synopkgland");
245
- mkdirSync(routeMarkerDir, { recursive: true });
246
- const routeMarker = join(routeMarkerDir, "route");
247
- writeFileSync(routeMarker, "package-only", "utf8");
248
- chmodSync(routeMarker, FILE_MODE);
249
202
  }
250
203
  const htmlDir = join(options.project.root, "html");
251
204
  if (existsSync(htmlDir)) {
@@ -254,12 +207,6 @@ export async function buildSpk(options) {
254
207
  cpSync(htmlDir, join(staging, "package", "compose", "html"), { recursive: true });
255
208
  }
256
209
  }
257
- if (options.embeddedAttestationToken !== undefined) {
258
- const attestationPath = join(staging, "package", EMBEDDED_ATTESTATION_RELATIVE_PATH);
259
- mkdirSync(join(staging, "package", ".synopkgland"), { recursive: true });
260
- writeFileSync(attestationPath, options.embeddedAttestationToken, "utf8");
261
- chmodSync(attestationPath, FILE_MODE);
262
- }
263
210
  if (contract.adminport !== undefined && resolvedWebLaunch(contract) === "dsm-window") {
264
211
  const uiDir = join(staging, "package", "ui");
265
212
  mkdirSync(uiDir, { recursive: true });
@@ -312,12 +259,6 @@ function renderInfo(contract, archFamily, isResourceWorker = false) {
312
259
  add("install_dep_packages", "WebStation");
313
260
  }
314
261
  }
315
- else {
316
- // synopkg's Container Manager dep gate reports "not ready" even when CM is
317
- // running. Depend on Broker only; Broker's adapter.probe() checks CM at
318
- // App install time (CM_CAPABILITY_MISSING).
319
- add("install_dep_packages", "SynoPkgLandService>=0.1.0-0016");
320
- }
321
262
  add("startable", "yes");
322
263
  const launch = resolvedWebLaunch(contract);
323
264
  if (contract.adminprotocol) {
@@ -354,47 +295,15 @@ function renderPrivilege() {
354
295
  },
355
296
  }, null, 2)}\n`;
356
297
  }
357
- const SHARED_FOLDER_MOUNT_CONSENT = "此 App 會用你在這台 NAS 選的資料夾;整顆應用都看得到。Shared Folder Mount 是整個 App Instance 的 container access:能打開這個 App 的使用者,容器裡的程式都看得到該 Share,即使該使用者在 File Station 看不到。這不是逐使用者 DSM ACL。要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
358
- const HOMES_MOUNT_CONSENT = "此 App 會使用這台 NAS 已啟用的 User Home;整顆應用的容器都看得到,容器路徑由 Compose 決定。這不是逐使用者 DSM ACL;要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
359
- const POSIX_ALL_SHARES_MAX_WARNING = "掛載這台 NAS 目前列出的每一個共用資料夾。容器內可讀(或讀寫,依 Compose 模式)所有這些資料夾的檔案。安裝後新建的共用資料夾不會自動加入。由管理員決定是否安裝。";
298
+ const SHARED_FOLDER_MOUNT_CONSENT = "此 App 會用你在這台 NAS 選的資料夾;整顆應用都看得到。Shared Folder Mount 是整個 App Instance 的 container access:能打開這個 App 的使用者,容器裡的程式都看得到該 Share,即使該使用者在 File Station 看不到。這不是逐使用者 DSM ACL。檔案權限取決於容器服務帳號;App 必須自行實作使用者授權。";
299
+ const HOMES_MOUNT_CONSENT = "此 App 會使用這台 NAS 已啟用的 User Home;整顆應用的容器都看得到,容器路徑由 Compose 決定。這不是逐使用者 DSM ACL;檔案權限取決於容器服務帳號;App 必須自行實作使用者授權。";
300
+ const POSIX_ALL_SHARES_MAX_WARNING = "此 App 會使用安裝當下這台 NAS 的全部 Shared Folder(App-wide,不依個別 DSM ACL);容器內可讀(或讀寫,依 Compose 模式)所有這些資料夾的檔案。安裝後新建的共用資料夾不會自動加入。Package Center 無法提供安裝當下的 Shared Folder 清單快照;要以全部 Share 安裝,請用 CLI `synopkgland deploy`,由 CLI 以管理員權限列舉全部 Share 並寫入快照。由管理員決定是否安裝。";
301
+ const POSIX_ALL_SHARES_UPGRADE_NOTICE = "升級會保留既有快照(安裝當下由 CLI 列舉的全部 Shared Folder);變更掛載範圍只能由 CLI 明確提供(`synopkgland deploy`)。Package Center 升級不會改動現有快照。";
360
302
  const DSM_USER_RUN_AS_CONSENT = "寫進 Share 的新檔在 File Station 顯示為你選的那位 DSM 使用者。點開此 App 的人共用這個磁碟身分,不是逐登入 ACL。";
361
303
  const DSM_USER_UPGRADE_CONSENT = "升級時更換 Container Run-as 不會 chown Share 上的舊檔;只有升級後寫入的新檔才會使用新的磁碟身分。";
304
+ const UPGRADE_VALUE_REUSE_DESC = "留空=沿用目前設定";
362
305
  const SHARED_FOLDER_RW_UPGRADE_CONSENT = "此版本把 Share 掛載改為讀寫;容器可讀取並寫入你選的 Share,不再是唯讀。";
363
306
  const APP_OWN_SPACE_CONSENT = "此 App 只用 App 自己的空間。";
364
- const OIDC_COMPAT_CONSENT = "此 App 可用你的 DSM 帳號登入;如果這台 NAS 尚未啟用相容模式,App 會使用自己的登入。登入不等於 File Station ACL。";
365
- function hasOidcCompat(contract) {
366
- const integration = contract.integration;
367
- if (!integration || typeof integration !== "object") {
368
- return false;
369
- }
370
- const identity = integration.identity;
371
- if (!identity || typeof identity !== "object") {
372
- return false;
373
- }
374
- const oidcCompat = identity.oidc_compat;
375
- return Boolean(oidcCompat && typeof oidcCompat === "object");
376
- }
377
- function renderFilesCapability(contract) {
378
- const permissions = contract.permissions;
379
- const files = permissions?.files;
380
- if (!files) {
381
- return null;
382
- }
383
- const parts = [];
384
- if (files.read) {
385
- parts.push("讀取檔案");
386
- }
387
- if (files.write) {
388
- parts.push("寫入檔案");
389
- }
390
- if (files.delete) {
391
- parts.push("刪除檔案");
392
- }
393
- if (parts.length === 0) {
394
- return null;
395
- }
396
- return `此 App 需要以下檔案能力:${parts.join("、")}。這是每位登入者的 DSM ACL,經 File Gateway。`;
397
- }
398
307
  function volumeSources(compose) {
399
308
  return volumeMounts(compose).map((mount) => mount.source);
400
309
  }
@@ -484,17 +393,11 @@ function renderInstallWizard(contract, compose) {
484
393
  const hasSharedFolder = params.some((item) => item.param.type === "shared_folder");
485
394
  const hasHomes = params.some((item) => item.param.type === "homes");
486
395
  const hasDsmUser = params.some((item) => item.param.type === "dsm_user");
487
- const capabilityText = renderFilesCapability(contract);
488
396
  if (compose && scanComposePrivilege(compose).length > 0) {
489
397
  items.push({
490
398
  desc: PRIVILEGE_MAX_WARNING,
491
399
  });
492
400
  }
493
- if (hasOidcCompat(contract)) {
494
- items.push({
495
- desc: OIDC_COMPAT_CONSENT,
496
- });
497
- }
498
401
  if (hasSharedFolder) {
499
402
  items.push({
500
403
  desc: SHARED_FOLDER_MOUNT_CONSENT,
@@ -510,15 +413,7 @@ function renderInstallWizard(contract, compose) {
510
413
  desc: DSM_USER_RUN_AS_CONSENT,
511
414
  });
512
415
  }
513
- if (capabilityText) {
514
- items.push({
515
- desc: capabilityText,
516
- });
517
- }
518
- // No compose at all is the native branch: the binary gets PKGVAR and nothing
519
- // else, so it earns the same line. Requiring a compose left native wizards
520
- // empty, which DSM rejects as an invalid package.
521
- if (!hasSharedFolder && !capabilityText && (!compose || composeUsesOnlyAppSpace(compose))) {
416
+ if (!hasSharedFolder && (!compose || composeUsesOnlyAppSpace(compose))) {
522
417
  items.push({
523
418
  desc: APP_OWN_SPACE_CONSENT,
524
419
  });
@@ -584,36 +479,19 @@ function renderInstallWizard(contract, compose) {
584
479
  continue;
585
480
  }
586
481
  if (param.type === "posix_all_shares") {
482
+ // A47 (install side): DSM's declarative wizard only ever renders a
483
+ // single-select combo here, so the former multiSelect control silently
484
+ // mounted exactly one Share instead of every Share. Package Center can
485
+ // therefore never carry the full Share list, and there is no honest
486
+ // control to render -- so this branch produces only an informational
487
+ // textblock. The full snapshot arrives exclusively via CLI
488
+ // extra_values (`synopkgland deploy`), which the preinst materializer
489
+ // expands. No subitem key is emitted, so the install uifile contains no
490
+ // ALL_SHARES and no CHANGE_ALL_SHARES.
587
491
  items.push({
588
492
  type: "textblock",
589
493
  desc: POSIX_ALL_SHARES_MAX_WARNING,
590
494
  });
591
- items.push({
592
- type: "combobox",
593
- multiSelect: true,
594
- desc: `POSIX all shares: ${name}`,
595
- subitems: [{
596
- key: name,
597
- desc: name,
598
- displayField: "name",
599
- valueField: "name",
600
- multiSelect: true,
601
- editable: false,
602
- forceSelection: true,
603
- autoSelect: false,
604
- mode: "remote",
605
- api_store: {
606
- api: "SYNO.Core.Share",
607
- method: "list",
608
- version: 1,
609
- baseParams: { limit: -1, offset: 0 },
610
- root: "shares",
611
- idProperty: "name",
612
- fields: ["name"],
613
- },
614
- validator: { allowBlank: false },
615
- }],
616
- });
617
495
  continue;
618
496
  }
619
497
  if (param.type === "dsm_user") {
@@ -651,6 +529,75 @@ function renderInstallWizard(contract, compose) {
651
529
  function renderUpgradeWizard(contract, compose) {
652
530
  const installWizard = renderInstallWizard(contract, compose);
653
531
  const params = collectInstallParameters(contract);
532
+ // Every type whose value must survive an upgrade the administrator did not
533
+ // deliberately change. Two mechanisms are needed because DSM renders them
534
+ // differently: a control that can be left blank just allows blank, while one
535
+ // that always carries a value needs an explicit "change this" gate.
536
+ // - dsm_user : measurably comes up blank, so allowBlank is enough
537
+ // - string : blank once the new version's default is withheld (A44)
538
+ // - shared_folder : DSM prefills the first store row
539
+ // - boolean : a checkbox has no blank state at all
540
+ // posix_all_shares is intentionally absent: it renders as a textblock with
541
+ // no subitem, so there is nothing to blank or gate on the upgrade wizard.
542
+ // Its value can only arrive via CLI extra_values, and the preinst
543
+ // change-gate shell (`upgrade_change_enabled`, `CHANGE_<name>`) still
544
+ // governs that path, reusing the canonical snapshot on an untouched upgrade.
545
+ const upgradeReuseTypes = new Map(params
546
+ .filter(({ param }) => param.type === "shared_folder" ||
547
+ param.type === "dsm_user" ||
548
+ param.type === "string" ||
549
+ param.type === "boolean")
550
+ .map(({ name, param }) => [name, param.type]));
551
+ const needsChangeGate = (type) => type === "shared_folder" || type === "boolean";
552
+ const upgradeItems = installWizard[0].items.flatMap((item) => {
553
+ if (!Array.isArray(item.subitems)) {
554
+ return [item];
555
+ }
556
+ const reuseKeys = [];
557
+ const subitems = item.subitems.map((subitem) => {
558
+ if (!subitem || typeof subitem !== "object") {
559
+ return subitem;
560
+ }
561
+ const record = subitem;
562
+ if (typeof record.key !== "string" || !upgradeReuseTypes.has(record.key)) {
563
+ return subitem;
564
+ }
565
+ reuseKeys.push(record.key);
566
+ const reused = {
567
+ ...record,
568
+ validator: { allowBlank: true },
569
+ };
570
+ // Withholding the default is what makes an untouched upgrade blank, and
571
+ // blank is what lets preinst fall through to the canonical .env value.
572
+ // Install keeps the default -- only this upgrade copy drops it.
573
+ delete reused.defaultValue;
574
+ return reused;
575
+ });
576
+ if (reuseKeys.length === 0) {
577
+ return [item];
578
+ }
579
+ const control = {
580
+ ...item,
581
+ desc: `${String(item.desc ?? "")}(${UPGRADE_VALUE_REUSE_DESC})`,
582
+ subitems,
583
+ };
584
+ const changeControls = reuseKeys
585
+ .filter((key) => needsChangeGate(upgradeReuseTypes.get(key)))
586
+ .map((key) => {
587
+ const type = upgradeReuseTypes.get(key);
588
+ const label = type === "shared_folder" ? "Shared Folder" : key;
589
+ return {
590
+ type: "multiselect",
591
+ desc: `變更 ${label}(不勾=沿用目前設定)`,
592
+ subitems: [{
593
+ key: `CHANGE_${key}`,
594
+ desc: `變更 ${label}`,
595
+ defaultValue: false,
596
+ }],
597
+ };
598
+ });
599
+ return [...changeControls, control];
600
+ });
654
601
  const sharedFolderNames = params
655
602
  .filter((item) => item.param.type === "shared_folder")
656
603
  .map((item) => item.name);
@@ -661,6 +608,14 @@ function renderUpgradeWizard(contract, compose) {
661
608
  volumeMounts(compose).some((mount) => !mount.readOnly &&
662
609
  sharedFolderNames.some((name) => mount.source === `\${${name}}` || mount.source.startsWith(`\${${name}}/`)));
663
610
  const upgradeConsent = [];
611
+ const posixAllSharesNames = params
612
+ .filter((item) => item.param.type === "posix_all_shares")
613
+ .map((item) => item.name);
614
+ if (posixAllSharesNames.length > 0) {
615
+ upgradeConsent.push({
616
+ desc: POSIX_ALL_SHARES_UPGRADE_NOTICE,
617
+ });
618
+ }
664
619
  if (hasReadWriteSharedFolder) {
665
620
  upgradeConsent.push({
666
621
  desc: SHARED_FOLDER_RW_UPGRADE_CONSENT,
@@ -675,11 +630,12 @@ function renderUpgradeWizard(contract, compose) {
675
630
  return [{
676
631
  ...installWizard[0],
677
632
  step_title: "升級設定",
633
+ items: upgradeItems,
678
634
  }];
679
635
  }
680
- const firstControl = installWizard[0].items.findIndex((item) => item.subitems !== undefined);
681
- const insertAt = firstControl === -1 ? installWizard[0].items.length : firstControl;
682
- const items = [...installWizard[0].items];
636
+ const firstControl = upgradeItems.findIndex((item) => item.subitems !== undefined);
637
+ const insertAt = firstControl === -1 ? upgradeItems.length : firstControl;
638
+ const items = [...upgradeItems];
683
639
  items.splice(insertAt, 0, ...upgradeConsent);
684
640
  return [{
685
641
  ...installWizard[0],
@@ -708,7 +664,6 @@ function renderUninstallWizard() {
708
664
  ],
709
665
  }];
710
666
  }
711
- const LEGACY_BROKER_STATE_REGISTRY_CODE = "COMPOSE_LEGACY_BROKER_STATE_PRESENT";
712
667
  function renderDynamicPreinstMaterializer(contract) {
713
668
  const allParams = collectInstallParameters(contract);
714
669
  const scalarParams = allParams.filter(({ param }) => param.type === "string" || param.type === "boolean");
@@ -721,18 +676,6 @@ function renderDynamicPreinstMaterializer(contract) {
721
676
  "umask 077",
722
677
  "# D3/D5.4 package-only carrier; values are wizard answers, canonical values, or defaults.",
723
678
  "die() { echo \"dynamic route materialization failed: $1\" >&2; exit 1; }",
724
- `# registry-code="${LEGACY_BROKER_STATE_REGISTRY_CODE}"`,
725
- 'APPDATA="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}"',
726
- 'if [ -d "$APPDATA" ]; then',
727
- ' broker_uid="$(id -u SynoPkgLandService 2>/dev/null || true)"',
728
- ' if [ -n "$broker_uid" ]; then',
729
- ' for item in "$APPDATA"/* "$APPDATA"/.[!.]* "$APPDATA"/..?*; do',
730
- ' [ -e "$item" ] || continue',
731
- ' item_uid="$(stat -c %u "$item" 2>/dev/null || true)"',
732
- ' [ "$item_uid" = "$broker_uid" ] && die "MIGRATION_UNSUPPORTED: ${SYNOPKG_PKGNAME} 曾由 Broker installer 安裝;dynamic 路線不支援就地遷移,請以新的 Package Identity 安裝或先完整卸載並清除 App Data"',
733
- " done",
734
- " fi",
735
- "fi",
736
679
  'T="${SYNOPKG_PKGINST_TEMP_DIR:?}"',
737
680
  'STATUS="${SYNOPKG_PKG_STATUS:?}"',
738
681
  'PROJECT="$T/compose"',
@@ -776,6 +719,16 @@ function renderDynamicPreinstMaterializer(contract) {
776
719
  " esac",
777
720
  "}",
778
721
  ];
722
+ // Emitted whenever anything reads a CHANGE_ gate. Boolean parameters (A44) use
723
+ // it too, and a package with only booleans has no Share resolvers -- leaving it
724
+ // behind that condition made `! upgrade_change_enabled ...` a missing command,
725
+ // which is true, so the gate silently always reused the canonical value.
726
+ const usesUpgradeChangeGate = sharedFolderParams.length > 0 ||
727
+ posixAllSharesParams.length > 0 ||
728
+ collectInstallParameters(contract).some(({ param }) => param.type === "boolean");
729
+ if (usesUpgradeChangeGate) {
730
+ 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", "}");
731
+ }
779
732
  if (sharedFolderParams.length > 0 || posixAllSharesParams.length > 0) {
780
733
  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\"", "}");
781
734
  }
@@ -785,14 +738,6 @@ function renderDynamicPreinstMaterializer(contract) {
785
738
  if (dsmUserParam) {
786
739
  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\"", "}");
787
740
  }
788
- if (hasOidcCompat(contract)) {
789
- const identity = contract.integration?.identity;
790
- const redirectPath = identity?.oidc_compat?.redirect_path ?? "";
791
- const adminProtocol = contract.adminprotocol ?? "http";
792
- const adminPort = Number(contract.adminport ?? 0);
793
- const escapedRedirectPath = redirectPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
794
- lines.push("oidc_register() {", " # DSM compose does not create missing bind sources: the secrets dir must exist even when compat is off.", " (umask 077 && mkdir -p \"$PROJECT/secrets\" && chmod 700 \"$PROJECT/secrets\") || die \"failed to create $PROJECT/secrets\"", " OIDC_ISSUER=\"\"", " OIDC_ISSUER_HOST=\"oidc.invalid\"", " OIDC_CLIENT_ID=\"\"", " OIDC_CLIENT_SECRET=\"\"", " identity_file=\"${SYNOPKGLAND_IDENTITY_FILE:-/var/packages/SynoPkgLandService/var/identity.json}\"", " if [ ! -r \"$identity_file\" ]; then", " identity_file=\"/volume1/@appdata/SynoPkgLandService/identity.json\"", " fi", " if [ ! -r \"$identity_file\" ]; then", " # Identity endpoint not found -> Broker not installed -> skip as 404", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " base_url=\"$(python3 -c 'import json, sys; data=json.load(open(sys.argv[1])); print(data.get(\"baseUrl\", \"\"))' \"$identity_file\" 2>/dev/null || true)\"", " if [ -z \"$base_url\" ]; then", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " attestation_file=\"$T/.synopkgland/attestation.jwt\"", " if [ ! -f \"$attestation_file\" ]; then", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " attestation_token=\"$(cat \"$attestation_file\")\"", " phase=\"install\"", " case \"$STATUS\" in", " UPGRADE) phase=\"upgrade\" ;;", " *) phase=\"install\" ;;", " esac", " req_body=\"$(python3 -c 'import json, sys; print(json.dumps({\"protocolVersion\": 1, \"attestation\": sys.argv[1], \"packageIdentity\": sys.argv[2], \"packageVersion\": sys.argv[3], \"redirectPath\": sys.argv[4], \"adminProtocol\": sys.argv[5], \"adminPort\": int(sys.argv[6]), \"phase\": sys.argv[7]}))' \"$attestation_token\" \"${SYNOPKG_PKGNAME:?}\" \"${SYNOPKG_PKGVER:?}\" \"" + escapedRedirectPath + "\" \"" + String(adminProtocol) + "\" \"" + String(adminPort) + "\" \"$phase\")\"", " resp_file=\"$(mktemp \"$T/oidc_resp.XXXXXX\")\"", " http_code=\"$(curl -s -S --connect-timeout 5 --max-time 30 -o \"$resp_file\" -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data-binary \"$req_body\" \"${base_url}/internal/v1/oidc/register\" 2>/dev/null || true)\"", " case \"$http_code\" in [0-9][0-9][0-9]) ;; *) http_code=\"000\" ;; esac", " if [ \"$http_code\" = \"200\" ]; then", " parsed_res=\"$(python3 -c 'import json, sys; d=json.load(open(sys.argv[1])); print(d.get(\"issuer\", \"\")); print(d.get(\"issuerHost\", \"\")); print(d.get(\"clientId\", \"\")); print(d.get(\"clientSecret\", \"\"))' \"$resp_file\" 2>/dev/null || true)\"", " rm -f \"$resp_file\"", " OIDC_ISSUER=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '1p')\"", " OIDC_ISSUER_HOST=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '2p')\"", " OIDC_CLIENT_ID=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '3p')\"", " OIDC_CLIENT_SECRET=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '4p')\"", " (umask 077 && mkdir -p \"$PROJECT/secrets\" && printf '%s' \"$OIDC_CLIENT_SECRET\" > \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\")", " elif [ \"$http_code\" = \"404\" ] || [ \"$http_code\" = \"000\" ]; then", " rm -f \"$resp_file\"", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " OIDC_ISSUER=\"\"", " OIDC_ISSUER_HOST=\"oidc.invalid\"", " OIDC_CLIENT_ID=\"\"", " else", " rm -f \"$resp_file\"", " die \"OIDC_REGISTER_FAILED $http_code\"", " fi", "}", "oidc_register");
795
- }
796
741
  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", "{");
797
742
  const valueVars = [];
798
743
  for (const { name, param } of scalarParams) {
@@ -804,16 +749,27 @@ function renderDynamicPreinstMaterializer(contract) {
804
749
  : type === "boolean"
805
750
  ? "false"
806
751
  : "";
807
- lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${valueVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
752
+ // A44: on UPGRADE the wizard always submits something for these controls, so
753
+ // "was it submitted" cannot mean "did the administrator choose it". A blank
754
+ // string, or a boolean whose CHANGE_ gate is unchecked, means "keep what is
755
+ // installed" -- otherwise the new version's default would silently replace a
756
+ // value chosen at install time. INSTALL is unaffected: STATUS is not UPGRADE,
757
+ // so the reuse branch is skipped and the default still applies.
758
+ const reuseGuard = type === "boolean"
759
+ ? `! upgrade_change_enabled "\${CHANGE_${name}:-}"`
760
+ : `[ -z "\${${name}:-}" ]`;
761
+ lines.push(` if [ "$STATUS" = "UPGRADE" ] && ${reuseGuard} && [ -f "$CANONICAL_ENV" ] && ` +
762
+ `[ ! -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" ] && ' +
808
763
  `${valueVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", " " + valueVar + "=" + shellQuote(fallback), " fi");
809
764
  }
810
765
  const shareVars = [];
811
766
  for (const { name } of sharedFolderParams) {
812
767
  const rawVar = "D54_RAW_SHARE_" + name;
813
768
  const pathVar = "D54_RESOLVED_PATH_" + name;
769
+ const changeVar = "CHANGE_" + name;
814
770
  shareVars.push({ name, pathVar });
815
- lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
816
- `${rawVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", ` die "missing required shared folder parameter ${name}"`, " fi", ` case "$${rawVar}" in`, " /*) " + pathVar + "=\"$" + rawVar + "\" ;;", " *) " + pathVar + "=\"$(resolve_shared_folder \"$" + rawVar + "\")\" ;;", " esac");
771
+ 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" ] && ' +
772
+ `${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");
817
773
  }
818
774
  const posixVars = [];
819
775
  for (const { name } of posixAllSharesParams) {
@@ -821,22 +777,18 @@ function renderDynamicPreinstMaterializer(contract) {
821
777
  const namesVar = "D54_POSIX_NAMES_" + name;
822
778
  const pathsVar = "D54_POSIX_PATHS_" + name;
823
779
  const countVar = "D54_POSIX_COUNT_" + name;
780
+ const changeVar = "CHANGE_" + name;
824
781
  posixVars.push({ name, namesVar, pathsVar, countVar });
825
- lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
826
- `${rawVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " 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"`);
782
+ 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" ] && ' +
783
+ `${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"`);
827
784
  }
828
785
  if (dsmUserParam) {
829
786
  const name = dsmUserParam.name;
830
787
  const rawUserVar = "D54_RAW_USER_" + name;
831
- lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawUserVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
832
- `${rawUserVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " 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"`);
833
- }
834
- if (hasOidcCompat(contract)) {
835
- lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "${SYNOPKG_PKGDEST_VOL:?}/@appstore/${SYNOPKG_PKGNAME:?}/compose/secrets"', ' append_env "SYNOPKGLAND_OIDC_ISSUER" "$OIDC_ISSUER"', ' append_env "SYNOPKGLAND_OIDC_ISSUER_HOST" "$OIDC_ISSUER_HOST"', ' append_env "SYNOPKGLAND_OIDC_CLIENT_ID" "$OIDC_CLIENT_ID"');
836
- }
837
- else {
838
- lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
788
+ lines.push(` if [ "\${${name}+x}" = x ] && [ -n "\${${name}}" ]; then`, ` ${rawUserVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
789
+ `${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"`);
839
790
  }
791
+ lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
840
792
  for (const { name, valueVar, type } of valueVars) {
841
793
  lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
842
794
  }
@@ -910,29 +862,11 @@ function renderManagedScript(name, contract, nativeContext, isResourceWorker = f
910
862
  if (name === "start-stop-status") {
911
863
  return renderNativeStartStopStatus(lifecycleInput);
912
864
  }
913
- if (name === "preinst" || name === "preupgrade") {
914
- return BROKER_SOCKET_REQUIRED_SCRIPT;
915
- }
916
865
  if (name === "postinst" || name === "postupgrade") {
917
- const installerScript = renderInstallerCallScript(contract, name === "postupgrade" ? "update" : "install");
918
- // The service process needs its scratch directory before it can start,
919
- // and the installer call is the script's exit status, so widen the
920
- // existing mkdir rather than appending after it.
921
- return installerScript.replace('mkdir -p "${SYNOPKG_PKGVAR}" "${SYNOPKG_PKGHOME}/secrets"', 'mkdir -p "${SYNOPKG_PKGVAR}" "${SYNOPKG_PKGVAR}/tmp" "${SYNOPKG_PKGHOME}/secrets"');
866
+ return renderNativePostinst(lifecycleInput);
922
867
  }
923
868
  if (name === "preuninst") {
924
- // Compose installer uninstall (with UPGRADE early exit) + stop native process
925
- const installerScript = renderInstallerCallScript(contract, "uninstall").replace("set -eu\n", 'set -eu\nif [ "${SYNOPKG_PKG_STATUS:-}" = "UPGRADE" ]; then\n exit 0\nfi\n');
926
- // Stop the process BEFORE deregistering: `set -eu` aborts the script if
927
- // the installer fails, so a stop appended after it would never run. The
928
- // stop itself is best effort -- DSM already calls `start-stop-status
929
- // stop` directly (N0), and a non-zero preuninst blocks uninstall.
930
- const preuninstStopBlock = `CONTROL="/var/packages/\${SYNOPKG_PKGNAME:-${contract.package}}/scripts/start-stop-status"
931
- if [ -x "$CONTROL" ]; then
932
- "$CONTROL" stop 2>/dev/null || true
933
- fi
934
- `;
935
- return installerScript.replace('"$INSTALLER"', `${preuninstStopBlock}"$INSTALLER"`);
869
+ return renderNativePreuninst(lifecycleInput);
936
870
  }
937
871
  if (name === "postuninst") {
938
872
  return renderNativePostuninst(lifecycleInput);
@@ -967,93 +901,7 @@ ${projectRoot}${renderDeleteAppDataBlock(deleteRoots)}exit 0
967
901
  }
968
902
  return `#!/bin/sh\nexit 0\n`;
969
903
  }
970
- if (name === "start-stop-status") {
971
- return `#!/bin/sh
972
- ${BROKER_INSTALLER_VARS}
973
- case "$1" in
974
- start) exit 0 ;;
975
- stop) exit 0 ;;
976
- status)
977
- ${renderBrokerStatusProbe(String(contract.package))}
978
- exit 0
979
- ;;
980
- *) exit 1 ;;
981
- esac
982
- `;
983
- }
984
- if (name === "preinst" || name === "preupgrade") {
985
- return BROKER_SOCKET_REQUIRED_SCRIPT;
986
- }
987
- if (name === "postinst" || name === "postupgrade") {
988
- return renderInstallerCallScript(contract, name === "postupgrade" ? "update" : "install");
989
- }
990
- if (name === "preuninst") {
991
- return renderInstallerCallScript(contract, "uninstall").replace("set -eu\n", 'set -eu\nif [ "${SYNOPKG_PKG_STATUS:-}" = "UPGRADE" ]; then\n exit 0\nfi\n');
992
- }
993
- if (name === "postuninst") {
994
- return `#!/bin/sh
995
- set -eu
996
- PKGVAR="\${SYNOPKG_PKGVAR}"
997
- PKGHOME="\${SYNOPKG_PKGHOME}"
998
- SECRETS="\${PKGHOME}/secrets"
999
- ${renderDeleteAppDataBlock(["PKGVAR", "SECRETS"])}exit 0
1000
- `;
1001
- }
1002
- return `#!/bin/sh
1003
- exit 0
1004
- `;
1005
- }
1006
- function renderInstallerCallScript(contract, phase) {
1007
- const params = collectInstallParameters(contract);
1008
- const secrets = params.filter((item) => item.param.type === "secret");
1009
- const nonSecrets = params.filter((item) => item.param.type !== "secret");
1010
- const lines = [
1011
- "#!/bin/sh",
1012
- "set -eu",
1013
- 'INSTALLER="${BROKER_INSTALLER:-/var/packages/SynoPkgLandService/target/bin/synopkgland-installer}"',
1014
- 'SOCKET="${BROKER_SOCKET:-/var/packages/SynoPkgLandService/var/installer.sock}"',
1015
- `PACKAGE_ROOT="/var/packages/${contract.package}"`,
1016
- 'ATTEMPT_ID="${SYNOPKG_PKGNAME}-${SYNOPKG_PKGVER}-$$"',
1017
- 'WORK="$(mktemp -d /tmp/synopkgland-install.XXXXXX)"',
1018
- 'chmod 700 "$WORK"',
1019
- "cleanup() { rm -rf \"$WORK\"; }",
1020
- "trap cleanup EXIT",
1021
- 'mkdir -p "${SYNOPKG_PKGVAR}" "${SYNOPKG_PKGHOME}/secrets"',
1022
- 'chmod 770 "${SYNOPKG_PKGVAR}" "${SYNOPKG_PKGHOME}" "${SYNOPKG_PKGHOME}/secrets" 2>/dev/null || true',
1023
- 'chgrp synopkgs "${SYNOPKG_PKGVAR}" "${SYNOPKG_PKGHOME}" "${SYNOPKG_PKGHOME}/secrets" 2>/dev/null || true',
1024
- 'VARS=""',
1025
- 'SECRET_FILES=""',
1026
- ];
1027
- for (const { name, param } of secrets) {
1028
- if (param.generate === true) {
1029
- continue;
1030
- }
1031
- lines.push(`if [ -n "\${${name}+x}" ]; then`);
1032
- lines.push(" umask 077");
1033
- lines.push(` printf '%s' "\$${name}" > "$WORK/${name}"`);
1034
- lines.push(` unset ${name}`);
1035
- lines.push(` SECRET_FILES="$SECRET_FILES --secret-file ${name}=$WORK/${name}"`);
1036
- lines.push("fi");
1037
- }
1038
- for (const { name } of nonSecrets) {
1039
- lines.push(`if [ -n "\${${name}+x}" ]; then`);
1040
- lines.push(` VARS="$VARS --var ${name}=\$${name}"`);
1041
- lines.push("fi");
1042
- }
1043
- const generateFlags = secrets
1044
- .filter((item) => item.param.generate === true)
1045
- .map((item) => `--generate-secret ${item.name}`)
1046
- .join(" ");
1047
- lines.push(`"$INSTALLER" --socket "$SOCKET" --phase ${phase} --attempt-id "$ATTEMPT_ID" \\`);
1048
- lines.push(` --package-identity "${contract.package}" --package-version "\${SYNOPKG_PKGVER}" \\`);
1049
- lines.push(' --package-path "$PACKAGE_ROOT/target" --bundle-path "$PACKAGE_ROOT/target" \\');
1050
- lines.push(' --pkg-var "$PACKAGE_ROOT/var" --pkg-home "$PACKAGE_ROOT/home" \\');
1051
- if (generateFlags) {
1052
- lines.push(` ${generateFlags} \\`);
1053
- }
1054
- lines.push(' $VARS $SECRET_FILES');
1055
- lines.push("exit $?");
1056
- return `${lines.join("\n")}\n`;
904
+ throw new Error("Unsupported container route");
1057
905
  }
1058
906
  async function createDeterministicSpk(stagingDir, outputPath) {
1059
907
  const payloadDir = join(stagingDir, "package");