@synopackageland/cli 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@ import { BROKER_INSTALLER_VARS, BROKER_SOCKET_REQUIRED_SCRIPT, renderBrokerStatu
17
17
  import { loadNative } from "../native/load.js";
18
18
  import { materializeNativePayload } from "./native-payload.js";
19
19
  import { renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuninst, } from "./native-lifecycle.js";
20
- import { renderResourceConfig, renderResourceWorkerStartStopStatus } from "./resource-worker.js";
20
+ import { renderHeadlessResourceMarker, renderResourceConfig, renderResourceWorkerStartStopStatus, } from "./resource-worker.js";
21
21
  const FILE_MODE = 0o644;
22
22
  const SCRIPT_MODE = 0o755;
23
23
  const INSTALL_UIFILE = "install_uifile";
@@ -161,7 +161,7 @@ export async function buildSpk(options) {
161
161
  mkdirSync(join(staging, "scripts"), { recursive: true });
162
162
  for (const name of SCRIPT_NAMES) {
163
163
  const scriptPath = join(staging, "scripts", name);
164
- writeFileSync(scriptPath, renderManagedScript(name, contract, undefined, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute), "utf8");
164
+ writeFileSync(scriptPath, renderManagedScript(name, contract, undefined, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute, rwEligibility?.headless === true), "utf8");
165
165
  chmodSync(scriptPath, SCRIPT_MODE);
166
166
  }
167
167
  mkdirSync(join(staging, "package"), { recursive: true });
@@ -580,6 +580,7 @@ function renderUninstallWizard() {
580
580
  ],
581
581
  }];
582
582
  }
583
+ const LEGACY_BROKER_STATE_REGISTRY_CODE = "COMPOSE_LEGACY_BROKER_STATE_PRESENT";
583
584
  function renderDynamicPreinstMaterializer(contract) {
584
585
  const allParams = collectInstallParameters(contract);
585
586
  const scalarParams = allParams.filter(({ param }) => param.type === "string" || param.type === "boolean");
@@ -591,6 +592,18 @@ function renderDynamicPreinstMaterializer(contract) {
591
592
  "umask 077",
592
593
  "# D3/D5.4 package-only carrier; values are wizard answers, canonical values, or defaults.",
593
594
  "die() { echo \"dynamic route materialization failed: $1\" >&2; exit 1; }",
595
+ `# registry-code="${LEGACY_BROKER_STATE_REGISTRY_CODE}"`,
596
+ 'APPDATA="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}"',
597
+ 'if [ -d "$APPDATA" ]; then',
598
+ ' broker_uid="$(id -u SynoPkgLandService 2>/dev/null || true)"',
599
+ ' if [ -n "$broker_uid" ]; then',
600
+ ' for item in "$APPDATA"/* "$APPDATA"/.[!.]* "$APPDATA"/..?*; do',
601
+ ' [ -e "$item" ] || continue',
602
+ ' item_uid="$(stat -c %u "$item" 2>/dev/null || true)"',
603
+ ' [ "$item_uid" = "$broker_uid" ] && die "MIGRATION_UNSUPPORTED: ${SYNOPKG_PKGNAME} 曾由 Broker installer 安裝;dynamic 路線不支援就地遷移,請以新的 Package Identity 安裝或先完整卸載並清除 App Data"',
604
+ " done",
605
+ " fi",
606
+ "fi",
594
607
  'T="${SYNOPKG_PKGINST_TEMP_DIR:?}"',
595
608
  'STATUS="${SYNOPKG_PKG_STATUS:?}"',
596
609
  'PROJECT="$T/compose"',
@@ -677,12 +690,13 @@ function renderDynamicPreinstMaterializer(contract) {
677
690
  }
678
691
  if (dsmUserParam) {
679
692
  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`);
693
+ const rawUserVar = "D54_RAW_USER_" + name;
694
+ lines.push(` append_env "${name}" "$${rawUserVar}" "string"`, ` append_env "${name}_UID" "$${name}_UID" "string"`, ` append_env "${name}_GID" "$${name}_GID" "string"`, ` idx=1`, ` for g in $${name}_GROUPS; do`, ` append_env "${name}_GROUP_\${idx}" "$g" "string"`, ` idx=$((idx + 1))`, ` done`);
681
695
  }
682
696
  lines.push(" } > \"$tmp\"", " mv \"$tmp\" \"$RUNTIME_ENV\"", " trap - EXIT HUP INT TERM", " [ ! -L \"$RUNTIME_ENV\" ] || die \"runtime file became a symlink\"", " [ \"$(stat -c %a \"$RUNTIME_ENV\")\" = \"600\" ] || die \"runtime env mode is not 0600\"", "exit 0");
683
697
  return lines.join("\n") + "\n";
684
698
  }
685
- function renderDynamicCanonicalMirror() {
699
+ function renderDynamicCanonicalMirror(headless = false) {
686
700
  const lines = [
687
701
  "#!/bin/sh",
688
702
  "set -eu",
@@ -719,14 +733,17 @@ function renderDynamicCanonicalMirror() {
719
733
  'trap - EXIT HUP INT TERM',
720
734
  '[ ! -L "$CANONICAL_ENV" ] || die "canonical file became a symlink"',
721
735
  '[ "$(stat -c %a "$CANONICAL_ENV")" = "600" ] || die "canonical env mode is not 0600"',
722
- "exit 0",
723
736
  ];
737
+ if (headless) {
738
+ lines.push('MARKER="${PKGVAR}/.docker-project-managed"', '[ ! -L "$MARKER" ] || die "docker-project marker is a symlink"', 'marker_tmp="$(mktemp "$PKGVAR/.docker-project-managed.tmp.XXXXXX")"', 'printf \'%s\\n\' managed > "$marker_tmp"', 'chmod 600 "$marker_tmp"', 'mv "$marker_tmp" "$MARKER"');
739
+ }
740
+ lines.push("exit 0");
724
741
  return lines.join("\n") + "\n";
725
742
  }
726
743
  function shellQuote(value) {
727
744
  return "'" + value.replace(/'/g, "'\\''") + "'";
728
745
  }
729
- function renderManagedScript(name, contract, nativeContext, isResourceWorker = false, resourceWorkerAdminport, isDynamicRoute = false) {
746
+ function renderManagedScript(name, contract, nativeContext, isResourceWorker = false, resourceWorkerAdminport, isDynamicRoute = false, isHeadlessRoute = false) {
730
747
  if (nativeContext) {
731
748
  const lifecycleInput = {
732
749
  packageIdentity: String(contract.package),
@@ -766,13 +783,16 @@ fi
766
783
  }
767
784
  if (isResourceWorker) {
768
785
  if (name === "start-stop-status") {
769
- return renderResourceWorkerStartStopStatus(resourceWorkerAdminport);
786
+ return renderResourceWorkerStartStopStatus(String(contract.package), resourceWorkerAdminport, isHeadlessRoute);
770
787
  }
771
788
  if (isDynamicRoute && name === "preinst") {
772
789
  return renderDynamicPreinstMaterializer(contract);
773
790
  }
774
791
  if (isDynamicRoute && (name === "postinst" || name === "postupgrade")) {
775
- return renderDynamicCanonicalMirror();
792
+ return renderDynamicCanonicalMirror(isHeadlessRoute);
793
+ }
794
+ if (isHeadlessRoute && (name === "postinst" || name === "postupgrade")) {
795
+ return renderHeadlessResourceMarker();
776
796
  }
777
797
  if (name === "postuninst") {
778
798
  const projectRoot = isDynamicRoute ? 'PROJECT="${SYNOPKG_PKGDEST}/compose"\n' : "";
@@ -11,4 +11,5 @@ export declare function renderResourceConfig(contract: AppContract, eligibility:
11
11
  * the worker timing hooks; the package script must wait for that operation to
12
12
  * leave a reachable service and must not claim running unconditionally.
13
13
  */
14
- export declare function renderResourceWorkerStartStopStatus(adminport?: number): string;
14
+ export declare function renderResourceWorkerStartStopStatus(packageId: string, adminport?: number, headless?: boolean): string;
15
+ export declare function renderHeadlessResourceMarker(): string;
@@ -77,7 +77,50 @@ export function renderResourceConfig(contract, eligibility) {
77
77
  * the worker timing hooks; the package script must wait for that operation to
78
78
  * leave a reachable service and must not claim running unconditionally.
79
79
  */
80
- export function renderResourceWorkerStartStopStatus(adminport) {
80
+ export function renderResourceWorkerStartStopStatus(packageId, adminport, headless = false) {
81
+ if (headless) {
82
+ const projectName = composeProjectName(packageId);
83
+ return `#!/bin/sh
84
+ PKGVAR="\${SYNOPKG_PKGVAR:-/var/packages/${packageId}/var}"
85
+ MARKER="$PKGVAR/.docker-project-managed"
86
+ PROJECT_NAME="${projectName}"
87
+
88
+ project_containers_running() {
89
+ command -v docker >/dev/null 2>&1 || return 2
90
+ containers="$(docker ps -aq --filter "label=com.docker.compose.project=$PROJECT_NAME" 2>/dev/null)" || return 2
91
+ [ -n "$containers" ] || return 1
92
+ for container in $containers; do
93
+ state="$(docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null)" || return 2
94
+ [ "$state" = "true" ] || return 1
95
+ done
96
+ return 0
97
+ }
98
+
99
+ marker_managed() {
100
+ [ -f "$MARKER" ] || return 1
101
+ [ ! -L "$MARKER" ] || return 1
102
+ [ "$(cat "$MARKER" 2>/dev/null || true)" = "managed" ]
103
+ }
104
+
105
+ case "$1" in
106
+ start|status)
107
+ project_containers_running
108
+ probe_status=$?
109
+ if [ "$probe_status" -eq 0 ]; then
110
+ exit 0
111
+ fi
112
+ # Package user may not have a usable Docker inspection path. DSM owns the
113
+ # docker-project lifecycle; the postinst marker records that ownership.
114
+ if [ "$probe_status" -eq 2 ] && marker_managed; then
115
+ exit 0
116
+ fi
117
+ exit 3
118
+ ;;
119
+ stop) exit 0 ;;
120
+ *) exit 1 ;;
121
+ esac
122
+ `;
123
+ }
81
124
  if (adminport === undefined) {
82
125
  return `#!/bin/sh
83
126
  case "$1" in
@@ -138,3 +181,20 @@ case "$1" in
138
181
  esac
139
182
  `;
140
183
  }
184
+ export function renderHeadlessResourceMarker() {
185
+ return `#!/bin/sh
186
+ set -eu
187
+ PKGVAR="\${SYNOPKG_PKGVAR:?}"
188
+ [ -d "$PKGVAR" ] || mkdir -p "$PKGVAR"
189
+ [ ! -L "$PKGVAR" ] || { echo "package var is a symlink" >&2; exit 1; }
190
+ MARKER="$PKGVAR/.docker-project-managed"
191
+ [ ! -L "$MARKER" ] || { echo "docker-project marker is a symlink" >&2; exit 1; }
192
+ tmp="$(mktemp "$PKGVAR/.docker-project-managed.tmp.XXXXXX")"
193
+ trap 'rm -f "$tmp"' EXIT HUP INT TERM
194
+ printf '%s\n' managed > "$tmp"
195
+ chmod 600 "$tmp"
196
+ mv "$tmp" "$MARKER"
197
+ trap - EXIT HUP INT TERM
198
+ exit 0
199
+ `;
200
+ }
@@ -14,6 +14,7 @@ export type ResourceWorkerIneligibleReason = "HAS_INSTALL_PARAMETERS" | "HAS_COM
14
14
  export interface ResourceWorkerEligibility {
15
15
  eligible: boolean;
16
16
  reasons: ResourceWorkerIneligibleReason[];
17
+ headless: boolean;
17
18
  adminport?: number;
18
19
  }
19
20
  /**
@@ -24,7 +25,7 @@ export interface ResourceWorkerEligibility {
24
25
  * - compose secrets or configs
25
26
  * - build: stanzas
26
27
  * - privileged / host network / device / root-like capabilities
27
- * - any App without a unique literal published adminport status probe
28
+ * - any non-headless App without a unique literal published adminport status probe
28
29
  */
29
30
  export declare function resourceWorkerEligibility(contract: AppContract, compose: ComposeDocument): ResourceWorkerEligibility;
30
31
  export type ComposeRoute = "static-zero-config" | "dynamic" | "broker-installer" | "reject";
@@ -112,10 +112,11 @@ export function collectPublishedTcpPorts(compose) {
112
112
  * - compose secrets or configs
113
113
  * - build: stanzas
114
114
  * - privileged / host network / device / root-like capabilities
115
- * - any App without a unique literal published adminport status probe
115
+ * - any non-headless App without a unique literal published adminport status probe
116
116
  */
117
117
  export function resourceWorkerEligibility(contract, compose) {
118
118
  const reasons = [];
119
+ const headless = contract.adminport === undefined && collectPublishedTcpPorts(compose).size === 0;
119
120
  if (contract.install && Object.keys(contract.install).length > 0) {
120
121
  reasons.push("HAS_INSTALL_PARAMETERS");
121
122
  }
@@ -134,7 +135,9 @@ export function resourceWorkerEligibility(contract, compose) {
134
135
  }
135
136
  let resolvedAdminport;
136
137
  if (contract.adminport === undefined) {
137
- reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
138
+ if (!headless) {
139
+ reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
140
+ }
138
141
  }
139
142
  else {
140
143
  const rawPort = Number(contract.adminport);
@@ -155,6 +158,7 @@ export function resourceWorkerEligibility(contract, compose) {
155
158
  return {
156
159
  eligible: reasons.length === 0,
157
160
  reasons,
161
+ headless,
158
162
  adminport: resolvedAdminport,
159
163
  };
160
164
  }
@@ -176,6 +180,17 @@ function hasBuildStanza(compose) {
176
180
  }
177
181
  return Object.values(compose.services).some((service) => "build" in service && service.build !== undefined);
178
182
  }
183
+ const HEADLESS_BROKER_FALLBACK_REASONS = new Set([
184
+ "HAS_COMPOSE_SECRETS_OR_CONFIGS",
185
+ "HAS_BUILD_STANZA",
186
+ "HAS_PRIVILEGED_CAPABILITY",
187
+ ]);
188
+ const HEADLESS_PACKAGE_ONLY_PARAMETER_TYPES = new Set([
189
+ "string",
190
+ "boolean",
191
+ "shared_folder",
192
+ "dsm_user",
193
+ ]);
179
194
  /**
180
195
  * Chooses the local route D0 can safely emit. The first dynamic slice is
181
196
  * deliberately small: only string/boolean wizard answers interpolated as
@@ -185,13 +200,18 @@ function hasBuildStanza(compose) {
185
200
  */
186
201
  export function classifyComposeRoute(contract, parsed, composeFile = "compose.yaml") {
187
202
  const eligibility = resourceWorkerEligibility(contract, parsed.compose);
188
- const rejectErrors = contract.adminport !== undefined
203
+ const hasHeadlessUnsupportedParameter = collectInstallParameters(contract).some(({ param }) => !HEADLESS_PACKAGE_ONLY_PARAMETER_TYPES.has(String(param.type)));
204
+ const headlessPackageOnly = eligibility.headless &&
205
+ !hasHeadlessUnsupportedParameter &&
206
+ !eligibility.reasons.some((reason) => HEADLESS_BROKER_FALLBACK_REASONS.has(reason));
207
+ const localRoute = contract.adminport !== undefined || headlessPackageOnly;
208
+ const rejectErrors = localRoute
189
209
  ? collectRouteRejectErrors(contract, parsed, composeFile)
190
210
  : [];
191
211
  if (rejectErrors.length > 0) {
192
212
  return { route: "reject", eligibility, errors: rejectErrors };
193
213
  }
194
- const dynamicError = contract.adminport !== undefined
214
+ const dynamicError = localRoute
195
215
  ? collectDynamicRouteError(contract, parsed, composeFile)
196
216
  : undefined;
197
217
  if (dynamicError) {
@@ -206,9 +226,11 @@ export function classifyComposeRoute(contract, parsed, composeFile = "compose.ya
206
226
  }
207
227
  // Keep the route decision in one place. build-spk must not independently
208
228
  // reinterpret an ineligible Compose as D1: only a route with a resolved
209
- // published adminport and at least one declared install parameter has a
210
- // package-only materializer. Every other buildable fallback is Broker.
211
- if (eligibility.adminport !== undefined && collectInstallParameters(contract).length > 0) {
229
+ // published adminport or an eligible headless Compose, plus at least one
230
+ // declared install parameter, has a package-only materializer. Every other
231
+ // buildable fallback is Broker.
232
+ if ((eligibility.adminport !== undefined || headlessPackageOnly) &&
233
+ collectInstallParameters(contract).length > 0) {
212
234
  return { route: "dynamic", eligibility, errors: [] };
213
235
  }
214
236
  return { route: "broker-installer", eligibility, errors: [] };
@@ -239,7 +261,8 @@ function collectRouteRejectErrors(contract, parsed, composeFile) {
239
261
  return dedupeValidationErrors(errors);
240
262
  }
241
263
  function collectPermanentRouteErrors(contract, parsed, composeFile) {
242
- if (contract.adminport === undefined) {
264
+ const headless = contract.adminport === undefined && collectPublishedTcpPorts(parsed.compose).size === 0;
265
+ if (contract.adminport === undefined && !headless) {
243
266
  return [];
244
267
  }
245
268
  const { compose } = parsed;
package/dist/doctor.js CHANGED
@@ -472,14 +472,36 @@ export async function runDoctor(options) {
472
472
  else {
473
473
  const info = await host.getAttestationInfo(targetPackageId);
474
474
  if (info.readiness === "NOT_INSTALLED") {
475
- const errorEntry = getErrorCode("PROJECT_BINDING_NOT_FOUND");
476
- checks.push({
477
- id: "host.package_binding",
478
- status: "fail",
479
- code: "PROJECT_BINDING_NOT_FOUND",
480
- message: `套件 ${targetPackageId} 在 Broker 中無有效 Project Binding`,
481
- suggestion: errorEntry?.suggestion ?? "請執行 synopkgland deploy 重新部署套件。",
482
- });
475
+ // Check if package is package-only route via Broker diagnostics
476
+ let isPackageOnly = false;
477
+ if (host.getDsmDiagnostics) {
478
+ try {
479
+ const diag = await host.getDsmDiagnostics(targetPackageId);
480
+ if (diag?.isPackageOnly === true || diag?.packageRoute === "package-only") {
481
+ isPackageOnly = true;
482
+ }
483
+ }
484
+ catch {
485
+ // ignore
486
+ }
487
+ }
488
+ if (isPackageOnly) {
489
+ checks.push({
490
+ id: "host.package_binding",
491
+ status: "skip",
492
+ message: "package-only 路線,無 Broker binding(略過)",
493
+ });
494
+ }
495
+ else {
496
+ const errorEntry = getErrorCode("PROJECT_BINDING_NOT_FOUND");
497
+ checks.push({
498
+ id: "host.package_binding",
499
+ status: "fail",
500
+ code: "PROJECT_BINDING_NOT_FOUND",
501
+ message: `套件 ${targetPackageId} 在 Broker 中無有效 Project Binding`,
502
+ suggestion: errorEntry?.suggestion ?? "請執行 synopkgland deploy 重新部署套件。",
503
+ });
504
+ }
483
505
  }
484
506
  else if (info.readiness === "PROJECT_BINDING_DRIFTED") {
485
507
  const errorEntry = getErrorCode("PROJECT_BINDING_DRIFTED");
@@ -116,6 +116,11 @@ export const ERROR_REGISTRY = {
116
116
  description: "Compose dynamic route 超出 D1/D2 白名單,或使用 DSM worker 不會載入的 secret/config 來源。",
117
117
  suggestion: "請只使用 string/boolean wizard answer 的 ${NAME} interpolation;secret/config 因 DSM worker 不讀 docker-compose.override.yml 而拒收。",
118
118
  },
119
+ COMPOSE_LEGACY_BROKER_STATE_PRESENT: {
120
+ code: "COMPOSE_LEGACY_BROKER_STATE_PRESENT",
121
+ description: "偵測到既有 Broker installer App Data,dynamic package-only route 不支援同一 Package Identity 的就地遷移。",
122
+ suggestion: "請改用新的 Package Identity 安裝,或先完整卸載舊套件並清除 App Data。",
123
+ },
119
124
  COMPOSE_BUILD_FORBIDDEN: {
120
125
  code: "COMPOSE_BUILD_FORBIDDEN",
121
126
  description: "D0 Compose route 禁止在 NAS install-time 使用 build:;build capability 永久拒收。",
@@ -8,6 +8,8 @@ export interface FakeNasHostOptions {
8
8
  installedEnv?: Record<string, Record<string, string>>;
9
9
  diagnostics?: {
10
10
  dsmResidue?: import("./types.js").DsmResidueInventory;
11
+ packageRoute?: string;
12
+ isPackageOnly?: boolean;
11
13
  [key: string]: unknown;
12
14
  };
13
15
  }
@@ -74,6 +74,8 @@ export interface NasHost {
74
74
  fetchContainerLogs(packageIdentity: string): Promise<string[]>;
75
75
  getDsmDiagnostics?(packageIdentity?: string): Promise<{
76
76
  dsmResidue?: DsmResidueInventory;
77
+ packageRoute?: "package-only" | "broker-installer" | string;
78
+ isPackageOnly?: boolean;
77
79
  [key: string]: unknown;
78
80
  } | undefined>;
79
81
  smokeRequireLogin(input: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {