@synopackageland/cli 0.32.0 → 0.33.0

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 (58) hide show
  1. package/CHANGELOG.md +7 -5
  2. package/README.md +4 -4
  3. package/dist/cli.js +33 -12
  4. package/dist/ctl/cli.d.ts +1 -0
  5. package/dist/ctl/cli.js +61 -19
  6. package/dist/ctl/dsm-auth.js +2 -2
  7. package/dist/ctl/errors.js +1 -1
  8. package/dist/ctl/input.js +2 -2
  9. package/dist/ctl/service.js +8 -6
  10. package/dist/ctl/store.d.ts +4 -0
  11. package/dist/ctl/store.js +33 -5
  12. package/dist/ctl/tls.d.ts +22 -0
  13. package/dist/ctl/tls.js +118 -0
  14. package/dist/ctl/transfers.d.ts +2 -0
  15. package/dist/ctl/transfers.js +5 -4
  16. package/dist/ctl/transport.d.ts +3 -1
  17. package/dist/ctl/transport.js +38 -8
  18. package/dist/error-registry.js +9 -11
  19. package/dist/host/catalog-install.d.ts +0 -10
  20. package/dist/host/catalog-install.js +24 -140
  21. package/dist/host/connection.d.ts +9 -0
  22. package/dist/host/connection.js +49 -0
  23. package/dist/host/credentials.d.ts +3 -1
  24. package/dist/host/credentials.js +27 -62
  25. package/dist/host/dsm-client.d.ts +4 -7
  26. package/dist/host/dsm-client.js +47 -92
  27. package/dist/host/dsm-http.d.ts +2 -0
  28. package/dist/host/dsm-http.js +21 -1
  29. package/dist/host/dsm-port-profile.js +7 -2
  30. package/dist/host/install-plan.d.ts +32 -0
  31. package/dist/host/install-plan.js +157 -0
  32. package/dist/host/package-commands.d.ts +75 -0
  33. package/dist/host/package-commands.js +205 -0
  34. package/dist/host/port-info-lookup.d.ts +1 -1
  35. package/dist/host/port-info-lookup.js +5 -2
  36. package/dist/host/share-lookup.d.ts +3 -2
  37. package/dist/host/share-lookup.js +5 -91
  38. package/dist/host/share-permission-lookup.d.ts +3 -3
  39. package/dist/host/share-permission-lookup.js +18 -30
  40. package/dist/host/types.d.ts +5 -1
  41. package/dist/init-upstream.js +6 -6
  42. package/dist/package-metadata.d.ts +8 -1
  43. package/dist/package-metadata.js +28 -1
  44. package/dist/skill.js +1 -0
  45. package/dist/spk/inspect.d.ts +1 -0
  46. package/dist/spk/inspect.js +2 -1
  47. package/dist/spk/validate-spk.d.ts +1 -0
  48. package/dist/spk/validate-spk.js +1 -0
  49. package/dist/upstream-attribution.js +4 -3
  50. package/package.json +1 -1
  51. package/schema/package-metadata.schema.json +32 -3
  52. package/schema/synology-app.schema.json +32 -3
  53. package/skills/SKILL.md +3 -1
  54. package/skills/reference/compose.md +1 -1
  55. package/skills/reference/ctl.md +4 -4
  56. package/skills/reference/deploy.md +2 -0
  57. package/skills/reference/host-install.md +55 -0
  58. package/skills/reference/upstream-fork.md +2 -1
@@ -1,11 +1,11 @@
1
- import { spawnSync } from "node:child_process";
2
- import { listShareNamesWithSynoagentcli, lookupSharePathWithSynoagentcli, } from "./share-lookup.js";
3
- import { createSynoagentcliApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
1
+ import { CtlError } from '../ctl/errors.js';
2
+ import { listShareNames, lookupSharePath, } from "./share-lookup.js";
3
+ import { createDsmApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
4
4
  import { fetchPortInfo } from "./port-info-lookup.js";
5
- import { dsmFetch } from "./dsm-http.js";
5
+ import { dsmFetch, registerDsmSession } from "./dsm-http.js";
6
6
  import { versionUnsupported } from "../runtime/dsm-version.js";
7
7
  import { fetchDsmPortProfileInventory } from "./dsm-port-profile.js";
8
- import { installPackageFromLocalSpk, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
8
+ import { installPackageFromLocalSpk, fetchInstalledPackageState, } from "./catalog-install.js";
9
9
  import { resolveHostCredentials } from "./credentials.js";
10
10
  export function createNasHost(options = {}) {
11
11
  const credentials = options.credentials ?? resolveHostCredentials(options.host);
@@ -14,18 +14,6 @@ export function createNasHost(options = {}) {
14
14
  }
15
15
  return createDsmNasHost(credentials, options.host);
16
16
  }
17
- function synoagentcli(args, profileName) {
18
- const fullArgs = [...args];
19
- if (profileName) {
20
- fullArgs.push("--host", profileName);
21
- }
22
- const result = spawnSync("synoagentcli", fullArgs, { encoding: "utf8" });
23
- return {
24
- status: result.status,
25
- stdout: result.stdout ?? "",
26
- stderr: result.stderr ?? "",
27
- };
28
- }
29
17
  /**
30
18
  * DSM's User Home read reports the selected volume, not the final share path.
31
19
  * Keep the projection strict: an unexpected response must not become a host
@@ -68,6 +56,11 @@ function createDsmNasHost(credentials, profileName) {
68
56
  const dsmOrigin = credentials.dsmOrigin.replace(/\/$/, "");
69
57
  let cookieHeader = credentials.cookieHeader;
70
58
  const ensureLogin = async () => {
59
+ if (credentials.loadSession && !cookieHeader) {
60
+ const session = await credentials.loadSession();
61
+ cookieHeader = session.cookie;
62
+ registerDsmSession(session, credentials.hostName ?? profileName ?? 'env', credentials.ca, credentials.fingerprint);
63
+ }
71
64
  if (cookieHeader) {
72
65
  return;
73
66
  }
@@ -76,7 +69,7 @@ function createDsmNasHost(credentials, profileName) {
76
69
  version: "3",
77
70
  method: "login",
78
71
  account: credentials.username,
79
- passwd: credentials.password,
72
+ passwd: credentials.password ?? "",
80
73
  session: "FileStation",
81
74
  format: "cookie",
82
75
  });
@@ -129,10 +122,21 @@ function createDsmNasHost(credentials, profileName) {
129
122
  await appCall("identity.logout", appCookie).catch(() => undefined);
130
123
  }
131
124
  };
125
+ const call = async (api, method, version, params = {}) => {
126
+ await ensureLogin();
127
+ const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi`, { method: 'POST', headers: { cookie: cookieHeader ?? '', 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ api, method, version: String(version), ...params }) });
128
+ const body = await response.json();
129
+ if (body?.success !== true)
130
+ throw Object.assign(new Error(`DSM ${api}/${method} failed (${Number(body?.error?.code) || 'invalid response'})`), { code: body?.error?.code });
131
+ return body;
132
+ };
132
133
  return {
133
134
  dsmOrigin,
134
135
  async installFromLocalSpk(input) {
135
136
  await ensureLogin();
137
+ const members = await call('SYNO.Core.Group.Member', 'list', 1, { group: 'administrators', offset: '0', limit: '-1' });
138
+ if (!Array.isArray(members.data?.users) || !members.data.users.some((u) => u.name === credentials.username))
139
+ throw new CtlError('NAS_ADMIN_REQUIRED', 'Package installation requires a DSM administrator account', 'Log in with a DSM administrator account', 4);
136
140
  await installPackageFromLocalSpk({
137
141
  dsmOrigin,
138
142
  cookieHeader: cookieHeader ?? "",
@@ -154,7 +158,9 @@ function createDsmNasHost(credentials, profileName) {
154
158
  return { ready: false, executor_digest: "", policy_digest: "", error: read.error };
155
159
  return explain(read.data);
156
160
  }
157
- catch {
161
+ catch (error) {
162
+ if (error instanceof CtlError)
163
+ throw error;
158
164
  return undefined;
159
165
  }
160
166
  },
@@ -163,7 +169,9 @@ function createDsmNasHost(credentials, profileName) {
163
169
  const read = await readApp(packageIdentity, "portal.status");
164
170
  return read && "data" in read ? read.data : undefined;
165
171
  }
166
- catch {
172
+ catch (error) {
173
+ if (error instanceof CtlError)
174
+ throw error;
167
175
  return undefined;
168
176
  }
169
177
  },
@@ -218,110 +226,57 @@ function createDsmNasHost(credentials, profileName) {
218
226
  const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?api=SYNO.Core.System&version=1&method=info`, { headers: { cookie: cookieHeader ?? "" } });
219
227
  const body = (await response.json());
220
228
  if (body.success !== true) {
229
+ if (Number(body.error?.code) === 105)
230
+ throw new CtlError('NAS_ADMIN_REQUIRED', 'NAS deployment requires a DSM administrator account', 'Log in with a DSM administrator account', 4);
221
231
  return undefined;
222
232
  }
223
233
  return archFamilyForCpuVendor(body.data?.cpu_vendor);
224
234
  },
225
235
  async resolveSharePath(logicalName) {
226
- return lookupSharePathWithSynoagentcli(logicalName, profileName);
236
+ return lookupSharePath(logicalName, call);
227
237
  },
228
238
  async listShareNames() {
229
- return listShareNamesWithSynoagentcli(profileName);
239
+ return listShareNames(call);
230
240
  },
231
241
  async listSharePermissions(logicalName) {
232
- return fetchSharePermissions(logicalName, createSynoagentcliApiCall(profileName));
242
+ return fetchSharePermissions(logicalName, createDsmApiCall(call));
233
243
  },
234
244
  async listUserGroups(userName) {
235
- return fetchUserGroups(userName, createSynoagentcliApiCall(profileName));
245
+ return fetchUserGroups(userName, createDsmApiCall(call));
236
246
  },
237
247
  async listPortInfo() {
238
248
  try {
239
249
  await ensureLogin();
240
250
  return fetchPortInfo(dsmOrigin, cookieHeader ?? "");
241
251
  }
242
- catch {
252
+ catch (error) {
253
+ if (error instanceof CtlError)
254
+ throw error;
243
255
  return undefined;
244
256
  }
245
257
  },
246
258
  async resolveHomesPath() {
247
- try {
248
- const result = synoagentcli([
249
- "dsm",
250
- "control-panel",
251
- "user-group",
252
- "advanced",
253
- "get",
254
- "--section",
255
- "home",
256
- ], profileName);
257
- return homesPathFromDsmUserHomeRead(result);
258
- }
259
- catch {
260
- return undefined;
261
- }
259
+ const body = await call('SYNO.Core.User.Home', 'get', 1);
260
+ return homesPathFromDsmUserHomeRead({ status: 0, stdout: JSON.stringify(body) });
262
261
  },
263
262
  async getInstalledPackageState(packageIdentity) {
264
263
  await ensureLogin();
265
- const viaDsm = await fetchInstalledPackageState(dsmOrigin, cookieHeader ?? "", packageIdentity);
266
- if (viaDsm) {
267
- return viaDsm;
268
- }
269
- const result = synoagentcli(["dsm", "package-center", "get", packageIdentity], profileName);
270
- if (result.status !== 0) {
271
- return undefined;
272
- }
273
- try {
274
- const parsed = JSON.parse(result.stdout);
275
- return packageStateFromRecord(parsed.data);
276
- }
277
- catch {
278
- return undefined;
279
- }
280
- },
281
- async getInstalledPackageVersion(packageIdentity) {
282
- await ensureLogin();
283
- const viaDsm = await fetchInstalledPackageState(dsmOrigin, cookieHeader ?? "", packageIdentity);
284
- if (viaDsm?.version) {
285
- return viaDsm.version;
286
- }
287
- const result = synoagentcli(["dsm", "package-center", "get", packageIdentity], profileName);
288
- if (result.status !== 0) {
289
- return undefined;
290
- }
291
- try {
292
- const parsed = JSON.parse(result.stdout);
293
- return parsed.data?.version;
294
- }
295
- catch {
296
- return undefined;
297
- }
298
- },
299
- async isPackageInstalled(packageIdentity) {
300
- await ensureLogin();
301
- const viaDsm = await fetchInstalledPackageState(dsmOrigin, cookieHeader ?? "", packageIdentity);
302
- if (viaDsm) {
303
- return viaDsm.installed;
304
- }
305
- const result = synoagentcli(["dsm", "package-center", "get", packageIdentity], profileName);
306
- return result.status === 0;
264
+ return fetchInstalledPackageState(dsmOrigin, cookieHeader ?? '', packageIdentity);
307
265
  },
266
+ async getInstalledPackageVersion(packageIdentity) { return (await this.getInstalledPackageState(packageIdentity))?.version; },
267
+ async isPackageInstalled(packageIdentity) { return (await this.getInstalledPackageState(packageIdentity))?.installed === true; },
308
268
  async fetchContainerLogs(packageIdentity) {
309
- const result = synoagentcli(["dsm", "package-center", "status", packageIdentity], profileName);
310
- if (result.status !== 0) {
311
- return [];
312
- }
313
- return result.stdout
314
- .split("\n")
315
- .map((line) => line.trim())
316
- .filter(Boolean)
317
- .map((line) => `[status] ${line}`);
269
+ const state = await this.getInstalledPackageState(packageIdentity);
270
+ return state?.installed ? [`[status] ${state.status ?? 'unknown'} ${state.version ?? ''}`] : [];
318
271
  },
319
272
  async getDsmPortProfileInventory() {
320
273
  try {
321
274
  await ensureLogin();
322
275
  return await fetchDsmPortProfileInventory(dsmOrigin, cookieHeader ?? "");
323
276
  }
324
- catch {
277
+ catch (error) {
278
+ if (error instanceof CtlError)
279
+ throw error;
325
280
  return undefined;
326
281
  }
327
282
  },
@@ -1 +1,3 @@
1
+ import { type DsmSession } from '../ctl/transport.js';
2
+ export declare function registerDsmSession(session: DsmSession, name: string, ca?: string, fingerprint?: string): void;
1
3
  export declare function dsmFetch(url: string, init?: RequestInit): Promise<Response>;
@@ -1,3 +1,7 @@
1
+ import { sessionFetch } from '../ctl/transport.js';
2
+ import { CtlError } from '../ctl/errors.js';
3
+ const sessions = new Map();
4
+ export function registerDsmSession(session, name, ca, fingerprint) { sessions.set(session.origin + '\0' + session.cookie, { session, name, ca, fingerprint }); }
1
5
  import { spawnSync } from "node:child_process";
2
6
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
7
  import { tmpdir } from "node:os";
@@ -39,13 +43,29 @@ function headerRecord(headers) {
39
43
  }
40
44
  export async function dsmFetch(url, init = {}) {
41
45
  try {
42
- return await fetch(url, init);
46
+ for (const [key, value] of Object.entries(headerRecord(init.headers)))
47
+ if (/[\r\n\0]/.test(key + value))
48
+ throw new Error('Invalid header value');
49
+ const headers = new Headers(init.headers);
50
+ const binding = sessions.get(new URL(url).origin + '\0' + (headers.get('cookie') ?? ''));
51
+ if (binding)
52
+ headers.set('X-SYNO-TOKEN', binding.session.token);
53
+ const request = { ...init, headers, redirect: 'error', signal: init.signal ?? AbortSignal.timeout(62000) };
54
+ const response = binding ? await sessionFetch(url, request, binding.session, binding.ca, { fingerprint: binding.fingerprint, name: binding.name }) : await fetch(url, request);
55
+ if (binding && new URL(url).pathname === '/webapi/entry.cgi') {
56
+ const body = await response.clone().json().catch(() => null);
57
+ if (body?.success === false && [106, 107, 119].includes(Number(body.error?.code)))
58
+ throw new CtlError('CTL_NOT_AUTHENTICATED', 'DSM session expired', `run: synopkgland host login ${binding.name}`, 2);
59
+ }
60
+ return response;
43
61
  }
44
62
  catch (error) {
45
63
  const code = errorCode(error);
46
64
  if (code && UNTRUSTED_CERTIFICATE.has(code)) {
47
65
  throw untrustedCertificate(url, code);
48
66
  }
67
+ if (error instanceof CtlError)
68
+ throw error;
49
69
  if (!isHostUnreachable(error)) {
50
70
  throw error;
51
71
  }
@@ -1,3 +1,4 @@
1
+ import { CtlError } from '../ctl/errors.js';
1
2
  import { basename } from "node:path";
2
3
  import { composeProjectName } from "../build-spk/resource-worker.js";
3
4
  import { dsmFetch } from "./dsm-http.js";
@@ -169,7 +170,9 @@ async function fetchJson(dsmOrigin, cookieHeader, params) {
169
170
  try {
170
171
  return await response.json();
171
172
  }
172
- catch {
173
+ catch (error) {
174
+ if (error instanceof CtlError)
175
+ throw error;
173
176
  return undefined;
174
177
  }
175
178
  }
@@ -200,7 +203,9 @@ export async function fetchDsmPortProfileInventory(dsmOrigin, cookieHeader) {
200
203
  });
201
204
  return dsmPortProfileInventoryFromPayloads(portInfoPayload, packageListPayload);
202
205
  }
203
- catch {
206
+ catch (error) {
207
+ if (error instanceof CtlError)
208
+ throw error;
204
209
  return undefined;
205
210
  }
206
211
  }
@@ -0,0 +1,32 @@
1
+ export interface NasTarget {
2
+ arch: string;
3
+ version: string;
4
+ build: number;
5
+ major: number;
6
+ minor: number;
7
+ }
8
+ export interface StoreEntry {
9
+ package: string;
10
+ version: string;
11
+ link: string;
12
+ size: number;
13
+ md5?: string;
14
+ sha256?: string;
15
+ arch?: string | string[];
16
+ os_min_ver?: string;
17
+ os_max_ver?: string;
18
+ deppkgs?: unknown;
19
+ }
20
+ export declare function compareVersions(a: string, b: string): number;
21
+ export declare function archMatches(value: string | string[] | undefined, arch: string): boolean;
22
+ export declare function assertCompatible(info: Record<string, string>, target: NasTarget): void;
23
+ export declare function sourceUrl(source: string, target: NasTarget): URL;
24
+ export declare function selectEntry(entries: StoreEntry[], id: string, version: string | undefined, target: NasTarget): StoreEntry;
25
+ export interface Dependency {
26
+ id: string;
27
+ constraints: Record<string, string>;
28
+ }
29
+ export declare function dependencies(value: unknown): Dependency[];
30
+ export declare function satisfies(version: string, dependency: Dependency): boolean;
31
+ export declare function resolveInstallPlan(root: StoreEntry, entries: StoreEntry[], target: NasTarget, installed: Map<string, string>): StoreEntry[];
32
+ export declare function verifyPins(bytes: Buffer, entry: Pick<StoreEntry, "size" | "md5" | "sha256">): void;
@@ -0,0 +1,157 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CtlError } from "../ctl/errors.js";
3
+ import { familyOfArchToken } from "../build-spk/dsm-arch.js";
4
+ const fail = (code, message, hint = "Check the package source and select a compatible package") => new CtlError(code, message, hint, 3);
5
+ export function compareVersions(a, b) {
6
+ const x = a.split(/[._-]/).map(Number), y = b.split(/[._-]/).map(Number);
7
+ if ([...x, ...y].some((n) => !Number.isSafeInteger(n) || n < 0))
8
+ throw fail("PACKAGE_VERSION_INVALID", "Package versions must contain numeric segments");
9
+ for (let i = 0; i < Math.max(x.length, y.length); i++)
10
+ if ((x[i] ?? 0) !== (y[i] ?? 0))
11
+ return (x[i] ?? 0) - (y[i] ?? 0);
12
+ return 0;
13
+ }
14
+ export function archMatches(value, arch) {
15
+ if (value === undefined)
16
+ return true; // A DSM feed may omit arch after filtering its query.
17
+ const tokens = Array.isArray(value) ? value : value.split(/\s+/);
18
+ return tokens.some((token) => token === "noarch" || token === arch || token === familyOfArchToken(arch));
19
+ }
20
+ export function assertCompatible(info, target) {
21
+ if (!info.arch || !archMatches(info.arch, target.arch))
22
+ throw fail("PACKAGE_ARCH_MISMATCH", "SPK architecture does not match this NAS");
23
+ if (info.exclude_arch && archMatches(info.exclude_arch, target.arch))
24
+ throw fail("PACKAGE_ARCH_MISMATCH", "SPK excludes this NAS architecture");
25
+ for (const [key, side] of [
26
+ ["os_min_ver", -1],
27
+ ["os_max_ver", 1],
28
+ ]) {
29
+ if (info[key] &&
30
+ Math.sign(compareVersions(target.version, info[key])) === side)
31
+ throw fail("PACKAGE_DSM_MISMATCH", `SPK ${key} is incompatible with this NAS`);
32
+ }
33
+ }
34
+ export function sourceUrl(source, target) {
35
+ const url = new URL(source);
36
+ if (url.protocol !== "https:" || url.username || url.password)
37
+ throw fail("PACKAGE_SOURCE_INVALID", "Package source must be HTTPS without URL credentials");
38
+ for (const [key, value] of Object.entries({
39
+ arch: target.arch,
40
+ build: target.build,
41
+ major: target.major,
42
+ minor: target.minor,
43
+ }))
44
+ url.searchParams.set(key, String(value));
45
+ return url;
46
+ }
47
+ export function selectEntry(entries, id, version, target) {
48
+ const matching = entries
49
+ .filter((e) => e.package === id &&
50
+ (!version || e.version === version) &&
51
+ archMatches(e.arch, target.arch) &&
52
+ (!e.os_min_ver || compareVersions(target.version, e.os_min_ver) >= 0) &&
53
+ (!e.os_max_ver || compareVersions(target.version, e.os_max_ver) <= 0))
54
+ .sort((a, b) => compareVersions(b.version, a.version));
55
+ if (!matching.length)
56
+ throw fail("PACKAGE_NOT_FOUND", `No compatible store release for ${id}${version ? "@" + version : ""}`);
57
+ const chosen = matching[0];
58
+ if (matching.filter((e) => e.version === chosen.version).length !== 1)
59
+ throw fail("PACKAGE_SOURCE_AMBIGUOUS", "Source offers overlapping package architectures");
60
+ if (!Number.isSafeInteger(chosen.size) ||
61
+ chosen.size <= 0 ||
62
+ (!chosen.md5 && !chosen.sha256))
63
+ throw fail("PACKAGE_PIN_INVALID", "Source must pin SPK size and MD5 or SHA-256");
64
+ const link = new URL(chosen.link);
65
+ if (link.protocol !== "https:" || link.username || link.password)
66
+ throw fail("PACKAGE_LINK_INVALID", "SPK download must be HTTPS without URL credentials");
67
+ return chosen;
68
+ }
69
+ export function dependencies(value) {
70
+ if (value === undefined || value === null || value === "")
71
+ return [];
72
+ if (typeof value === "string")
73
+ return value
74
+ .trim()
75
+ .split(/[:\s]+/)
76
+ .map((part) => {
77
+ const match = /^([A-Za-z][A-Za-z0-9_.+-]*)(?:(>=|<=|==|=|>|<)([0-9][0-9._-]*))?$/.exec(part);
78
+ if (!match)
79
+ throw fail("PACKAGE_DEPENDENCY_INVALID", "Unsupported package dependency syntax");
80
+ return {
81
+ id: match[1],
82
+ constraints: match[2] ? { [match[2]]: match[3] } : {},
83
+ };
84
+ });
85
+ if (typeof value === "object" && !Array.isArray(value))
86
+ return Object.entries(value).map(([id, rules]) => {
87
+ if (!/^[A-Za-z][A-Za-z0-9_.+-]*$/.test(id))
88
+ throw fail("PACKAGE_DEPENDENCY_INVALID", "Invalid dependency package ID");
89
+ if (rules === null)
90
+ return { id, constraints: {} };
91
+ if (typeof rules !== "object" ||
92
+ Array.isArray(rules) ||
93
+ Object.entries(rules).some(([op, v]) => ![">=", ">", "<=", "<", "==", "="].includes(op) ||
94
+ typeof v !== "string" ||
95
+ !/^\d[\d._-]*$/.test(v)))
96
+ throw fail("PACKAGE_DEPENDENCY_INVALID", "Unsupported package dependency rules");
97
+ return { id, constraints: rules };
98
+ });
99
+ throw fail("PACKAGE_DEPENDENCY_INVALID", "Unsupported package dependency format");
100
+ }
101
+ export function satisfies(version, dependency) {
102
+ return Object.entries(dependency.constraints).every(([op, want]) => {
103
+ const c = compareVersions(version, want);
104
+ return op === ">="
105
+ ? c >= 0
106
+ : op === ">"
107
+ ? c > 0
108
+ : op === "<="
109
+ ? c <= 0
110
+ : op === "<"
111
+ ? c < 0
112
+ : c === 0;
113
+ });
114
+ }
115
+ export function resolveInstallPlan(root, entries, target, installed) {
116
+ const plan = [], visiting = new Set(), chosen = new Map();
117
+ const visit = (entry) => {
118
+ if (visiting.has(entry.package))
119
+ throw fail("PACKAGE_DEPENDENCY_CYCLE", "Package dependency cycle detected");
120
+ if (chosen.has(entry.package))
121
+ return;
122
+ visiting.add(entry.package);
123
+ for (const dep of dependencies(entry.deppkgs)) {
124
+ const existing = chosen.get(dep.id);
125
+ if (existing) {
126
+ if (!satisfies(existing.version, dep))
127
+ throw fail("PACKAGE_DEPENDENCY_CONFLICT", `Conflicting dependency ${dep.id}`);
128
+ continue;
129
+ }
130
+ if (installed.has(dep.id) && satisfies(installed.get(dep.id), dep))
131
+ continue;
132
+ const candidates = entries.filter((e) => e.package === dep.id && satisfies(e.version, dep));
133
+ if (!candidates.length)
134
+ throw fail("PACKAGE_DEPENDENCY_REQUIRED", `Install required Synology package ${dep.id}${Object.entries(dep.constraints)
135
+ .map(([k, v]) => k + v)
136
+ .join(",")} first`, "Install the dependency in DSM Package Center, then retry");
137
+ visit(selectEntry(candidates, dep.id, undefined, target));
138
+ }
139
+ visiting.delete(entry.package);
140
+ chosen.set(entry.package, entry);
141
+ plan.push(entry);
142
+ };
143
+ visit(root);
144
+ return plan;
145
+ }
146
+ export function verifyPins(bytes, entry) {
147
+ if (bytes.length !== entry.size)
148
+ throw fail("PACKAGE_SIZE_MISMATCH", "Downloaded SPK size differs from the feed pin");
149
+ for (const algorithm of ["md5", "sha256"]) {
150
+ const pin = entry[algorithm];
151
+ if (pin === undefined)
152
+ continue;
153
+ if (!new RegExp(`^[a-fA-F0-9]{${algorithm === "md5" ? 32 : 64}}$`).test(pin) ||
154
+ createHash(algorithm).update(bytes).digest("hex") !== pin.toLowerCase())
155
+ throw fail("PACKAGE_DIGEST_MISMATCH", `Downloaded SPK ${algorithm.toUpperCase()} differs from the feed pin`);
156
+ }
157
+ }
@@ -0,0 +1,75 @@
1
+ import { type NasConnection } from "./connection.js";
2
+ import { type NasTarget, type StoreEntry } from "./install-plan.js";
3
+ export declare const DEFAULT_PACKAGE_SOURCE = "https://packages.synopkgland.com";
4
+ export declare function packageId(value: string): string;
5
+ export declare function nasTarget(nas: NasConnection): Promise<NasTarget>;
6
+ export declare function readFeed(source: string, target: NasTarget): Promise<StoreEntry[]>;
7
+ export interface InstallOptions {
8
+ host?: string;
9
+ source?: string;
10
+ param?: Record<string, string>;
11
+ yes?: boolean;
12
+ dryRun?: boolean;
13
+ }
14
+ export declare function installPackage(spec: string, options?: InstallOptions, connection?: NasConnection): Promise<{
15
+ host: string;
16
+ target: NasTarget;
17
+ source: string;
18
+ packages: {
19
+ signed?: boolean | undefined;
20
+ package: string;
21
+ version: string;
22
+ size: number;
23
+ action: string;
24
+ }[];
25
+ parameters: string[];
26
+ dryRun: boolean;
27
+ } | {
28
+ status: import("./catalog-install.js").InstalledPackageState | undefined;
29
+ host: string;
30
+ target: NasTarget;
31
+ source: string;
32
+ packages: {
33
+ signed?: boolean | undefined;
34
+ package: string;
35
+ version: string;
36
+ size: number;
37
+ action: string;
38
+ }[];
39
+ parameters: string[];
40
+ }>;
41
+ export declare function uninstallPackage(id: string, options?: {
42
+ host?: string;
43
+ yes?: boolean;
44
+ dryRun?: boolean;
45
+ }): Promise<{
46
+ dryRun: boolean;
47
+ host: string;
48
+ package: string;
49
+ action: string;
50
+ installed?: undefined;
51
+ } | {
52
+ host: string;
53
+ package: string;
54
+ installed: boolean;
55
+ dryRun?: undefined;
56
+ action?: undefined;
57
+ }>;
58
+ export declare function packageStatus(id: string, host?: string): Promise<{
59
+ installed: boolean;
60
+ version?: string;
61
+ status?: string;
62
+ error?: {
63
+ code?: number;
64
+ description?: string;
65
+ workerMsg?: unknown[];
66
+ };
67
+ brokenBy?: string;
68
+ stage?: string;
69
+ lastStage?: string;
70
+ statusCode?: number;
71
+ statusDescription?: string;
72
+ statusOrigin?: string;
73
+ host: string;
74
+ package: string;
75
+ }>;