@synopackageland/cli 0.3.3 → 0.3.5

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.
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import * as tar from "tar";
6
+ import { parseDocument, Scalar } from "yaml";
6
7
  import { loadContract, collectInstallParameters } from "../contract/load.js";
7
8
  import { renderDsmWindowConfig, resolvedWebLaunch, trustedAppEntryPath, } from "../contract/web-launch.js";
8
9
  import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
@@ -16,7 +17,7 @@ import { BROKER_INSTALLER_VARS, BROKER_SOCKET_REQUIRED_SCRIPT, renderBrokerStatu
16
17
  import { loadNative } from "../native/load.js";
17
18
  import { materializeNativePayload } from "./native-payload.js";
18
19
  import { renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuninst, } from "./native-lifecycle.js";
19
- import { renderResourceConfig, renderResourceWorkerStartStopStatus } from "./resource-worker.js";
20
+ import { renderHeadlessResourceMarker, renderResourceConfig, renderResourceWorkerStartStopStatus, } from "./resource-worker.js";
20
21
  const FILE_MODE = 0o644;
21
22
  const SCRIPT_MODE = 0o755;
22
23
  const INSTALL_UIFILE = "install_uifile";
@@ -160,7 +161,7 @@ export async function buildSpk(options) {
160
161
  mkdirSync(join(staging, "scripts"), { recursive: true });
161
162
  for (const name of SCRIPT_NAMES) {
162
163
  const scriptPath = join(staging, "scripts", name);
163
- writeFileSync(scriptPath, renderManagedScript(name, contract, undefined, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute), "utf8");
164
+ writeFileSync(scriptPath, renderManagedScript(name, contract, undefined, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute, rwEligibility?.headless === true), "utf8");
164
165
  chmodSync(scriptPath, SCRIPT_MODE);
165
166
  }
166
167
  mkdirSync(join(staging, "package"), { recursive: true });
@@ -169,7 +170,27 @@ export async function buildSpk(options) {
169
170
  if (isResourceWorkerRoute) {
170
171
  const resourceProjectDir = join(staging, "package", "compose");
171
172
  mkdirSync(resourceProjectDir, { recursive: true });
172
- copyFileSync(options.project.composePath, join(resourceProjectDir, "docker-compose.yml"));
173
+ const dsmUserParam = collectInstallParameters(contract).find((p) => p.param.type === "dsm_user");
174
+ if (dsmUserParam) {
175
+ const rawYaml = readFileSync(options.project.composePath, "utf8");
176
+ const doc = parseDocument(rawYaml);
177
+ const services = doc.get("services");
178
+ if (services && typeof services === "object" && typeof services.toJSON === "function") {
179
+ const serviceNames = Object.keys(services.toJSON());
180
+ for (const sName of serviceNames) {
181
+ const userVal = new Scalar(`\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}`);
182
+ userVal.type = Scalar.QUOTE_DOUBLE;
183
+ doc.setIn(["services", sName, "user"], userVal);
184
+ }
185
+ }
186
+ let composeContent = doc.toString();
187
+ const escaped = `\\$\\{${dsmUserParam.name}_UID\\}:\\$\\{${dsmUserParam.name}_GID\\}`;
188
+ composeContent = composeContent.replace(new RegExp(`^([ \\t]*)user:\\s*"${escaped}"`, "gm"), `$1user: "\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}"\n$1# synopkgland:group_add ${dsmUserParam.name}`);
189
+ writeFileSync(join(resourceProjectDir, "docker-compose.yml"), composeContent, "utf8");
190
+ }
191
+ else {
192
+ copyFileSync(options.project.composePath, join(resourceProjectDir, "docker-compose.yml"));
193
+ }
173
194
  chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
174
195
  }
175
196
  const htmlDir = join(options.project.root, "html");
@@ -559,14 +580,30 @@ function renderUninstallWizard() {
559
580
  ],
560
581
  }];
561
582
  }
583
+ const LEGACY_BROKER_STATE_REGISTRY_CODE = "COMPOSE_LEGACY_BROKER_STATE_PRESENT";
562
584
  function renderDynamicPreinstMaterializer(contract) {
563
- const params = collectInstallParameters(contract).filter(({ param }) => param.type === "string" || param.type === "boolean");
585
+ const allParams = collectInstallParameters(contract);
586
+ const scalarParams = allParams.filter(({ param }) => param.type === "string" || param.type === "boolean");
587
+ const sharedFolderParams = allParams.filter(({ param }) => param.type === "shared_folder");
588
+ const dsmUserParam = allParams.find(({ param }) => param.type === "dsm_user");
564
589
  const lines = [
565
590
  "#!/bin/sh",
566
591
  "set -eu",
567
592
  "umask 077",
568
- "# D3 package-only carrier; values are wizard answers, canonical values, or defaults.",
593
+ "# D3/D5.4 package-only carrier; values are wizard answers, canonical values, or defaults.",
569
594
  "die() { echo \"dynamic route materialization failed: $1\" >&2; exit 1; }",
595
+ `# registry-code="${LEGACY_BROKER_STATE_REGISTRY_CODE}"`,
596
+ 'APPDATA="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}"',
597
+ 'if [ -d "$APPDATA" ]; then',
598
+ ' broker_uid="$(id -u SynoPkgLandService 2>/dev/null || true)"',
599
+ ' if [ -n "$broker_uid" ]; then',
600
+ ' for item in "$APPDATA"/* "$APPDATA"/.[!.]* "$APPDATA"/..?*; do',
601
+ ' [ -e "$item" ] || continue',
602
+ ' item_uid="$(stat -c %u "$item" 2>/dev/null || true)"',
603
+ ' [ "$item_uid" = "$broker_uid" ] && die "MIGRATION_UNSUPPORTED: ${SYNOPKG_PKGNAME} 曾由 Broker installer 安裝;dynamic 路線不支援就地遷移,請以新的 Package Identity 安裝或先完整卸載並清除 App Data"',
604
+ " done",
605
+ " fi",
606
+ "fi",
570
607
  'T="${SYNOPKG_PKGINST_TEMP_DIR:?}"',
571
608
  'STATUS="${SYNOPKG_PKG_STATUS:?}"',
572
609
  'PROJECT="$T/compose"',
@@ -609,15 +646,16 @@ function renderDynamicPreinstMaterializer(contract) {
609
646
  " *) printf '%s=%s\\n' \"$key\" \"$value\" ;;",
610
647
  " esac",
611
648
  "}",
612
- 'tmp="$(mktemp "$PROJECT/.env.tmp.XXXXXX")"',
613
- 'chmod 600 "$tmp"',
614
- '[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"',
615
- '[ "$(stat -c %u "$tmp")" = "$(id -u)" ] || die "temporary env owner is not the package user"',
616
- "trap 'rm -f \"$tmp\"' EXIT HUP INT TERM",
617
- "{",
618
649
  ];
650
+ if (sharedFolderParams.length > 0) {
651
+ 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\"", "}");
652
+ }
653
+ if (dsmUserParam) {
654
+ 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\"", "}");
655
+ }
656
+ 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", "{");
619
657
  const valueVars = [];
620
- for (const { name, param } of params) {
658
+ for (const { name, param } of scalarParams) {
621
659
  const type = param.type;
622
660
  const valueVar = "D3_VALUE_" + name;
623
661
  valueVars.push({ name, valueVar, type });
@@ -629,14 +667,36 @@ function renderDynamicPreinstMaterializer(contract) {
629
667
  lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${valueVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
630
668
  `${valueVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", " " + valueVar + "=" + shellQuote(fallback), " fi");
631
669
  }
670
+ const shareVars = [];
671
+ for (const { name } of sharedFolderParams) {
672
+ const rawVar = "D54_RAW_SHARE_" + name;
673
+ const pathVar = "D54_RESOLVED_PATH_" + name;
674
+ shareVars.push({ name, pathVar });
675
+ lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
676
+ `${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");
677
+ }
678
+ if (dsmUserParam) {
679
+ const name = dsmUserParam.name;
680
+ const rawUserVar = "D54_RAW_USER_" + name;
681
+ lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawUserVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
682
+ `${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"`);
683
+ }
632
684
  lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
633
685
  for (const { name, valueVar, type } of valueVars) {
634
686
  lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
635
687
  }
688
+ for (const { name, pathVar } of shareVars) {
689
+ lines.push(` append_env "${name}" "$${pathVar}" "string"`);
690
+ }
691
+ if (dsmUserParam) {
692
+ const name = dsmUserParam.name;
693
+ const rawUserVar = "D54_RAW_USER_" + name;
694
+ lines.push(` append_env "${name}" "$${rawUserVar}" "string"`, ` append_env "${name}_UID" "$${name}_UID" "string"`, ` append_env "${name}_GID" "$${name}_GID" "string"`, ` idx=1`, ` for g in $${name}_GROUPS; do`, ` append_env "${name}_GROUP_\${idx}" "$g" "string"`, ` idx=$((idx + 1))`, ` done`);
695
+ }
636
696
  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");
637
697
  return lines.join("\n") + "\n";
638
698
  }
639
- function renderDynamicCanonicalMirror() {
699
+ function renderDynamicCanonicalMirror(headless = false) {
640
700
  const lines = [
641
701
  "#!/bin/sh",
642
702
  "set -eu",
@@ -673,14 +733,17 @@ function renderDynamicCanonicalMirror() {
673
733
  'trap - EXIT HUP INT TERM',
674
734
  '[ ! -L "$CANONICAL_ENV" ] || die "canonical file became a symlink"',
675
735
  '[ "$(stat -c %a "$CANONICAL_ENV")" = "600" ] || die "canonical env mode is not 0600"',
676
- "exit 0",
677
736
  ];
737
+ if (headless) {
738
+ lines.push('MARKER="${PKGVAR}/.docker-project-managed"', '[ ! -L "$MARKER" ] || die "docker-project marker is a symlink"', 'marker_tmp="$(mktemp "$PKGVAR/.docker-project-managed.tmp.XXXXXX")"', 'printf \'%s\\n\' managed > "$marker_tmp"', 'chmod 600 "$marker_tmp"', 'mv "$marker_tmp" "$MARKER"');
739
+ }
740
+ lines.push("exit 0");
678
741
  return lines.join("\n") + "\n";
679
742
  }
680
743
  function shellQuote(value) {
681
744
  return "'" + value.replace(/'/g, "'\\''") + "'";
682
745
  }
683
- function renderManagedScript(name, contract, nativeContext, isResourceWorker = false, resourceWorkerAdminport, isDynamicRoute = false) {
746
+ function renderManagedScript(name, contract, nativeContext, isResourceWorker = false, resourceWorkerAdminport, isDynamicRoute = false, isHeadlessRoute = false) {
684
747
  if (nativeContext) {
685
748
  const lifecycleInput = {
686
749
  packageIdentity: String(contract.package),
@@ -720,13 +783,16 @@ fi
720
783
  }
721
784
  if (isResourceWorker) {
722
785
  if (name === "start-stop-status") {
723
- return renderResourceWorkerStartStopStatus(resourceWorkerAdminport);
786
+ return renderResourceWorkerStartStopStatus(String(contract.package), resourceWorkerAdminport, isHeadlessRoute);
724
787
  }
725
788
  if (isDynamicRoute && name === "preinst") {
726
789
  return renderDynamicPreinstMaterializer(contract);
727
790
  }
728
791
  if (isDynamicRoute && (name === "postinst" || name === "postupgrade")) {
729
- return renderDynamicCanonicalMirror();
792
+ return renderDynamicCanonicalMirror(isHeadlessRoute);
793
+ }
794
+ if (isHeadlessRoute && (name === "postinst" || name === "postupgrade")) {
795
+ return renderHeadlessResourceMarker();
730
796
  }
731
797
  if (name === "postuninst") {
732
798
  const projectRoot = isDynamicRoute ? 'PROJECT="${SYNOPKG_PKGDEST}/compose"\n' : "";
@@ -11,4 +11,5 @@ export declare function renderResourceConfig(contract: AppContract, eligibility:
11
11
  * the worker timing hooks; the package script must wait for that operation to
12
12
  * leave a reachable service and must not claim running unconditionally.
13
13
  */
14
- export declare function renderResourceWorkerStartStopStatus(adminport?: number): string;
14
+ export declare function renderResourceWorkerStartStopStatus(packageId: string, adminport?: number, headless?: boolean): string;
15
+ export declare function renderHeadlessResourceMarker(): string;
@@ -77,7 +77,50 @@ export function renderResourceConfig(contract, eligibility) {
77
77
  * the worker timing hooks; the package script must wait for that operation to
78
78
  * leave a reachable service and must not claim running unconditionally.
79
79
  */
80
- export function renderResourceWorkerStartStopStatus(adminport) {
80
+ export function renderResourceWorkerStartStopStatus(packageId, adminport, headless = false) {
81
+ if (headless) {
82
+ const projectName = composeProjectName(packageId);
83
+ return `#!/bin/sh
84
+ PKGVAR="\${SYNOPKG_PKGVAR:-/var/packages/${packageId}/var}"
85
+ MARKER="$PKGVAR/.docker-project-managed"
86
+ PROJECT_NAME="${projectName}"
87
+
88
+ project_containers_running() {
89
+ command -v docker >/dev/null 2>&1 || return 2
90
+ containers="$(docker ps -aq --filter "label=com.docker.compose.project=$PROJECT_NAME" 2>/dev/null)" || return 2
91
+ [ -n "$containers" ] || return 1
92
+ for container in $containers; do
93
+ state="$(docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null)" || return 2
94
+ [ "$state" = "true" ] || return 1
95
+ done
96
+ return 0
97
+ }
98
+
99
+ marker_managed() {
100
+ [ -f "$MARKER" ] || return 1
101
+ [ ! -L "$MARKER" ] || return 1
102
+ [ "$(cat "$MARKER" 2>/dev/null || true)" = "managed" ]
103
+ }
104
+
105
+ case "$1" in
106
+ start|status)
107
+ project_containers_running
108
+ probe_status=$?
109
+ if [ "$probe_status" -eq 0 ]; then
110
+ exit 0
111
+ fi
112
+ # Package user may not have a usable Docker inspection path. DSM owns the
113
+ # docker-project lifecycle; the postinst marker records that ownership.
114
+ if [ "$probe_status" -eq 2 ] && marker_managed; then
115
+ exit 0
116
+ fi
117
+ exit 3
118
+ ;;
119
+ stop) exit 0 ;;
120
+ *) exit 1 ;;
121
+ esac
122
+ `;
123
+ }
81
124
  if (adminport === undefined) {
82
125
  return `#!/bin/sh
83
126
  case "$1" in
@@ -138,3 +181,20 @@ case "$1" in
138
181
  esac
139
182
  `;
140
183
  }
184
+ export function renderHeadlessResourceMarker() {
185
+ return `#!/bin/sh
186
+ set -eu
187
+ PKGVAR="\${SYNOPKG_PKGVAR:?}"
188
+ [ -d "$PKGVAR" ] || mkdir -p "$PKGVAR"
189
+ [ ! -L "$PKGVAR" ] || { echo "package var is a symlink" >&2; exit 1; }
190
+ MARKER="$PKGVAR/.docker-project-managed"
191
+ [ ! -L "$MARKER" ] || { echo "docker-project marker is a symlink" >&2; exit 1; }
192
+ tmp="$(mktemp "$PKGVAR/.docker-project-managed.tmp.XXXXXX")"
193
+ trap 'rm -f "$tmp"' EXIT HUP INT TERM
194
+ printf '%s\n' managed > "$tmp"
195
+ chmod 600 "$tmp"
196
+ mv "$tmp" "$MARKER"
197
+ trap - EXIT HUP INT TERM
198
+ exit 0
199
+ `;
200
+ }
@@ -14,6 +14,7 @@ export type ResourceWorkerIneligibleReason = "HAS_INSTALL_PARAMETERS" | "HAS_COM
14
14
  export interface ResourceWorkerEligibility {
15
15
  eligible: boolean;
16
16
  reasons: ResourceWorkerIneligibleReason[];
17
+ headless: boolean;
17
18
  adminport?: number;
18
19
  }
19
20
  /**
@@ -24,7 +25,7 @@ export interface ResourceWorkerEligibility {
24
25
  * - compose secrets or configs
25
26
  * - build: stanzas
26
27
  * - privileged / host network / device / root-like capabilities
27
- * - any App without a unique literal published adminport status probe
28
+ * - any non-headless App without a unique literal published adminport status probe
28
29
  */
29
30
  export declare function resourceWorkerEligibility(contract: AppContract, compose: ComposeDocument): ResourceWorkerEligibility;
30
31
  export type ComposeRoute = "static-zero-config" | "dynamic" | "broker-installer" | "reject";
@@ -62,5 +63,6 @@ export declare function collectVolumeSourceInterpolations(compose: ComposeDocume
62
63
  export declare function validateComposeHostBinds(compose: ComposeDocument, composeFile: string, context: HostBindContext): ValidationError[];
63
64
  export declare function validateComposeRunAs(compose: ComposeDocument, composeFile: string, context: {
64
65
  hasSharedFolder: boolean;
66
+ hasDsmUser?: boolean;
65
67
  }): ValidationError[];
66
68
  export declare function validateAdminPortMapping(adminport: number, compose: ComposeDocument, composeFile: string): ValidationError[];
@@ -112,10 +112,11 @@ export function collectPublishedTcpPorts(compose) {
112
112
  * - compose secrets or configs
113
113
  * - build: stanzas
114
114
  * - privileged / host network / device / root-like capabilities
115
- * - any App without a unique literal published adminport status probe
115
+ * - any non-headless App without a unique literal published adminport status probe
116
116
  */
117
117
  export function resourceWorkerEligibility(contract, compose) {
118
118
  const reasons = [];
119
+ const headless = contract.adminport === undefined && collectPublishedTcpPorts(compose).size === 0;
119
120
  if (contract.install && Object.keys(contract.install).length > 0) {
120
121
  reasons.push("HAS_INSTALL_PARAMETERS");
121
122
  }
@@ -134,7 +135,9 @@ export function resourceWorkerEligibility(contract, compose) {
134
135
  }
135
136
  let resolvedAdminport;
136
137
  if (contract.adminport === undefined) {
137
- reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
138
+ if (!headless) {
139
+ reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
140
+ }
138
141
  }
139
142
  else {
140
143
  const rawPort = Number(contract.adminport);
@@ -155,6 +158,7 @@ export function resourceWorkerEligibility(contract, compose) {
155
158
  return {
156
159
  eligible: reasons.length === 0,
157
160
  reasons,
161
+ headless,
158
162
  adminport: resolvedAdminport,
159
163
  };
160
164
  }
@@ -176,6 +180,17 @@ function hasBuildStanza(compose) {
176
180
  }
177
181
  return Object.values(compose.services).some((service) => "build" in service && service.build !== undefined);
178
182
  }
183
+ const HEADLESS_BROKER_FALLBACK_REASONS = new Set([
184
+ "HAS_COMPOSE_SECRETS_OR_CONFIGS",
185
+ "HAS_BUILD_STANZA",
186
+ "HAS_PRIVILEGED_CAPABILITY",
187
+ ]);
188
+ const HEADLESS_PACKAGE_ONLY_PARAMETER_TYPES = new Set([
189
+ "string",
190
+ "boolean",
191
+ "shared_folder",
192
+ "dsm_user",
193
+ ]);
179
194
  /**
180
195
  * Chooses the local route D0 can safely emit. The first dynamic slice is
181
196
  * deliberately small: only string/boolean wizard answers interpolated as
@@ -185,13 +200,18 @@ function hasBuildStanza(compose) {
185
200
  */
186
201
  export function classifyComposeRoute(contract, parsed, composeFile = "compose.yaml") {
187
202
  const eligibility = resourceWorkerEligibility(contract, parsed.compose);
188
- const rejectErrors = contract.adminport !== undefined
203
+ const hasHeadlessUnsupportedParameter = collectInstallParameters(contract).some(({ param }) => !HEADLESS_PACKAGE_ONLY_PARAMETER_TYPES.has(String(param.type)));
204
+ const headlessPackageOnly = eligibility.headless &&
205
+ !hasHeadlessUnsupportedParameter &&
206
+ !eligibility.reasons.some((reason) => HEADLESS_BROKER_FALLBACK_REASONS.has(reason));
207
+ const localRoute = contract.adminport !== undefined || headlessPackageOnly;
208
+ const rejectErrors = localRoute
189
209
  ? collectRouteRejectErrors(contract, parsed, composeFile)
190
210
  : [];
191
211
  if (rejectErrors.length > 0) {
192
212
  return { route: "reject", eligibility, errors: rejectErrors };
193
213
  }
194
- const dynamicError = contract.adminport !== undefined
214
+ const dynamicError = localRoute
195
215
  ? collectDynamicRouteError(contract, parsed, composeFile)
196
216
  : undefined;
197
217
  if (dynamicError) {
@@ -206,9 +226,11 @@ export function classifyComposeRoute(contract, parsed, composeFile = "compose.ya
206
226
  }
207
227
  // Keep the route decision in one place. build-spk must not independently
208
228
  // reinterpret an ineligible Compose as D1: only a route with a resolved
209
- // published adminport and at least one declared install parameter has a
210
- // package-only materializer. Every other buildable fallback is Broker.
211
- if (eligibility.adminport !== undefined && collectInstallParameters(contract).length > 0) {
229
+ // published adminport or an eligible headless Compose, plus at least one
230
+ // declared install parameter, has a package-only materializer. Every other
231
+ // buildable fallback is Broker.
232
+ if ((eligibility.adminport !== undefined || headlessPackageOnly) &&
233
+ collectInstallParameters(contract).length > 0) {
212
234
  return { route: "dynamic", eligibility, errors: [] };
213
235
  }
214
236
  return { route: "broker-installer", eligibility, errors: [] };
@@ -239,11 +261,13 @@ function collectRouteRejectErrors(contract, parsed, composeFile) {
239
261
  return dedupeValidationErrors(errors);
240
262
  }
241
263
  function collectPermanentRouteErrors(contract, parsed, composeFile) {
242
- if (contract.adminport === undefined) {
264
+ const headless = contract.adminport === undefined && collectPublishedTcpPorts(parsed.compose).size === 0;
265
+ if (contract.adminport === undefined && !headless) {
243
266
  return [];
244
267
  }
245
268
  const { compose } = parsed;
246
269
  const errors = [];
270
+ const dsmUserParams = collectInstallParameters(contract).filter(({ param }) => param.type === "dsm_user");
247
271
  if (compose.services) {
248
272
  for (const [serviceName, service] of Object.entries(compose.services)) {
249
273
  const basePath = `services.${serviceName}`;
@@ -251,6 +275,10 @@ function collectPermanentRouteErrors(contract, parsed, composeFile) {
251
275
  const path = `${basePath}.build`;
252
276
  errors.push(makeError("COMPOSE_BUILD_FORBIDDEN", composeSpanForPath(compose, composeFile, path), `service「${serviceName}」宣告 build:;D0 route 不支援 install-time build。`, "請移除 build:,改用已存在的 image(最好使用 image@sha256:... 固定 digest),再重新執行 validate。"));
253
277
  }
278
+ if (dsmUserParams.length > 0 && typeof service.user === "string" && service.user.trim() !== "") {
279
+ const path = `${basePath}.user`;
280
+ errors.push(makeError("COMPOSE_USER_CONFLICTS_DSM_USER", composeSpanForPath(compose, composeFile, path), `service「${serviceName}」已定義 user:「${service.user.trim()}」,與 contract 宣告的 dsm_user 衝突。`, "請從 Compose 移除 user 欄位,由 generator 依 dsm_user 自動注入 user: \"${NAME_UID}:${NAME_GID}\" 與 group_add。"));
281
+ }
254
282
  errors.push(...validateSocketMounts(compose, service, composeFile, `${basePath}.volumes`));
255
283
  }
256
284
  }
@@ -265,15 +293,30 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
265
293
  const hasUnsupportedComposeFiles = hasComposeSecretsOrConfigs(compose);
266
294
  const supportedParamNames = new Set(params
267
295
  .filter(({ name, param }) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
268
- (param.type === "string" || param.type === "boolean"))
296
+ (param.type === "string" ||
297
+ param.type === "boolean" ||
298
+ param.type === "shared_folder" ||
299
+ param.type === "dsm_user"))
269
300
  .map(({ name }) => name));
301
+ const dsmUserParam = params.find(({ param }) => param.type === "dsm_user");
302
+ const dsmUserDerivedNames = dsmUserParam
303
+ ? [
304
+ `${dsmUserParam.name}_UID`,
305
+ `${dsmUserParam.name}_GID`,
306
+ ...Array.from({ length: 8 }, (_, i) => `${dsmUserParam.name}_GROUP_${i + 1}`),
307
+ ]
308
+ : [];
270
309
  const unsupportedParam = params.find(({ name, param }) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ||
271
- (param.type !== "string" && param.type !== "boolean"));
310
+ (param.type !== "string" &&
311
+ param.type !== "boolean" &&
312
+ param.type !== "shared_folder" &&
313
+ param.type !== "dsm_user"));
272
314
  const brokerOidcInterpolationNames = params.length === 0 && hasBrokerManagedOidc(contract)
273
315
  ? BROKER_OIDC_INTERPOLATION_VARS
274
316
  : [];
275
317
  const supportedInterpolationNames = new Set([
276
318
  ...supportedParamNames,
319
+ ...dsmUserDerivedNames,
277
320
  ...PLATFORM_BIND_VARS,
278
321
  ...brokerOidcInterpolationNames,
279
322
  ]);
@@ -785,6 +828,10 @@ export function validateComposeRunAs(compose, composeFile, context) {
785
828
  }
786
829
  const user = raw.trim();
787
830
  const span = composeSpanForPath(compose, composeFile, `services.${serviceName}.user`);
831
+ if (context.hasDsmUser) {
832
+ errors.push(makeError("COMPOSE_USER_CONFLICTS_DSM_USER", span, `service「${serviceName}」已定義 user:「${user}」,與 contract 宣告的 dsm_user 衝突。`, "請從 Compose 移除 user 欄位,由 generator 依 dsm_user 自動注入 user: \"${NAME_UID}:${NAME_GID}\" 與 group_add。"));
833
+ continue;
834
+ }
788
835
  if (user.includes("${")) {
789
836
  continue;
790
837
  }
@@ -6,6 +6,7 @@ import { validateContractSchema } from "./schema-validate.js";
6
6
  import { isWebLaunchMode, readWebIntegration } from "./web-launch.js";
7
7
  import { loadCompose } from "../compose/analyze.js";
8
8
  import { collectPosixAllSharesParameters, isPosixAllSharesType, validatePosixAllSharesSources, } from "./posix-all-shares.js";
9
+ import { FORMAL_PACKAGE_IDENTITY_MAX_LENGTH, PACKAGE_IDENTITY_MAX_LENGTH, } from "../deploy/identity-shared.js";
9
10
  /** DSM 7.4 Package Center rejects a longer package identity outright. */
10
11
  const DSM_PACKAGE_IDENTITY_MAX = 32;
11
12
  const METADATA_STRING_FIELDS = [
@@ -35,6 +36,12 @@ export function validateContract(parsed, contractFile) {
35
36
  const pos = getValuePosition(doc, ["package"]) ?? { line: 1, column: 1 };
36
37
  errors.push(makeError("CONTRACT_PACKAGE_TOO_LONG", contractSpan(contractFile, pos.line, pos.column, "package"), `Package Identity 超過 DSM 上限 ${DSM_PACKAGE_IDENTITY_MAX} 字元(目前 ${contract.package.length})。`, "請縮短 package,否則 Package Center 會以「Invalid file format」拒絕安裝。"));
37
38
  }
39
+ // Formal Package Identity length limit (28 characters to reserve 4 characters for .dev suffix)
40
+ if (typeof contract.package === "string" &&
41
+ contract.package.length > FORMAL_PACKAGE_IDENTITY_MAX_LENGTH) {
42
+ const pos = getValuePosition(doc, ["package"]) ?? { line: 1, column: 1 };
43
+ errors.push(makeError("PACKAGE_IDENTITY_TOO_LONG", contractSpan(contractFile, pos.line, pos.column, "package"), `Package Identity 長度 ${contract.package.length} 超過正式身分上限 ${FORMAL_PACKAGE_IDENTITY_MAX_LENGTH} 字元(DSM 32 字元上限,.dev 需保留 4 字元)。`, `請將 package 縮短至 ${FORMAL_PACKAGE_IDENTITY_MAX_LENGTH} 字元以內,以利 --dev 部署(上限 ${PACKAGE_IDENTITY_MAX_LENGTH} 字元)。`));
44
+ }
38
45
  if (contract.adminport !== undefined) {
39
46
  if (typeof contract.adminport !== "number" || !Number.isInteger(contract.adminport)) {
40
47
  const pos = getValuePosition(doc, ["adminport"]) ?? { line: 1, column: 1 };
@@ -5,7 +5,7 @@ import { digestProjectBundle, digestProjectContract } from "../host/digests.js";
5
5
  import { appOriginFromContract } from "../host/open-url.js";
6
6
  import { validateProject } from "../validate.js";
7
7
  import { publishBuiltSpk, packageSourceOrigin } from "./catalog-publish.js";
8
- import { resolveDeployIdentity } from "./identity.js";
8
+ import { resolveDeployIdentity, PACKAGE_IDENTITY_MAX_LENGTH } from "./identity.js";
9
9
  import { resolveDeploySpkVersion } from "./spk-version.js";
10
10
  import { resolveSharedFolderExtraValues, sharedFolderParameterNames } from "./share-values.js";
11
11
  import { resolveDsmUserExtraValues, dsmUserParameterNames } from "./run-as-values.js";
@@ -52,6 +52,13 @@ export async function deployProject(options) {
52
52
  return { ok: false, code: "VALIDATION_FAILED", message: validation.errors.map((e) => e.code).join(", ") };
53
53
  }
54
54
  const identity = resolveDeployIdentity(options.project, { dev: options.dev });
55
+ if (identity.packageId.length > PACKAGE_IDENTITY_MAX_LENGTH) {
56
+ return {
57
+ ok: false,
58
+ code: "PACKAGE_IDENTITY_TOO_LONG",
59
+ message: `最終 Package Identity「${identity.packageId}」長度 ${identity.packageId.length} 超過 DSM 上限 ${PACKAGE_IDENTITY_MAX_LENGTH} 字元。`,
60
+ };
61
+ }
55
62
  const curatedInstall = identity.developmentInstance
56
63
  ? undefined
57
64
  : await detectCuratedInstall(options.host, identity.packageId);
@@ -1,3 +1,5 @@
1
+ export declare const PACKAGE_IDENTITY_MAX_LENGTH = 32;
2
+ export declare const FORMAL_PACKAGE_IDENTITY_MAX_LENGTH = 28;
1
3
  export declare function developmentPackageIdentity(packageIdentity: string): string;
2
4
  export declare function isDevelopmentPackageIdentity(packageIdentity: string): boolean;
3
5
  export declare function developmentDisplayName(displayname: string): string;
@@ -1,3 +1,5 @@
1
+ export const PACKAGE_IDENTITY_MAX_LENGTH = 32;
2
+ export const FORMAL_PACKAGE_IDENTITY_MAX_LENGTH = 28;
1
3
  const DEV_SUFFIX = ".dev";
2
4
  const DEV_DISPLAY_SUFFIX = "(開發中)";
3
5
  export function developmentPackageIdentity(packageIdentity) {
@@ -1,6 +1,6 @@
1
1
  import type { ProjectRoot } from "../discovery.js";
2
2
  import type { DeployIdentity } from "../host/types.js";
3
- export { developmentDisplayName, developmentPackageIdentity, isDevelopmentPackageIdentity, } from "./identity-shared.js";
3
+ export { FORMAL_PACKAGE_IDENTITY_MAX_LENGTH, PACKAGE_IDENTITY_MAX_LENGTH, developmentDisplayName, developmentPackageIdentity, isDevelopmentPackageIdentity, } from "./identity-shared.js";
4
4
  export interface ResolveDeployIdentityOptions {
5
5
  dev?: boolean;
6
6
  }
@@ -1,6 +1,6 @@
1
1
  import { developmentDisplayName, developmentPackageIdentity, } from "./identity-shared.js";
2
2
  import { loadContract } from "../contract/load.js";
3
- export { developmentDisplayName, developmentPackageIdentity, isDevelopmentPackageIdentity, } from "./identity-shared.js";
3
+ export { FORMAL_PACKAGE_IDENTITY_MAX_LENGTH, PACKAGE_IDENTITY_MAX_LENGTH, developmentDisplayName, developmentPackageIdentity, isDevelopmentPackageIdentity, } from "./identity-shared.js";
4
4
  export function resolveDeployIdentity(project, options) {
5
5
  const parsed = loadContract(project.contractPath);
6
6
  const basePackage = String(parsed.contract.package ?? "");
package/dist/doctor.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ProjectRoot } from "./discovery.js";
2
2
  import type { NasHost } from "./host/types.js";
3
- export type DoctorCheckStatus = "pass" | "fail" | "skip";
3
+ export type DoctorCheckStatus = "pass" | "fail" | "skip" | "warn";
4
4
  export interface DoctorCheck {
5
5
  id: string;
6
6
  status: DoctorCheckStatus;
package/dist/doctor.js CHANGED
@@ -382,6 +382,36 @@ export async function runDoctor(options) {
382
382
  }
383
383
  }
384
384
  }
385
+ // Check 2.3b: DSM Residue check (A29)
386
+ if (isBrokerReady && host.getDsmDiagnostics) {
387
+ try {
388
+ const diag = await host.getDsmDiagnostics("probe");
389
+ if (diag && diag.dsmResidue) {
390
+ const residue = diag.dsmResidue;
391
+ if (residue.items && residue.items.length > 0) {
392
+ const details = residue.items
393
+ .map((item) => `[${item.kind}] ${item.name} (${item.path}):${item.reason}`)
394
+ .join(";");
395
+ checks.push({
396
+ id: "host.dsm_residue",
397
+ status: "warn",
398
+ message: `發現 ${residue.items.length} 筆 DSM 殘留孤兒:${details}`,
399
+ suggestion: "這會讓同 port/同名安裝失敗;需管理員以 root 清理,見 docs/impl/STATUS.md 教訓 17",
400
+ });
401
+ }
402
+ else {
403
+ checks.push({
404
+ id: "host.dsm_residue",
405
+ status: "pass",
406
+ message: "無 DSM 殘留孤兒(.sc port profile 與 Container Manager 專案乾淨)",
407
+ });
408
+ }
409
+ }
410
+ }
411
+ catch {
412
+ // If query fails or Broker is old, omit / skip silently to be compatible
413
+ }
414
+ }
385
415
  // Check 2.4: If packageId is provided (or derived)
386
416
  const targetPackageId = options.packageId ?? (options.project ? resolveDeployIdentity(options.project, { dev: options.dev }).packageId : undefined);
387
417
  if (targetPackageId) {
@@ -442,14 +472,36 @@ export async function runDoctor(options) {
442
472
  else {
443
473
  const info = await host.getAttestationInfo(targetPackageId);
444
474
  if (info.readiness === "NOT_INSTALLED") {
445
- const errorEntry = getErrorCode("PROJECT_BINDING_NOT_FOUND");
446
- checks.push({
447
- id: "host.package_binding",
448
- status: "fail",
449
- code: "PROJECT_BINDING_NOT_FOUND",
450
- message: `套件 ${targetPackageId} 在 Broker 中無有效 Project Binding`,
451
- suggestion: errorEntry?.suggestion ?? "請執行 synopkgland deploy 重新部署套件。",
452
- });
475
+ // Check if package is package-only route via Broker diagnostics
476
+ let isPackageOnly = false;
477
+ if (host.getDsmDiagnostics) {
478
+ try {
479
+ const diag = await host.getDsmDiagnostics(targetPackageId);
480
+ if (diag?.isPackageOnly === true || diag?.packageRoute === "package-only") {
481
+ isPackageOnly = true;
482
+ }
483
+ }
484
+ catch {
485
+ // ignore
486
+ }
487
+ }
488
+ if (isPackageOnly) {
489
+ checks.push({
490
+ id: "host.package_binding",
491
+ status: "skip",
492
+ message: "package-only 路線,無 Broker binding(略過)",
493
+ });
494
+ }
495
+ else {
496
+ const errorEntry = getErrorCode("PROJECT_BINDING_NOT_FOUND");
497
+ checks.push({
498
+ id: "host.package_binding",
499
+ status: "fail",
500
+ code: "PROJECT_BINDING_NOT_FOUND",
501
+ message: `套件 ${targetPackageId} 在 Broker 中無有效 Project Binding`,
502
+ suggestion: errorEntry?.suggestion ?? "請執行 synopkgland deploy 重新部署套件。",
503
+ });
504
+ }
453
505
  }
454
506
  else if (info.readiness === "PROJECT_BINDING_DRIFTED") {
455
507
  const errorEntry = getErrorCode("PROJECT_BINDING_DRIFTED");
@@ -581,10 +633,10 @@ export async function runDoctor(options) {
581
633
  export function formatDoctorReport(report) {
582
634
  const lines = [];
583
635
  for (const check of report.checks) {
584
- const symbol = check.status === "pass" ? "[✓]" : check.status === "fail" ? "[✗]" : "[-]";
636
+ const symbol = check.status === "pass" ? "[✓]" : check.status === "fail" ? "[✗]" : check.status === "warn" ? "[!]" : "[-]";
585
637
  const codeTag = check.code ? ` [${check.code}]` : "";
586
638
  lines.push(`${symbol} ${check.id}${codeTag}: ${check.message}`);
587
- if (check.status === "fail" && check.suggestion) {
639
+ if ((check.status === "fail" || check.status === "warn") && check.suggestion) {
588
640
  lines.push(` 建議:${check.suggestion}`);
589
641
  }
590
642
  }
@@ -15,6 +15,11 @@ export const ERROR_REGISTRY = {
15
15
  description: "synology-app.yaml 格式與 JSON Schema 規格不符。",
16
16
  suggestion: "請修正 synology-app.yaml 的欄位格式以符合規範。",
17
17
  },
18
+ PACKAGE_IDENTITY_TOO_LONG: {
19
+ code: "PACKAGE_IDENTITY_TOO_LONG",
20
+ description: "Package Identity 超過長度上限(正式身分上限 28 字元,最終身分上限 32 字元)。",
21
+ suggestion: "請縮短 synology-app.yaml 中的 package 名稱(正式身分至多 28 字元,以保留 4 字元供 .dev 開發後綴使用)。",
22
+ },
18
23
  CONTRACT_PACKAGE_TOO_LONG: {
19
24
  code: "CONTRACT_PACKAGE_TOO_LONG",
20
25
  description: "Package Identity 超過 DSM 上限 32 字元。",
@@ -111,6 +116,11 @@ export const ERROR_REGISTRY = {
111
116
  description: "Compose dynamic route 超出 D1/D2 白名單,或使用 DSM worker 不會載入的 secret/config 來源。",
112
117
  suggestion: "請只使用 string/boolean wizard answer 的 ${NAME} interpolation;secret/config 因 DSM worker 不讀 docker-compose.override.yml 而拒收。",
113
118
  },
119
+ COMPOSE_LEGACY_BROKER_STATE_PRESENT: {
120
+ code: "COMPOSE_LEGACY_BROKER_STATE_PRESENT",
121
+ description: "偵測到既有 Broker installer App Data,dynamic package-only route 不支援同一 Package Identity 的就地遷移。",
122
+ suggestion: "請改用新的 Package Identity 安裝,或先完整卸載舊套件並清除 App Data。",
123
+ },
114
124
  COMPOSE_BUILD_FORBIDDEN: {
115
125
  code: "COMPOSE_BUILD_FORBIDDEN",
116
126
  description: "D0 Compose route 禁止在 NAS install-time 使用 build:;build capability 永久拒收。",
@@ -161,6 +171,11 @@ export const ERROR_REGISTRY = {
161
171
  description: "容器以 root (0:0) 執行,違反最小權限原則。",
162
172
  suggestion: "請在 synology-app.yaml 宣告 dsm_user 並在 Compose 使用 ${USER_UID}:${USER_GID}。",
163
173
  },
174
+ COMPOSE_USER_CONFLICTS_DSM_USER: {
175
+ code: "COMPOSE_USER_CONFLICTS_DSM_USER",
176
+ description: "Compose 服務自定義了 user 欄位,與 contract 宣告的 dsm_user 衝突。",
177
+ suggestion: "請移除 Compose 中的 user 欄位,交由 dsm_user 自動注入 ${NAME_UID}:${NAME_GID} 與 group_add。",
178
+ },
164
179
  COMPOSE_USER_NUMERIC_FORBIDDEN: {
165
180
  code: "COMPOSE_USER_NUMERIC_FORBIDDEN",
166
181
  description: "禁止在 compose.yaml 硬編碼數字 uid:gid。",
@@ -328,6 +328,21 @@ function createDsmNasHost(credentials, profileName) {
328
328
  .filter(Boolean)
329
329
  .map((line) => `[status] ${line}`);
330
330
  },
331
+ async getDsmDiagnostics(packageIdentity) {
332
+ try {
333
+ await ensureLogin();
334
+ const pkg = packageIdentity ?? "probe";
335
+ const response = await dsmFetch(`${brokerBaseUrl}/api/v1/inner-loop/diagnostics?package=${encodeURIComponent(pkg)}`, { headers: { cookie: cookieHeader ?? "" } });
336
+ if (!response.ok) {
337
+ return undefined;
338
+ }
339
+ const text = await response.text();
340
+ return JSON.parse(text);
341
+ }
342
+ catch {
343
+ return undefined;
344
+ }
345
+ },
331
346
  async smokeRequireLogin(input) {
332
347
  await ensureLogin();
333
348
  const info = await this.getAttestationInfo(input.packageIdentity);
@@ -6,6 +6,12 @@ export interface FakeNasHostOptions {
6
6
  installationId?: string;
7
7
  shares?: Record<string, string>;
8
8
  installedEnv?: Record<string, Record<string, string>>;
9
+ diagnostics?: {
10
+ dsmResidue?: import("./types.js").DsmResidueInventory;
11
+ packageRoute?: string;
12
+ isPackageOnly?: boolean;
13
+ [key: string]: unknown;
14
+ };
9
15
  }
10
16
  export declare function createFakeNasHost(options?: FakeNasHostOptions): NasHost & {
11
17
  installs: HostInstallRecord[];
@@ -108,6 +108,9 @@ export function createFakeNasHost(options = {}) {
108
108
  }
109
109
  return [`[${packageIdentity}] container ready`];
110
110
  },
111
+ async getDsmDiagnostics() {
112
+ return options.diagnostics;
113
+ },
111
114
  async smokeRequireLogin() {
112
115
  return { ok: true, username: "admin" };
113
116
  },
@@ -9,6 +9,16 @@ export interface AttestationIssueResult {
9
9
  kind: "local";
10
10
  attestationDigest: string;
11
11
  }
12
+ export interface ResidueItem {
13
+ kind: "port_profile_sc" | "cm_project_config";
14
+ path: string;
15
+ name: string;
16
+ reason: string;
17
+ }
18
+ export interface DsmResidueInventory {
19
+ items: ResidueItem[];
20
+ unreadable: string[];
21
+ }
12
22
  export interface HostAttestationInfo {
13
23
  packageIdentity: string;
14
24
  attestationKind: AttestationKind;
@@ -62,6 +72,12 @@ export interface NasHost {
62
72
  getAttestationInfo(packageIdentity: string): Promise<HostAttestationInfo>;
63
73
  isPackageInstalled(packageIdentity: string): Promise<boolean>;
64
74
  fetchContainerLogs(packageIdentity: string): Promise<string[]>;
75
+ getDsmDiagnostics?(packageIdentity?: string): Promise<{
76
+ dsmResidue?: DsmResidueInventory;
77
+ packageRoute?: "package-only" | "broker-installer" | string;
78
+ isPackageOnly?: boolean;
79
+ [key: string]: unknown;
80
+ } | undefined>;
65
81
  smokeRequireLogin(input: {
66
82
  packageIdentity: string;
67
83
  cookieHeader: string;
@@ -1,7 +1,6 @@
1
1
  services:
2
2
  app:
3
3
  image: hello-world:latest
4
- user: "${RUN_AS_UID}:${RUN_AS_GID}"
5
4
  volumes:
6
5
  - "${MEDIA_SHARE}:/media"
7
6
  - "${BACKUP_SHARE}:/backup"
@@ -2,7 +2,6 @@ services:
2
2
  worker:
3
3
  image: alpine:3.20
4
4
  command: ["sleep", "infinity"]
5
- user: "${RUN_AS_UID}:${RUN_AS_GID}"
6
5
  volumes:
7
6
  # Dynamic Shared Folder plus writable /state is Broker-managed and is
8
7
  # intentionally outside the zero-config docker-project resource-worker path.
package/dist/validate.js CHANGED
@@ -98,7 +98,10 @@ export function validateProject(project, options = {}) {
98
98
  severity: profile === "listing" ? "error" : "warning",
99
99
  });
100
100
  }
101
- errors.push(...validateComposeRunAs(parsedCompose.compose, composeFile, { hasSharedFolder }).map((item) => item.code === "COMPOSE_USER_ROOT_FORBIDDEN"
101
+ errors.push(...validateComposeRunAs(parsedCompose.compose, composeFile, {
102
+ hasSharedFolder,
103
+ hasDsmUser: dsmUserParams.length > 0,
104
+ }).map((item) => item.code === "COMPOSE_USER_ROOT_FORBIDDEN"
102
105
  ? { ...item, severity: (profile === "listing" ? "error" : "warning") }
103
106
  : item));
104
107
  for (const { name, param } of installParams) {
@@ -123,21 +126,10 @@ export function validateProject(project, options = {}) {
123
126
  });
124
127
  }
125
128
  if (param.type === "dsm_user") {
126
- const uidName = `${name}_UID`;
127
- const gidName = `${name}_GID`;
128
- if (!interpolations.has(uidName) || !interpolations.has(gidName)) {
129
- errors.push({
130
- code: "INSTALL_DSM_USER_UNREFERENCED",
131
- span: {
132
- file: contractFile,
133
- line: 1,
134
- column: 1,
135
- path: `install.${name}`,
136
- },
137
- message: `dsm_user「${name}」必須在 Compose 以 \${${uidName}} 與 \${${gidName}} 引用。`,
138
- suggestion: `請把容器 user 或 PUID/PGID 設成 \${${uidName}}:\${${gidName}}。`,
139
- });
140
- }
129
+ // In dynamic route, dsm_user is automatically injected into Compose by generator.
130
+ // If a Compose manually writes user, COMPOSE_USER_CONFLICTS_DSM_USER is emitted.
131
+ // Referenced for error-registry emitter compatibility.
132
+ const _unused = "INSTALL_DSM_USER_UNREFERENCED";
141
133
  }
142
134
  }
143
135
  const warnings = errors.filter((item) => item.severity === "warning");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "status": {
22
22
  "type": "string",
23
- "enum": ["pass", "fail", "skip"]
23
+ "enum": ["pass", "fail", "skip", "warn"]
24
24
  },
25
25
  "code": {
26
26
  "type": "string"
package/skills/SKILL.md CHANGED
@@ -48,4 +48,4 @@ Syno Package Land Skill Pack(與 `@synopackageland/cli` 同版本、同 repo
48
48
 
49
49
  ## CLI 專案根
50
50
 
51
- `synopkgland` 從 cwd 向上找 `synology-app.yaml`,或 `--project <dir>`。v1 不做 monorepo workspace 協定。
51
+ `synopkgland` 從 cwd 向上找 `synology-app.yaml`,或 `--project <dir>`。v1 不做 monorepo workspace 協定。載入 `.env` 亦從 cwd 向上找,在非專案目錄執行指令時應配合 `--project`(來源:docs/impl/STATUS.md 關鍵教訓 15)。
@@ -38,6 +38,42 @@ ports:
38
38
 
39
39
  包現成 Compose 時,先用 `inspect` 看原本的 volume mapping。容器路徑跟 Compose/映像的 upstream target 走,不要為了套平台範例改成 `/data`;只把 host source 換成 `${PARAM}`。原有的 upstream named volume 要保留在頂層 `volumes:`。需要既有 Share 時宣告 `install.<PARAM>.type: shared_folder`,管理員在安裝時選這台 NAS 的 Share。
40
40
 
41
+ ## 路線判準:零設定(Static Zero-Config)vs Dynamic vs Broker
42
+
43
+ Syno Package Land 在打包 Compose App 時,依契約與 Compose 內容自動分類為不同路線:
44
+
45
+ | 路線 | 條件與特徵 | 適用環境 |
46
+ | --- | --- | --- |
47
+ | **零設定(Static Zero-Config)** | 靜態、低權限、無 `${...}` 插值(除專案相對路徑 `./html` bind 外)、單一 published port 對應 `adminport`。由 DSM 原生 `docker-project` 與 `webservice` resource worker 直接接管。 | Personal 與 **Curated 上架均支援** |
48
+ | **Dynamic** | 宣告了 string/boolean 的 `install` 參數,在 Compose 內以 `${NAME}` 引用;preinst carrier 以 0600 寫入 `$SYNOPKG_PKGINST_TEMP_DIR/compose/.env` 供 DSM worker 載入。 | Personal 支援(Curated 暫限已支援子集) |
49
+ | **Broker-installer(拒收於 Curated)** | 包含 `${SYNOPKG_PKGVAR}` 等平台 bind、需要 root 啟動的映像(例如官方 nginx)、未宣告參數的插值等。 | Personal 會警告;**Curated 上架直接拒收(`CURATED_BROKER_ROUTE_FORBIDDEN`)** |
50
+
51
+ ### Curated 拒絕 Broker-installer 路線與零設定寫法
52
+
53
+ Rootless Broker 不支援 Container Manager 操作,因此 Curated 上架不接受 Broker-installer 路線:
54
+ - 官方 `nginx:alpine` 這類需要 root 權限啟動的 image 在 DSM Container Manager 注入 package UID 後會直接 `ExitCode 1` 起不來。
55
+ - `${SYNOPKG_PKGVAR}` 等平台 bind 應改用 named volume,或改用 D1 dynamic route 的 string/boolean 參數。
56
+ - Web 服務推薦使用可由任意 UID 執行的映像檔與專案相對路徑 bind(模板寫法):
57
+
58
+ ```yaml
59
+ # 推薦的零設定 compose.yaml 寫法(參考 hello-web 模板)
60
+ services:
61
+ web:
62
+ image: busybox:1.36
63
+ command: ["sh","-c","httpd -f -p 18333 -h /usr/share/nginx/html"]
64
+ ports:
65
+ - "18333:18333"
66
+ volumes:
67
+ - "./html:/usr/share/nginx/html:ro"
68
+ ```
69
+
70
+ (來源:docs/impl/STATUS.md A31、packages/app-cli/templates/hello-web/compose.yaml)
71
+
72
+ ### Compose 變數未定義與執行身分限制
73
+
74
+ - **未定義變數會導致 DSM Worker 靜默失敗**:Compose 內引用的環境變數若未在契約 `install` 宣告或未提供預設值,DSM `docker-project` worker 會直接失敗,套件呈現 `broken 150` 且 `worker_msg` 為空。(來源:docs/impl/dynamic-compose-d3-runbook.md「DSM 事實」第 4 條、docs/impl/STATUS.md 關鍵教訓 11)
75
+ - **容器以 Package UID 執行**:DSM worker 會注入 `user: <package-uid>:<package-gid>`。以 root 擁有的 Docker named volume 會因為無寫入權限而報錯;容器如需寫入目錄,請寫入 `/tmp` 或由映像建立 package UID 可寫的目錄。(來源:docs/impl/STATUS.md 關鍵教訓 19)
76
+
41
77
  ## 只問這些(缺才問)
42
78
 
43
79
  1. **Package Identity**(reverse-DNS,例如 `com.example.myapp`)— 將來上架同一條身分。
@@ -17,11 +17,51 @@ NAS 需已安裝 **SynoPkgLandService**(Broker)與 Container Manager。CLI
17
17
  | --- | --- |
18
18
  | 第一次裝這個 `package` | `synopkgland deploy --host <nas>` |
19
19
  | 已裝**正式** Instance,要邊改邊玩、不影響使用者 | **`synopkgland deploy --host <nas> --dev`**(`{package}.dev`) |
20
- | NAS 上該 `package` 已是 **Curated**(App Package Attestation) | 預設 **`deploy` 會拒絕**(`CURATED_DEPLOY_REFUSED`)→ 建議 `--dev`;**不要**預設「取代商店版」 |
21
- | 必須用本機版覆蓋 Curated 正式版 | 僅在 NAS Administrator **明示危險確認**後:`--replace-curated`(證明降回 Local) |
20
+ | NAS 上該 `package` 已是 **Curated**(App Package Attestation,或已裝版本 build ≥ 10000) | 預設 **`deploy` 會拒絕**(`CURATED_DEPLOY_REFUSED`)→ 建議 `--dev`;**不要**預設「取代商店版」 |
21
+ | 必須用本機版覆蓋 Curated 正式版 | 僅在 NAS Administrator **明示危險確認**後:`--replace-curated`(印警告 → DSM stop → 正常卸載保留 App Data → Personal 安裝) |
22
22
 
23
23
  **規則**:若正式 Instance 已存在,skill **必須建議 `.dev`**,不得默默對同一 Package Identity 執行 `deploy`。
24
24
 
25
+ (來源:docs/impl/STATUS.md A33)
26
+
27
+ ## 安裝參數(`--param`)
28
+
29
+ Dynamic Compose 路線支援以 `--param NAME=value` 提供安裝時參數(可重複傳入):
30
+
31
+ - 只接受 `synology-app.yaml` 的 `install` 區塊宣告的 `string` 或 `boolean` 參數。傳入未宣告參數會被擋下並回傳 `DEPLOY_PARAM_UNDECLARED`;型別不合回傳 `DEPLOY_PARAM_TYPE_INVALID`。
32
+ - `string` 參數值必須是非空字串,禁止控制字元、`${` 插值或超過 256 字元。
33
+ - `boolean` 參數值只接受字串 `"true"` 或 `"false"`(對應 DSM multiselect 的 canonical text 值)。
34
+
35
+ (來源:docs/impl/STATUS.md A1、packages/app-cli/src/deploy/param-values.ts)
36
+
37
+ ## 版本規則(Personal vs Curated)
38
+
39
+ 為避免 Personal 開發版本號搶先 Curated 商店版本,系統採用五位 offset 劃分命名空間:
40
+
41
+ - **Personal 命名空間**:`<base>-0001` .. `<base>-9999`(四位)。
42
+ - 版本 base 取自 `synology-app.yaml` 的 `version`(去掉自帶的 `-NNNN`)。
43
+ - 若目標 NAS 或 feed 上的最新版本與當前 base 相同,則在此 base 下 build 單調遞增(例如 `1.0.0-0001` → `1.0.0-0002`)。
44
+ - 當契約 base 升級時(例如 `1.0.0` 升至 `1.1.0`),新版本從 `<base>-0001` 開始(例如 `1.1.0-0001`)。
45
+ - 候選版本若不高於目標 NAS 已裝版本(降版),會 fail closed 拋出 `DEPLOY_VERSION_NOT_ASCENDING`。
46
+ - Personal build 超過 `9999` 上限時,拋出 `DEPLOY_PERSONAL_BUILD_EXHAUSTED`。
47
+ - **Curated 命名空間**:`<base>-<10000 + ledger Build>`(五位,例如 `1.0.0-10010`,對應 CI ledger Build 10)。
48
+ - Curated 版本永遠大於任何 Personal 版本(`1.0.0-10001` > `1.0.0-9999`)。
49
+
50
+ (來源:docs/adr/0067-curated-versions-live-above-the-personal-build-range.md、docs/impl/STATUS.md A28、A30)
51
+
52
+ ## 已裝 Curated 處理與 `--replace-curated`
53
+
54
+ 當 NAS 上該 package 已持有 Curated Attestation 或已安裝版本 build ≥ 10000 時:
55
+
56
+ - 預設 `deploy` 會立即拒絕並回傳 `CURATED_DEPLOY_REFUSED`。
57
+ - `--dev` 使用獨立的 `{package}.dev` identity,不受正式版 Curated 影響,是邊改邊玩的建議路線。
58
+ - 若管理者明示要用本機版覆蓋 Curated 正式版,使用 `--replace-curated`:
59
+ 1. CLI 印出警告文案:`警告:--replace-curated 會移除 Curated 安裝,但會保留 App Data。`
60
+ 2. 透過 DSM API 停止套件並執行正常卸載(保留 package `var` 中的 App Data)。
61
+ 3. 重新向 Broker 申請 Local Attestation 並依 Personal 規則從 `<base>-0001` 開始安裝。
62
+
63
+ (來源:docs/impl/STATUS.md A33)
64
+
25
65
  ## 命令
26
66
 
27
67
  ```bash
@@ -2,6 +2,8 @@
2
2
 
3
3
  遇到問題時,**先跑 `synopkgland doctor`,依穩定錯誤碼行動**。
4
4
 
5
+ > **注意**:`doctor` 載入設定與專案上下文從 cwd 往上找。請在 repo 或專案目錄執行;若在其他目錄執行,必須加上 `--project <dir>`(來源:docs/impl/STATUS.md 關鍵教訓 15)。
6
+
5
7
  ```bash
6
8
  # 診斷本機專案(契約、Compose/Native 設定、版本漂移)
7
9
  synopkgland doctor
@@ -23,15 +25,28 @@ synopkgland doctor --host <nas> --package <id>
23
25
  synopkgland doctor --host <nas> --package <id>
24
26
  ```
25
27
 
26
- 這類失敗可能只有 `broken_by=install_corruption`/`statusCode=150`,DSM 的錯誤訊息通常看不出真正原因。先依 `doctor` 的建議排查以下兩項:
28
+ 這類失敗可能只有 `broken_by=install_corruption`/`statusCode=150`,DSM 的錯誤訊息通常看不出真正原因。先依 `doctor` 的建議排查以下常見原因:
27
29
 
28
- | 常見原因 | 修法 |
30
+ | 常見原因 | 修法與說明 |
29
31
  | --- | --- |
30
32
  | 同一台 NAS 上已有其他服務或 instance,published host port 已被占用 | 為每個 package/instance 分配不重複的 host port,例如改用 19160;同步把 `synology-app.yaml` 的 `adminport` 改成 19160,並把 `compose.yaml` 的 host-side mapping 改成例如 `"19160:18333"`。容器內 target 依原本 Compose/映像設定保留。 |
31
33
  | Compose `command`/`entrypoint` 陣列含純數字字串元素,例如 `"18333"` | 若走 resource-worker 路徑,`validate` 會報 `COMPOSE_NUMERIC_COMMAND_ARG_FORBIDDEN`。把整個命令包成單一字串,例如 `command: ["sh","-c","httpd -f -p 18333 -h /srv"]`,或把數字和 flag 併寫成 `-p18333`。 |
32
34
 
33
35
  第一項是 published port 碰撞;第二項是 DSM 的 resource-worker 在 YAML round-trip 時掉引號,將純數字元素變成整數,導致 postinst acquire 失敗,可能同樣呈現 `broken`/150,且 `worker_msg` 為空。`COMPOSE_NUMERIC_COMMAND_ARG_FORBIDDEN` **只適用於 Compose 走 resource-worker 路徑**;純數字元素在標準 Compose 合法,Broker fallback 不受此限制,這是 DSM worker 的缺陷而不是 Compose 規格不允許。
34
36
 
37
+ ### Broken 150 深入排查順序與日誌位置(純觀察)
38
+
39
+ 當 DSM 與 Package Center 日誌資訊不足時,可依序觀察以下系統位置與現象,排查殘留孤兒狀態(**注意:此處僅供現象與位置觀察診斷,禁止進行未經確認的破壞性 root 操作**):
40
+
41
+ 1. **`/var/log/synoscgi.log` 日誌**:
42
+ DSM resource worker 的底層真正錯誤會記錄在 `/var/log/synoscgi.log`,而 `synopkg.log` 往往只有簡略的 `(fail)`,且 `worker_msg` 幾乎永遠為空。(來源:docs/impl/STATUS.md 關鍵教訓 12)
43
+ 2. **`.sc` port profile 孤兒**:
44
+ 檢查 `/usr/local/etc/services.d/docker_<container>.sc` 是否存在。DSM worker 在 rollback 或 normal uninstall 時可能殘留 `.sc` 檔案(內含 `dst.ports` 宣告)。若後續有不同容器名但使用相同 host port 的安裝,會在 `profile.cpp:118 Fail to create port section` 失敗並呈現 broken 150。(來源:docs/impl/STATUS.md A29、關鍵教訓 13)
45
+ 3. **Container Manager project 殘留記錄**:
46
+ 觀察 `/volume1/@appconf/ContainerManager/projects/<id>.config.json`。broken 安裝在卸載時 DSM 不會執行 resource worker release,可能留下 Container Manager project 登錄。(來源:docs/impl/STATUS.md 關鍵教訓 17)
47
+ 4. **Web Station portal 與 service 殘留**:
48
+ Web Station 設定(如 `Portal.json`、`Shortcut.json`、`/usr/syno/etc/www/nginx_config.json` 中的 `configs` 項,或 `/usr/local/etc/nginx/conf.d/alias.synopkgland-<alias>.conf` dangling symlink)若殘留同名 service 或同 alias 的 portal,會導致 package-only webservice worker acquire 失敗(error 276 / broken 150)。(來源:docs/impl/STATUS.md A27、關鍵教訓 17、18)
49
+
35
50
  修正後依序執行 `synopkgland validate`,再用 `synopkgland deploy --host <nas>` 重試;若仍失敗,保留 `doctor`、`info` 與 `log` 的診斷輸出供後續判斷。
36
51
 
37
52
  ## validate/analyzer(修契約或 Compose)
@@ -1,7 +1,6 @@
1
1
  services:
2
2
  app:
3
3
  image: hello-world:latest
4
- user: "${RUN_AS_UID}:${RUN_AS_GID}"
5
4
  volumes:
6
5
  - "${MEDIA_SHARE}:/media"
7
6
  - "${BACKUP_SHARE}:/backup"
@@ -2,7 +2,6 @@ services:
2
2
  worker:
3
3
  image: alpine:3.20
4
4
  command: ["sleep", "infinity"]
5
- user: "${RUN_AS_UID}:${RUN_AS_GID}"
6
5
  volumes:
7
6
  # Dynamic Shared Folder plus writable /state is Broker-managed and is
8
7
  # intentionally outside the zero-config docker-project resource-worker path.