@synopackageland/cli 0.3.1 → 0.3.3

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.
Files changed (69) hide show
  1. package/dist/attestation/app-package.js +8 -1
  2. package/dist/attestation/platform-service.d.ts +70 -0
  3. package/dist/attestation/platform-service.js +250 -0
  4. package/dist/bin.js +0 -0
  5. package/dist/build-spk/generator.js +200 -10
  6. package/dist/build-spk/resource-worker.d.ts +14 -0
  7. package/dist/build-spk/resource-worker.js +140 -0
  8. package/dist/cli.js +69 -10
  9. package/dist/compose/analyze.d.ts +42 -1
  10. package/dist/compose/analyze.js +482 -18
  11. package/dist/contract/load.d.ts +1 -0
  12. package/dist/contract/load.js +1 -1
  13. package/dist/contract/posix-all-shares.d.ts +16 -0
  14. package/dist/contract/posix-all-shares.js +63 -0
  15. package/dist/contract/schema-validate.js +26 -1
  16. package/dist/contract/validate.js +57 -0
  17. package/dist/contract/web-launch.d.ts +6 -0
  18. package/dist/contract/web-launch.js +25 -0
  19. package/dist/deploy/deploy.d.ts +1 -0
  20. package/dist/deploy/deploy.js +94 -2
  21. package/dist/deploy/homes-values.d.ts +19 -0
  22. package/dist/deploy/homes-values.js +29 -0
  23. package/dist/deploy/param-values.d.ts +21 -0
  24. package/dist/deploy/param-values.js +51 -0
  25. package/dist/deploy/spk-version.d.ts +13 -0
  26. package/dist/deploy/spk-version.js +80 -27
  27. package/dist/doctor.d.ts +22 -0
  28. package/dist/doctor.js +594 -0
  29. package/dist/error-collector.d.ts +2 -0
  30. package/dist/error-collector.js +46 -0
  31. package/dist/error-registry.d.ts +7 -0
  32. package/dist/error-registry.js +837 -0
  33. package/dist/host/broker-upgrade-preflight.d.ts +61 -0
  34. package/dist/host/broker-upgrade-preflight.js +156 -0
  35. package/dist/host/catalog-install.d.ts +20 -0
  36. package/dist/host/catalog-install.js +143 -17
  37. package/dist/host/credentials.js +35 -5
  38. package/dist/host/dsm-client.d.ts +12 -0
  39. package/dist/host/dsm-client.js +69 -1
  40. package/dist/host/dsm-compound.d.ts +37 -0
  41. package/dist/host/dsm-compound.js +120 -0
  42. package/dist/host/fake-nas.d.ts +1 -0
  43. package/dist/host/fake-nas.js +3 -0
  44. package/dist/host/types.d.ts +6 -0
  45. package/dist/publish/curated-release.js +8 -4
  46. package/dist/publish/platform-service-release.d.ts +60 -0
  47. package/dist/publish/platform-service-release.js +333 -0
  48. package/dist/publish/preflight.js +24 -2
  49. package/dist/release-bin.js +5 -1
  50. package/dist/templates/hello-files/compose.yaml +2 -0
  51. package/dist/templates/hello-web/compose.yaml +14 -3
  52. package/dist/templates/hello-web/synology-app.yaml +1 -1
  53. package/dist/templates/hello-web-page/compose.yaml +14 -3
  54. package/dist/templates/hello-web-page/synology-app.yaml +1 -1
  55. package/dist/test-host.d.ts +2 -0
  56. package/dist/test-host.js +15 -2
  57. package/dist/types.d.ts +1 -1
  58. package/dist/validate.js +14 -3
  59. package/package.json +2 -2
  60. package/schema/doctor.schema.json +38 -0
  61. package/schema/synology-app.schema.json +1 -1
  62. package/skills/reference/create.md +19 -0
  63. package/skills/reference/diagnose.md +35 -2
  64. package/skills/reference/files.md +10 -0
  65. package/templates/hello-files/compose.yaml +2 -0
  66. package/templates/hello-web/compose.yaml +14 -3
  67. package/templates/hello-web/synology-app.yaml +1 -1
  68. package/templates/hello-web-page/compose.yaml +14 -3
  69. package/templates/hello-web-page/synology-app.yaml +1 -1
@@ -0,0 +1,61 @@
1
+ export declare const BROKER_UPGRADE_TARGET_HOST = "10.17.83.200";
2
+ export type DsmUpgradeResponseKind = "error-4552" | "success" | "consent" | "unknown";
3
+ export interface ConsentDialogEvidence {
4
+ consentDialog: {
5
+ dependers: string[];
6
+ };
7
+ }
8
+ export interface BrokerUpgradeDependerBaseline {
9
+ packageIdentity: string;
10
+ version: string;
11
+ status: string;
12
+ running: boolean;
13
+ }
14
+ export interface BrokerUpgradeDepender extends BrokerUpgradeDependerBaseline {
15
+ healthCheckOk: boolean;
16
+ a23Classified: boolean;
17
+ dataSnapshotOk: boolean;
18
+ }
19
+ export interface BrokerUpgradePreflightInput {
20
+ targetHost: string;
21
+ broker: {
22
+ version: string;
23
+ status: string;
24
+ running: boolean;
25
+ };
26
+ dependers: BrokerUpgradeDepender[];
27
+ previousSpk: {
28
+ available: boolean;
29
+ sha256?: string;
30
+ };
31
+ canonicalizeGateSatisfied: boolean;
32
+ catalogEntry: {
33
+ version: string;
34
+ };
35
+ }
36
+ export type PreflightDecision = {
37
+ ok: true;
38
+ } | {
39
+ ok: false;
40
+ codes: string[];
41
+ };
42
+ export interface Evaluate4552BranchInput {
43
+ response: unknown;
44
+ dependers: BrokerUpgradeDependerBaseline[];
45
+ }
46
+ /**
47
+ * Classify only already-parsed DSM evidence. Strings are deliberately never
48
+ * parsed or searched: a textual mention of 4552 is not a DSM error response.
49
+ */
50
+ export declare function classifyDsmUpgradeResponse(raw: unknown): DsmUpgradeResponseKind;
51
+ /**
52
+ * Evaluate every local prerequisite without performing an upgrade or
53
+ * proposing a stop/start action. A missing or malformed prerequisite is a
54
+ * failure, even when another part of the input looks healthy.
55
+ */
56
+ export declare function evaluateBrokerUpgradePreflight(input: BrokerUpgradePreflightInput): PreflightDecision;
57
+ /**
58
+ * The 4552 path is entered only for a numeric DSM error and a non-empty,
59
+ * fully identified running baseline inventory.
60
+ */
61
+ export declare function evaluate4552Branch(input: Evaluate4552BranchInput): PreflightDecision;
@@ -0,0 +1,156 @@
1
+ import { comparePackageVersions } from "@synopackageland/package-source/index-manifest";
2
+ export const BROKER_UPGRADE_TARGET_HOST = "10.17.83.200";
3
+ /**
4
+ * Classify only already-parsed DSM evidence. Strings are deliberately never
5
+ * parsed or searched: a textual mention of 4552 is not a DSM error response.
6
+ */
7
+ export function classifyDsmUpgradeResponse(raw) {
8
+ if (!isRecord(raw)) {
9
+ return "unknown";
10
+ }
11
+ if (isRecord(raw.error) && raw.error.code === 4552) {
12
+ return "error-4552";
13
+ }
14
+ const hasError = Object.prototype.hasOwnProperty.call(raw, "error") &&
15
+ raw.error !== null &&
16
+ raw.error !== undefined;
17
+ if (raw.success === true && !hasError) {
18
+ return "success";
19
+ }
20
+ if (isConsentDialogEvidence(raw)) {
21
+ return "consent";
22
+ }
23
+ return "unknown";
24
+ }
25
+ /**
26
+ * Evaluate every local prerequisite without performing an upgrade or
27
+ * proposing a stop/start action. A missing or malformed prerequisite is a
28
+ * failure, even when another part of the input looks healthy.
29
+ */
30
+ export function evaluateBrokerUpgradePreflight(input) {
31
+ const candidate = isRecord(input) ? input : {};
32
+ const codes = [];
33
+ const addCode = (code) => {
34
+ if (!codes.includes(code)) {
35
+ codes.push(code);
36
+ }
37
+ };
38
+ if (candidate.targetHost !== BROKER_UPGRADE_TARGET_HOST) {
39
+ addCode("PREFLIGHT_WRONG_TARGET");
40
+ }
41
+ const broker = candidate.broker;
42
+ if (!isRecord(broker)) {
43
+ addCode("PREFLIGHT_BROKER_NOT_RUNNING");
44
+ addCode("PREFLIGHT_BROKER_VERSION_INVALID");
45
+ }
46
+ else {
47
+ if (broker.running !== true || broker.status !== "running") {
48
+ addCode("PREFLIGHT_BROKER_NOT_RUNNING");
49
+ }
50
+ if (!isNonEmptyString(broker.version)) {
51
+ addCode("PREFLIGHT_BROKER_VERSION_INVALID");
52
+ }
53
+ }
54
+ const dependers = candidate.dependers;
55
+ if (!Array.isArray(dependers)) {
56
+ addCode("PREFLIGHT_DEPENDER_INVENTORY_INVALID");
57
+ }
58
+ else {
59
+ for (const depender of dependers) {
60
+ if (!isRecord(depender)) {
61
+ addCode("PREFLIGHT_DEPENDER_INVENTORY_INVALID");
62
+ continue;
63
+ }
64
+ if (!isNonEmptyString(depender.packageIdentity) || !isNonEmptyString(depender.version)) {
65
+ addCode("PREFLIGHT_DEPENDER_INVENTORY_INVALID");
66
+ }
67
+ if (depender.running !== true) {
68
+ addCode("PREFLIGHT_DEPENDER_NOT_RUNNING");
69
+ }
70
+ if (depender.status !== "running") {
71
+ addCode("PREFLIGHT_DEPENDER_UNSAFE_STATUS");
72
+ }
73
+ if (depender.healthCheckOk !== true) {
74
+ addCode("PREFLIGHT_DEPENDER_HEALTHCHECK_FAILED");
75
+ }
76
+ if (depender.a23Classified !== true) {
77
+ addCode("PREFLIGHT_DEPENDER_A23_UNCLASSIFIED");
78
+ }
79
+ if (depender.dataSnapshotOk !== true) {
80
+ addCode("PREFLIGHT_DEPENDER_DATA_SNAPSHOT_MISSING");
81
+ }
82
+ }
83
+ }
84
+ const previousSpk = candidate.previousSpk;
85
+ if (!isRecord(previousSpk) || previousSpk.available !== true) {
86
+ addCode("PREFLIGHT_PREVIOUS_SPK_UNAVAILABLE");
87
+ }
88
+ else if (!isSha256(previousSpk.sha256)) {
89
+ addCode("PREFLIGHT_PREVIOUS_SPK_DIGEST_INVALID");
90
+ }
91
+ if (candidate.canonicalizeGateSatisfied !== true) {
92
+ addCode("PREFLIGHT_CANONICALIZE_GATE_UNSATISFIED");
93
+ }
94
+ const catalogEntry = candidate.catalogEntry;
95
+ if (!isRecord(catalogEntry) || !isNonEmptyString(catalogEntry.version)) {
96
+ addCode("PREFLIGHT_CATALOG_VERSION_INVALID");
97
+ }
98
+ else if (!isRecord(broker) || !isNonEmptyString(broker.version)) {
99
+ // The broker version gate is already reported above; without both version
100
+ // strings there is no safe comparison to perform.
101
+ addCode("PREFLIGHT_VERSION_COMPARISON_INVALID");
102
+ }
103
+ else {
104
+ const comparison = comparePackageVersions(catalogEntry.version, broker.version);
105
+ if (!Number.isFinite(comparison)) {
106
+ addCode("PREFLIGHT_VERSION_COMPARISON_INVALID");
107
+ }
108
+ else if (comparison <= 0) {
109
+ addCode("PREFLIGHT_CATALOG_VERSION_NOT_NEWER");
110
+ }
111
+ }
112
+ return decisionFromCodes(codes);
113
+ }
114
+ /**
115
+ * The 4552 path is entered only for a numeric DSM error and a non-empty,
116
+ * fully identified running baseline inventory.
117
+ */
118
+ export function evaluate4552Branch(input) {
119
+ const candidate = isRecord(input) ? input : {};
120
+ const codes = [];
121
+ if (classifyDsmUpgradeResponse(candidate.response) !== "error-4552") {
122
+ codes.push("PREFLIGHT_RESPONSE_NOT_4552");
123
+ }
124
+ const dependers = candidate.dependers;
125
+ if (!Array.isArray(dependers) ||
126
+ dependers.length === 0 ||
127
+ dependers.some((depender) => !isRunningBaseline(depender))) {
128
+ codes.push("PREFLIGHT_DEPENDER_INVENTORY_INCOMPLETE");
129
+ }
130
+ return decisionFromCodes(codes);
131
+ }
132
+ function decisionFromCodes(codes) {
133
+ return codes.length === 0 ? { ok: true } : { ok: false, codes };
134
+ }
135
+ function isRunningBaseline(value) {
136
+ return (isRecord(value) &&
137
+ isNonEmptyString(value.packageIdentity) &&
138
+ isNonEmptyString(value.version) &&
139
+ value.status === "running" &&
140
+ value.running === true);
141
+ }
142
+ function isConsentDialogEvidence(value) {
143
+ const dialog = value.consentDialog;
144
+ return (isRecord(dialog) &&
145
+ Array.isArray(dialog.dependers) &&
146
+ dialog.dependers.every((depender) => typeof depender === "string"));
147
+ }
148
+ function isRecord(value) {
149
+ return value !== null && typeof value === "object" && !Array.isArray(value);
150
+ }
151
+ function isNonEmptyString(value) {
152
+ return typeof value === "string" && value.length > 0;
153
+ }
154
+ function isSha256(value) {
155
+ return typeof value === "string" && /^[0-9a-fA-F]{64}$/.test(value);
156
+ }
@@ -23,11 +23,23 @@ export interface InstalledPackageState {
23
23
  installed: boolean;
24
24
  version?: string;
25
25
  status?: string;
26
+ error?: {
27
+ code?: number;
28
+ description?: string;
29
+ workerMsg?: unknown[];
30
+ };
31
+ brokenBy?: string;
32
+ stage?: string;
33
+ lastStage?: string;
34
+ statusCode?: number;
35
+ statusDescription?: string;
36
+ statusOrigin?: string;
26
37
  }
27
38
  export interface UploadedCatalogPackage {
28
39
  id: string;
29
40
  taskId: string;
30
41
  }
42
+ export declare function packageStateFromRecord(value: unknown): InstalledPackageState;
31
43
  export declare function packageStateFromListPayload(payload: unknown, packageId: string): InstalledPackageState | undefined;
32
44
  export declare function fetchInstalledPackageState(dsmOrigin: string, cookieHeader: string, packageId: string): Promise<InstalledPackageState | undefined>;
33
45
  export declare function waitForInstalledVersion(readState: () => InstalledPackageState | undefined | Promise<InstalledPackageState | undefined>, options: {
@@ -36,6 +48,14 @@ export declare function waitForInstalledVersion(readState: () => InstalledPackag
36
48
  deadlineMs?: number;
37
49
  pollMs?: number;
38
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>;
39
59
  export declare function catalogInstallationRequests(entry: Pick<CatalogPackageEntry, "package" | "version" | "size" | "beta" | "link" | "md5" | "qinst" | "deppkgs" | "breakpkgs" | "replacepkgs">, installed: boolean, sid: string, volumePath?: string): {
40
60
  check: Record<string, string>;
41
61
  install: Record<string, string>;
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { dsmFetch } from "./dsm-http.js";
4
+ import { assertDsmCompoundResponse } from "./dsm-compound.js";
4
5
  function normalizeDeppkgs(deppkgs) {
5
6
  if (typeof deppkgs === "string") {
6
7
  const match = /^([^><=]+)(>=|>|<=|<|==)(.+)$/.exec(deppkgs.trim());
@@ -27,7 +28,9 @@ async function dsmWebApi(dsmOrigin, cookieHeader, params, options = {}) {
27
28
  });
28
29
  const body = (await response.json());
29
30
  const errorCode = body.error?.code;
30
- if (!body.success && !(errorCode && options.allowErrorCodes?.includes(errorCode))) {
31
+ if (!body.success &&
32
+ !options.allowFailure &&
33
+ !(errorCode && options.allowErrorCodes?.includes(errorCode))) {
31
34
  throw new Error(`DSM ${params.api}/${params.method} failed: ${JSON.stringify(body.error ?? body)}`);
32
35
  }
33
36
  return body;
@@ -92,29 +95,90 @@ function readInstalledPackageState(packageId, profileName) {
92
95
  }
93
96
  try {
94
97
  const parsed = JSON.parse(result.stdout);
95
- if (!parsed.data?.installed) {
96
- return { installed: false, version: parsed.data?.version, status: parsed.data?.status };
97
- }
98
- return { installed: true, version: parsed.data.version, status: parsed.data.status };
98
+ return packageStateFromRecord(parsed.data);
99
99
  }
100
100
  catch {
101
101
  return undefined;
102
102
  }
103
103
  }
104
+ function record(value) {
105
+ return value && typeof value === "object" && !Array.isArray(value)
106
+ ? value
107
+ : {};
108
+ }
109
+ function stringValue(...values) {
110
+ return values.find((value) => typeof value === "string" && value.length > 0);
111
+ }
112
+ function numberValue(...values) {
113
+ return values.find((value) => typeof value === "number" && Number.isFinite(value));
114
+ }
115
+ function packageError(value) {
116
+ const source = record(value);
117
+ const code = numberValue(source.code);
118
+ const description = stringValue(source.description);
119
+ const workerMsg = Array.isArray(source.worker_msg)
120
+ ? source.worker_msg
121
+ : Array.isArray(source.workerMsg)
122
+ ? source.workerMsg
123
+ : undefined;
124
+ if (code === undefined && description === undefined && workerMsg === undefined) {
125
+ return undefined;
126
+ }
127
+ const error = {};
128
+ if (code !== undefined)
129
+ error.code = code;
130
+ if (description !== undefined)
131
+ error.description = description;
132
+ if (workerMsg !== undefined)
133
+ error.workerMsg = workerMsg;
134
+ return error;
135
+ }
136
+ export function packageStateFromRecord(value) {
137
+ const match = record(value);
138
+ const additional = record(match.additional);
139
+ const installedInfo = record(additional.installed_info ?? match.installedInfo);
140
+ const installedPath = stringValue(installedInfo.path, match.installedPath);
141
+ const version = stringValue(match.version);
142
+ const status = stringValue(additional.status, match.rawStatus, match.status);
143
+ const error = packageError(additional.error ?? match.error);
144
+ const state = {
145
+ installed: Boolean(installedPath) || match.installed === true,
146
+ };
147
+ if (version !== undefined)
148
+ state.version = version;
149
+ if (status !== undefined)
150
+ state.status = status;
151
+ if (error !== undefined)
152
+ state.error = error;
153
+ const brokenBy = stringValue(additional.broken_by, additional.brokenBy, match.broken_by, match.brokenBy);
154
+ if (brokenBy !== undefined)
155
+ state.brokenBy = brokenBy;
156
+ const stage = stringValue(additional.stage, match.stage);
157
+ if (stage !== undefined)
158
+ state.stage = stage;
159
+ const lastStage = stringValue(additional.last_stage, additional.lastStage, match.last_stage, match.lastStage);
160
+ if (lastStage !== undefined)
161
+ state.lastStage = lastStage;
162
+ const statusCode = numberValue(additional.status_code, additional.statusCode, match.status_code, match.statusCode, match.rawStatusCode);
163
+ if (statusCode !== undefined)
164
+ state.statusCode = statusCode;
165
+ const statusDescription = stringValue(additional.status_description, additional.statusDescription, match.status_description, match.statusDescription, match.rawStatusDescription);
166
+ if (statusDescription !== undefined)
167
+ state.statusDescription = statusDescription;
168
+ const statusOrigin = stringValue(additional.status_origin, additional.statusOrigin, match.status_origin, match.statusOrigin, match.rawStatusOrigin);
169
+ if (statusOrigin !== undefined)
170
+ state.statusOrigin = statusOrigin;
171
+ return state;
172
+ }
104
173
  export function packageStateFromListPayload(payload, packageId) {
105
- const record = payload && typeof payload === "object" ? payload : undefined;
106
- const data = record?.data && typeof record.data === "object" ? record.data : record;
174
+ const root = payload && typeof payload === "object" ? payload : undefined;
175
+ const data = root?.data && typeof root.data === "object" ? root.data : root;
107
176
  const packages = Array.isArray(data?.packages) ? data.packages : [];
108
177
  const match = packages.find((item) => item && typeof item === "object" && item.id === packageId);
109
178
  if (!match) {
110
179
  return { installed: false };
111
180
  }
112
- const additional = match.additional;
113
- const installedPath = additional?.installed_info?.path;
114
- const installed = Boolean(installedPath) || match.installed === true;
115
- const status = typeof additional?.status === "string" ? additional.status : undefined;
116
- const version = typeof match.version === "string" ? match.version : undefined;
117
- return { installed, version, status };
181
+ return packageStateFromRecord(match);
118
182
  }
119
183
  export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packageId) {
120
184
  try {
@@ -160,6 +224,66 @@ export async function waitForInstalledVersion(readState, options) {
160
224
  ? `Timed out waiting for ${options.packageId} to reach version ${options.expectedVersion}`
161
225
  : `Timed out waiting for ${options.packageId} to install from catalog`);
162
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
+ }
163
287
  export function catalogInstallationRequests(entry, installed, sid, volumePath) {
164
288
  const install = {
165
289
  api: "SYNO.Core.Package.Installation",
@@ -265,7 +389,7 @@ export async function installPackageFromLocalSpk(options) {
265
389
  const installed = dsmState?.installed === true || isPackageInstalled(options.packageId, options.profileName);
266
390
  const uploaded = await uploadLocalSpk(options, sid);
267
391
  const compound = JSON.stringify(catalogCompoundSteps(uploaded, { version: options.version, size: readFileSync(options.spkPath).byteLength }, { blupgrade: installed, extraValues: options.extraValues }));
268
- await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
392
+ const compoundResponse = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
269
393
  api: "SYNO.Entry.Request",
270
394
  version: "1",
271
395
  method: "request",
@@ -273,7 +397,8 @@ export async function installPackageFromLocalSpk(options) {
273
397
  mode: jsonApiParam("sequential"),
274
398
  compound,
275
399
  _sid: sid,
276
- });
400
+ }, { allowFailure: true });
401
+ assertDsmCompoundResponse(compoundResponse);
277
402
  await waitForInstalledVersion(async () => (await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId)) ??
278
403
  readInstalledPackageState(options.packageId, options.profileName), { packageId: options.packageId, expectedVersion: options.version });
279
404
  }
@@ -303,7 +428,7 @@ async function uploadCatalogSpk(options, entry, sid) {
303
428
  async function installCatalogViaUpload(options, entry, sid, installed) {
304
429
  const uploaded = await uploadCatalogSpk(options, entry, sid);
305
430
  const compound = JSON.stringify(catalogCompoundSteps(uploaded, entry, { blupgrade: installed, extraValues: options.extraValues }));
306
- await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
431
+ const compoundResponse = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
307
432
  api: "SYNO.Entry.Request",
308
433
  version: "1",
309
434
  method: "request",
@@ -311,7 +436,8 @@ async function installCatalogViaUpload(options, entry, sid, installed) {
311
436
  mode: jsonApiParam("sequential"),
312
437
  compound,
313
438
  _sid: sid,
314
- });
439
+ }, { allowFailure: true });
440
+ assertDsmCompoundResponse(compoundResponse);
315
441
  }
316
442
  export async function installPackageFromCatalog(options) {
317
443
  const entry = options.expectedVersion
@@ -1,6 +1,32 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ function findProfile(settings, input) {
5
+ if (!input) {
6
+ return settings.hosts?.[settings.defaultHost ?? "default"];
7
+ }
8
+ // 1. Direct match by profile name
9
+ if (settings.hosts?.[input]) {
10
+ return settings.hosts[input];
11
+ }
12
+ // 2. Match by URL hostname or exact URL
13
+ for (const profile of Object.values(settings.hosts ?? {})) {
14
+ if (profile?.url) {
15
+ try {
16
+ const u = new URL(profile.url);
17
+ if (u.hostname === input ||
18
+ profile.url === input ||
19
+ profile.url.replace(/\/$/, "") === input.replace(/\/$/, "")) {
20
+ return profile;
21
+ }
22
+ }
23
+ catch {
24
+ // ignore invalid URL
25
+ }
26
+ }
27
+ }
28
+ return undefined;
29
+ }
4
30
  export function resolveHostCredentials(hostName) {
5
31
  const settingsPath = join(homedir(), ".synoagentcli", "setting.json");
6
32
  let settings;
@@ -8,10 +34,9 @@ export function resolveHostCredentials(hostName) {
8
34
  settings = JSON.parse(readFileSync(settingsPath, "utf8"));
9
35
  }
10
36
  catch {
11
- return fromEnv();
37
+ return fromEnv(hostName);
12
38
  }
13
- const selected = hostName ?? settings.defaultHost ?? "default";
14
- const profile = settings.hosts?.[selected] ?? settings.hosts?.[settings.defaultHost ?? "default"];
39
+ const profile = findProfile(settings, hostName);
15
40
  const username = profile?.username ?? profile?.auth?.username;
16
41
  const password = profile?.password ?? profile?.auth?.password;
17
42
  if (profile?.url && username && password) {
@@ -24,10 +49,15 @@ export function resolveHostCredentials(hostName) {
24
49
  if (profile?.url) {
25
50
  return fromEnv(profile.url);
26
51
  }
27
- return fromEnv();
52
+ return fromEnv(hostName);
28
53
  }
29
54
  function fromEnv(urlOverride) {
30
- const dsmOrigin = urlOverride ?? process.env.DSM_URL;
55
+ let dsmOrigin = process.env.DSM_URL;
56
+ if (urlOverride) {
57
+ dsmOrigin = urlOverride.startsWith("http://") || urlOverride.startsWith("https://")
58
+ ? urlOverride
59
+ : `http://${urlOverride}:5000`;
60
+ }
31
61
  const password = process.env.DSM_PASSWORD;
32
62
  const username = process.env.DSM_USERNAME ?? "admin";
33
63
  if (!dsmOrigin || !password) {
@@ -4,6 +4,17 @@ export interface CreateNasHostOptions {
4
4
  credentials?: HostCredentials;
5
5
  }
6
6
  export declare function createNasHost(options?: CreateNasHostOptions): NasHost | null;
7
+ declare function synoagentcli(args: string[], profileName?: string): {
8
+ status: number | null;
9
+ stdout: string;
10
+ stderr: string;
11
+ };
12
+ /**
13
+ * DSM's User Home read reports the selected volume, not the final share path.
14
+ * Keep the projection strict: an unexpected response must not become a host
15
+ * bind to an arbitrary path.
16
+ */
17
+ export declare function homesPathFromDsmUserHomeRead(result: Pick<ReturnType<typeof synoagentcli>, "status" | "stdout">): string | undefined;
7
18
  /**
8
19
  * DSM reports the CPU vendor, not the ISA. Every x86_64 platform Synology
9
20
  * ships is Intel or AMD; every aarch64 one is Realtek, Marvell, Annapurna or
@@ -11,3 +22,4 @@ export declare function createNasHost(options?: CreateNasHostOptions): NasHost |
11
22
  * `--arch` rather than guessing wrong and building an unusable SPK.
12
23
  */
13
24
  export declare function archFamilyForCpuVendor(vendor: string | undefined): "x86_64" | "aarch64" | undefined;
25
+ export {};
@@ -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 } 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);
@@ -27,6 +27,34 @@ function synoagentcli(args, profileName) {
27
27
  stderr: result.stderr ?? "",
28
28
  };
29
29
  }
30
+ /**
31
+ * DSM's User Home read reports the selected volume, not the final share path.
32
+ * Keep the projection strict: an unexpected response must not become a host
33
+ * bind to an arbitrary path.
34
+ */
35
+ export function homesPathFromDsmUserHomeRead(result) {
36
+ if (result.status !== 0) {
37
+ return undefined;
38
+ }
39
+ let payload;
40
+ try {
41
+ payload = JSON.parse(result.stdout);
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ if (payload.success !== true || payload.data?.enable !== true) {
47
+ return undefined;
48
+ }
49
+ if (typeof payload.data.location !== "string") {
50
+ return undefined;
51
+ }
52
+ const volume = payload.data.location.trim().replace(/\/+$/, "");
53
+ if (!/^\/volume[1-9][0-9]*$/.test(volume)) {
54
+ return undefined;
55
+ }
56
+ return `${volume}/homes`;
57
+ }
30
58
  function createDsmNasHost(credentials, profileName) {
31
59
  const dsmOrigin = credentials.dsmOrigin.replace(/\/$/, "");
32
60
  const brokerBaseUrl = `${dsmOrigin}${IDENTITY_CGI_PATH}`;
@@ -172,6 +200,19 @@ function createDsmNasHost(credentials, profileName) {
172
200
  projectEnvCache.delete(input.packageId);
173
201
  }
174
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
+ },
175
216
  async getArchFamily() {
176
217
  await ensureLogin();
177
218
  const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?api=SYNO.Core.System&version=1&method=info`, { headers: { cookie: cookieHeader ?? "" } });
@@ -184,6 +225,15 @@ function createDsmNasHost(credentials, profileName) {
184
225
  async resolveSharePath(logicalName) {
185
226
  return lookupSharePathWithSynoagentcli(logicalName, profileName);
186
227
  },
228
+ async resolveHomesPath() {
229
+ try {
230
+ const result = synoagentcli(["dsm", "control-panel", "user-group", "advanced", "get", "--section", "home"], profileName);
231
+ return homesPathFromDsmUserHomeRead(result);
232
+ }
233
+ catch {
234
+ return undefined;
235
+ }
236
+ },
187
237
  async getInstalledProjectEnv(packageIdentity) {
188
238
  const cached = projectEnvCache.get(packageIdentity);
189
239
  if (cached) {
@@ -222,6 +272,24 @@ function createDsmNasHost(credentials, profileName) {
222
272
  throw error;
223
273
  }
224
274
  },
275
+ async getInstalledPackageState(packageIdentity) {
276
+ await ensureLogin();
277
+ const viaDsm = await fetchInstalledPackageState(dsmOrigin, cookieHeader ?? "", packageIdentity);
278
+ if (viaDsm) {
279
+ return viaDsm;
280
+ }
281
+ const result = synoagentcli(["dsm", "package-center", "get", packageIdentity], profileName);
282
+ if (result.status !== 0) {
283
+ return undefined;
284
+ }
285
+ try {
286
+ const parsed = JSON.parse(result.stdout);
287
+ return packageStateFromRecord(parsed.data);
288
+ }
289
+ catch {
290
+ return undefined;
291
+ }
292
+ },
225
293
  async getInstalledPackageVersion(packageIdentity) {
226
294
  await ensureLogin();
227
295
  const viaDsm = await fetchInstalledPackageState(dsmOrigin, cookieHeader ?? "", packageIdentity);