@synopackageland/cli 0.3.5 → 0.3.7

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.
@@ -20,9 +20,23 @@ import { renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuni
20
20
  import { renderHeadlessResourceMarker, renderResourceConfig, renderResourceWorkerStartStopStatus, } from "./resource-worker.js";
21
21
  const FILE_MODE = 0o644;
22
22
  const SCRIPT_MODE = 0o755;
23
+ function hasInterpolation(value) {
24
+ if (typeof value === "string") {
25
+ return /\$\{([^}]+)\}/.test(value);
26
+ }
27
+ if (Array.isArray(value)) {
28
+ return value.some(hasInterpolation);
29
+ }
30
+ if (value && typeof value === "object") {
31
+ return Object.values(value).some(hasInterpolation);
32
+ }
33
+ return false;
34
+ }
23
35
  const INSTALL_UIFILE = "install_uifile";
24
36
  const UPGRADE_UIFILE = "upgrade_uifile";
25
37
  const UNINSTALL_UIFILE = "uninstall_uifile";
38
+ const HEADLESS_PROBE_VOLUME = "./.synopkgland:/run/synopkgland:ro";
39
+ const HEADLESS_PROBE_README = "Reserved bind target for package-user headless status probing.\n";
26
40
  export const EMBEDDED_ATTESTATION_RELATIVE_PATH = ".synopkgland/attestation.jwt";
27
41
  const SCRIPT_NAMES = [
28
42
  "preinst",
@@ -171,11 +185,11 @@ export async function buildSpk(options) {
171
185
  const resourceProjectDir = join(staging, "package", "compose");
172
186
  mkdirSync(resourceProjectDir, { recursive: true });
173
187
  const dsmUserParam = collectInstallParameters(contract).find((p) => p.param.type === "dsm_user");
174
- if (dsmUserParam) {
188
+ if (dsmUserParam || rwEligibility?.headless === true || hasOidcCompat(contract)) {
175
189
  const rawYaml = readFileSync(options.project.composePath, "utf8");
176
190
  const doc = parseDocument(rawYaml);
177
191
  const services = doc.get("services");
178
- if (services && typeof services === "object" && typeof services.toJSON === "function") {
192
+ if (dsmUserParam && services && typeof services === "object" && typeof services.toJSON === "function") {
179
193
  const serviceNames = Object.keys(services.toJSON());
180
194
  for (const sName of serviceNames) {
181
195
  const userVal = new Scalar(`\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}`);
@@ -183,15 +197,53 @@ export async function buildSpk(options) {
183
197
  doc.setIn(["services", sName, "user"], userVal);
184
198
  }
185
199
  }
200
+ if (hasOidcCompat(contract) && services && typeof services === "object" && typeof services.toJSON === "function") {
201
+ const rawServices = services.toJSON();
202
+ for (const [sName, sValue] of Object.entries(rawServices)) {
203
+ if (sValue && typeof sValue === "object" && hasInterpolation(sValue)) {
204
+ const str = JSON.stringify(sValue);
205
+ if (str.includes("SYNOPKGLAND_OIDC_ISSUER")) {
206
+ const serviceDoc = doc.getIn(["services", sName]);
207
+ const existingExtraHosts = serviceDoc?.get?.("extra_hosts");
208
+ const hostGatewayMapping = "${SYNOPKGLAND_OIDC_ISSUER_HOST}:host-gateway";
209
+ if (!existingExtraHosts) {
210
+ doc.setIn(["services", sName, "extra_hosts"], [hostGatewayMapping]);
211
+ }
212
+ else if (Array.isArray(existingExtraHosts.toJSON?.())) {
213
+ const arr = existingExtraHosts.toJSON();
214
+ if (!arr.includes(hostGatewayMapping)) {
215
+ doc.setIn(["services", sName, "extra_hosts"], [...arr, hostGatewayMapping]);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ }
222
+ if (rwEligibility?.headless === true) {
223
+ injectHeadlessProbeVolume(doc);
224
+ }
186
225
  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}`);
226
+ if (dsmUserParam) {
227
+ const escaped = `\\$\\{${dsmUserParam.name}_UID\\}:\\$\\{${dsmUserParam.name}_GID\\}`;
228
+ composeContent = composeContent.replace(new RegExp(`^([ \\t]*)user:\\s*"${escaped}"`, "gm"), `$1user: "\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}"\n$1# synopkgland:group_add ${dsmUserParam.name}`);
229
+ }
189
230
  writeFileSync(join(resourceProjectDir, "docker-compose.yml"), composeContent, "utf8");
190
231
  }
191
232
  else {
192
233
  copyFileSync(options.project.composePath, join(resourceProjectDir, "docker-compose.yml"));
193
234
  }
194
235
  chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
236
+ if (rwEligibility?.headless === true) {
237
+ const probePayloadDir = join(resourceProjectDir, ".synopkgland");
238
+ mkdirSync(probePayloadDir, { recursive: true });
239
+ writeFileSync(join(probePayloadDir, "README"), HEADLESS_PROBE_README, "utf8");
240
+ chmodSync(join(probePayloadDir, "README"), FILE_MODE);
241
+ }
242
+ const routeMarkerDir = join(staging, "package", ".synopkgland");
243
+ mkdirSync(routeMarkerDir, { recursive: true });
244
+ const routeMarker = join(routeMarkerDir, "route");
245
+ writeFileSync(routeMarker, "package-only", "utf8");
246
+ chmodSync(routeMarker, FILE_MODE);
195
247
  }
196
248
  const htmlDir = join(options.project.root, "html");
197
249
  if (existsSync(htmlDir)) {
@@ -382,6 +434,23 @@ function composeUsesOnlyAppSpace(compose) {
382
434
  const sources = volumeSources(compose);
383
435
  return sources.every((source) => source.startsWith("${SYNOPKG_PKGVAR}") || source.startsWith("${SYNO_APP_SECRETS_DIR}"));
384
436
  }
437
+ function injectHeadlessProbeVolume(doc) {
438
+ const compose = (doc.toJSON() ?? {});
439
+ const serviceName = Object.keys(compose.services ?? {})[0];
440
+ if (!serviceName) {
441
+ return;
442
+ }
443
+ const existingVolumes = compose.services?.[serviceName]?.volumes;
444
+ const volumes = existingVolumes == null
445
+ ? []
446
+ : Array.isArray(existingVolumes)
447
+ ? [...existingVolumes]
448
+ : [existingVolumes];
449
+ if (!volumes.some((volume) => volume === HEADLESS_PROBE_VOLUME)) {
450
+ volumes.push(HEADLESS_PROBE_VOLUME);
451
+ }
452
+ doc.setIn(["services", serviceName, "volumes"], volumes);
453
+ }
385
454
  const PRIVILEGE_MAX_WARNING = "此 App 的容器有高權限,接近這台 NAS 的 root、幾乎整台機器。是否安裝由你這位管理員決定。";
386
455
  function renderInstallWizard(contract, compose) {
387
456
  const items = [];
@@ -653,6 +722,14 @@ function renderDynamicPreinstMaterializer(contract) {
653
722
  if (dsmUserParam) {
654
723
  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
724
  }
725
+ if (hasOidcCompat(contract)) {
726
+ const identity = contract.integration?.identity;
727
+ const redirectPath = identity?.oidc_compat?.redirect_path ?? "";
728
+ const adminProtocol = contract.adminprotocol ?? "http";
729
+ const adminPort = Number(contract.adminport ?? 0);
730
+ const escapedRedirectPath = redirectPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
731
+ lines.push("oidc_register() {", " # DSM compose does not create missing bind sources: the secrets dir must exist even when compat is off.", " (umask 077 && mkdir -p \"$PROJECT/secrets\" && chmod 700 \"$PROJECT/secrets\") || die \"failed to create $PROJECT/secrets\"", " OIDC_ISSUER=\"\"", " OIDC_ISSUER_HOST=\"oidc.invalid\"", " OIDC_CLIENT_ID=\"\"", " OIDC_CLIENT_SECRET=\"\"", " identity_file=\"${SYNOPKGLAND_IDENTITY_FILE:-/var/packages/SynoPkgLandService/var/identity.json}\"", " if [ ! -r \"$identity_file\" ]; then", " identity_file=\"/volume1/@appdata/SynoPkgLandService/identity.json\"", " fi", " if [ ! -r \"$identity_file\" ]; then", " # Identity endpoint not found -> Broker not installed -> skip as 404", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " base_url=\"$(python3 -c 'import json, sys; data=json.load(open(sys.argv[1])); print(data.get(\"baseUrl\", \"\"))' \"$identity_file\" 2>/dev/null || true)\"", " if [ -z \"$base_url\" ]; then", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " attestation_file=\"$T/.synopkgland/attestation.jwt\"", " if [ ! -f \"$attestation_file\" ]; then", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " attestation_token=\"$(cat \"$attestation_file\")\"", " phase=\"install\"", " case \"$STATUS\" in", " UPGRADE) phase=\"upgrade\" ;;", " *) phase=\"install\" ;;", " esac", " req_body=\"$(python3 -c 'import json, sys; print(json.dumps({\"protocolVersion\": 1, \"attestation\": sys.argv[1], \"packageIdentity\": sys.argv[2], \"packageVersion\": sys.argv[3], \"redirectPath\": sys.argv[4], \"adminProtocol\": sys.argv[5], \"adminPort\": int(sys.argv[6]), \"phase\": sys.argv[7]}))' \"$attestation_token\" \"${SYNOPKG_PKGNAME:?}\" \"${SYNOPKG_PKGVER:?}\" \"" + escapedRedirectPath + "\" \"" + String(adminProtocol) + "\" \"" + String(adminPort) + "\" \"$phase\")\"", " resp_file=\"$(mktemp \"$T/oidc_resp.XXXXXX\")\"", " http_code=\"$(curl -s -S --connect-timeout 5 --max-time 30 -o \"$resp_file\" -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data-binary \"$req_body\" \"${base_url}/internal/v1/oidc/register\" 2>/dev/null || true)\"", " case \"$http_code\" in [0-9][0-9][0-9]) ;; *) http_code=\"000\" ;; esac", " if [ \"$http_code\" = \"200\" ]; then", " parsed_res=\"$(python3 -c 'import json, sys; d=json.load(open(sys.argv[1])); print(d.get(\"issuer\", \"\")); print(d.get(\"issuerHost\", \"\")); print(d.get(\"clientId\", \"\")); print(d.get(\"clientSecret\", \"\"))' \"$resp_file\" 2>/dev/null || true)\"", " rm -f \"$resp_file\"", " OIDC_ISSUER=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '1p')\"", " OIDC_ISSUER_HOST=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '2p')\"", " OIDC_CLIENT_ID=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '3p')\"", " OIDC_CLIENT_SECRET=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '4p')\"", " (umask 077 && mkdir -p \"$PROJECT/secrets\" && printf '%s' \"$OIDC_CLIENT_SECRET\" > \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\")", " elif [ \"$http_code\" = \"404\" ] || [ \"$http_code\" = \"000\" ]; then", " rm -f \"$resp_file\"", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " OIDC_ISSUER=\"\"", " OIDC_ISSUER_HOST=\"oidc.invalid\"", " OIDC_CLIENT_ID=\"\"", " else", " rm -f \"$resp_file\"", " die \"OIDC_REGISTER_FAILED $http_code\"", " fi", "}", "oidc_register");
732
+ }
656
733
  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", "{");
657
734
  const valueVars = [];
658
735
  for (const { name, param } of scalarParams) {
@@ -681,7 +758,12 @@ function renderDynamicPreinstMaterializer(contract) {
681
758
  lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawUserVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
682
759
  `${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
760
  }
684
- lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
761
+ if (hasOidcCompat(contract)) {
762
+ lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "${SYNOPKG_PKGDEST_VOL:?}/@appstore/${SYNOPKG_PKGNAME:?}/compose/secrets"', ' append_env "SYNOPKGLAND_OIDC_ISSUER" "$OIDC_ISSUER"', ' append_env "SYNOPKGLAND_OIDC_ISSUER_HOST" "$OIDC_ISSUER_HOST"', ' append_env "SYNOPKGLAND_OIDC_CLIENT_ID" "$OIDC_CLIENT_ID"');
763
+ }
764
+ else {
765
+ lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
766
+ }
685
767
  for (const { name, valueVar, type } of valueVars) {
686
768
  lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
687
769
  }
@@ -79,44 +79,29 @@ export function renderResourceConfig(contract, eligibility) {
79
79
  */
80
80
  export function renderResourceWorkerStartStopStatus(packageId, adminport, headless = false) {
81
81
  if (headless) {
82
- const projectName = composeProjectName(packageId);
83
82
  return `#!/bin/sh
84
- PKGVAR="\${SYNOPKG_PKGVAR:-/var/packages/${packageId}/var}"
85
- MARKER="$PKGVAR/.docker-project-managed"
86
- PROJECT_NAME="${projectName}"
83
+ SYNOPKG_PKGNAME="\${SYNOPKG_PKGNAME:-${packageId}}"
84
+ SYNOPKGLAND_PROC_ROOT="\${SYNOPKGLAND_PROC_ROOT:-/proc}"
87
85
 
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
86
+ probe_project_process() {
87
+ for p in "$SYNOPKGLAND_PROC_ROOT"/[0-9]*; do
88
+ [ -d "$p" ] || continue
89
+ if grep -q docker "$p/cgroup" 2>/dev/null &&
90
+ grep -q \\
91
+ "@appstore/\${SYNOPKG_PKGNAME}/compose/" \\
92
+ "$p/mountinfo" 2>/dev/null; then
93
+ return 0
94
+ fi
95
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" ]
96
+ return 1
103
97
  }
104
98
 
105
99
  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
100
+ start|stop) exit 0 ;;
101
+ status)
102
+ probe_project_process
103
+ exit $?
118
104
  ;;
119
- stop) exit 0 ;;
120
105
  *) exit 1 ;;
121
106
  esac
122
107
  `;
@@ -227,10 +227,10 @@ export function classifyComposeRoute(contract, parsed, composeFile = "compose.ya
227
227
  // Keep the route decision in one place. build-spk must not independently
228
228
  // reinterpret an ineligible Compose as D1: only a route with a resolved
229
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.
230
+ // declared install parameter (or oidc_compat declaration), has a package-only materializer.
231
+ // Every other buildable fallback is Broker.
232
232
  if ((eligibility.adminport !== undefined || headlessPackageOnly) &&
233
- collectInstallParameters(contract).length > 0) {
233
+ (collectInstallParameters(contract).length > 0 || hasBrokerManagedOidc(contract))) {
234
234
  return { route: "dynamic", eligibility, errors: [] };
235
235
  }
236
236
  return { route: "broker-installer", eligibility, errors: [] };
@@ -311,7 +311,7 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
311
311
  param.type !== "boolean" &&
312
312
  param.type !== "shared_folder" &&
313
313
  param.type !== "dsm_user"));
314
- const brokerOidcInterpolationNames = params.length === 0 && hasBrokerManagedOidc(contract)
314
+ const brokerOidcInterpolationNames = hasBrokerManagedOidc(contract)
315
315
  ? BROKER_OIDC_INTERPOLATION_VARS
316
316
  : [];
317
317
  const supportedInterpolationNames = new Set([
@@ -614,6 +614,7 @@ const PLATFORM_BIND_VARS = new Set(["SYNOPKG_PKGVAR", "SYNO_APP_SECRETS_DIR"]);
614
614
  const BROKER_OIDC_INTERPOLATION_VARS = new Set([
615
615
  "SYNOPKGLAND_OIDC_ISSUER",
616
616
  "SYNOPKGLAND_OIDC_CLIENT_ID",
617
+ "SYNOPKGLAND_OIDC_ISSUER_HOST",
617
618
  ]);
618
619
  function hasBrokerManagedOidc(contract) {
619
620
  if (!contract.integration || typeof contract.integration !== "object") {
@@ -129,8 +129,9 @@ export async function deployProject(options) {
129
129
  }
130
130
  }
131
131
  }
132
+ let attestationResult;
132
133
  try {
133
- await options.host.requestAttestation({
134
+ attestationResult = await options.host.requestAttestation({
134
135
  packageIdentity: identity.packageId,
135
136
  contractDigest,
136
137
  bundleDigest,
@@ -185,6 +186,7 @@ export async function deployProject(options) {
185
186
  displayname: identity.displayname,
186
187
  spkVersion,
187
188
  archFamily,
189
+ embeddedAttestationToken: attestationResult.token,
188
190
  });
189
191
  if (!build.ok || !build.outputPath) {
190
192
  return { ok: false, code: "BUILD_SPK_FAILED", message: build.errors.join(", ") };
package/dist/doctor.js CHANGED
@@ -6,6 +6,19 @@ import { loadContract } from "./contract/load.js";
6
6
  import { getErrorCode } from "./error-registry.js";
7
7
  import { dsmFetch } from "./host/dsm-http.js";
8
8
  import { appOriginFromContract } from "./host/open-url.js";
9
+ function packageRouteFromDiagnostics(diagnostics) {
10
+ switch (diagnostics?.packageRoute) {
11
+ case "package-only":
12
+ case "broker-installer":
13
+ case "unknown":
14
+ return diagnostics.packageRoute;
15
+ default:
16
+ break;
17
+ }
18
+ // Keep compatibility with the boolean-only response emitted by older
19
+ // Brokers, but never infer broker-installer from missing evidence.
20
+ return diagnostics?.isPackageOnly === true ? "package-only" : "unknown";
21
+ }
9
22
  const PROJECT_FOUR_FILES = ["synology-app.yaml", "README.md", "icon.png"];
10
23
  function packageFailureSummary(state) {
11
24
  const fields = [];
@@ -199,6 +212,11 @@ export async function runDoctor(options) {
199
212
  message: "未指定 --host,略過 Trusted App Entry 檢查",
200
213
  });
201
214
  }
215
+ checks.push({
216
+ id: "host.oidc_origin",
217
+ status: "skip",
218
+ message: "未指定 --host,略過 OIDC origin 檢查",
219
+ });
202
220
  }
203
221
  else {
204
222
  const host = options.host;
@@ -240,6 +258,11 @@ export async function runDoctor(options) {
240
258
  status: "skip",
241
259
  message: "NAS 無法連線,略過 Attestation Trust Root 檢查",
242
260
  });
261
+ checks.push({
262
+ id: "host.oidc_origin",
263
+ status: "skip",
264
+ message: "NAS 無法連線,略過 OIDC origin 檢查",
265
+ });
243
266
  if (options.packageId) {
244
267
  checks.push({
245
268
  id: "host.package_binding",
@@ -382,6 +405,50 @@ export async function runDoctor(options) {
382
405
  }
383
406
  }
384
407
  }
408
+ // Check 2.3a: OIDC-compatible public origin. This is deliberately
409
+ // optional so a pre-A39 Broker remains a usable doctor target.
410
+ if (!isBrokerReady || !host.getOidcCompatSettings) {
411
+ checks.push({
412
+ id: "host.oidc_origin",
413
+ status: "skip",
414
+ message: !isBrokerReady
415
+ ? "Broker 未就緒,略過 OIDC origin 檢查"
416
+ : "舊版 Broker 沒有 OIDC origin endpoint,略過",
417
+ });
418
+ }
419
+ else {
420
+ try {
421
+ const oidc = await host.getOidcCompatSettings();
422
+ if (!oidc) {
423
+ checks.push({
424
+ id: "host.oidc_origin",
425
+ status: "skip",
426
+ message: "舊版 Broker 沒有 OIDC origin endpoint,略過",
427
+ });
428
+ }
429
+ else if (oidc.origin.ready && oidc.origin.hostname) {
430
+ checks.push({
431
+ id: "host.oidc_origin",
432
+ status: "pass",
433
+ message: `${oidc.origin.kind} ${oidc.origin.hostname}`,
434
+ });
435
+ }
436
+ else {
437
+ checks.push({
438
+ id: "host.oidc_origin",
439
+ status: "skip",
440
+ message: `未就緒(${oidc.origin.kind}):${oidc.origin.setupHint}`,
441
+ });
442
+ }
443
+ }
444
+ catch {
445
+ checks.push({
446
+ id: "host.oidc_origin",
447
+ status: "skip",
448
+ message: "OIDC origin endpoint 無法取得,略過",
449
+ });
450
+ }
451
+ }
385
452
  // Check 2.3b: DSM Residue check (A29)
386
453
  if (isBrokerReady && host.getDsmDiagnostics) {
387
454
  try {
@@ -472,26 +539,34 @@ export async function runDoctor(options) {
472
539
  else {
473
540
  const info = await host.getAttestationInfo(targetPackageId);
474
541
  if (info.readiness === "NOT_INSTALLED") {
475
- // Check if package is package-only route via Broker diagnostics
476
- let isPackageOnly = false;
542
+ // Check the diagnosed route before treating a missing binding
543
+ // as a Broker-installer failure. Unknown is deliberately
544
+ // non-failing because the Broker may not be able to read the
545
+ // DSM package resource.
546
+ let packageRoute = "unknown";
477
547
  if (host.getDsmDiagnostics) {
478
548
  try {
479
549
  const diag = await host.getDsmDiagnostics(targetPackageId);
480
- if (diag?.isPackageOnly === true || diag?.packageRoute === "package-only") {
481
- isPackageOnly = true;
482
- }
550
+ packageRoute = packageRouteFromDiagnostics(diag);
483
551
  }
484
552
  catch {
485
553
  // ignore
486
554
  }
487
555
  }
488
- if (isPackageOnly) {
556
+ if (packageRoute === "package-only") {
489
557
  checks.push({
490
558
  id: "host.package_binding",
491
559
  status: "skip",
492
560
  message: "package-only 路線,無 Broker binding(略過)",
493
561
  });
494
562
  }
563
+ else if (packageRoute === "unknown") {
564
+ checks.push({
565
+ id: "host.package_binding",
566
+ status: "skip",
567
+ message: "Broker 無法判定套件路線,略過 Package Binding 檢查",
568
+ });
569
+ }
495
570
  else {
496
571
  const errorEntry = getErrorCode("PROJECT_BINDING_NOT_FOUND");
497
572
  checks.push({
@@ -8,6 +8,7 @@ import { lookupSharePathWithSynoagentcli } from "./share-lookup.js";
8
8
  import { dsmFetch } from "./dsm-http.js";
9
9
  import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
10
10
  import { resolveHostCredentials } from "./credentials.js";
11
+ const OIDC_COMPAT_PATH = "/api/v1/inner-loop/oidc-compat";
11
12
  export function createNasHost(options = {}) {
12
13
  const credentials = options.credentials ?? resolveHostCredentials(options.host);
13
14
  if (!credentials) {
@@ -160,7 +161,12 @@ function createDsmNasHost(credentials, profileName) {
160
161
  error.code = body.error?.code;
161
162
  throw error;
162
163
  }
163
- return { kind: "local", attestationDigest: body.attestationDigest ?? "" };
164
+ const token = typeof body.token === "string" && body.token.length > 0 ? body.token : undefined;
165
+ return {
166
+ kind: "local",
167
+ attestationDigest: body.attestationDigest ?? "",
168
+ ...(token !== undefined ? { token } : {}),
169
+ };
164
170
  },
165
171
  async ensurePackageSource(origin) {
166
172
  ensurePackageSourceFeed(origin);
@@ -343,6 +349,51 @@ function createDsmNasHost(credentials, profileName) {
343
349
  return undefined;
344
350
  }
345
351
  },
352
+ async getOidcCompatSettings() {
353
+ try {
354
+ await ensureLogin();
355
+ const response = await dsmFetch(`${brokerBaseUrl}${OIDC_COMPAT_PATH}`, {
356
+ headers: { cookie: cookieHeader ?? "" },
357
+ });
358
+ const text = await response.text();
359
+ if (response.status === 404 || text.trimStart().startsWith("<")) {
360
+ return undefined;
361
+ }
362
+ if (!response.ok) {
363
+ return undefined;
364
+ }
365
+ const payload = JSON.parse(text);
366
+ const settings = payload.settings;
367
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
368
+ return undefined;
369
+ }
370
+ const record = settings;
371
+ const origin = record.origin;
372
+ if (!origin || typeof origin !== "object" || Array.isArray(origin)) {
373
+ return undefined;
374
+ }
375
+ const originRecord = origin;
376
+ if (typeof record.enabled !== "boolean" ||
377
+ typeof originRecord.ready !== "boolean" ||
378
+ typeof originRecord.kind !== "string" ||
379
+ typeof originRecord.setupHint !== "string") {
380
+ return undefined;
381
+ }
382
+ return {
383
+ enabled: record.enabled,
384
+ origin: {
385
+ ready: originRecord.ready,
386
+ kind: originRecord.kind,
387
+ hostname: typeof originRecord.hostname === "string" ? originRecord.hostname : undefined,
388
+ issuerUrl: typeof originRecord.issuerUrl === "string" ? originRecord.issuerUrl : undefined,
389
+ setupHint: originRecord.setupHint,
390
+ },
391
+ };
392
+ }
393
+ catch {
394
+ return undefined;
395
+ }
396
+ },
346
397
  async smokeRequireLogin(input) {
347
398
  await ensureLogin();
348
399
  const info = await this.getAttestationInfo(input.packageIdentity);
@@ -1,4 +1,4 @@
1
- import type { AttestationKind, HostInstallRecord, NasHost } from "./types.js";
1
+ import type { AttestationKind, HostInstallRecord, HostOidcCompatSettings, NasHost, PackageRoute } from "./types.js";
2
2
  export interface FakeNasHostOptions {
3
3
  curated?: string[];
4
4
  dsmOrigin?: string;
@@ -8,10 +8,12 @@ export interface FakeNasHostOptions {
8
8
  installedEnv?: Record<string, Record<string, string>>;
9
9
  diagnostics?: {
10
10
  dsmResidue?: import("./types.js").DsmResidueInventory;
11
- packageRoute?: string;
11
+ packageRoute?: PackageRoute;
12
12
  isPackageOnly?: boolean;
13
13
  [key: string]: unknown;
14
14
  };
15
+ /** Omit to model an older Broker without the OIDC settings endpoint. */
16
+ oidcCompat?: HostOidcCompatSettings;
15
17
  }
16
18
  export declare function createFakeNasHost(options?: FakeNasHostOptions): NasHost & {
17
19
  installs: HostInstallRecord[];
@@ -49,7 +49,11 @@ export function createFakeNasHost(options = {}) {
49
49
  }
50
50
  const digest = `${input.packageIdentity}:${input.bundleDigest}:${input.contractDigest}`;
51
51
  host.attestations.add(input.packageIdentity);
52
- return { kind: "local", attestationDigest: digest };
52
+ return {
53
+ kind: "local",
54
+ attestationDigest: digest,
55
+ token: `fake-local-attestation.${input.packageIdentity}`,
56
+ };
53
57
  },
54
58
  async ensurePackageSource() { },
55
59
  async installFromCatalog(input) {
@@ -111,6 +115,15 @@ export function createFakeNasHost(options = {}) {
111
115
  async getDsmDiagnostics() {
112
116
  return options.diagnostics;
113
117
  },
118
+ async getOidcCompatSettings() {
119
+ if (!options.oidcCompat) {
120
+ return undefined;
121
+ }
122
+ return {
123
+ enabled: options.oidcCompat.enabled,
124
+ origin: { ...options.oidcCompat.origin },
125
+ };
126
+ },
114
127
  async smokeRequireLogin() {
115
128
  return { ok: true, username: "admin" };
116
129
  },
@@ -1,5 +1,18 @@
1
1
  import type { InstalledPackageState } from "./catalog-install.js";
2
2
  export type AttestationKind = "local" | "app-package" | "none";
3
+ export type PackageRoute = "package-only" | "broker-installer" | "unknown";
4
+ export type OidcOriginKind = "public-ca" | "quickconnect" | "lan-ip" | "self-signed" | "private-ca" | "http";
5
+ export interface HostOidcOriginEvaluation {
6
+ ready: boolean;
7
+ kind: OidcOriginKind;
8
+ hostname?: string;
9
+ issuerUrl?: string;
10
+ setupHint: string;
11
+ }
12
+ export interface HostOidcCompatSettings {
13
+ enabled: boolean;
14
+ origin: HostOidcOriginEvaluation;
15
+ }
3
16
  export interface DeployIdentity {
4
17
  packageId: string;
5
18
  displayname: string;
@@ -8,6 +21,7 @@ export interface DeployIdentity {
8
21
  export interface AttestationIssueResult {
9
22
  kind: "local";
10
23
  attestationDigest: string;
24
+ token?: string;
11
25
  }
12
26
  export interface ResidueItem {
13
27
  kind: "port_profile_sc" | "cm_project_config";
@@ -74,10 +88,12 @@ export interface NasHost {
74
88
  fetchContainerLogs(packageIdentity: string): Promise<string[]>;
75
89
  getDsmDiagnostics?(packageIdentity?: string): Promise<{
76
90
  dsmResidue?: DsmResidueInventory;
77
- packageRoute?: "package-only" | "broker-installer" | string;
91
+ packageRoute?: PackageRoute;
78
92
  isPackageOnly?: boolean;
79
93
  [key: string]: unknown;
80
94
  } | undefined>;
95
+ /** Read the optional administrator OIDC compatibility settings endpoint. */
96
+ getOidcCompatSettings?(): Promise<HostOidcCompatSettings | undefined>;
81
97
  smokeRequireLogin(input: {
82
98
  packageIdentity: string;
83
99
  cookieHeader: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  # 部署(Deploy)
2
2
 
3
- Inner Loop:thin SPK、NAS Administrator DSM 授權、Local Attestation、經 Package Center 安裝。**不要 SSH**,不要把 Compose 貼進 Container Manager 手工裝。
3
+ Inner Loop:thin SPK、NAS Administrator DSM 授權、Local Attestation、經 Package Center 安裝。Personal deploy 會把 Broker 簽發的 local attestation 內嵌到套件供 preinst 使用。**不要 SSH**,不要把 Compose 貼進 Container Manager 手工裝。
4
4
 
5
5
  ## 前置
6
6