@synopackageland/cli 0.3.2 → 0.3.4

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";
@@ -138,9 +139,7 @@ export async function buildSpk(options) {
138
139
  return { ok: false, errors: route.errors.map((error) => error.code) };
139
140
  }
140
141
  const rwEligibility = route?.eligibility;
141
- const isDynamicRoute = route?.route === "dynamic" &&
142
- rwEligibility?.adminport !== undefined &&
143
- collectInstallParameters(contract).length > 0;
142
+ const isDynamicRoute = route?.route === "dynamic";
144
143
  const isResourceWorkerRoute = route?.route === "static-zero-config" ||
145
144
  isDynamicRoute;
146
145
  const staging = mkdtempSync(join(tmpdir(), "syno-spk-"));
@@ -171,7 +170,27 @@ export async function buildSpk(options) {
171
170
  if (isResourceWorkerRoute) {
172
171
  const resourceProjectDir = join(staging, "package", "compose");
173
172
  mkdirSync(resourceProjectDir, { recursive: true });
174
- 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
+ }
175
194
  chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
176
195
  }
177
196
  const htmlDir = join(options.project.root, "html");
@@ -562,12 +581,15 @@ function renderUninstallWizard() {
562
581
  }];
563
582
  }
564
583
  function renderDynamicPreinstMaterializer(contract) {
565
- const params = collectInstallParameters(contract).filter(({ param }) => param.type === "string" || param.type === "boolean");
584
+ const allParams = collectInstallParameters(contract);
585
+ const scalarParams = allParams.filter(({ param }) => param.type === "string" || param.type === "boolean");
586
+ const sharedFolderParams = allParams.filter(({ param }) => param.type === "shared_folder");
587
+ const dsmUserParam = allParams.find(({ param }) => param.type === "dsm_user");
566
588
  const lines = [
567
589
  "#!/bin/sh",
568
590
  "set -eu",
569
591
  "umask 077",
570
- "# D3 package-only carrier; values are wizard answers, canonical values, or defaults.",
592
+ "# D3/D5.4 package-only carrier; values are wizard answers, canonical values, or defaults.",
571
593
  "die() { echo \"dynamic route materialization failed: $1\" >&2; exit 1; }",
572
594
  'T="${SYNOPKG_PKGINST_TEMP_DIR:?}"',
573
595
  'STATUS="${SYNOPKG_PKG_STATUS:?}"',
@@ -611,15 +633,16 @@ function renderDynamicPreinstMaterializer(contract) {
611
633
  " *) printf '%s=%s\\n' \"$key\" \"$value\" ;;",
612
634
  " esac",
613
635
  "}",
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
636
  ];
637
+ if (sharedFolderParams.length > 0) {
638
+ 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\"", "}");
639
+ }
640
+ if (dsmUserParam) {
641
+ 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\"", "}");
642
+ }
643
+ 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", "{");
621
644
  const valueVars = [];
622
- for (const { name, param } of params) {
645
+ for (const { name, param } of scalarParams) {
623
646
  const type = param.type;
624
647
  const valueVar = "D3_VALUE_" + name;
625
648
  valueVars.push({ name, valueVar, type });
@@ -631,10 +654,31 @@ function renderDynamicPreinstMaterializer(contract) {
631
654
  lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${valueVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
632
655
  `${valueVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", " " + valueVar + "=" + shellQuote(fallback), " fi");
633
656
  }
657
+ const shareVars = [];
658
+ for (const { name } of sharedFolderParams) {
659
+ const rawVar = "D54_RAW_SHARE_" + name;
660
+ const pathVar = "D54_RESOLVED_PATH_" + name;
661
+ shareVars.push({ name, pathVar });
662
+ lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
663
+ `${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");
664
+ }
665
+ if (dsmUserParam) {
666
+ const name = dsmUserParam.name;
667
+ const rawUserVar = "D54_RAW_USER_" + name;
668
+ lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawUserVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
669
+ `${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"`);
670
+ }
634
671
  lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
635
672
  for (const { name, valueVar, type } of valueVars) {
636
673
  lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
637
674
  }
675
+ for (const { name, pathVar } of shareVars) {
676
+ lines.push(` append_env "${name}" "$${pathVar}" "string"`);
677
+ }
678
+ if (dsmUserParam) {
679
+ const name = dsmUserParam.name;
680
+ lines.push(` 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`);
681
+ }
638
682
  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
683
  return lines.join("\n") + "\n";
640
684
  }
@@ -27,7 +27,7 @@ export interface ResourceWorkerEligibility {
27
27
  * - any App without a unique literal published adminport status probe
28
28
  */
29
29
  export declare function resourceWorkerEligibility(contract: AppContract, compose: ComposeDocument): ResourceWorkerEligibility;
30
- export type ComposeRoute = "static-zero-config" | "dynamic" | "reject";
30
+ export type ComposeRoute = "static-zero-config" | "dynamic" | "broker-installer" | "reject";
31
31
  export interface ComposeRouteClassification {
32
32
  route: ComposeRoute;
33
33
  eligibility: ResourceWorkerEligibility;
@@ -41,6 +41,7 @@ export interface ComposeRouteClassification {
41
41
  * placed in the project .env.
42
42
  */
43
43
  export declare function classifyComposeRoute(contract: AppContract, parsed: ParsedCompose, composeFile?: string): ComposeRouteClassification;
44
+ export declare function brokerInstallerRouteWarning(compose: ComposeDocument, composeFile?: string): ValidationError;
44
45
  export declare function collectComposeInterpolations(compose: ComposeDocument): Set<string>;
45
46
  export declare function validateComposeSecurity(compose: ComposeDocument, composeFile: string, options: {
46
47
  profile: "personal" | "listing";
@@ -61,5 +62,6 @@ export declare function collectVolumeSourceInterpolations(compose: ComposeDocume
61
62
  export declare function validateComposeHostBinds(compose: ComposeDocument, composeFile: string, context: HostBindContext): ValidationError[];
62
63
  export declare function validateComposeRunAs(compose: ComposeDocument, composeFile: string, context: {
63
64
  hasSharedFolder: boolean;
65
+ hasDsmUser?: boolean;
64
66
  }): ValidationError[];
65
67
  export declare function validateAdminPortMapping(adminport: number, compose: ComposeDocument, composeFile: string): ValidationError[];
@@ -204,7 +204,25 @@ export function classifyComposeRoute(contract, parsed, composeFile = "compose.ya
204
204
  if (eligibility.eligible) {
205
205
  return { route: "static-zero-config", eligibility, errors: [] };
206
206
  }
207
- return { route: "dynamic", eligibility, errors: [] };
207
+ // Keep the route decision in one place. build-spk must not independently
208
+ // 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) {
212
+ return { route: "dynamic", eligibility, errors: [] };
213
+ }
214
+ return { route: "broker-installer", eligibility, errors: [] };
215
+ }
216
+ export function brokerInstallerRouteWarning(compose, composeFile = "compose.yaml") {
217
+ const path = findDynamicComposePath(compose) ?? "services";
218
+ return makeError("CURATED_BROKER_ROUTE_FORBIDDEN", composeSpanForPath(compose, composeFile, path), "此 Compose 會落到 Broker installer 路線;rootless Broker 不支援 Container Manager,無法作為 Curated production runtime。", "請改成零設定 resource-worker:使用 ./html bind 與預先 build 的 image;將 ${SYNOPKG_PKGVAR} 等平台 bind 改成 named volume,或改用 D1 dynamic route 的 string/boolean install 參數。", "warning");
219
+ }
220
+ function hasImmutableServiceImages(compose) {
221
+ const services = compose.services;
222
+ if (!services || Object.keys(services).length === 0) {
223
+ return false;
224
+ }
225
+ return Object.values(services).every((service) => typeof service.image === "string" && /@sha256:[a-f0-9]{64}$/.test(service.image.trim()));
208
226
  }
209
227
  function collectRouteRejectErrors(contract, parsed, composeFile) {
210
228
  const { compose } = parsed;
@@ -226,6 +244,7 @@ function collectPermanentRouteErrors(contract, parsed, composeFile) {
226
244
  }
227
245
  const { compose } = parsed;
228
246
  const errors = [];
247
+ const dsmUserParams = collectInstallParameters(contract).filter(({ param }) => param.type === "dsm_user");
229
248
  if (compose.services) {
230
249
  for (const [serviceName, service] of Object.entries(compose.services)) {
231
250
  const basePath = `services.${serviceName}`;
@@ -233,6 +252,10 @@ function collectPermanentRouteErrors(contract, parsed, composeFile) {
233
252
  const path = `${basePath}.build`;
234
253
  errors.push(makeError("COMPOSE_BUILD_FORBIDDEN", composeSpanForPath(compose, composeFile, path), `service「${serviceName}」宣告 build:;D0 route 不支援 install-time build。`, "請移除 build:,改用已存在的 image(最好使用 image@sha256:... 固定 digest),再重新執行 validate。"));
235
254
  }
255
+ if (dsmUserParams.length > 0 && typeof service.user === "string" && service.user.trim() !== "") {
256
+ const path = `${basePath}.user`;
257
+ 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。"));
258
+ }
236
259
  errors.push(...validateSocketMounts(compose, service, composeFile, `${basePath}.volumes`));
237
260
  }
238
261
  }
@@ -247,15 +270,30 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
247
270
  const hasUnsupportedComposeFiles = hasComposeSecretsOrConfigs(compose);
248
271
  const supportedParamNames = new Set(params
249
272
  .filter(({ name, param }) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
250
- (param.type === "string" || param.type === "boolean"))
273
+ (param.type === "string" ||
274
+ param.type === "boolean" ||
275
+ param.type === "shared_folder" ||
276
+ param.type === "dsm_user"))
251
277
  .map(({ name }) => name));
278
+ const dsmUserParam = params.find(({ param }) => param.type === "dsm_user");
279
+ const dsmUserDerivedNames = dsmUserParam
280
+ ? [
281
+ `${dsmUserParam.name}_UID`,
282
+ `${dsmUserParam.name}_GID`,
283
+ ...Array.from({ length: 8 }, (_, i) => `${dsmUserParam.name}_GROUP_${i + 1}`),
284
+ ]
285
+ : [];
252
286
  const unsupportedParam = params.find(({ name, param }) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ||
253
- (param.type !== "string" && param.type !== "boolean"));
287
+ (param.type !== "string" &&
288
+ param.type !== "boolean" &&
289
+ param.type !== "shared_folder" &&
290
+ param.type !== "dsm_user"));
254
291
  const brokerOidcInterpolationNames = params.length === 0 && hasBrokerManagedOidc(contract)
255
292
  ? BROKER_OIDC_INTERPOLATION_VARS
256
293
  : [];
257
294
  const supportedInterpolationNames = new Set([
258
295
  ...supportedParamNames,
296
+ ...dsmUserDerivedNames,
259
297
  ...PLATFORM_BIND_VARS,
260
298
  ...brokerOidcInterpolationNames,
261
299
  ]);
@@ -429,10 +467,16 @@ function walkForInterpolation(value, names) {
429
467
  export function validateComposeSecurity(compose, composeFile, options) {
430
468
  const errors = [];
431
469
  const contract = composeContracts.get(compose);
432
- const routeErrors = contract
433
- ? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile).errors.filter((error) => !HOST_BIND_ERROR_CODES.has(error.code))
470
+ const route = contract
471
+ ? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile)
472
+ : undefined;
473
+ const routeErrors = route
474
+ ? route.errors.filter((error) => !HOST_BIND_ERROR_CODES.has(error.code))
434
475
  : [];
435
476
  errors.push(...routeErrors);
477
+ if (route?.route === "broker-installer" && hasImmutableServiceImages(compose)) {
478
+ errors.push(brokerInstallerRouteWarning(compose, composeFile));
479
+ }
436
480
  const routeErrorKeys = new Set(routeErrors.map(validationErrorKey));
437
481
  if (!compose.services) {
438
482
  return errors;
@@ -761,6 +805,10 @@ export function validateComposeRunAs(compose, composeFile, context) {
761
805
  }
762
806
  const user = raw.trim();
763
807
  const span = composeSpanForPath(compose, composeFile, `services.${serviceName}.user`);
808
+ if (context.hasDsmUser) {
809
+ 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。"));
810
+ continue;
811
+ }
764
812
  if (user.includes("${")) {
765
813
  continue;
766
814
  }
@@ -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,12 +5,13 @@ 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";
12
12
  import { homesParameterNames, resolveHomesExtraValues } from "./homes-values.js";
13
13
  import { resolveInstallParameterExtraValues } from "./param-values.js";
14
+ import { CURATED_BUILD_OFFSET } from "@synopackageland/package-source/index-manifest";
14
15
  const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
15
16
  function brokerCapabilitiesFromContract(project) {
16
17
  const contract = loadContract(project.contractPath).contract;
@@ -21,12 +22,50 @@ function brokerCapabilitiesFromContract(project) {
21
22
  }
22
23
  return capabilities;
23
24
  }
25
+ function versionBuildIsCurated(version) {
26
+ if (!version) {
27
+ return false;
28
+ }
29
+ const match = /-(\d+)$/.exec(version);
30
+ if (!match) {
31
+ return false;
32
+ }
33
+ const build = Number(match[1]);
34
+ return Number.isSafeInteger(build) && build >= CURATED_BUILD_OFFSET;
35
+ }
36
+ async function detectCuratedInstall(host, packageId) {
37
+ let brokerRecord = false;
38
+ try {
39
+ brokerRecord = (await host.getAttestationInfo(packageId)).attestationKind === "app-package";
40
+ }
41
+ catch {
42
+ // requestAttestation below remains the authoritative error path when the Broker info probe is unavailable.
43
+ }
44
+ return {
45
+ brokerRecord,
46
+ installedVersion: await host.getInstalledPackageVersion?.(packageId),
47
+ };
48
+ }
24
49
  export async function deployProject(options) {
25
50
  const validation = validateProject(options.project);
26
51
  if (!validation.ok) {
27
52
  return { ok: false, code: "VALIDATION_FAILED", message: validation.errors.map((e) => e.code).join(", ") };
28
53
  }
29
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
+ }
62
+ const curatedInstall = identity.developmentInstance
63
+ ? undefined
64
+ : await detectCuratedInstall(options.host, identity.packageId);
65
+ const hasCuratedInstall = curatedInstall?.brokerRecord === true || versionBuildIsCurated(curatedInstall?.installedVersion);
66
+ if (hasCuratedInstall && !options.replaceCurated) {
67
+ return { ok: false, code: "CURATED_DEPLOY_REFUSED", message: CURATED_DEPLOY_REFUSED_MESSAGE };
68
+ }
30
69
  const contract = loadContract(options.project.contractPath).contract;
31
70
  const paramResolved = resolveInstallParameterExtraValues({
32
71
  contract,
@@ -67,6 +106,29 @@ export async function deployProject(options) {
67
106
  };
68
107
  const contractDigest = digestProjectContract(options.project);
69
108
  const bundleDigest = digestProjectBundle(options.project);
109
+ if (hasCuratedInstall && options.replaceCurated) {
110
+ const installed = await options.host.isPackageInstalled(identity.packageId);
111
+ if (installed) {
112
+ console.warn("警告:--replace-curated 會移除 Curated 安裝,但會保留 App Data。");
113
+ if (!options.host.uninstallPackage) {
114
+ return {
115
+ ok: false,
116
+ code: "CATALOG_INSTALL_FAILED",
117
+ message: "此 NAS Host 不支援保留 App Data 的 Curated replacement uninstall。",
118
+ };
119
+ }
120
+ try {
121
+ await options.host.uninstallPackage(identity.packageId);
122
+ }
123
+ catch (error) {
124
+ return {
125
+ ok: false,
126
+ code: "CATALOG_INSTALL_FAILED",
127
+ message: error instanceof Error ? error.message : String(error),
128
+ };
129
+ }
130
+ }
131
+ }
70
132
  try {
71
133
  await options.host.requestAttestation({
72
134
  packageIdentity: identity.packageId,
@@ -95,10 +157,12 @@ export async function deployProject(options) {
95
157
  }
96
158
  catch (error) {
97
159
  const code = error.code;
98
- if (code === "DEPLOY_PERSONAL_BUILD_EXHAUSTED" || code === "PERSONAL_BUILD_EXHAUSTED") {
160
+ if (code === "DEPLOY_PERSONAL_BUILD_EXHAUSTED" ||
161
+ code === "PERSONAL_BUILD_EXHAUSTED" ||
162
+ code === "DEPLOY_VERSION_NOT_ASCENDING") {
99
163
  return {
100
164
  ok: false,
101
- code: "DEPLOY_PERSONAL_BUILD_EXHAUSTED",
165
+ code: code === "DEPLOY_VERSION_NOT_ASCENDING" ? code : "DEPLOY_PERSONAL_BUILD_EXHAUSTED",
102
166
  message: error instanceof Error ? error.message : String(error),
103
167
  };
104
168
  }
@@ -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 ?? "");
@@ -4,6 +4,14 @@ export declare class DeployPersonalBuildExhaustedError extends Error {
4
4
  readonly code = "DEPLOY_PERSONAL_BUILD_EXHAUSTED";
5
5
  constructor();
6
6
  }
7
- export declare function bumpBuildNumber(version: string): string;
7
+ export declare class DeployVersionNotAscendingError extends Error {
8
+ readonly code = "DEPLOY_VERSION_NOT_ASCENDING";
9
+ constructor(contractVersion: string, proposedVersion: string, existingVersion: string);
10
+ }
11
+ export interface DeployVersionCandidates {
12
+ installed?: string;
13
+ feed?: string;
14
+ }
15
+ export declare function resolvePersonalDeploySpkVersion(contractVersion: string, candidates?: DeployVersionCandidates): string;
8
16
  export declare function resolveDeploySpkVersion(project: ProjectRoot, host: NasHost, packageId: string): Promise<string>;
9
17
  export declare function publishDeploySpk(spkPath: string): Promise<string>;
@@ -1,6 +1,5 @@
1
1
  import { loadContract } from "../contract/load.js";
2
2
  import { comparePackageVersions, CURATED_BUILD_OFFSET, PERSONAL_BUILD_MAX, } from "@synopackageland/package-source/index-manifest";
3
- import { PersonalBuildExhaustedError, resolveNextSpkVersion, } from "@synopackageland/package-source/next-version";
4
3
  import { fetchPackageSourceManifest, fetchPackageSourceManifestFromR2, resolvePublishTarget, } from "@synopackageland/package-source/publish-spk";
5
4
  import { publishBuiltSpk } from "./catalog-publish.js";
6
5
  import { isDevelopmentPackageIdentity } from "./identity-shared.js";
@@ -12,32 +11,61 @@ export class DeployPersonalBuildExhaustedError extends Error {
12
11
  this.name = "DeployPersonalBuildExhaustedError";
13
12
  }
14
13
  }
15
- function defaultSpkVersion(contractVersion) {
16
- const match = /-(\d+)$/.exec(contractVersion);
17
- if (!match) {
18
- return `${contractVersion}-0001`;
19
- }
20
- const build = Number(match[1]);
21
- if (!Number.isSafeInteger(build) || build >= CURATED_BUILD_OFFSET) {
22
- throw new DeployPersonalBuildExhaustedError();
14
+ export class DeployVersionNotAscendingError extends Error {
15
+ code = "DEPLOY_VERSION_NOT_ASCENDING";
16
+ constructor(contractVersion, proposedVersion, existingVersion) {
17
+ super(`Contract version ${contractVersion} would produce ${proposedVersion}, which is not higher than installed package version ${existingVersion}.`);
18
+ this.name = "DeployVersionNotAscendingError";
23
19
  }
24
- return contractVersion;
25
20
  }
26
- export function bumpBuildNumber(version) {
21
+ function baseVersion(version) {
27
22
  const match = /^(.+)-(\d+)$/.exec(version);
23
+ return match ? match[1] : version;
24
+ }
25
+ function buildNumber(version) {
26
+ const match = /^.+-(\d+)$/.exec(version);
28
27
  if (!match) {
29
- return `${version}-0002`;
28
+ return undefined;
30
29
  }
31
- const current = Number(match[2]);
32
- if (!Number.isSafeInteger(current) || current >= CURATED_BUILD_OFFSET) {
33
- throw new DeployPersonalBuildExhaustedError();
30
+ const build = Number(match[1]);
31
+ return Number.isSafeInteger(build) ? build : undefined;
32
+ }
33
+ function personalBuildNumber(version) {
34
+ const build = buildNumber(version);
35
+ return build !== undefined && build >= 1 && build <= PERSONAL_BUILD_MAX ? build : undefined;
36
+ }
37
+ export function resolvePersonalDeploySpkVersion(contractVersion, candidates = {}) {
38
+ const base = baseVersion(contractVersion);
39
+ const existingVersions = [candidates.installed, candidates.feed].filter((version) => typeof version === "string" && version.length > 0);
40
+ const latestExisting = existingVersions.sort(comparePackageVersions).at(-1);
41
+ let proposedVersion;
42
+ if (!latestExisting || baseVersion(latestExisting) !== base) {
43
+ proposedVersion = `${base}-0001`;
34
44
  }
35
- const nextBuild = current + 1;
36
- if (nextBuild > PERSONAL_BUILD_MAX) {
37
- throw new DeployPersonalBuildExhaustedError();
45
+ else {
46
+ const latestBuild = buildNumber(latestExisting);
47
+ if (latestBuild === undefined || latestBuild >= CURATED_BUILD_OFFSET) {
48
+ throw new DeployPersonalBuildExhaustedError();
49
+ }
50
+ const nextBuild = latestBuild + 1;
51
+ if (nextBuild > PERSONAL_BUILD_MAX) {
52
+ throw new DeployPersonalBuildExhaustedError();
53
+ }
54
+ proposedVersion = `${base}-${String(nextBuild).padStart(4, "0")}`;
55
+ }
56
+ if (candidates.installed &&
57
+ comparePackageVersions(proposedVersion, candidates.installed) <= 0) {
58
+ throw new DeployVersionNotAscendingError(contractVersion, proposedVersion, candidates.installed);
38
59
  }
39
- const next = String(nextBuild).padStart(4, "0");
40
- return `${match[1]}-${next}`;
60
+ return proposedVersion;
61
+ }
62
+ function latestPersonalFeedVersion(packageId, manifest) {
63
+ return (manifest?.packages ?? [])
64
+ .filter((entry) => entry.package === packageId)
65
+ .map((entry) => entry.version)
66
+ .filter((version) => personalBuildNumber(version) !== undefined)
67
+ .sort(comparePackageVersions)
68
+ .at(-1);
41
69
  }
42
70
  async function resolvePackageSourceManifest(origin) {
43
71
  try {
@@ -56,12 +84,11 @@ async function resolvePackageSourceManifest(origin) {
56
84
  }
57
85
  async function resolveOfflineDeploySpkVersion(project, host, packageId) {
58
86
  const contract = loadContract(project.contractPath).contract;
59
- const base = defaultSpkVersion(String(contract.version ?? "1.0.0"));
60
- if (!(await host.isPackageInstalled(packageId))) {
61
- return base;
62
- }
63
- const installed = await host.getInstalledPackageVersion?.(packageId);
64
- return installed ? bumpBuildNumber(installed) : base;
87
+ const contractVersion = String(contract.version ?? "1.0.0");
88
+ const installed = (await host.isPackageInstalled(packageId))
89
+ ? await host.getInstalledPackageVersion?.(packageId)
90
+ : undefined;
91
+ return resolvePersonalDeploySpkVersion(contractVersion, { installed });
65
92
  }
66
93
  export async function resolveDeploySpkVersion(project, host, packageId) {
67
94
  try {
@@ -72,19 +99,13 @@ export async function resolveDeploySpkVersion(project, host, packageId) {
72
99
  const target = resolvePublishTarget();
73
100
  const manifest = (await fetchPackageSourceManifestFromR2(target)) ??
74
101
  (await resolvePackageSourceManifest(target.origin));
75
- let version = resolveNextSpkVersion(String(contract.version ?? "1.0.0"), packageId, manifest);
102
+ const contractVersion = String(contract.version ?? "1.0.0");
76
103
  const installed = await host.getInstalledPackageVersion?.(packageId);
77
- if (installed) {
78
- const installedNext = bumpBuildNumber(installed);
79
- if (comparePackageVersions(installedNext, version) > 0) {
80
- version = installedNext;
81
- }
82
- }
83
- return version;
104
+ const feed = latestPersonalFeedVersion(packageId, manifest);
105
+ return resolvePersonalDeploySpkVersion(contractVersion, { installed, feed });
84
106
  }
85
107
  catch (error) {
86
- if (error instanceof PersonalBuildExhaustedError ||
87
- error.code === "PERSONAL_BUILD_EXHAUSTED") {
108
+ if (error.code === "PERSONAL_BUILD_EXHAUSTED") {
88
109
  throw new DeployPersonalBuildExhaustedError();
89
110
  }
90
111
  throw error;
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) {
@@ -581,10 +611,10 @@ export async function runDoctor(options) {
581
611
  export function formatDoctorReport(report) {
582
612
  const lines = [];
583
613
  for (const check of report.checks) {
584
- const symbol = check.status === "pass" ? "[✓]" : check.status === "fail" ? "[✗]" : "[-]";
614
+ const symbol = check.status === "pass" ? "[✓]" : check.status === "fail" ? "[✗]" : check.status === "warn" ? "[!]" : "[-]";
585
615
  const codeTag = check.code ? ` [${check.code}]` : "";
586
616
  lines.push(`${symbol} ${check.id}${codeTag}: ${check.message}`);
587
- if (check.status === "fail" && check.suggestion) {
617
+ if ((check.status === "fail" || check.status === "warn") && check.suggestion) {
588
618
  lines.push(` 建議:${check.suggestion}`);
589
619
  }
590
620
  }
@@ -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 字元。",
@@ -161,6 +166,11 @@ export const ERROR_REGISTRY = {
161
166
  description: "容器以 root (0:0) 執行,違反最小權限原則。",
162
167
  suggestion: "請在 synology-app.yaml 宣告 dsm_user 並在 Compose 使用 ${USER_UID}:${USER_GID}。",
163
168
  },
169
+ COMPOSE_USER_CONFLICTS_DSM_USER: {
170
+ code: "COMPOSE_USER_CONFLICTS_DSM_USER",
171
+ description: "Compose 服務自定義了 user 欄位,與 contract 宣告的 dsm_user 衝突。",
172
+ suggestion: "請移除 Compose 中的 user 欄位,交由 dsm_user 自動注入 ${NAME_UID}:${NAME_GID} 與 group_add。",
173
+ },
164
174
  COMPOSE_USER_NUMERIC_FORBIDDEN: {
165
175
  code: "COMPOSE_USER_NUMERIC_FORBIDDEN",
166
176
  description: "禁止在 compose.yaml 硬編碼數字 uid:gid。",
@@ -454,6 +464,11 @@ export const ERROR_REGISTRY = {
454
464
  description: "Personal App 的 Package Build 已用盡 0001–9999 命名範圍。",
455
465
  suggestion: "請改用新的 App Version,或先清理並確認不會與 Curated 版本命名空間混用。",
456
466
  },
467
+ DEPLOY_VERSION_NOT_ASCENDING: {
468
+ code: "DEPLOY_VERSION_NOT_ASCENDING",
469
+ description: "部署產生的 Package Version 不高於既有版本。",
470
+ suggestion: "請提高 synology-app.yaml 的 version,或確認目標 NAS 與 Package Source 上的既有版本。",
471
+ },
457
472
  SHARE_VALUE_REQUIRED: {
458
473
  code: "SHARE_VALUE_REQUIRED",
459
474
  description: "部署時缺少合約中宣告的 shared_folder 實際路徑映射。",
@@ -755,6 +770,11 @@ export const ERROR_REGISTRY = {
755
770
  description: "compose.yaml 缺少 services 區塊或服務為空。",
756
771
  suggestion: "請在 compose.yaml 至少定義一個容器服務。",
757
772
  },
773
+ CURATED_BROKER_ROUTE_FORBIDDEN: {
774
+ code: "CURATED_BROKER_ROUTE_FORBIDDEN",
775
+ description: "此 Compose 會落到 Broker installer 路線;rootless Broker 不支援 Container Manager,Curated listing 拒絕。",
776
+ suggestion: "請改成零設定 resource-worker:使用 ./html bind 與預先 build 的 image;將 ${SYNOPKG_PKGVAR} 等平台 bind 改成 named volume,或改用 D1 dynamic route 的 string/boolean install 參數。",
777
+ },
758
778
  CURATED_BUNDLE_FILE_MISSING: {
759
779
  code: "CURATED_BUNDLE_FILE_MISSING",
760
780
  description: "打包 Curated Release 時缺少必要的 Bundle 檔案。",
@@ -48,6 +48,14 @@ export declare function waitForInstalledVersion(readState: () => InstalledPackag
48
48
  deadlineMs?: number;
49
49
  pollMs?: number;
50
50
  }): Promise<void>;
51
+ export interface UninstallPackageOptions {
52
+ dsmOrigin: string;
53
+ cookieHeader: string;
54
+ packageId: string;
55
+ deadlineMs?: number;
56
+ pollMs?: number;
57
+ }
58
+ export declare function uninstallPackageFromDsm(options: UninstallPackageOptions): Promise<void>;
51
59
  export declare function catalogInstallationRequests(entry: Pick<CatalogPackageEntry, "package" | "version" | "size" | "beta" | "link" | "md5" | "qinst" | "deppkgs" | "breakpkgs" | "replacepkgs">, installed: boolean, sid: string, volumePath?: string): {
52
60
  check: Record<string, string>;
53
61
  install: Record<string, string>;
@@ -224,6 +224,66 @@ export async function waitForInstalledVersion(readState, options) {
224
224
  ? `Timed out waiting for ${options.packageId} to reach version ${options.expectedVersion}`
225
225
  : `Timed out waiting for ${options.packageId} to install from catalog`);
226
226
  }
227
+ function packageStatusIsStopped(state) {
228
+ const status = state?.status?.trim().toLowerCase();
229
+ return state?.installed === true && (status === "stop" || status === "stopped" || status === "manually-stopped");
230
+ }
231
+ async function waitForPackageStopped(readState, options) {
232
+ const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
233
+ const pollMs = options.pollMs ?? 1000;
234
+ while (Date.now() < deadline) {
235
+ const state = await readState();
236
+ if (packageStatusIsStopped(state)) {
237
+ return;
238
+ }
239
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
240
+ }
241
+ throw new Error(`Timed out waiting for ${options.packageId} to stop before uninstall`);
242
+ }
243
+ async function waitForPackageUninstalled(readState, options) {
244
+ const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
245
+ const pollMs = options.pollMs ?? 1000;
246
+ while (Date.now() < deadline) {
247
+ const state = await readState();
248
+ if (state?.installed === false) {
249
+ return;
250
+ }
251
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
252
+ }
253
+ throw new Error(`Timed out waiting for ${options.packageId} to be uninstalled`);
254
+ }
255
+ function uninstallFailure(packageId, error) {
256
+ const detail = error instanceof Error ? error.message : String(error);
257
+ const failure = new Error(`Failed to uninstall ${packageId}: ${detail}`);
258
+ failure.code = "CATALOG_INSTALL_FAILED";
259
+ return failure;
260
+ }
261
+ export async function uninstallPackageFromDsm(options) {
262
+ try {
263
+ const sid = extractSid(options.cookieHeader);
264
+ const readState = () => fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId);
265
+ await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
266
+ api: "SYNO.Core.Package.Control",
267
+ version: "1",
268
+ method: "stop",
269
+ id: jsonApiParam(options.packageId),
270
+ _sid: sid,
271
+ });
272
+ await waitForPackageStopped(readState, options);
273
+ await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
274
+ api: "SYNO.Core.Package.Uninstallation",
275
+ version: "1",
276
+ method: "uninstall",
277
+ id: jsonApiParam(options.packageId),
278
+ extra_values: jsonApiParam("{}"),
279
+ _sid: sid,
280
+ });
281
+ await waitForPackageUninstalled(readState, options);
282
+ }
283
+ catch (error) {
284
+ throw uninstallFailure(options.packageId, error);
285
+ }
286
+ }
227
287
  export function catalogInstallationRequests(entry, installed, sid, volumePath) {
228
288
  const install = {
229
289
  api: "SYNO.Core.Package.Installation",
@@ -6,7 +6,7 @@ import { IDENTITY_CGI_PATH } from "./broker-paths.js";
6
6
  import { packageSourceOrigin } from "../deploy/catalog-publish.js";
7
7
  import { lookupSharePathWithSynoagentcli } from "./share-lookup.js";
8
8
  import { dsmFetch } from "./dsm-http.js";
9
- import { installPackageFromCatalog, installPackageFromLocalSpk, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
9
+ import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
10
10
  import { resolveHostCredentials } from "./credentials.js";
11
11
  export function createNasHost(options = {}) {
12
12
  const credentials = options.credentials ?? resolveHostCredentials(options.host);
@@ -200,6 +200,19 @@ function createDsmNasHost(credentials, profileName) {
200
200
  projectEnvCache.delete(input.packageId);
201
201
  }
202
202
  },
203
+ async uninstallPackage(packageIdentity) {
204
+ try {
205
+ await ensureLogin();
206
+ await uninstallPackageFromDsm({
207
+ dsmOrigin,
208
+ cookieHeader: cookieHeader ?? "",
209
+ packageId: packageIdentity,
210
+ });
211
+ }
212
+ finally {
213
+ projectEnvCache.delete(packageIdentity);
214
+ }
215
+ },
203
216
  async getArchFamily() {
204
217
  await ensureLogin();
205
218
  const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?api=SYNO.Core.System&version=1&method=info`, { headers: { cookie: cookieHeader ?? "" } });
@@ -315,6 +328,21 @@ function createDsmNasHost(credentials, profileName) {
315
328
  .filter(Boolean)
316
329
  .map((line) => `[status] ${line}`);
317
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
+ },
318
346
  async smokeRequireLogin(input) {
319
347
  await ensureLogin();
320
348
  const info = await this.getAttestationInfo(input.packageIdentity);
@@ -6,6 +6,10 @@ 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
+ [key: string]: unknown;
12
+ };
9
13
  }
10
14
  export declare function createFakeNasHost(options?: FakeNasHostOptions): NasHost & {
11
15
  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;
@@ -44,6 +54,8 @@ export interface NasHost {
44
54
  spkPath: string;
45
55
  extraValues?: Record<string, string>;
46
56
  }): Promise<void>;
57
+ /** Stop and normally uninstall a formal package while preserving App Data. */
58
+ uninstallPackage?(packageIdentity: string): Promise<void>;
47
59
  /**
48
60
  * CPU family of this NAS, for picking a native App's target. Returns
49
61
  * undefined when the DSM answer does not map to a family we ship, so the
@@ -60,6 +72,10 @@ export interface NasHost {
60
72
  getAttestationInfo(packageIdentity: string): Promise<HostAttestationInfo>;
61
73
  isPackageInstalled(packageIdentity: string): Promise<boolean>;
62
74
  fetchContainerLogs(packageIdentity: string): Promise<string[]>;
75
+ getDsmDiagnostics?(packageIdentity?: string): Promise<{
76
+ dsmResidue?: DsmResidueInventory;
77
+ [key: string]: unknown;
78
+ } | undefined>;
63
79
  smokeRequireLogin(input: {
64
80
  packageIdentity: string;
65
81
  cookieHeader: string;
@@ -9,7 +9,7 @@ import { createR2CuratedReleasePublisher, publishCuratedRelease, } from "@synopa
9
9
  import { digestAppPackageClaims, digestCuratedReleaseClaims, normalizeCapabilities, signCuratedAppPackageAttestationToken, signCuratedReleaseAttestationToken, } from "../attestation/app-package.js";
10
10
  import { CuratedTrustRootError, loadCuratedTrustRoot, resolveCuratedSigningTrust, } from "../attestation/keyring.js";
11
11
  import { loadContract } from "../contract/load.js";
12
- import { loadCompose } from "../compose/analyze.js";
12
+ import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
13
13
  import { loadNative } from "../native/load.js";
14
14
  import { infoArchValue, isNativeArchFamily } from "../build-spk/dsm-arch.js";
15
15
  import { EMBEDDED_ATTESTATION_RELATIVE_PATH, buildSpk, extractSpk, } from "../build-spk/generator.js";
@@ -524,6 +524,10 @@ function prepareRelease(project, build) {
524
524
  if (!parsedCompose) {
525
525
  throw new CuratedReleaseError("CURATED_COMPOSE_MISSING");
526
526
  }
527
+ const route = classifyComposeRoute(contract, parsedCompose);
528
+ if (route.route === "broker-installer" && contract.adminport !== undefined) {
529
+ throw new CuratedReleaseError("CURATED_BROKER_ROUTE_FORBIDDEN");
530
+ }
527
531
  const composeGate = validateCuratedCompose(parsedCompose.compose);
528
532
  if (!composeGate.ok) {
529
533
  throw new CuratedReleaseError("CURATED_COMPOSE_GATE_FAILED", composeGate.errors.join(","));
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { CURATED_BUILD_OFFSET, isDevelopmentPackageIdentity, } from "@synopackageland/package-source/index-manifest";
5
5
  import { buildSpk } from "../build-spk/generator.js";
6
- import { loadCompose } from "../compose/analyze.js";
6
+ import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
7
7
  import { loadContract } from "../contract/load.js";
8
8
  import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
9
9
  import { loadNative } from "../native/load.js";
@@ -89,6 +89,15 @@ export async function runPublicReleasePreflight(project) {
89
89
  ok: gate.ok,
90
90
  detail: gate.ok ? undefined : gate.errors.join(", "),
91
91
  });
92
+ const route = parsedCompose
93
+ ? classifyComposeRoute(contract, parsedCompose)
94
+ : undefined;
95
+ add({
96
+ id: "curated-route",
97
+ title: "Compose 不使用 Broker installer 路線",
98
+ ok: route?.route !== "broker-installer",
99
+ detail: route?.route === "broker-installer" ? "CURATED_BROKER_ROUTE_FORBIDDEN" : undefined,
100
+ });
92
101
  }
93
102
  // The strongest local gate: actually produce the artifact. For native this
94
103
  // fetches each pinned asset and refuses it unless the bytes hash to the
@@ -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.2",
3
+ "version": "0.3.4",
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.