@synopackageland/cli 0.3.9 → 0.3.10

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.
@@ -13,6 +13,8 @@ import { runSharePermissionPreflight } from "./share-permission-preflight.js";
13
13
  import { homesParameterNames, resolveHomesExtraValues } from "./homes-values.js";
14
14
  import { resolveInstallParameterExtraValues } from "./param-values.js";
15
15
  import { CURATED_BUILD_OFFSET } from "@synopackageland/package-source/index-manifest";
16
+ import { loadCompose } from "../compose/analyze.js";
17
+ import { runPortPreflight } from "./port-preflight.js";
16
18
  const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
17
19
  const POSIX_ALL_SHARES_LOOKUP_FAILED = "DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED";
18
20
  function brokerCapabilitiesFromContract(project) {
@@ -143,6 +145,16 @@ export async function deployProject(options) {
143
145
  if (!sharePermission.ok) {
144
146
  return { ok: false, code: sharePermission.code, message: sharePermission.message };
145
147
  }
148
+ const portPreflight = await runPortPreflight({
149
+ compose: loadCompose(options.project.composePath)?.compose,
150
+ packageIdentity: identity.packageId,
151
+ listPortInfo: options.host.listPortInfo
152
+ ? () => options.host.listPortInfo()
153
+ : undefined,
154
+ });
155
+ if (!portPreflight.ok) {
156
+ return { ok: false, code: portPreflight.code, message: portPreflight.message };
157
+ }
146
158
  const extraValues = {
147
159
  ...paramResolved.extraValues,
148
160
  ...homesResolved.extraValues,
@@ -0,0 +1,21 @@
1
+ import type { ComposeDocument } from "../types.js";
2
+ import { type PortInfoEntry } from "../host/port-info.js";
3
+ export declare const PORT_ALREADY_IN_USE = "PORT_ALREADY_IN_USE";
4
+ export interface PortPreflightInput {
5
+ compose?: ComposeDocument;
6
+ packageIdentity: string;
7
+ listPortInfo?: () => Promise<PortInfoEntry[] | undefined>;
8
+ }
9
+ export type PortPreflightResult = {
10
+ ok: true;
11
+ } | {
12
+ ok: false;
13
+ code: typeof PORT_ALREADY_IN_USE;
14
+ message: string;
15
+ };
16
+ /**
17
+ * Checks Compose's published TCP host ports against DSM's administrator-only
18
+ * PortInfo listing. Missing capability, transport failure, malformed data,
19
+ * and an empty listing all mean "cannot tell" and therefore pass through.
20
+ */
21
+ export declare function runPortPreflight(input: PortPreflightInput): Promise<PortPreflightResult>;
@@ -0,0 +1,42 @@
1
+ import { collectPublishedTcpPorts } from "../compose/analyze.js";
2
+ import { findPortConflicts, formatPortOccupant, } from "../host/port-info.js";
3
+ export const PORT_ALREADY_IN_USE = "PORT_ALREADY_IN_USE";
4
+ /**
5
+ * Checks Compose's published TCP host ports against DSM's administrator-only
6
+ * PortInfo listing. Missing capability, transport failure, malformed data,
7
+ * and an empty listing all mean "cannot tell" and therefore pass through.
8
+ */
9
+ export async function runPortPreflight(input) {
10
+ const publishedPorts = input.compose
11
+ ? collectPublishedTcpPorts(input.compose)
12
+ : new Map();
13
+ if (publishedPorts.size === 0 || !input.listPortInfo) {
14
+ return { ok: true };
15
+ }
16
+ let portInfo;
17
+ try {
18
+ portInfo = await input.listPortInfo();
19
+ }
20
+ catch {
21
+ portInfo = undefined;
22
+ }
23
+ if (!portInfo || portInfo.length === 0) {
24
+ return { ok: true };
25
+ }
26
+ const conflicts = findPortConflicts({
27
+ publishedPorts,
28
+ packageIdentity: input.packageIdentity,
29
+ portInfo,
30
+ });
31
+ const conflict = conflicts[0];
32
+ if (!conflict) {
33
+ return { ok: true };
34
+ }
35
+ const occupants = conflict.occupants.map(formatPortOccupant).join("、");
36
+ return {
37
+ ok: false,
38
+ code: PORT_ALREADY_IN_USE,
39
+ message: `Published port ${conflict.port} 已被 ${occupants} 佔用(Compose service:${conflict.services.join("、")})。` +
40
+ "請改用未被其他服務使用的 host port;同一 Package Identity 的既有容器會在升級/重裝時放行。",
41
+ };
42
+ }
package/dist/doctor.js CHANGED
@@ -6,6 +6,7 @@ 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
+ import { DSM_PORT_PROFILE_PRECISION_WARNING, } from "./host/dsm-port-profile.js";
9
10
  function packageRouteFromDiagnostics(diagnostics) {
10
11
  switch (diagnostics?.packageRoute) {
11
12
  case "package-only":
@@ -67,6 +68,98 @@ function packageFailureSuggestion(state) {
67
68
  : "DSM API 的 worker_msg 實測為空或未提供;";
68
69
  return `${diagnosis} ${workerMessage}worker 內部完整訊息只存在 NAS 的 /var/log/synoplugin.log(需 root),doctor 不會假裝取得。`;
69
70
  }
71
+ function formatDsmPortProfile(profile) {
72
+ const ports = profile.dstPorts
73
+ .map((range) => range.start === range.end ? String(range.start) : `${range.start}-${range.end}`)
74
+ .join(",");
75
+ return `${profile.containerName} (project ${profile.projectName}, port ${ports})`;
76
+ }
77
+ function formatDsmPortProfiles(profiles) {
78
+ return profiles.map(formatDsmPortProfile).join(";");
79
+ }
80
+ function brokerPortProfileNames(residue) {
81
+ return new Set((residue?.items ?? [])
82
+ .filter((item) => item.kind === "port_profile_sc")
83
+ .map((item) => item.name));
84
+ }
85
+ async function appendDsmPortProfileCheck(checks, host, options) {
86
+ // The API fallback is authoritative only when the Broker is absent. An
87
+ // installed-but-unready Broker has no trustworthy residue conclusion, so do
88
+ // not silently substitute a lower-precision result for it.
89
+ if (!options.brokerReady && options.brokerInstalled !== false) {
90
+ checks.push({
91
+ id: "host.dsm_port_profile",
92
+ status: "skip",
93
+ message: "Broker 未就緒,略過 DSM PortInfo port profile 盤點",
94
+ });
95
+ return;
96
+ }
97
+ if (!host.getDsmPortProfileInventory) {
98
+ checks.push({
99
+ id: "host.dsm_port_profile",
100
+ status: "skip",
101
+ message: "DSM PortInfo API 能力不可用,略過 port profile 盤點",
102
+ });
103
+ return;
104
+ }
105
+ let inventory;
106
+ try {
107
+ inventory = await host.getDsmPortProfileInventory();
108
+ }
109
+ catch {
110
+ inventory = undefined;
111
+ }
112
+ if (!inventory) {
113
+ checks.push({
114
+ id: "host.dsm_port_profile",
115
+ status: "skip",
116
+ message: "DSM PortInfo API 或已安裝套件清單無法取得,略過 port profile 盤點",
117
+ });
118
+ return;
119
+ }
120
+ if (options.brokerReady) {
121
+ const apiNames = new Set(inventory.orphaned.map((profile) => profile.containerName));
122
+ const brokerNames = brokerPortProfileNames(options.brokerResidue);
123
+ const agrees = options.brokerResidue !== undefined &&
124
+ apiNames.size === brokerNames.size &&
125
+ [...apiNames].every((name) => brokerNames.has(name));
126
+ if (agrees) {
127
+ checks.push({
128
+ id: "host.dsm_port_profile",
129
+ status: "pass",
130
+ message: `DSM PortInfo API 交叉檢查與 Broker 的 port profile 結果一致(${apiNames.size} 筆;Broker 判定優先)`,
131
+ });
132
+ }
133
+ else {
134
+ const apiDetail = inventory.orphaned.length > 0
135
+ ? `API 候選 ${inventory.orphaned.length} 筆:${formatDsmPortProfiles(inventory.orphaned)}`
136
+ : "API 未發現候選";
137
+ checks.push({
138
+ id: "host.dsm_port_profile",
139
+ status: "skip",
140
+ message: `DSM PortInfo API 交叉檢查${apiDetail};Broker 判定優先,未覆蓋 host.dsm_residue(${DSM_PORT_PROFILE_PRECISION_WARNING})`,
141
+ });
142
+ }
143
+ return;
144
+ }
145
+ // No Broker: this is the primary result, but explicitly expose the one
146
+ // precision gap that the administrator session cannot close.
147
+ if (inventory.orphaned.length > 0) {
148
+ checks.push({
149
+ id: "host.dsm_port_profile",
150
+ status: "warn",
151
+ message: `發現 ${inventory.orphaned.length} 筆 DSM port profile 疑似孤兒(${DSM_PORT_PROFILE_PRECISION_WARNING}):${formatDsmPortProfiles(inventory.orphaned)}`,
152
+ suggestion: "這是未經 conf/resource 交叉比對的 API 候選;請先人工確認 Compose project 名,再由管理員清理。",
153
+ });
154
+ }
155
+ else {
156
+ checks.push({
157
+ id: "host.dsm_port_profile",
158
+ status: "pass",
159
+ message: `無 DSM port profile 孤兒候選(${DSM_PORT_PROFILE_PRECISION_WARNING})`,
160
+ });
161
+ }
162
+ }
70
163
  export async function runDoctor(options) {
71
164
  const checks = [];
72
165
  // --- 1. Local checks (Always run) ---
@@ -450,11 +543,13 @@ export async function runDoctor(options) {
450
543
  }
451
544
  }
452
545
  // Check 2.3b: DSM Residue check (A29)
546
+ let brokerResidue;
453
547
  if (isBrokerReady && host.getDsmDiagnostics) {
454
548
  try {
455
549
  const diag = await host.getDsmDiagnostics("probe");
456
550
  if (diag && diag.dsmResidue) {
457
551
  const residue = diag.dsmResidue;
552
+ brokerResidue = residue;
458
553
  if (residue.items && residue.items.length > 0) {
459
554
  const details = residue.items
460
555
  .map((item) => `[${item.kind}] ${item.name} (${item.path}):${item.reason}`)
@@ -479,6 +574,12 @@ export async function runDoctor(options) {
479
574
  // If query fails or Broker is old, omit / skip silently to be compatible
480
575
  }
481
576
  }
577
+ // Check 2.3c: administrator-only DSM PortInfo fallback / cross-check.
578
+ await appendDsmPortProfileCheck(checks, host, {
579
+ brokerInstalled,
580
+ brokerReady: isBrokerReady,
581
+ brokerResidue,
582
+ });
482
583
  // Check 2.4: If packageId is provided (or derived)
483
584
  const targetPackageId = options.packageId ?? (options.project ? resolveDeployIdentity(options.project, { dev: options.dev }).packageId : undefined);
484
585
  if (targetPackageId) {
@@ -504,6 +504,11 @@ export const ERROR_REGISTRY = {
504
504
  description: "deploy 的安裝前檢查發現選定的 Container Run-as 使用者對選定的 Shared Folder 沒有權限(或被明確拒絕)。容器會以該使用者掛載該 Share,安裝會成功但 runtime 讀寫會拿到 Permission denied。",
505
505
  suggestion: "請在 DSM 控制台把該 Shared Folder 的權限授予該使用者(或其群組),或改選別的使用者/Share,再重新部署。此檢查只在 deploy 進行;Package Center 安裝精靈不經過這裡,ACL 仍是管理員的前提責任。",
506
506
  },
507
+ PORT_ALREADY_IN_USE: {
508
+ code: "PORT_ALREADY_IN_USE",
509
+ description: "deploy 的安裝前檢查發現 Compose published TCP port 已被其他服務或容器佔用。",
510
+ suggestion: "請更換 synology-app.yaml/compose.yaml 的 published host port;同一 Package Identity 的既有容器會在升級或重裝時放行。",
511
+ },
507
512
  CURATED_DEPLOY_REFUSED: {
508
513
  code: "CURATED_DEPLOY_REFUSED",
509
514
  description: "目標 NAS 已持有 Curated App Package Attestation,禁止隨意覆蓋部署。",
@@ -6,7 +6,9 @@ import { IDENTITY_CGI_PATH } from "./broker-paths.js";
6
6
  import { packageSourceOrigin } from "../deploy/catalog-publish.js";
7
7
  import { listShareNamesWithSynoagentcli, lookupSharePathWithSynoagentcli } from "./share-lookup.js";
8
8
  import { createSynoagentcliApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
9
+ import { fetchPortInfo } from "./port-info-lookup.js";
9
10
  import { dsmFetch } from "./dsm-http.js";
11
+ import { fetchDsmPortProfileInventory } from "./dsm-port-profile.js";
10
12
  import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
11
13
  import { resolveHostCredentials } from "./credentials.js";
12
14
  const OIDC_COMPAT_PATH = "/api/v1/inner-loop/oidc-compat";
@@ -241,6 +243,15 @@ function createDsmNasHost(credentials, profileName) {
241
243
  async listUserGroups(userName) {
242
244
  return fetchUserGroups(userName, createSynoagentcliApiCall(profileName));
243
245
  },
246
+ async listPortInfo() {
247
+ try {
248
+ await ensureLogin();
249
+ return fetchPortInfo(dsmOrigin, cookieHeader ?? "");
250
+ }
251
+ catch {
252
+ return undefined;
253
+ }
254
+ },
244
255
  async resolveHomesPath() {
245
256
  try {
246
257
  const result = synoagentcli(["dsm", "control-panel", "user-group", "advanced", "get", "--section", "home"], profileName);
@@ -359,6 +370,15 @@ function createDsmNasHost(credentials, profileName) {
359
370
  return undefined;
360
371
  }
361
372
  },
373
+ async getDsmPortProfileInventory() {
374
+ try {
375
+ await ensureLogin();
376
+ return await fetchDsmPortProfileInventory(dsmOrigin, cookieHeader ?? "");
377
+ }
378
+ catch {
379
+ return undefined;
380
+ }
381
+ },
362
382
  async getOidcCompatSettings() {
363
383
  try {
364
384
  await ensureLogin();
@@ -0,0 +1,48 @@
1
+ /** The precision caveat for the administrator-only fallback. */
2
+ export declare const DSM_PORT_PROFILE_PRECISION_WARNING = "API \u7248\u672A\u8B80\u53D6 conf/resource\uFF1B\u82E5\u6709 App \u81EA\u8A02 Compose project \u540D\u53EF\u80FD\u8AA4\u5831";
3
+ export interface DsmPortRange {
4
+ start: number;
5
+ end: number;
6
+ }
7
+ export interface DsmPortProfile {
8
+ portId: string;
9
+ containerName: string;
10
+ projectName: string;
11
+ /** Kept for diagnostics only; the API orphan test must use projectName. */
12
+ presumedPackageIdentity: string;
13
+ dstPorts: DsmPortRange[];
14
+ }
15
+ export interface DsmPortProfileInventory {
16
+ profiles: DsmPortProfile[];
17
+ orphaned: DsmPortProfile[];
18
+ }
19
+ /**
20
+ * Parses the Docker .sc filename using the same rules as the Broker's
21
+ * parsePortProfileFilename(). In particular, projectName is everything before
22
+ * the final two dash-separated container segments.
23
+ */
24
+ export declare function parsePortProfileFilename(filename: string): {
25
+ containerName: string;
26
+ projectName: string;
27
+ presumedPackageIdentity: string;
28
+ } | undefined;
29
+ /** Parse one DSM dst_port value, including the documented range form. */
30
+ export declare function parseDsmPortRange(value: string): DsmPortRange | undefined;
31
+ /** Parse a successful SYNO.Core.Service.PortInfo/load response. */
32
+ export declare function parseDsmPortInfoPayload(payload: unknown): DsmPortProfile[] | undefined;
33
+ /** Parse the installed identity set from a successful DSM package list. */
34
+ export declare function installedPackageIdentitiesFromPayload(payload: unknown): string[] | undefined;
35
+ /**
36
+ * Build the API-only inventory from the two independent DSM responses.
37
+ *
38
+ * The package list is a forward mapping: package identity -> project name.
39
+ * Do not use presumedPackageIdentity here; replacing dashes with dots is
40
+ * lossy for identities such as com.example.hello-web.
41
+ */
42
+ export declare function dsmPortProfileInventoryFromPayloads(portInfoPayload: unknown, packageListPayload: unknown): DsmPortProfileInventory | undefined;
43
+ /**
44
+ * Read PortInfo.load and the administrator-visible installed package list.
45
+ * Any transport, HTTP, JSON, or DSM API failure becomes undefined so callers
46
+ * can skip without inventing residue.
47
+ */
48
+ export declare function fetchDsmPortProfileInventory(dsmOrigin: string, cookieHeader: string): Promise<DsmPortProfileInventory | undefined>;
@@ -0,0 +1,208 @@
1
+ import { basename } from "node:path";
2
+ import { composeProjectName } from "../build-spk/resource-worker.js";
3
+ import { dsmFetch } from "./dsm-http.js";
4
+ /** The precision caveat for the administrator-only fallback. */
5
+ export const DSM_PORT_PROFILE_PRECISION_WARNING = "API 版未讀取 conf/resource;若有 App 自訂 Compose project 名可能誤報";
6
+ /**
7
+ * Parses the Docker .sc filename using the same rules as the Broker's
8
+ * parsePortProfileFilename(). In particular, projectName is everything before
9
+ * the final two dash-separated container segments.
10
+ */
11
+ export function parsePortProfileFilename(filename) {
12
+ const base = basename(filename);
13
+ if (!base.startsWith("docker_") || !base.endsWith(".sc")) {
14
+ return undefined;
15
+ }
16
+ const containerName = base.slice("docker_".length, base.length - ".sc".length);
17
+ if (!containerName) {
18
+ return undefined;
19
+ }
20
+ // Keep this split aligned with packages/broker/src/diagnostics/dsm-residue.ts:
21
+ // remove the final -<service>-<replica> pair, not an identity-specific guess.
22
+ const lastDash = containerName.lastIndexOf("-");
23
+ if (lastDash <= 0) {
24
+ return undefined;
25
+ }
26
+ const secondLastDash = containerName.lastIndexOf("-", lastDash - 1);
27
+ if (secondLastDash <= 0) {
28
+ return undefined;
29
+ }
30
+ const projectName = containerName.slice(0, secondLastDash);
31
+ const presumedPackageIdentity = projectName.replace(/-/g, ".");
32
+ return {
33
+ containerName,
34
+ projectName,
35
+ presumedPackageIdentity,
36
+ };
37
+ }
38
+ /** Parse one DSM dst_port value, including the documented range form. */
39
+ export function parseDsmPortRange(value) {
40
+ const match = /^([0-9]{1,5})(?:-([0-9]{1,5}))?$/.exec(value.trim());
41
+ if (!match) {
42
+ return undefined;
43
+ }
44
+ const start = Number(match[1]);
45
+ const end = Number(match[2] ?? match[1]);
46
+ if (!Number.isInteger(start) ||
47
+ !Number.isInteger(end) ||
48
+ start < 1 ||
49
+ end > 65535 ||
50
+ start > end) {
51
+ return undefined;
52
+ }
53
+ return { start, end };
54
+ }
55
+ function parseDsmPortRanges(value) {
56
+ if (!Array.isArray(value) || value.length === 0) {
57
+ return undefined;
58
+ }
59
+ const ranges = [];
60
+ for (const entry of value) {
61
+ if (typeof entry !== "string") {
62
+ return undefined;
63
+ }
64
+ const range = parseDsmPortRange(entry);
65
+ if (!range) {
66
+ return undefined;
67
+ }
68
+ ranges.push(range);
69
+ }
70
+ return ranges;
71
+ }
72
+ function record(value) {
73
+ return value && typeof value === "object" && !Array.isArray(value)
74
+ ? value
75
+ : undefined;
76
+ }
77
+ function nonEmptyString(value) {
78
+ return typeof value === "string" && value.length > 0 ? value : undefined;
79
+ }
80
+ function parseDockerPortInfo(value) {
81
+ const entry = record(value);
82
+ if (!entry) {
83
+ return undefined;
84
+ }
85
+ // PortInfo also contains ordinary DSM service rows. Only the documented
86
+ // Docker(TCP) rows correspond to docker_<container>.sc profiles.
87
+ if (entry.protocol !== "tcp" || entry.name !== "Docker(TCP)") {
88
+ return undefined;
89
+ }
90
+ const portId = nonEmptyString(entry.port_id);
91
+ if (!portId?.endsWith("_tcp")) {
92
+ return undefined;
93
+ }
94
+ const containerName = portId.slice(0, -"_tcp".length);
95
+ if (!containerName || entry.desc !== `Docker ${containerName}`) {
96
+ return undefined;
97
+ }
98
+ const parsed = parsePortProfileFilename(`docker_${containerName}.sc`);
99
+ const dstPorts = parseDsmPortRanges(entry.dst_port);
100
+ if (!parsed || !dstPorts) {
101
+ return undefined;
102
+ }
103
+ return {
104
+ portId,
105
+ containerName: parsed.containerName,
106
+ projectName: parsed.projectName,
107
+ presumedPackageIdentity: parsed.presumedPackageIdentity,
108
+ dstPorts,
109
+ };
110
+ }
111
+ /** Parse a successful SYNO.Core.Service.PortInfo/load response. */
112
+ export function parseDsmPortInfoPayload(payload) {
113
+ const root = record(payload);
114
+ const data = record(root?.data);
115
+ if (root?.success !== true || !data || !Array.isArray(data.port_info)) {
116
+ return undefined;
117
+ }
118
+ return data.port_info.flatMap((entry) => {
119
+ const profile = parseDockerPortInfo(entry);
120
+ return profile ? [profile] : [];
121
+ });
122
+ }
123
+ /** Parse the installed identity set from a successful DSM package list. */
124
+ export function installedPackageIdentitiesFromPayload(payload) {
125
+ const root = record(payload);
126
+ const data = record(root?.data);
127
+ if (root?.success !== true || !data || !Array.isArray(data.packages)) {
128
+ return undefined;
129
+ }
130
+ return [
131
+ ...new Set(data.packages
132
+ .map((entry) => record(entry)?.id)
133
+ .filter((id) => typeof id === "string" && id.length > 0)),
134
+ ];
135
+ }
136
+ /**
137
+ * Build the API-only inventory from the two independent DSM responses.
138
+ *
139
+ * The package list is a forward mapping: package identity -> project name.
140
+ * Do not use presumedPackageIdentity here; replacing dashes with dots is
141
+ * lossy for identities such as com.example.hello-web.
142
+ */
143
+ export function dsmPortProfileInventoryFromPayloads(portInfoPayload, packageListPayload) {
144
+ const profiles = parseDsmPortInfoPayload(portInfoPayload);
145
+ if (!profiles) {
146
+ return undefined;
147
+ }
148
+ // An empty PortInfo list is a safe, successful "nothing to report" result;
149
+ // there is no reason to turn an unavailable package list into an orphan.
150
+ if (profiles.length === 0) {
151
+ return { profiles, orphaned: [] };
152
+ }
153
+ const installedPackages = installedPackageIdentitiesFromPayload(packageListPayload);
154
+ // An empty or unavailable package list cannot prove that any profile is an
155
+ // orphan. Fail closed instead of classifying every visible Docker row.
156
+ if (!installedPackages || installedPackages.length === 0) {
157
+ return undefined;
158
+ }
159
+ const declaredProjectNames = new Set(installedPackages.map(composeProjectName));
160
+ const orphaned = profiles.filter((profile) => !declaredProjectNames.has(profile.projectName));
161
+ return { profiles, orphaned };
162
+ }
163
+ async function fetchJson(dsmOrigin, cookieHeader, params) {
164
+ const query = new URLSearchParams(params);
165
+ const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?${query.toString()}`, {
166
+ headers: { cookie: cookieHeader },
167
+ });
168
+ if (!response.ok) {
169
+ return undefined;
170
+ }
171
+ try {
172
+ return await response.json();
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
178
+ /**
179
+ * Read PortInfo.load and the administrator-visible installed package list.
180
+ * Any transport, HTTP, JSON, or DSM API failure becomes undefined so callers
181
+ * can skip without inventing residue.
182
+ */
183
+ export async function fetchDsmPortProfileInventory(dsmOrigin, cookieHeader) {
184
+ try {
185
+ const portInfoPayload = await fetchJson(dsmOrigin, cookieHeader, {
186
+ api: "SYNO.Core.Service.PortInfo",
187
+ version: "1",
188
+ method: "load",
189
+ });
190
+ const profiles = parseDsmPortInfoPayload(portInfoPayload);
191
+ if (!profiles) {
192
+ return undefined;
193
+ }
194
+ if (profiles.length === 0) {
195
+ return { profiles, orphaned: [] };
196
+ }
197
+ const packageListPayload = await fetchJson(dsmOrigin, cookieHeader, {
198
+ api: "SYNO.Core.Package",
199
+ version: "2",
200
+ method: "list",
201
+ additional: JSON.stringify(["status"]),
202
+ });
203
+ return dsmPortProfileInventoryFromPayloads(portInfoPayload, packageListPayload);
204
+ }
205
+ catch {
206
+ return undefined;
207
+ }
208
+ }
@@ -1,5 +1,7 @@
1
1
  import type { SharePermissionRow } from "./share-permission.js";
2
+ import type { PortInfoEntry } from "./port-info.js";
2
3
  import type { AttestationKind, HostInstallRecord, HostOidcCompatSettings, NasHost, PackageRoute } from "./types.js";
4
+ import type { DsmPortProfileInventory } from "./dsm-port-profile.js";
3
5
  export type SharePermissionRowInput = {
4
6
  name: string;
5
7
  } & Partial<Omit<SharePermissionRow, "name">>;
@@ -21,6 +23,10 @@ export interface FakeNasHostOptions {
21
23
  }>;
22
24
  /** Group membership per DSM user, for share-permission resolution. */
23
25
  userGroups?: Record<string, string[]>;
26
+ /** SYNO.Core.Service.PortInfo rows; omit to model a host without the capability. */
27
+ portInfo?: PortInfoEntry[];
28
+ /** Model a host whose PortInfo capability is present but the API failed. */
29
+ portInfoFailure?: boolean;
24
30
  installedEnv?: Record<string, Record<string, string>>;
25
31
  diagnostics?: {
26
32
  dsmResidue?: import("./types.js").DsmResidueInventory;
@@ -28,6 +34,8 @@ export interface FakeNasHostOptions {
28
34
  isPackageOnly?: boolean;
29
35
  [key: string]: unknown;
30
36
  };
37
+ /** Administrator-only DSM PortInfo inventory, when the fake should expose it. */
38
+ portProfileInventory?: DsmPortProfileInventory;
31
39
  /** Omit to model an older Broker without the OIDC settings endpoint. */
32
40
  oidcCompat?: HostOidcCompatSettings;
33
41
  }
@@ -63,6 +63,22 @@ export function createFakeNasHost(options = {}) {
63
63
  },
64
64
  }
65
65
  : {}),
66
+ ...(options.portInfo !== undefined || options.portInfoFailure
67
+ ? {
68
+ async listPortInfo() {
69
+ if (options.portInfoFailure) {
70
+ return undefined;
71
+ }
72
+ return options.portInfo?.map((entry) => ({
73
+ ...entry,
74
+ dst_port: [...entry.dst_port],
75
+ ...(entry.src_port === undefined
76
+ ? {}
77
+ : { src_port: entry.src_port === null ? null : [...entry.src_port] }),
78
+ }));
79
+ },
80
+ }
81
+ : {}),
66
82
  async resolveHomesPath() {
67
83
  return options.homesPath;
68
84
  },
@@ -154,6 +170,9 @@ export function createFakeNasHost(options = {}) {
154
170
  async getDsmDiagnostics() {
155
171
  return options.diagnostics;
156
172
  },
173
+ async getDsmPortProfileInventory() {
174
+ return options.portProfileInventory;
175
+ },
157
176
  async getOidcCompatSettings() {
158
177
  if (!options.oidcCompat) {
159
178
  return undefined;
@@ -0,0 +1,9 @@
1
+ import type { PortInfoEntry } from "./port-info.js";
2
+ export type PortInfoHttpRequest = (url: string, init?: RequestInit) => Promise<Response>;
3
+ /**
4
+ * Parses the native DSM response, not a synoagentcli operation envelope.
5
+ * `undefined` means "cannot tell" and deliberately allows deployment to
6
+ * continue. An empty `port_info` list has the same fail-open meaning.
7
+ */
8
+ export declare function parsePortInfo(payload: unknown): PortInfoEntry[] | undefined;
9
+ export declare function fetchPortInfo(dsmOrigin: string, cookieHeader: string, request?: PortInfoHttpRequest): Promise<PortInfoEntry[] | undefined>;
@@ -0,0 +1,87 @@
1
+ import { dsmFetch } from "./dsm-http.js";
2
+ const PORT_INFO_API = "SYNO.Core.Service.PortInfo";
3
+ function asRecord(value) {
4
+ if (value && typeof value === "object" && !Array.isArray(value)) {
5
+ return value;
6
+ }
7
+ return undefined;
8
+ }
9
+ function validPortRange(value) {
10
+ const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(value.trim());
11
+ if (!match) {
12
+ return false;
13
+ }
14
+ const start = Number(match[1]);
15
+ const end = Number(match[2] ?? match[1]);
16
+ return (Number.isSafeInteger(start) &&
17
+ Number.isSafeInteger(end) &&
18
+ start >= 1 &&
19
+ start <= end &&
20
+ end <= 65535);
21
+ }
22
+ /**
23
+ * Parses the native DSM response, not a synoagentcli operation envelope.
24
+ * `undefined` means "cannot tell" and deliberately allows deployment to
25
+ * continue. An empty `port_info` list has the same fail-open meaning.
26
+ */
27
+ export function parsePortInfo(payload) {
28
+ const outer = asRecord(payload);
29
+ const data = asRecord(outer?.data);
30
+ const items = data?.port_info;
31
+ if (outer?.success !== true || !Array.isArray(items) || items.length === 0) {
32
+ return undefined;
33
+ }
34
+ const entries = [];
35
+ for (const item of items) {
36
+ const record = asRecord(item);
37
+ if (!record) {
38
+ return undefined;
39
+ }
40
+ const desc = record?.desc;
41
+ const dstPort = record?.dst_port;
42
+ const name = record?.name;
43
+ const portId = record?.port_id;
44
+ const protocol = record?.protocol;
45
+ if (typeof desc !== "string" ||
46
+ typeof name !== "string" ||
47
+ typeof portId !== "string" ||
48
+ typeof protocol !== "string" ||
49
+ !Array.isArray(dstPort) ||
50
+ dstPort.length === 0 ||
51
+ !dstPort.every((value) => typeof value === "string" && validPortRange(value))) {
52
+ return undefined;
53
+ }
54
+ const srcPort = record.src_port;
55
+ if (srcPort !== undefined &&
56
+ srcPort !== null &&
57
+ (!Array.isArray(srcPort) || !srcPort.every((value) => typeof value === "string"))) {
58
+ return undefined;
59
+ }
60
+ entries.push({
61
+ desc,
62
+ dst_port: [...dstPort],
63
+ name,
64
+ port_id: portId,
65
+ protocol,
66
+ ...(srcPort === undefined ? {} : { src_port: srcPort === null ? null : [...srcPort] }),
67
+ });
68
+ }
69
+ return entries;
70
+ }
71
+ export async function fetchPortInfo(dsmOrigin, cookieHeader, request = dsmFetch) {
72
+ try {
73
+ const query = new URLSearchParams({
74
+ api: PORT_INFO_API,
75
+ version: "1",
76
+ method: "load",
77
+ });
78
+ const response = await request(`${dsmOrigin.replace(/\/$/, "")}/webapi/entry.cgi?${query.toString()}`, { headers: { cookie: cookieHeader } });
79
+ if (!response.ok) {
80
+ return undefined;
81
+ }
82
+ return parsePortInfo(await response.json());
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
@@ -0,0 +1,33 @@
1
+ /** One row returned by SYNO.Core.Service.PortInfo/load. */
2
+ export interface PortInfoEntry {
3
+ desc: string;
4
+ dst_port: readonly string[];
5
+ name: string;
6
+ port_id: string;
7
+ protocol: string;
8
+ src_port?: readonly string[] | null;
9
+ }
10
+ export type DsmPortInfoEntry = PortInfoEntry;
11
+ export type PortInfoRow = PortInfoEntry;
12
+ export interface PortConflict {
13
+ port: number;
14
+ services: readonly string[];
15
+ occupants: readonly PortInfoEntry[];
16
+ }
17
+ /**
18
+ * Returns the Docker container name represented by a DSM port row, when the
19
+ * row has Docker's known `port_id` or `desc` shape. Other DSM services are
20
+ * intentionally returned as undefined so they remain conflicts.
21
+ */
22
+ export declare function dockerContainerName(entry: PortInfoEntry): string | undefined;
23
+ export declare function formatPortOccupant(entry: PortInfoEntry): string;
24
+ /**
25
+ * Finds occupied published ports, ignoring only containers that are one of
26
+ * the Compose containers for this exact package identity. A same-identity
27
+ * occupant is the existing install being upgraded or reinstalled.
28
+ */
29
+ export declare function findPortConflicts(input: {
30
+ publishedPorts: ReadonlyMap<number, readonly string[]>;
31
+ packageIdentity: string;
32
+ portInfo: readonly PortInfoEntry[];
33
+ }): PortConflict[];
@@ -0,0 +1,66 @@
1
+ import { composeProjectName } from "../build-spk/resource-worker.js";
2
+ function parsePortRange(value) {
3
+ const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(value.trim());
4
+ if (!match) {
5
+ return undefined;
6
+ }
7
+ const start = Number(match[1]);
8
+ const end = Number(match[2] ?? match[1]);
9
+ if (!Number.isSafeInteger(start) ||
10
+ !Number.isSafeInteger(end) ||
11
+ start < 1 ||
12
+ end > 65535 ||
13
+ start > end) {
14
+ return undefined;
15
+ }
16
+ return { start, end };
17
+ }
18
+ function escapeRegExp(value) {
19
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ }
21
+ function entryCoversPort(entry, port) {
22
+ return entry.dst_port.some((value) => {
23
+ const range = parsePortRange(value);
24
+ return range !== undefined && range.start <= port && port <= range.end;
25
+ });
26
+ }
27
+ /**
28
+ * Returns the Docker container name represented by a DSM port row, when the
29
+ * row has Docker's known `port_id` or `desc` shape. Other DSM services are
30
+ * intentionally returned as undefined so they remain conflicts.
31
+ */
32
+ export function dockerContainerName(entry) {
33
+ const portIdMatch = /^(.*)_tcp$/i.exec(entry.port_id.trim());
34
+ if (portIdMatch?.[1]) {
35
+ return portIdMatch[1];
36
+ }
37
+ const descMatch = /^Docker\s+(.+)$/i.exec(entry.desc.trim());
38
+ return descMatch?.[1]?.trim() || undefined;
39
+ }
40
+ export function formatPortOccupant(entry) {
41
+ return dockerContainerName(entry) ?? (entry.desc.trim() || entry.port_id.trim() || entry.name.trim());
42
+ }
43
+ /**
44
+ * Finds occupied published ports, ignoring only containers that are one of
45
+ * the Compose containers for this exact package identity. A same-identity
46
+ * occupant is the existing install being upgraded or reinstalled.
47
+ */
48
+ export function findPortConflicts(input) {
49
+ const conflicts = [];
50
+ const projectName = composeProjectName(input.packageIdentity);
51
+ for (const [port, services] of input.publishedPorts) {
52
+ // Compose names a container <project>-<service>-<replica>. Match the
53
+ // replica as a number rather than assuming -1: a scaled service would
54
+ // otherwise look like a foreign occupant and refuse this package's own
55
+ // redeploy. The service name is still matched exactly, so a different
56
+ // package whose project name merely shares a prefix stays a conflict.
57
+ const ownContainer = (containerName) => services.some((service) => new RegExp(`^${escapeRegExp(`${projectName}-${service}-`)}\\d+$`).test(containerName));
58
+ const occupants = input.portInfo.filter((entry) => entry.protocol.trim().toLowerCase() === "tcp" &&
59
+ entryCoversPort(entry, port) &&
60
+ !ownContainer(dockerContainerName(entry) ?? ""));
61
+ if (occupants.length > 0) {
62
+ conflicts.push({ port, services, occupants });
63
+ }
64
+ }
65
+ return conflicts;
66
+ }
@@ -1,5 +1,7 @@
1
1
  import type { SharePermissionListing } from "../deploy/share-permission-preflight.js";
2
2
  import type { InstalledPackageState } from "./catalog-install.js";
3
+ import type { PortInfoEntry } from "./port-info.js";
4
+ import type { DsmPortProfileInventory } from "./dsm-port-profile.js";
3
5
  export type AttestationKind = "local" | "app-package" | "none";
4
6
  export type PackageRoute = "package-only" | "broker-installer" | "unknown";
5
7
  export type OidcOriginKind = "public-ca" | "quickconnect" | "lan-ip" | "self-signed" | "private-ca" | "http";
@@ -88,6 +90,8 @@ export interface NasHost {
88
90
  listSharePermissions?(logicalName: string): Promise<SharePermissionListing | undefined>;
89
91
  /** Group names a DSM user belongs to, for resolving share permission. */
90
92
  listUserGroups?(userName: string): Promise<string[] | undefined>;
93
+ /** Read SYNO.Core.Service.PortInfo/load using an administrator session. */
94
+ listPortInfo?(): Promise<PortInfoEntry[] | undefined>;
91
95
  /** Resolve the NAS-specific user-home root when DSM User Home is enabled. */
92
96
  resolveHomesPath?(): Promise<string | undefined>;
93
97
  getInstalledProjectEnv?(packageIdentity: string): Promise<Record<string, string> | undefined>;
@@ -103,6 +107,12 @@ export interface NasHost {
103
107
  isPackageOnly?: boolean;
104
108
  [key: string]: unknown;
105
109
  } | undefined>;
110
+ /**
111
+ * Read DSM PortInfo plus the administrator-visible installed package list.
112
+ * This path does not require SynoPkgLandService; undefined means the host
113
+ * could not answer and must be treated as unknown by diagnostics.
114
+ */
115
+ getDsmPortProfileInventory?(): Promise<DsmPortProfileInventory | undefined>;
106
116
  /** Read the optional administrator OIDC compatibility settings endpoint. */
107
117
  getOidcCompatSettings?(): Promise<HostOidcCompatSettings | undefined>;
108
118
  smokeRequireLogin(input: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {