@synopackageland/cli 0.3.0 → 0.3.2

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 (69) hide show
  1. package/dist/attestation/app-package.js +8 -1
  2. package/dist/attestation/platform-service.d.ts +70 -0
  3. package/dist/attestation/platform-service.js +250 -0
  4. package/dist/bin.js +0 -0
  5. package/dist/build-spk/generator.js +210 -14
  6. package/dist/build-spk/resource-worker.d.ts +14 -0
  7. package/dist/build-spk/resource-worker.js +140 -0
  8. package/dist/cli.js +69 -10
  9. package/dist/compose/analyze.d.ts +41 -1
  10. package/dist/compose/analyze.js +458 -18
  11. package/dist/contract/load.d.ts +1 -0
  12. package/dist/contract/load.js +1 -1
  13. package/dist/contract/posix-all-shares.d.ts +16 -0
  14. package/dist/contract/posix-all-shares.js +63 -0
  15. package/dist/contract/schema-validate.js +26 -1
  16. package/dist/contract/validate.js +67 -0
  17. package/dist/contract/web-launch.d.ts +6 -0
  18. package/dist/contract/web-launch.js +25 -0
  19. package/dist/deploy/deploy.d.ts +3 -0
  20. package/dist/deploy/deploy.js +38 -2
  21. package/dist/deploy/homes-values.d.ts +19 -0
  22. package/dist/deploy/homes-values.js +29 -0
  23. package/dist/deploy/param-values.d.ts +21 -0
  24. package/dist/deploy/param-values.js +51 -0
  25. package/dist/deploy/spk-version.d.ts +5 -0
  26. package/dist/deploy/spk-version.js +50 -18
  27. package/dist/doctor.d.ts +22 -0
  28. package/dist/doctor.js +594 -0
  29. package/dist/error-collector.d.ts +2 -0
  30. package/dist/error-collector.js +46 -0
  31. package/dist/error-registry.d.ts +7 -0
  32. package/dist/error-registry.js +827 -0
  33. package/dist/host/broker-upgrade-preflight.d.ts +61 -0
  34. package/dist/host/broker-upgrade-preflight.js +156 -0
  35. package/dist/host/catalog-install.d.ts +12 -0
  36. package/dist/host/catalog-install.js +83 -17
  37. package/dist/host/credentials.js +35 -5
  38. package/dist/host/dsm-client.d.ts +12 -0
  39. package/dist/host/dsm-client.js +56 -1
  40. package/dist/host/dsm-compound.d.ts +37 -0
  41. package/dist/host/dsm-compound.js +120 -0
  42. package/dist/host/fake-nas.d.ts +1 -0
  43. package/dist/host/fake-nas.js +3 -0
  44. package/dist/host/types.d.ts +4 -0
  45. package/dist/publish/curated-release.js +3 -3
  46. package/dist/publish/platform-service-release.d.ts +60 -0
  47. package/dist/publish/platform-service-release.js +333 -0
  48. package/dist/publish/preflight.js +14 -1
  49. package/dist/release-bin.js +5 -1
  50. package/dist/templates/hello-files/compose.yaml +2 -0
  51. package/dist/templates/hello-web/compose.yaml +14 -3
  52. package/dist/templates/hello-web/synology-app.yaml +1 -1
  53. package/dist/templates/hello-web-page/compose.yaml +14 -3
  54. package/dist/templates/hello-web-page/synology-app.yaml +1 -1
  55. package/dist/test-host.d.ts +2 -0
  56. package/dist/test-host.js +15 -2
  57. package/dist/types.d.ts +1 -1
  58. package/dist/validate.js +14 -3
  59. package/package.json +2 -2
  60. package/schema/doctor.schema.json +38 -0
  61. package/schema/synology-app.schema.json +1 -1
  62. package/skills/reference/create.md +19 -0
  63. package/skills/reference/diagnose.md +35 -2
  64. package/skills/reference/files.md +10 -0
  65. package/templates/hello-files/compose.yaml +2 -0
  66. package/templates/hello-web/compose.yaml +14 -3
  67. package/templates/hello-web/synology-app.yaml +1 -1
  68. package/templates/hello-web-page/compose.yaml +14 -3
  69. package/templates/hello-web-page/synology-app.yaml +1 -1
@@ -5,7 +5,7 @@ import { createHash } from "node:crypto";
5
5
  import * as tar from "tar";
6
6
  import { loadContract, collectInstallParameters } from "../contract/load.js";
7
7
  import { renderDsmWindowConfig, resolvedWebLaunch, trustedAppEntryPath, } from "../contract/web-launch.js";
8
- import { loadCompose } from "../compose/analyze.js";
8
+ import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
9
9
  import { scanComposePrivilege } from "../compose/privilege.js";
10
10
  import { validateProject } from "../validate.js";
11
11
  import { writeDsmWindowIcons, writePackageCenterIcons } from "./dsm-icons.js";
@@ -16,6 +16,7 @@ import { BROKER_INSTALLER_VARS, BROKER_SOCKET_REQUIRED_SCRIPT, renderBrokerStatu
16
16
  import { loadNative } from "../native/load.js";
17
17
  import { materializeNativePayload } from "./native-payload.js";
18
18
  import { renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuninst, } from "./native-lifecycle.js";
19
+ import { renderResourceConfig, renderResourceWorkerStartStopStatus } from "./resource-worker.js";
19
20
  const FILE_MODE = 0o644;
20
21
  const SCRIPT_MODE = 0o755;
21
22
  const INSTALL_UIFILE = "install_uifile";
@@ -129,30 +130,56 @@ export async function buildSpk(options) {
129
130
  rmSync(staging, { recursive: true, force: true });
130
131
  }
131
132
  }
133
+ const parsedCompose = loadCompose(options.project.composePath);
134
+ const route = parsedCompose?.compose
135
+ ? classifyComposeRoute(contract, parsedCompose)
136
+ : undefined;
137
+ if (route && route.errors.length > 0) {
138
+ return { ok: false, errors: route.errors.map((error) => error.code) };
139
+ }
140
+ const rwEligibility = route?.eligibility;
141
+ const isDynamicRoute = route?.route === "dynamic" &&
142
+ rwEligibility?.adminport !== undefined &&
143
+ collectInstallParameters(contract).length > 0;
144
+ const isResourceWorkerRoute = route?.route === "static-zero-config" ||
145
+ isDynamicRoute;
132
146
  const staging = mkdtempSync(join(tmpdir(), "syno-spk-"));
133
147
  try {
134
- writeFileSync(join(staging, "INFO"), renderInfo(contract, options.archFamily), "utf8");
148
+ writeFileSync(join(staging, "INFO"), renderInfo(contract, options.archFamily, isResourceWorkerRoute), "utf8");
135
149
  writePackageCenterIcons(staging);
136
150
  mkdirSync(join(staging, "conf"), { recursive: true });
137
151
  writeFileSync(join(staging, "conf", "privilege"), renderPrivilege(), "utf8");
138
152
  writeFileSync(join(staging, "privilege"), renderPrivilege(), "utf8");
153
+ if (isResourceWorkerRoute) {
154
+ const resourceConfig = renderResourceConfig(contract, rwEligibility);
155
+ writeFileSync(join(staging, "conf", "resource"), resourceConfig, "utf8");
156
+ chmodSync(join(staging, "conf", "resource"), FILE_MODE);
157
+ }
139
158
  mkdirSync(join(staging, "WIZARD_UIFILES"), { recursive: true });
140
- const parsedCompose = loadCompose(options.project.composePath);
141
159
  writeFileSync(join(staging, "WIZARD_UIFILES", INSTALL_UIFILE), JSON.stringify(renderInstallWizard(contract, parsedCompose?.compose), null, 2), "utf8");
142
160
  writeFileSync(join(staging, "WIZARD_UIFILES", UPGRADE_UIFILE), JSON.stringify(renderUpgradeWizard(contract, parsedCompose?.compose), null, 2), "utf8");
143
161
  writeFileSync(join(staging, "WIZARD_UIFILES", UNINSTALL_UIFILE), JSON.stringify(renderUninstallWizard(), null, 2), "utf8");
144
162
  mkdirSync(join(staging, "scripts"), { recursive: true });
145
163
  for (const name of SCRIPT_NAMES) {
146
164
  const scriptPath = join(staging, "scripts", name);
147
- writeFileSync(scriptPath, renderManagedScript(name, contract), "utf8");
165
+ writeFileSync(scriptPath, renderManagedScript(name, contract, undefined, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute), "utf8");
148
166
  chmodSync(scriptPath, SCRIPT_MODE);
149
167
  }
150
168
  mkdirSync(join(staging, "package"), { recursive: true });
151
169
  copyFileSync(options.project.composePath, join(staging, "package", "compose.yaml"));
152
170
  copyFileSync(options.project.contractPath, join(staging, "package", "synology-app.yaml"));
171
+ if (isResourceWorkerRoute) {
172
+ const resourceProjectDir = join(staging, "package", "compose");
173
+ mkdirSync(resourceProjectDir, { recursive: true });
174
+ copyFileSync(options.project.composePath, join(resourceProjectDir, "docker-compose.yml"));
175
+ chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
176
+ }
153
177
  const htmlDir = join(options.project.root, "html");
154
178
  if (existsSync(htmlDir)) {
155
179
  cpSync(htmlDir, join(staging, "package", "html"), { recursive: true });
180
+ if (isResourceWorkerRoute) {
181
+ cpSync(htmlDir, join(staging, "package", "compose", "html"), { recursive: true });
182
+ }
156
183
  }
157
184
  if (options.embeddedAttestationToken !== undefined) {
158
185
  const attestationPath = join(staging, "package", EMBEDDED_ATTESTATION_RELATIVE_PATH);
@@ -190,7 +217,7 @@ function quoteInfo(value) {
190
217
  function spkVersion(version) {
191
218
  return /-\d+$/.test(version) ? version : `${version}-0001`;
192
219
  }
193
- function renderInfo(contract, archFamily) {
220
+ function renderInfo(contract, archFamily, isResourceWorker = false) {
194
221
  const lines = [];
195
222
  const add = (key, value) => {
196
223
  lines.push(`${key}=${quoteInfo(String(value))}`);
@@ -202,10 +229,22 @@ function renderInfo(contract, archFamily) {
202
229
  add("maintainer", String(contract.maintainer));
203
230
  add("arch", archFamily === undefined ? "noarch" : infoArchValue(archFamily));
204
231
  add("os_min_ver", String(contract.os_min_ver));
205
- // synopkg's Container Manager dep gate reports "not ready" even when CM is
206
- // running. Depend on Broker only; Broker's adapter.probe() checks CM at
207
- // App install time (CM_CAPABILITY_MISSING).
208
- add("install_dep_packages", "SynoPkgLandService>=0.1.0-0016");
232
+ if (isResourceWorker) {
233
+ // DSM reports Container Manager as "not ready" through the package
234
+ // dependency gate even while pkgctl-ContainerManager is running. The
235
+ // docker-project worker itself remains the authoritative live prerequisite
236
+ // (ContainerManager >= 1432); keep only the reliable Web Station package
237
+ // dependency here so a valid resource-worker SPK can reach acquisition.
238
+ if (contract.adminport !== undefined) {
239
+ add("install_dep_packages", "WebStation");
240
+ }
241
+ }
242
+ else {
243
+ // synopkg's Container Manager dep gate reports "not ready" even when CM is
244
+ // running. Depend on Broker only; Broker's adapter.probe() checks CM at
245
+ // App install time (CM_CAPABILITY_MISSING).
246
+ add("install_dep_packages", "SynoPkgLandService>=0.1.0-0016");
247
+ }
209
248
  add("startable", "yes");
210
249
  const launch = resolvedWebLaunch(contract);
211
250
  if (contract.adminprotocol) {
@@ -243,6 +282,7 @@ function renderPrivilege() {
243
282
  }, null, 2)}\n`;
244
283
  }
245
284
  const SHARED_FOLDER_MOUNT_CONSENT = "此 App 會用你在這台 NAS 選的資料夾;整顆應用都看得到。Shared Folder Mount 是整個 App Instance 的 container access:能打開這個 App 的使用者,容器裡的程式都看得到該 Share,即使該使用者在 File Station 看不到。這不是逐使用者 DSM ACL。要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
285
+ const HOMES_MOUNT_CONSENT = "此 App 會使用這台 NAS 已啟用的 User Home;整顆應用的容器都看得到,容器路徑由 Compose 決定。這不是逐使用者 DSM ACL;要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
246
286
  const DSM_USER_RUN_AS_CONSENT = "寫進 Share 的新檔在 File Station 顯示為你選的那位 DSM 使用者。點開此 App 的人共用這個磁碟身分,不是逐登入 ACL。";
247
287
  const DSM_USER_UPGRADE_CONSENT = "升級時更換 Container Run-as 不會 chown Share 上的舊檔;只有升級後寫入的新檔才會使用新的磁碟身分。";
248
288
  const SHARED_FOLDER_RW_UPGRADE_CONSENT = "此版本把 Share 掛載改為讀寫;容器可讀取並寫入你選的 Share,不再是唯讀。";
@@ -315,11 +355,12 @@ function volumeMounts(compose) {
315
355
  }
316
356
  return mounts;
317
357
  }
358
+ // A Compose that mounts nothing reaches even less of the NAS than one that
359
+ // mounts only PKGVAR, so it earns the same consent line. Returning false for
360
+ // zero sources left the wizard step with no items at all, and DSM's Package
361
+ // Center rejects that SPK with "Invalid file format".
318
362
  function composeUsesOnlyAppSpace(compose) {
319
363
  const sources = volumeSources(compose);
320
- if (sources.length === 0) {
321
- return false;
322
- }
323
364
  return sources.every((source) => source.startsWith("${SYNOPKG_PKGVAR}") || source.startsWith("${SYNO_APP_SECRETS_DIR}"));
324
365
  }
325
366
  const PRIVILEGE_MAX_WARNING = "此 App 的容器有高權限,接近這台 NAS 的 root、幾乎整台機器。是否安裝由你這位管理員決定。";
@@ -327,6 +368,7 @@ function renderInstallWizard(contract, compose) {
327
368
  const items = [];
328
369
  const params = collectInstallParameters(contract);
329
370
  const hasSharedFolder = params.some((item) => item.param.type === "shared_folder");
371
+ const hasHomes = params.some((item) => item.param.type === "homes");
330
372
  const hasDsmUser = params.some((item) => item.param.type === "dsm_user");
331
373
  const capabilityText = renderFilesCapability(contract);
332
374
  if (compose && scanComposePrivilege(compose).length > 0) {
@@ -344,6 +386,11 @@ function renderInstallWizard(contract, compose) {
344
386
  desc: SHARED_FOLDER_MOUNT_CONSENT,
345
387
  });
346
388
  }
389
+ if (hasHomes) {
390
+ items.push({
391
+ desc: HOMES_MOUNT_CONSENT,
392
+ });
393
+ }
347
394
  if (hasDsmUser) {
348
395
  items.push({
349
396
  desc: DSM_USER_RUN_AS_CONSENT,
@@ -354,7 +401,10 @@ function renderInstallWizard(contract, compose) {
354
401
  desc: capabilityText,
355
402
  });
356
403
  }
357
- if (!hasSharedFolder && !capabilityText && compose && composeUsesOnlyAppSpace(compose)) {
404
+ // No compose at all is the native branch: the binary gets PKGVAR and nothing
405
+ // else, so it earns the same line. Requiring a compose left native wizards
406
+ // empty, which DSM rejects as an invalid package.
407
+ if (!hasSharedFolder && !capabilityText && (!compose || composeUsesOnlyAppSpace(compose))) {
358
408
  items.push({
359
409
  desc: APP_OWN_SPACE_CONSENT,
360
410
  });
@@ -511,7 +561,128 @@ function renderUninstallWizard() {
511
561
  ],
512
562
  }];
513
563
  }
514
- function renderManagedScript(name, contract, nativeContext) {
564
+ function renderDynamicPreinstMaterializer(contract) {
565
+ const params = collectInstallParameters(contract).filter(({ param }) => param.type === "string" || param.type === "boolean");
566
+ const lines = [
567
+ "#!/bin/sh",
568
+ "set -eu",
569
+ "umask 077",
570
+ "# D3 package-only carrier; values are wizard answers, canonical values, or defaults.",
571
+ "die() { echo \"dynamic route materialization failed: $1\" >&2; exit 1; }",
572
+ 'T="${SYNOPKG_PKGINST_TEMP_DIR:?}"',
573
+ 'STATUS="${SYNOPKG_PKG_STATUS:?}"',
574
+ 'PROJECT="$T/compose"',
575
+ 'RUNTIME_ENV="$PROJECT/.env"',
576
+ 'CANONICAL_ENV="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}/.env"',
577
+ 'PLATFORM_PKGVAR="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}"',
578
+ 'PLATFORM_SECRETS="${SYNOPKG_PKGDEST_VOL:?}/@apphome/${SYNOPKG_PKGNAME:?}/secrets"',
579
+ '[ -d "$PROJECT" ] || die "compose directory is missing"',
580
+ '[ ! -L "$PROJECT" ] || die "compose directory is a symlink"',
581
+ '[ "$(stat -c %u "$PROJECT")" = "$(id -u)" ] || die "compose directory owner is not the package user"',
582
+ '[ ! -L "$RUNTIME_ENV" ] || die "runtime file is a symlink"',
583
+ 'if [ -e "$RUNTIME_ENV" ] && [ ! -f "$RUNTIME_ENV" ]; then die "runtime path is not a regular file"; fi',
584
+ "read_env_value() {",
585
+ " file=\"$1\"",
586
+ " key=\"$2\"",
587
+ " 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\"",
588
+ "}",
589
+ "append_env() {",
590
+ " key=\"$1\"",
591
+ " value=\"$2\"",
592
+ " type=\"${3:-string}\"",
593
+ " if printf '%s' \"$value\" | LC_ALL=C grep -q '[[:cntrl:]]'; then",
594
+ " die \"control character in environment value $key\"",
595
+ " fi",
596
+ " case \"$value\" in",
597
+ " *[[:cntrl:]]*) die \"control character in environment value $key\" ;;",
598
+ " esac",
599
+ " if [ \"$type\" = \"boolean\" ]; then",
600
+ " normalized=\"$(printf '%s' \"$value\" | LC_ALL=C tr '[:upper:]' '[:lower:]')\"",
601
+ " case \"$normalized\" in",
602
+ " true|false) value=\"$normalized\" ;;",
603
+ " *) die \"invalid boolean value $key\" ;;",
604
+ " esac",
605
+ " fi",
606
+ " case \"$value\" in",
607
+ " *[!A-Za-z0-9_./:@-]*)",
608
+ " escaped=\"$(printf '%s' \"$value\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\"",
609
+ " printf '%s=\"%s\"\\n' \"$key\" \"$escaped\"",
610
+ " ;;",
611
+ " *) printf '%s=%s\\n' \"$key\" \"$value\" ;;",
612
+ " esac",
613
+ "}",
614
+ 'tmp="$(mktemp "$PROJECT/.env.tmp.XXXXXX")"',
615
+ 'chmod 600 "$tmp"',
616
+ '[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"',
617
+ '[ "$(stat -c %u "$tmp")" = "$(id -u)" ] || die "temporary env owner is not the package user"',
618
+ "trap 'rm -f \"$tmp\"' EXIT HUP INT TERM",
619
+ "{",
620
+ ];
621
+ const valueVars = [];
622
+ for (const { name, param } of params) {
623
+ const type = param.type;
624
+ const valueVar = "D3_VALUE_" + name;
625
+ valueVars.push({ name, valueVar, type });
626
+ const fallback = param.default !== undefined
627
+ ? String(param.default)
628
+ : type === "boolean"
629
+ ? "false"
630
+ : "";
631
+ lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${valueVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
632
+ `${valueVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", " " + valueVar + "=" + shellQuote(fallback), " fi");
633
+ }
634
+ lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
635
+ for (const { name, valueVar, type } of valueVars) {
636
+ lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
637
+ }
638
+ 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");
639
+ return lines.join("\n") + "\n";
640
+ }
641
+ function renderDynamicCanonicalMirror() {
642
+ const lines = [
643
+ "#!/bin/sh",
644
+ "set -eu",
645
+ "umask 077",
646
+ "# D3 post-worker mirror; the runtime .env is the only source of truth here.",
647
+ "die() { echo \"dynamic route canonical mirror failed: $1\" >&2; exit 1; }",
648
+ 'PKGVAR="${SYNOPKG_PKGVAR:?}"',
649
+ 'PKGHOME="${SYNOPKG_PKGHOME:?}"',
650
+ 'PKGDEST="${SYNOPKG_PKGDEST:?}"',
651
+ 'PROJECT="${PKGDEST}/compose"',
652
+ 'RUNTIME_ENV="${PROJECT}/.env"',
653
+ 'CANONICAL_ENV="${PKGVAR}/.env"',
654
+ 'SECRETS="${PKGHOME}/secrets"',
655
+ 'mkdir -p "$PKGVAR"',
656
+ '[ -d "$PKGVAR" ] || die "PKGVAR is not a directory"',
657
+ 'if [ -L "$SECRETS" ]; then die "secret root is a symlink"; fi',
658
+ 'mkdir -p "$SECRETS"',
659
+ '[ -d "$SECRETS" ] || die "secret root is not a directory"',
660
+ 'chmod 700 "$SECRETS"',
661
+ '[ "$(stat -c %a "$SECRETS")" = "700" ] || die "secret root mode is not 0700"',
662
+ '[ -d "$PROJECT" ] || die "project directory is missing"',
663
+ '[ ! -L "$PROJECT" ] || die "project directory is a symlink"',
664
+ '[ ! -L "$RUNTIME_ENV" ] || die "runtime file is a symlink"',
665
+ '[ -f "$RUNTIME_ENV" ] || die "runtime file is not a regular file"',
666
+ '[ ! -L "$CANONICAL_ENV" ] || die "canonical file is a symlink"',
667
+ 'if [ -e "$CANONICAL_ENV" ] && [ ! -f "$CANONICAL_ENV" ]; then die "canonical path is not a regular file"; fi',
668
+ 'tmp="$(mktemp "$PKGVAR/.env.tmp.XXXXXX")"',
669
+ 'chmod 600 "$tmp"',
670
+ '[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"',
671
+ 'trap \'rm -f "$tmp"\' EXIT HUP INT TERM',
672
+ 'cp "$RUNTIME_ENV" "$tmp"',
673
+ 'chmod 600 "$tmp"',
674
+ 'mv "$tmp" "$CANONICAL_ENV"',
675
+ 'trap - EXIT HUP INT TERM',
676
+ '[ ! -L "$CANONICAL_ENV" ] || die "canonical file became a symlink"',
677
+ '[ "$(stat -c %a "$CANONICAL_ENV")" = "600" ] || die "canonical env mode is not 0600"',
678
+ "exit 0",
679
+ ];
680
+ return lines.join("\n") + "\n";
681
+ }
682
+ function shellQuote(value) {
683
+ return "'" + value.replace(/'/g, "'\\''") + "'";
684
+ }
685
+ function renderManagedScript(name, contract, nativeContext, isResourceWorker = false, resourceWorkerAdminport, isDynamicRoute = false) {
515
686
  if (nativeContext) {
516
687
  const lifecycleInput = {
517
688
  packageIdentity: String(contract.package),
@@ -549,6 +720,31 @@ fi
549
720
  }
550
721
  return `#!/bin/sh\nexit 0\n`;
551
722
  }
723
+ if (isResourceWorker) {
724
+ if (name === "start-stop-status") {
725
+ return renderResourceWorkerStartStopStatus(resourceWorkerAdminport);
726
+ }
727
+ if (isDynamicRoute && name === "preinst") {
728
+ return renderDynamicPreinstMaterializer(contract);
729
+ }
730
+ if (isDynamicRoute && (name === "postinst" || name === "postupgrade")) {
731
+ return renderDynamicCanonicalMirror();
732
+ }
733
+ if (name === "postuninst") {
734
+ const projectRoot = isDynamicRoute ? 'PROJECT="${SYNOPKG_PKGDEST}/compose"\n' : "";
735
+ const deleteRoots = isDynamicRoute
736
+ ? ["PKGVAR", "PROJECT", "SECRETS"]
737
+ : ["PKGVAR", "SECRETS"];
738
+ return `#!/bin/sh
739
+ set -eu
740
+ PKGVAR="\${SYNOPKG_PKGVAR}"
741
+ PKGHOME="\${SYNOPKG_PKGHOME}"
742
+ SECRETS="\${PKGHOME}/secrets"
743
+ ${projectRoot}${renderDeleteAppDataBlock(deleteRoots)}exit 0
744
+ `;
745
+ }
746
+ return `#!/bin/sh\nexit 0\n`;
747
+ }
552
748
  if (name === "start-stop-status") {
553
749
  return `#!/bin/sh
554
750
  ${BROKER_INSTALLER_VARS}
@@ -0,0 +1,14 @@
1
+ import type { AppContract } from "../types.js";
2
+ import type { ResourceWorkerEligibility } from "../compose/analyze.js";
3
+ export { classifyComposeRoute, resourceWorkerEligibility, type ComposeRoute, type ComposeRouteClassification, type ResourceWorkerEligibility, type ResourceWorkerIneligibleReason, } from "../compose/analyze.js";
4
+ export declare function composeProjectName(packageId: string): string;
5
+ export declare function renderDockerProjectResource(packageId: string, projectPath?: string): Record<string, unknown>;
6
+ export declare function renderWebserviceResource(contract: AppContract, adminport: number): Record<string, unknown>;
7
+ export declare function renderResourceConfig(contract: AppContract, eligibility: ResourceWorkerEligibility): string;
8
+ /**
9
+ * Renders the lifecycle control script for the Docker Project resource-worker
10
+ * route. ContainerManager owns the actual project start/stop operation through
11
+ * the worker timing hooks; the package script must wait for that operation to
12
+ * leave a reachable service and must not claim running unconditionally.
13
+ */
14
+ export declare function renderResourceWorkerStartStopStatus(adminport?: number): string;
@@ -0,0 +1,140 @@
1
+ import { cookieStripHeaders, resolvedWebLaunch, webStationAlias, webStationServiceName, } from "../contract/web-launch.js";
2
+ export { classifyComposeRoute, resourceWorkerEligibility, } from "../compose/analyze.js";
3
+ export function composeProjectName(packageId) {
4
+ const slug = packageId
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9_-]+/g, "-")
7
+ .replace(/^[-_]+|[-_]+$/g, "");
8
+ return slug.length > 0 ? slug : "app";
9
+ }
10
+ export function renderDockerProjectResource(packageId, projectPath = "compose") {
11
+ const projectName = composeProjectName(packageId);
12
+ return {
13
+ "docker-project": {
14
+ projects: [
15
+ {
16
+ name: projectName,
17
+ path: projectPath,
18
+ build_params: {
19
+ force_pull: true,
20
+ force_recreate: true,
21
+ build: false,
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ };
27
+ }
28
+ export function renderWebserviceResource(contract, adminport) {
29
+ const alias = webStationAlias(String(contract.package));
30
+ const launch = resolvedWebLaunch(contract);
31
+ const cookieHeaders = cookieStripHeaders(contract);
32
+ const proxyHeaderEntries = cookieHeaders.map((header) => ({
33
+ name: header.name,
34
+ value: header.value,
35
+ }));
36
+ const service = {
37
+ service: webStationServiceName(alias),
38
+ display_name: String(contract.displayname ?? contract.package),
39
+ type: "reverse_proxy",
40
+ support_alias: true,
41
+ support_server: false,
42
+ proxy_target: `http://127.0.0.1:${adminport}`,
43
+ proxy_headers: proxyHeaderEntries,
44
+ };
45
+ const portal = {
46
+ service: webStationServiceName(alias),
47
+ name: alias,
48
+ type: "alias",
49
+ alias,
50
+ };
51
+ if (launch === "dsm-window") {
52
+ portal.app = String(contract.package);
53
+ }
54
+ return {
55
+ webservice: {
56
+ services: [service],
57
+ portals: [portal],
58
+ },
59
+ };
60
+ }
61
+ export function renderResourceConfig(contract, eligibility) {
62
+ const packageId = String(contract.package);
63
+ const dockerResource = renderDockerProjectResource(packageId, "compose");
64
+ let combined = { ...dockerResource };
65
+ if (eligibility.adminport !== undefined) {
66
+ const webResource = renderWebserviceResource(contract, eligibility.adminport);
67
+ combined = {
68
+ ...combined,
69
+ ...webResource,
70
+ };
71
+ }
72
+ return `${JSON.stringify(combined, null, 2)}\n`;
73
+ }
74
+ /**
75
+ * Renders the lifecycle control script for the Docker Project resource-worker
76
+ * route. ContainerManager owns the actual project start/stop operation through
77
+ * the worker timing hooks; the package script must wait for that operation to
78
+ * leave a reachable service and must not claim running unconditionally.
79
+ */
80
+ export function renderResourceWorkerStartStopStatus(adminport) {
81
+ if (adminport === undefined) {
82
+ return `#!/bin/sh
83
+ case "$1" in
84
+ start|status)
85
+ echo "resource-worker package has no published port to probe" >&2
86
+ exit 3
87
+ ;;
88
+ stop) exit 0 ;;
89
+ *) exit 1 ;;
90
+ esac
91
+ `;
92
+ }
93
+ const url = `http://127.0.0.1:${adminport}/`;
94
+ return `#!/bin/sh
95
+ probe_service() {
96
+ if command -v curl >/dev/null 2>&1; then
97
+ curl --silent --output /dev/null --connect-timeout 2 --max-time 5 "${url}"
98
+ return $?
99
+ fi
100
+ if command -v wget >/dev/null 2>&1; then
101
+ wget --quiet --output-document=/dev/null --timeout=5 --tries=1 "${url}"
102
+ return $?
103
+ fi
104
+ return 127
105
+ }
106
+
107
+ wait_for_service() {
108
+ attempts=0
109
+ while [ "$attempts" -lt 30 ]; do
110
+ if probe_service; then
111
+ return 0
112
+ fi
113
+ attempts=$((attempts + 1))
114
+ sleep 1
115
+ done
116
+ return 1
117
+ }
118
+
119
+ case "$1" in
120
+ start)
121
+ if wait_for_service; then
122
+ exit 0
123
+ fi
124
+ exit 3
125
+ ;;
126
+ stop)
127
+ exit 0
128
+ ;;
129
+ status)
130
+ if probe_service; then
131
+ exit 0
132
+ fi
133
+ exit 3
134
+ ;;
135
+ *)
136
+ exit 1
137
+ ;;
138
+ esac
139
+ `;
140
+ }
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { resolve, dirname } from "node:path";
3
3
  import { findProjectRoot } from "./discovery.js";
4
+ import { loadContract } from "./contract/load.js";
4
5
  import { formatErrors } from "./errors.js";
5
6
  import { formatInspectReport, inspectProject } from "./inspect.js";
6
7
  import { initFromTemplate, isTemplateName, listTemplates } from "./init.js";
@@ -19,6 +20,7 @@ import { publishProject, resolveCommunityAppsRoot } from "./publish/publish.js";
19
20
  import { formatPreflight, runPublicReleasePreflight } from "./publish/preflight.js";
20
21
  import { createExecGitHubCli, createExecGitOps } from "./publish/github-pr.js";
21
22
  import { communityAppsGitHubRepo } from "./publish/github-repo.js";
23
+ import { resolveInstallParameterExtraValues } from "./deploy/param-values.js";
22
24
  function collectKvOption(value, previous, flag, example) {
23
25
  const separator = value.indexOf("=");
24
26
  if (separator <= 0) {
@@ -35,9 +37,15 @@ function collectShareOption(value, previous) {
35
37
  function collectRunAsOption(value, previous) {
36
38
  return collectKvOption(value, previous, "--run-as", "--run-as RUN_AS=alice");
37
39
  }
40
+ function collectParamOption(value, previous) {
41
+ return collectKvOption(value, previous, "--param", "--param APP_MODE=prod");
42
+ }
38
43
  function resolveProject(cwd, project) {
39
44
  const start = project ? resolve(cwd, project) : cwd;
40
- const found = findProjectRoot(start);
45
+ return findProjectRoot(start);
46
+ }
47
+ function requireProject(cwd, project) {
48
+ const found = resolveProject(cwd, project);
41
49
  if (!found) {
42
50
  console.error("找不到 synology-app.yaml。請在專案根目錄執行,或使用 --project 指定。");
43
51
  process.exit(1);
@@ -50,12 +58,53 @@ export async function runCli(argv) {
50
58
  .name("synopkgland")
51
59
  .description("Syno Package Land Developer Kit CLI")
52
60
  .option("--project <dir>", "App 專案根目錄");
61
+ program
62
+ .command("doctor")
63
+ .description("唯讀診斷本機合約/Compose 與 NAS 部署狀態")
64
+ .option("--host <name>", "synoagentcli profile 或 NAS 名稱")
65
+ .option("--package <id>", "指定診斷的 Package Identity(省略時由合約判定)")
66
+ .option("--dev", "診斷 Development Package Identity({package}.dev)")
67
+ .option("--json", "輸出 machine-readable JSON 診斷報告")
68
+ .action(async (options, command) => {
69
+ const global = command.parent?.opts() ?? {};
70
+ const project = resolveProject(process.cwd(), global.project);
71
+ let host = null;
72
+ if (options.host) {
73
+ host = createNasHost({ host: options.host });
74
+ if (!host) {
75
+ console.error(`[${"HOST_CREDENTIALS_MISSING"}] 找不到 NAS 憑證。請設定 synoagentcli profile 或 DSM_URL/DSM_PASSWORD。`);
76
+ process.exit(1);
77
+ }
78
+ }
79
+ try {
80
+ const { runDoctor, formatDoctorReport } = await import("./doctor.js");
81
+ const report = await runDoctor({
82
+ project,
83
+ host,
84
+ packageId: options.package,
85
+ dev: options.dev,
86
+ });
87
+ if (options.json) {
88
+ console.log(JSON.stringify(report, null, 2));
89
+ }
90
+ else {
91
+ console.log(formatDoctorReport(report));
92
+ }
93
+ if (!report.ok) {
94
+ process.exit(1);
95
+ }
96
+ }
97
+ catch (error) {
98
+ console.error(error instanceof Error ? error.message : String(error));
99
+ process.exit(1);
100
+ }
101
+ });
53
102
  program
54
103
  .command("validate")
55
104
  .description("驗證 App Contract 與 Compose")
56
105
  .action((options, command) => {
57
106
  const global = command.parent?.opts() ?? {};
58
- const project = resolveProject(process.cwd(), global.project);
107
+ const project = requireProject(process.cwd(), global.project);
59
108
  const result = validateProject(project, {
60
109
  profile: "personal",
61
110
  });
@@ -73,7 +122,7 @@ export async function runCli(argv) {
73
122
  .description("檢視 Compose 意圖與 Web entry 提示")
74
123
  .action((_options, command) => {
75
124
  const global = command.parent?.opts() ?? {};
76
- const project = resolveProject(process.cwd(), global.project);
125
+ const project = requireProject(process.cwd(), global.project);
77
126
  const report = inspectProject(project);
78
127
  console.log(formatInspectReport(report));
79
128
  });
@@ -112,7 +161,7 @@ export async function runCli(argv) {
112
161
  .option("--files-root <dir>", "將本機目錄對應為 NAS Logical Path")
113
162
  .action(async (options, command) => {
114
163
  const global = command.parent?.opts() ?? {};
115
- const project = resolveProject(process.cwd(), global.project);
164
+ const project = requireProject(process.cwd(), global.project);
116
165
  const validation = validateProject(project);
117
166
  if (!validation.ok) {
118
167
  console.error(formatErrors(validation.errors));
@@ -146,7 +195,7 @@ export async function runCli(argv) {
146
195
  .option("--dev", "檢查 Development Package Identity({package}.dev)")
147
196
  .action(async (options, command) => {
148
197
  const global = command.parent?.opts() ?? {};
149
- const project = resolveProject(process.cwd(), global.project);
198
+ const project = requireProject(process.cwd(), global.project);
150
199
  if (options.host) {
151
200
  const host = createNasHost({ host: options.host });
152
201
  if (!host) {
@@ -189,9 +238,18 @@ export async function runCli(argv) {
189
238
  .option("--replace-curated", "危險:將既有 Curated App Package Attestation 降級為 Local Attestation")
190
239
  .option("--share <slot=name>", "此 NAS 上的 Shared Folder 邏輯名(契約 shared_folder 槽,可重複)", collectShareOption)
191
240
  .option("--run-as <slot=name>", "此 NAS 上的 DSM 使用者邏輯名(契約 dsm_user 槽)", collectRunAsOption)
241
+ .option("--param <NAME=value>", "契約 string/boolean Install Parameter 答案(可重複)", collectParamOption)
192
242
  .action(async (options, command) => {
193
243
  const global = command.parent?.opts() ?? {};
194
- const project = resolveProject(process.cwd(), global.project);
244
+ const project = requireProject(process.cwd(), global.project);
245
+ const parameterCheck = resolveInstallParameterExtraValues({
246
+ contract: loadContract(project.contractPath).contract,
247
+ provided: options.param,
248
+ });
249
+ if (!parameterCheck.ok) {
250
+ console.error(`[${parameterCheck.code}] ${parameterCheck.message}`);
251
+ process.exit(1);
252
+ }
195
253
  const host = createNasHost({ host: options.host });
196
254
  if (!host) {
197
255
  console.error("找不到 NAS 憑證。請設定 synoagentcli profile 或 DSM_URL/DSM_PASSWORD。");
@@ -210,6 +268,7 @@ export async function runCli(argv) {
210
268
  replaceCurated: options.replaceCurated,
211
269
  shareValues: options.share,
212
270
  runAsValues: options.runAs,
271
+ paramValues: options.param,
213
272
  });
214
273
  if (!result.ok) {
215
274
  console.error(`[${result.code ?? "DEPLOY_FAILED"}] ${result.message ?? "部署失敗。"}`);
@@ -232,7 +291,7 @@ export async function runCli(argv) {
232
291
  .option("--dev", "查詢 Development Package Identity")
233
292
  .action(async (options, command) => {
234
293
  const global = command.parent?.opts() ?? {};
235
- const project = resolveProject(process.cwd(), global.project);
294
+ const project = requireProject(process.cwd(), global.project);
236
295
  const host = createNasHost({ host: options.host });
237
296
  if (!host) {
238
297
  console.error("找不到 NAS 憑證。請設定 synoagentcli profile 或 DSM_URL/DSM_PASSWORD。");
@@ -254,7 +313,7 @@ export async function runCli(argv) {
254
313
  .option("--dev", "查詢 Development Package Identity")
255
314
  .action(async (options, command) => {
256
315
  const global = command.parent?.opts() ?? {};
257
- const project = resolveProject(process.cwd(), global.project);
316
+ const project = requireProject(process.cwd(), global.project);
258
317
  const host = createNasHost({ host: options.host });
259
318
  if (!host) {
260
319
  console.error("找不到 NAS 憑證。請設定 synoagentcli profile 或 DSM_URL/DSM_PASSWORD。");
@@ -312,7 +371,7 @@ export async function runCli(argv) {
312
371
  .option("--arch <family>", "native App 的目標架構:x86_64 或 aarch64(Compose App 不需要)")
313
372
  .action(async (options, command) => {
314
373
  const global = command.parent?.opts() ?? {};
315
- const project = resolveProject(process.cwd(), global.project);
374
+ const project = requireProject(process.cwd(), global.project);
316
375
  if (options.arch !== undefined && !isNativeArchFamily(String(options.arch))) {
317
376
  console.error(`--arch 只接受 x86_64 或 aarch64,收到 ${String(options.arch)}。`);
318
377
  process.exit(1);
@@ -336,7 +395,7 @@ export async function runCli(argv) {
336
395
  .option("--base <branch>", "PR 目標分支", "master")
337
396
  .action(async (options, command) => {
338
397
  const global = command.parent?.opts() ?? {};
339
- const project = resolveProject(process.cwd(), global.project);
398
+ const project = requireProject(process.cwd(), global.project);
340
399
  const preflight = await runPublicReleasePreflight(project);
341
400
  if (!preflight.ok) {
342
401
  // A gate's reasons belong on stderr so CI logs and `2>` capture them.