@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,3 +1,4 @@
1
+ import { type HostTls } from './tls.js';
1
2
  import { type DsmSession } from './transport.js';
2
3
  export declare function localFile(file: unknown): {
3
4
  file: string;
@@ -11,4 +12,5 @@ export declare function transferRequest(session: DsmSession, path: string, metho
11
12
  };
12
13
  ca?: string;
13
14
  timeoutMs?: number;
15
+ trust?: HostTls;
14
16
  }): Promise<any>;
@@ -1,3 +1,4 @@
1
+ import { pinnedAgent, networkError, timeoutError } from './tls.js';
1
2
  import { createReadStream, statSync } from 'node:fs';
2
3
  import http from 'node:http';
3
4
  import https from 'node:https';
@@ -26,7 +27,7 @@ export async function transferRequest(session, path, method, params, options = {
26
27
  const raw = file ? undefined : Buffer.from(JSON.stringify({ method, params }));
27
28
  return new Promise((resolve, reject) => {
28
29
  const headers = { 'content-type': file ? 'application/octet-stream' : 'application/json', cookie: session.cookie, 'X-SYNO-TOKEN': session.token, origin: session.origin, 'content-length': String(file?.size ?? raw.length) };
29
- const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: options.timeoutMs ?? CTL_TIMEOUT_MS, ...(options.ca ? { ca: options.ca } : {}) }, res => {
30
+ const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: options.timeoutMs ?? CTL_TIMEOUT_MS, ...(options.ca ? { ca: options.ca } : {}), ...(url.protocol === 'https:' && options.trust?.fingerprint ? { agent: pinnedAgent(session.origin, options.trust, options.timeoutMs ?? CTL_TIMEOUT_MS) } : {}) }, res => {
30
31
  const expected = file ? 'application/json' : 'application/x-ndjson';
31
32
  if (res.statusCode !== 200 || !res.headers['content-type']?.includes(expected)) {
32
33
  res.resume();
@@ -61,10 +62,10 @@ export async function transferRequest(session, path, method, params, options = {
61
62
  reject(new CtlError('CTL_TRANSFER_FAILED', 'Invalid or failed transfer response.', 'Inspect the App result; do not retry writes blindly.', 5));
62
63
  } });
63
64
  });
64
- const wallTimer = setTimeout(() => req.destroy(), options.timeoutMs ?? CTL_TIMEOUT_MS);
65
+ const wallTimer = setTimeout(() => req.destroy(timeoutError()), options.timeoutMs ?? CTL_TIMEOUT_MS);
65
66
  req.once('close', () => clearTimeout(wallTimer));
66
- req.on('timeout', () => req.destroy());
67
- req.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer interrupted; completion may be uncertain.', 'Check App state before retrying.', 6)));
67
+ req.on('timeout', () => req.destroy(timeoutError()));
68
+ req.on('error', e => reject(networkError(e)));
68
69
  if (file) {
69
70
  const source = createReadStream(file.file);
70
71
  source.on('error', () => req.destroy());
@@ -1,3 +1,4 @@
1
+ import { type HostTls } from './tls.js';
1
2
  export declare const CTL_TIMEOUT_MS = 62000;
2
3
  export declare const APP_COOKIE: unique symbol;
3
4
  export interface DsmSession {
@@ -9,4 +10,5 @@ export interface DsmSession {
9
10
  }
10
11
  export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
11
12
  export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
12
- export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string): JsonRequest;
13
+ export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string, trust?: HostTls): JsonRequest;
14
+ export declare function sessionFetch(url: string, init: RequestInit, session: DsmSession, ca?: string, trust?: HostTls): Promise<Response>;
@@ -1,3 +1,4 @@
1
+ import { pinnedAgent, networkError, timeoutError } from './tls.js';
1
2
  import { strictJson } from './json.js';
2
3
  import http from 'node:http';
3
4
  import https from 'node:https';
@@ -10,13 +11,13 @@ export function canonicalOrigin(input, tunnel = false) {
10
11
  url = new URL(input);
11
12
  }
12
13
  catch {
13
- throw new CtlError('CTL_URL_INVALID', 'A NAS origin URL is required.', 'run: synopkgland ctl host add <name> --url https://<nas>:5001');
14
+ throw new CtlError('CTL_URL_INVALID', 'A NAS origin URL is required.', 'run: synopkgland host add <name> --url https://<nas>:5001');
14
15
  }
15
16
  if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || !(url.protocol === 'https:' || (tunnel && url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname))))
16
- throw new CtlError('CTL_URL_INVALID', 'Use HTTPS, or explicit --ssh-tunnel for a loopback HTTP tunnel.', 'run: synopkgland ctl host add <name> --url https://<nas>:5001');
17
+ throw new CtlError('CTL_URL_INVALID', 'Use HTTPS, or explicit --ssh-tunnel for a loopback HTTP tunnel.', 'run: synopkgland host add <name> --url https://<nas>:5001');
17
18
  return url.origin;
18
19
  }
19
- export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca) {
20
+ export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca, trust) {
20
21
  return async (path, body, session) => {
21
22
  if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
22
23
  throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
@@ -25,7 +26,7 @@ export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca) {
25
26
  throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
26
27
  const url = new URL(path, origin);
27
28
  if (url.origin !== origin || (session && session.origin !== origin))
28
- throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins.', 'run: synopkgland ctl host list', 4);
29
+ throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins.', 'run: synopkgland host list', 4);
29
30
  const form = body instanceof URLSearchParams;
30
31
  const payload = Buffer.from(form ? body.toString() : JSON.stringify(body));
31
32
  if (payload.length > 1048576)
@@ -36,7 +37,7 @@ export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca) {
36
37
  headers.cookie = session.cookie;
37
38
  headers['X-SYNO-TOKEN'] = session.token;
38
39
  }
39
- const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: remaining, ...(ca ? { ca } : {}) }, res => {
40
+ const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: remaining, ...(ca ? { ca } : {}), ...(url.protocol === 'https:' && trust?.fingerprint ? { agent: pinnedAgent(origin, trust, remaining) } : {}) }, res => {
40
41
  if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
41
42
  res.resume();
42
43
  reject(new CtlError('CTL_HTTP_ERROR', `HTTP ${res.statusCode}; redirects are never followed.`, 'Check the NAS URL and connection.', 6));
@@ -73,11 +74,40 @@ export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca) {
73
74
  }
74
75
  });
75
76
  });
76
- const wallTimer = setTimeout(() => req.destroy(), remaining);
77
+ const wallTimer = setTimeout(() => req.destroy(timeoutError()), remaining);
77
78
  req.once('close', () => clearTimeout(wallTimer));
78
- req.on('timeout', () => req.destroy());
79
- req.on('error', () => reject(new CtlError('CTL_NETWORK', 'NAS request failed or timed out.', 'Check the NAS connection and trusted CA; requests are not replayed.', 6)));
79
+ req.on('timeout', () => req.destroy(timeoutError()));
80
+ req.on('error', error => reject(networkError(error)));
80
81
  req.end(payload);
81
82
  });
82
83
  };
83
84
  }
85
+ export async function sessionFetch(url, init, session, ca, trust) {
86
+ if (new URL(url).origin !== session.origin)
87
+ throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins', 'Check the registered NAS origin', 4);
88
+ const headers = new Headers(init.headers);
89
+ headers.set('cookie', session.cookie);
90
+ headers.set('X-SYNO-TOKEN', session.token);
91
+ const request = new Request(url, { ...init, headers }), bytes = init.body ? Buffer.from(await request.arrayBuffer()) : undefined;
92
+ return new Promise((resolve, reject) => {
93
+ const target = new URL(url);
94
+ const req = (target.protocol === 'https:' ? https : http).request(target, { method: request.method, headers: Object.fromEntries(request.headers), ca, timeout: 62000, ...(trust?.fingerprint && target.protocol === 'https:' ? { agent: pinnedAgent(target.origin, trust, 62000) } : {}) }, res => {
95
+ const chunks = [];
96
+ let length = 0;
97
+ res.on('data', c => { length += c.length; if (length > 16 * 1024 * 1024)
98
+ res.destroy(new CtlError('CTL_RESPONSE_TOO_LARGE', 'DSM response exceeds 16 MiB', 'Use a narrower operation', 5));
99
+ else
100
+ chunks.push(c); });
101
+ res.on('error', () => reject(new CtlError('DSM_NETWORK', 'DSM response interrupted', 'Check the NAS connection', 6)));
102
+ res.on('end', () => { const headers = new Headers(); for (const [k, v] of Object.entries(res.headers))
103
+ for (const x of Array.isArray(v) ? v : [v])
104
+ if (x !== undefined)
105
+ headers.append(k, x); resolve(new Response(Buffer.concat(chunks), { status: res.statusCode, headers })); });
106
+ });
107
+ const timer = setTimeout(() => req.destroy(timeoutError()), 62000);
108
+ req.once('close', () => clearTimeout(timer));
109
+ req.on('timeout', () => req.destroy(timeoutError()));
110
+ req.on('error', e => reject(networkError(e)));
111
+ req.end(bytes);
112
+ });
113
+ }
@@ -1,7 +1,15 @@
1
1
  import { COMPOSE_ERRORS } from "./standalone/compose-errors.js";
2
2
  export const ERROR_REGISTRY = {
3
3
  ...COMPOSE_ERRORS,
4
- INIT_UPSTREAM_INVALID: { code: "INIT_UPSTREAM_INVALID", description: "The upstream repository URL is invalid.", suggestion: "Supply --upstream with an http(s) repository URL without credentials." },
4
+ HOST_CERT_CHANGED: { "code": "HOST_CERT_CHANGED", "description": "The NAS certificate differs from its pinned SHA-256", "suggestion": "Verify old/new fingerprints, then run synopkgland host trust <name>" },
5
+ HOST_TLS_UNTRUSTED: { "code": "HOST_TLS_UNTRUSTED", "description": "The NAS TLS certificate is not trusted", "suggestion": "Use host login for first-use pinning, a trusted --ca file, or an expected --fingerprint" },
6
+ HOST_DNS_FAILED: { "code": "HOST_DNS_FAILED", "description": "The NAS hostname could not be resolved", "suggestion": "Check the registered hostname and DNS" },
7
+ HOST_CONNECTION_REFUSED: { "code": "HOST_CONNECTION_REFUSED", "description": "The NAS refused the connection", "suggestion": "Check the DSM port and NAS availability" },
8
+ HOST_TIMEOUT: { "code": "HOST_TIMEOUT", "description": "The NAS request timed out", "suggestion": "Check connection and firewall; inspect state before retrying writes" },
9
+ CTL_HOST_REQUIRED: { "code": "CTL_HOST_REQUIRED", "description": "Select a registered NAS host", "suggestion": "Run synopkgland host add, select --host, or set host default" },
10
+ CTL_NOT_AUTHENTICATED: { "code": "CTL_NOT_AUTHENTICATED", "description": "The DSM session is absent or expired", "suggestion": "Run synopkgland host login <name>" },
11
+ NAS_ADMIN_REQUIRED: { "code": "NAS_ADMIN_REQUIRED", "description": "Package installation and removal require an administrator", "suggestion": "Register and log in with a DSM administrator account" },
12
+ INIT_UPSTREAM_INVALID: { code: "INIT_UPSTREAM_INVALID", description: "The upstream repository URL is invalid.", suggestion: "Supply --upstream with an HTTPS repository URL without credentials." },
5
13
  INIT_PUBLISHER_INVALID: { code: "INIT_PUBLISHER_INVALID", description: "The attribution name or publisher URL is invalid.", suggestion: "Use plain single-line project/publisher names and an http(s) publisher URL without credentials." },
6
14
  UPSTREAM_ATTRIBUTION: { code: "UPSTREAM_ATTRIBUTION", description: "Upstream App attribution is incomplete or names the distributor as maintainer.", suggestion: "Name the original project/team or owner as maintainer; set metadata.maintainer_url to the upstream repository and metadata.distributor/distributor_url to your publisher; resolve TODOs before release." },
7
15
  INSTALL_SETTINGS_INVALID: { "code": "INSTALL_SETTINGS_INVALID", "description": "The installation setting declaration is invalid.", "suggestion": "Declare 1–32 lowercase identifier keys in install.settings, each with type https-origin, a plain label and an explicit boolean required; omit defaults and values." },
@@ -725,16 +733,6 @@ export const ERROR_REGISTRY = {
725
733
  description: "Writing a single file needs an architecture.",
726
734
  suggestion: "Combine -o with a single --arch, or use --out-dir.",
727
735
  },
728
- HOST_REQUIRED: {
729
- code: "HOST_REQUIRED",
730
- description: "No NAS was specified.",
731
- suggestion: "Use --host <profile>; for a local deploy check use --dry-run.",
732
- },
733
- NAS_CREDENTIALS_MISSING: {
734
- code: "NAS_CREDENTIALS_MISSING",
735
- description: "NAS credentials were not found.",
736
- suggestion: "Set a synoagentcli profile or DSM_URL/DSM_PASSWORD.",
737
- },
738
736
  NATIVE_LAYOUT_SCRIPT_NOT_EXECUTABLE: {
739
737
  code: "NATIVE_LAYOUT_SCRIPT_NOT_EXECUTABLE",
740
738
  description: "A scripts/ lifecycle script in the native layout metadata is not executable (inspect's SPK_SCRIPT_NOT_EXECUTABLE).",
@@ -10,15 +10,6 @@ interface CatalogPackageEntry {
10
10
  breakpkgs?: unknown;
11
11
  replacepkgs?: unknown;
12
12
  }
13
- export interface CatalogInstallOptions {
14
- dsmOrigin: string;
15
- cookieHeader: string;
16
- packageId: string;
17
- profileName?: string;
18
- expectedVersion?: string;
19
- packageSourceOrigin?: string;
20
- extraValues?: Record<string, string>;
21
- }
22
13
  export interface InstalledPackageState {
23
14
  installed: boolean;
24
15
  version?: string;
@@ -75,5 +66,4 @@ export interface LocalSpkInstallOptions {
75
66
  extraValues?: Record<string, string>;
76
67
  }
77
68
  export declare function installPackageFromLocalSpk(options: LocalSpkInstallOptions): Promise<void>;
78
- export declare function installPackageFromCatalog(options: CatalogInstallOptions): Promise<void>;
79
69
  export {};
@@ -1,5 +1,5 @@
1
+ import { CtlError } from '../ctl/errors.js';
1
2
  import { readFileSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
3
3
  import { dsmFetch } from "./dsm-http.js";
4
4
  import { assertDsmCompoundResponse } from "./dsm-compound.js";
5
5
  function normalizeDeppkgs(deppkgs) {
@@ -31,81 +31,11 @@ async function dsmWebApi(dsmOrigin, cookieHeader, params, options = {}) {
31
31
  if (!body.success &&
32
32
  !options.allowFailure &&
33
33
  !(errorCode && options.allowErrorCodes?.includes(errorCode))) {
34
- throw new Error(`DSM ${params.api}/${params.method} failed: ${JSON.stringify(body.error ?? body)}`);
34
+ throw new Error(`DSM ${params.api}/${params.method} failed: ${Number(body.error?.code) || "invalid response"}`);
35
35
  }
36
36
  return body;
37
37
  }
38
- async function fetchCatalogEntry(options) {
39
- const sid = extractSid(options.cookieHeader);
40
- const list = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
41
- api: "SYNO.Core.Package.Server",
42
- version: "2",
43
- method: "list",
44
- blforcereload: "true",
45
- blloadothers: "true",
46
- _sid: sid,
47
- });
48
- const data = list.data;
49
- const entries = [...(data.packages ?? []), ...(data.beta_packages ?? [])];
50
- const entry = entries.find((item) => item.package === options.packageId);
51
- if (!entry) {
52
- throw new Error(`Package ${options.packageId} not found in Package Source catalog`);
53
- }
54
- return entry;
55
- }
56
- async function fetchCatalogEntryFromOrigin(origin, packageId) {
57
- const catalogUrl = `${origin.replace(/\/$/, "")}/?arch=noarch&build=69057&major=7`;
58
- try {
59
- const response = await dsmFetch(catalogUrl);
60
- if (!response.ok) {
61
- return undefined;
62
- }
63
- const data = (await response.json());
64
- const entries = [...(data.packages ?? []), ...(data.beta_packages ?? [])];
65
- return entries.find((item) => item.package === packageId);
66
- }
67
- catch {
68
- return undefined;
69
- }
70
- }
71
- async function waitForCatalogEntry(options, expectedVersion) {
72
- const deadline = Date.now() + 5 * 60 * 1000;
73
- let lastVersion;
74
- while (Date.now() < deadline) {
75
- const originEntry = options.packageSourceOrigin
76
- ? await fetchCatalogEntryFromOrigin(options.packageSourceOrigin, options.packageId)
77
- : undefined;
78
- const entry = originEntry ?? await fetchCatalogEntry(options);
79
- lastVersion = entry.version;
80
- if (entry.version === expectedVersion) {
81
- return entry;
82
- }
83
- await new Promise((resolve) => setTimeout(resolve, 5000));
84
- }
85
- throw new Error(`Package ${options.packageId} catalog shows ${lastVersion ?? "unknown"}; expected ${expectedVersion}`);
86
- }
87
- function readInstalledPackageState(packageId, profileName) {
88
- const args = ["dsm", "package-center", "get", packageId];
89
- if (profileName) {
90
- args.push("--host", profileName);
91
- }
92
- const result = spawnSync("synoagentcli", args, { encoding: "utf8" });
93
- if (result.status !== 0) {
94
- return undefined;
95
- }
96
- try {
97
- const parsed = JSON.parse(result.stdout);
98
- return packageStateFromRecord(parsed.data);
99
- }
100
- catch {
101
- return undefined;
102
- }
103
- }
104
- function record(value) {
105
- return value && typeof value === "object" && !Array.isArray(value)
106
- ? value
107
- : {};
108
- }
38
+ function record(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
109
39
  function stringValue(...values) {
110
40
  return values.find((value) => typeof value === "string" && value.length > 0);
111
41
  }
@@ -191,19 +121,17 @@ export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packag
191
121
  });
192
122
  return packageStateFromListPayload(body, packageId);
193
123
  }
194
- catch {
124
+ catch (error) {
125
+ if (error instanceof CtlError)
126
+ throw error;
195
127
  return undefined;
196
128
  }
197
129
  }
198
- function isPackageInstalled(packageId, profileName) {
199
- const state = readInstalledPackageState(packageId, profileName);
200
- return state?.installed === true;
201
- }
202
130
  function installStatusIsBroken(status) {
203
131
  if (!status) {
204
132
  return false;
205
133
  }
206
- return /(broken|error|fail|installing|150)/i.test(status);
134
+ return /(broken|error|fail|150)/i.test(status);
207
135
  }
208
136
  export async function waitForInstalledVersion(readState, options) {
209
137
  const deadline = Date.now() + (options.deadlineMs ?? 15 * 60 * 1000);
@@ -213,7 +141,7 @@ export async function waitForInstalledVersion(readState, options) {
213
141
  if (state?.installed && installStatusIsBroken(state.status)) {
214
142
  throw new Error(`Package ${options.packageId} install did not complete (Package Center status ${state.status}). Check synopkg postinst / webservice acquire.`);
215
143
  }
216
- if (state?.installed) {
144
+ if (state?.installed && !/installing|upgrading/i.test(state.status ?? "")) {
217
145
  if (!options.expectedVersion || state.version === options.expectedVersion) {
218
146
  return;
219
147
  }
@@ -262,14 +190,19 @@ export async function uninstallPackageFromDsm(options) {
262
190
  try {
263
191
  const sid = extractSid(options.cookieHeader);
264
192
  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);
193
+ const before = await readState();
194
+ if (!before)
195
+ throw new Error("Cannot verify package state before removal");
196
+ if (!packageStatusIsStopped(before)) {
197
+ await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
198
+ api: "SYNO.Core.Package.Control",
199
+ version: "1",
200
+ method: "stop",
201
+ id: jsonApiParam(options.packageId),
202
+ _sid: sid,
203
+ });
204
+ await waitForPackageStopped(readState, options);
205
+ }
273
206
  await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
274
207
  api: "SYNO.Core.Package.Uninstallation",
275
208
  version: "1",
@@ -379,14 +312,14 @@ async function uploadLocalSpk(options, sid) {
379
312
  });
380
313
  const body = (await uploadResponse.json());
381
314
  if (!body.success || !body.data?.id || !body.data.task_id) {
382
- throw new Error(`DSM SYNO.Core.Package.Installation/upload failed: ${JSON.stringify(body.error ?? body)}`);
315
+ throw new Error(`DSM SYNO.Core.Package.Installation/upload failed: ${Number(body.error?.code) || "invalid response"}`);
383
316
  }
384
317
  return { id: body.data.id, taskId: body.data.task_id };
385
318
  }
386
319
  export async function installPackageFromLocalSpk(options) {
387
320
  const sid = extractSid(options.cookieHeader);
388
321
  const dsmState = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId);
389
- const installed = dsmState?.installed === true || isPackageInstalled(options.packageId, options.profileName);
322
+ const installed = dsmState?.installed === true;
390
323
  const uploaded = await uploadLocalSpk(options, sid);
391
324
  const compound = JSON.stringify(catalogCompoundSteps(uploaded, { version: options.version, size: readFileSync(options.spkPath).byteLength }, { blupgrade: installed, extraValues: options.extraValues }));
392
325
  const compoundResponse = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
@@ -399,54 +332,5 @@ export async function installPackageFromLocalSpk(options) {
399
332
  _sid: sid,
400
333
  }, { allowFailure: true });
401
334
  assertDsmCompoundResponse(compoundResponse);
402
- await waitForInstalledVersion(async () => (await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId)) ??
403
- readInstalledPackageState(options.packageId, options.profileName), { packageId: options.packageId, expectedVersion: options.version });
404
- }
405
- async function uploadCatalogSpk(options, entry, sid) {
406
- const response = await dsmFetch(entry.link);
407
- if (!response.ok) {
408
- throw new Error(`Failed to download catalog SPK for ${entry.package}: HTTP ${response.status}`);
409
- }
410
- const bytes = Buffer.from(await response.arrayBuffer());
411
- const form = new FormData();
412
- form.append("api", "SYNO.Core.Package.Installation");
413
- form.append("method", "upload");
414
- form.append("version", "1");
415
- form.append("_sid", sid);
416
- form.append("file", new Blob([bytes]), `${entry.package}.spk`);
417
- const uploadResponse = await dsmFetch(`${options.dsmOrigin}/webapi/entry.cgi`, {
418
- method: "POST",
419
- headers: { cookie: options.cookieHeader },
420
- body: form,
421
- });
422
- const body = (await uploadResponse.json());
423
- if (!body.success || !body.data?.id || !body.data.task_id) {
424
- throw new Error(`DSM SYNO.Core.Package.Installation/upload failed: ${JSON.stringify(body.error ?? body)}`);
425
- }
426
- return { id: body.data.id, taskId: body.data.task_id };
427
- }
428
- async function installCatalogViaUpload(options, entry, sid, installed) {
429
- const uploaded = await uploadCatalogSpk(options, entry, sid);
430
- const compound = JSON.stringify(catalogCompoundSteps(uploaded, entry, { blupgrade: installed, extraValues: options.extraValues }));
431
- const compoundResponse = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
432
- api: "SYNO.Entry.Request",
433
- version: "1",
434
- method: "request",
435
- stop_when_error: "true",
436
- mode: jsonApiParam("sequential"),
437
- compound,
438
- _sid: sid,
439
- }, { allowFailure: true });
440
- assertDsmCompoundResponse(compoundResponse);
441
- }
442
- export async function installPackageFromCatalog(options) {
443
- const entry = options.expectedVersion
444
- ? await waitForCatalogEntry(options, options.expectedVersion)
445
- : await fetchCatalogEntry(options);
446
- const sid = extractSid(options.cookieHeader);
447
- const dsmState = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId);
448
- const installed = dsmState?.installed === true || isPackageInstalled(options.packageId, options.profileName);
449
- await installCatalogViaUpload(options, entry, sid, installed);
450
- await waitForInstalledVersion(async () => (await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId)) ??
451
- readInstalledPackageState(options.packageId, options.profileName), { packageId: options.packageId, expectedVersion: entry.version });
335
+ await waitForInstalledVersion(async () => await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId), { packageId: options.packageId, expectedVersion: options.version });
452
336
  }
@@ -0,0 +1,9 @@
1
+ import { type DsmSession, type JsonRequest } from "../ctl/transport.js";
2
+ export interface NasConnection {
3
+ name: string;
4
+ session: DsmSession;
5
+ request: JsonRequest;
6
+ call: (api: string, method: string, version: number, params?: Record<string, string>) => Promise<any>;
7
+ }
8
+ export declare function connectNas(name?: string): Promise<NasConnection>;
9
+ export declare function requireAdministrator(nas: NasConnection): Promise<void>;
@@ -0,0 +1,49 @@
1
+ import { CtlError } from "../ctl/errors.js";
2
+ import { createRequest, } from "../ctl/transport.js";
3
+ import { resolveHostCredentials } from "./credentials.js";
4
+ import { registerDsmSession } from "./dsm-http.js";
5
+ export async function connectNas(name) {
6
+ const credentials = resolveHostCredentials(name);
7
+ const session = await credentials.loadSession();
8
+ registerDsmSession(session, credentials.hostName, credentials.ca, credentials.fingerprint);
9
+ const request = createRequest(session.origin, 62000, credentials.ca, {
10
+ fingerprint: credentials.fingerprint,
11
+ name: credentials.hostName,
12
+ });
13
+ return {
14
+ name: credentials.hostName,
15
+ session,
16
+ request,
17
+ call: async (api, method, version, params = {}) => {
18
+ const body = await request("/webapi/entry.cgi", new URLSearchParams({
19
+ api,
20
+ method,
21
+ version: String(version),
22
+ ...params,
23
+ }), session);
24
+ const code = Number(body?.error?.code);
25
+ if (body?.success !== true)
26
+ throw new CtlError([106, 107, 119].includes(code)
27
+ ? "CTL_NOT_AUTHENTICATED"
28
+ : code === 105
29
+ ? "NAS_ADMIN_REQUIRED"
30
+ : "DSM_API_FAILED", `DSM ${api}/${method} failed (${code || "invalid response"})`, [106, 107, 119].includes(code)
31
+ ? `run: synopkgland host login ${credentials.hostName}`
32
+ : code === 105
33
+ ? "Log in with a DSM administrator account"
34
+ : "Check DSM permissions and Package Center", 2);
35
+ return body.data;
36
+ },
37
+ };
38
+ }
39
+ export async function requireAdministrator(nas) {
40
+ const data = await nas.call("SYNO.Core.Group.Member", "list", 1, {
41
+ group: "administrators",
42
+ offset: "0",
43
+ limit: "-1",
44
+ });
45
+ const members = data?.users;
46
+ if (!Array.isArray(members) ||
47
+ !members.some((u) => u.name === nas.session.username))
48
+ throw new CtlError("NAS_ADMIN_REQUIRED", "Package installation and removal require a DSM administrator account", "Register and log in with a DSM administrator account", 4);
49
+ }
@@ -1,2 +1,4 @@
1
+ import { CtlStore } from "../ctl/store.js";
1
2
  import type { HostCredentials } from "./types.js";
2
- export declare function resolveHostCredentials(hostName?: string): HostCredentials | null;
3
+ /** Host metadata is public; session carriers are loaded only from secure storage. */
4
+ export declare function resolveHostCredentials(name?: string, store?: CtlStore): HostCredentials | null;
@@ -1,67 +1,32 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
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
- }
30
- export function resolveHostCredentials(hostName) {
31
- const settingsPath = join(homedir(), ".synoagentcli", "setting.json");
32
- let settings;
33
- try {
34
- settings = JSON.parse(readFileSync(settingsPath, "utf8"));
35
- }
36
- catch {
37
- return fromEnv(hostName);
38
- }
39
- const profile = findProfile(settings, hostName);
40
- const username = profile?.username ?? profile?.auth?.username;
41
- const password = profile?.password ?? profile?.auth?.password;
42
- if (profile?.url && username && password) {
2
+ import { CtlStore } from "../ctl/store.js";
3
+ import { canonicalOrigin } from "../ctl/transport.js";
4
+ import { loginDsm } from "../ctl/dsm-auth.js";
5
+ /** Host metadata is public; session carriers are loaded only from secure storage. */
6
+ export function resolveHostCredentials(name, store = new CtlStore()) {
7
+ if (!name &&
8
+ !store.list().length &&
9
+ process.env.DSM_URL &&
10
+ process.env.DSM_USERNAME &&
11
+ process.env.DSM_PASSWORD) {
12
+ const origin = canonicalOrigin(process.env.DSM_URL);
43
13
  return {
44
- dsmOrigin: profile.url,
45
- username,
46
- password,
14
+ dsmOrigin: origin,
15
+ username: process.env.DSM_USERNAME,
16
+ hostName: "env",
17
+ loadSession: () => loginDsm(origin, process.env.DSM_USERNAME, process.env.DSM_PASSWORD, {
18
+ remember: false,
19
+ otp: async () => process.env.DSM_OTP ?? "",
20
+ }),
47
21
  };
48
22
  }
49
- if (profile?.url) {
50
- return fromEnv(profile.url);
51
- }
52
- return fromEnv(hostName);
53
- }
54
- function fromEnv(urlOverride) {
55
- let dsmOrigin = process.env.DSM_URL;
56
- if (urlOverride) {
57
- dsmOrigin = urlOverride.startsWith("http://") || urlOverride.startsWith("https://")
58
- ? urlOverride
59
- : `https://${urlOverride}:5001`;
60
- }
61
- const password = process.env.DSM_PASSWORD;
62
- const username = process.env.DSM_USERNAME ?? "admin";
63
- if (!dsmOrigin || !password) {
64
- return null;
65
- }
66
- return { dsmOrigin, username, password };
23
+ const host = store.select(name);
24
+ return {
25
+ dsmOrigin: host.origin,
26
+ username: host.username,
27
+ hostName: host.name,
28
+ fingerprint: host.fingerprint,
29
+ ca: host.ca ? readFileSync(host.ca, "utf8") : undefined,
30
+ loadSession: () => store.session(host),
31
+ };
67
32
  }
@@ -5,17 +5,15 @@ export interface CreateNasHostOptions {
5
5
  credentials?: HostCredentials;
6
6
  }
7
7
  export declare function createNasHost(options?: CreateNasHostOptions): NasHost | null;
8
- declare function synoagentcli(args: string[], profileName?: string): {
9
- status: number | null;
10
- stdout: string;
11
- stderr: string;
12
- };
13
8
  /**
14
9
  * DSM's User Home read reports the selected volume, not the final share path.
15
10
  * Keep the projection strict: an unexpected response must not become a host
16
11
  * bind to an arbitrary path.
17
12
  */
18
- export declare function homesPathFromDsmUserHomeRead(result: Pick<ReturnType<typeof synoagentcli>, "status" | "stdout">): string | undefined;
13
+ export declare function homesPathFromDsmUserHomeRead(result: {
14
+ status: number | null;
15
+ stdout: string;
16
+ }): string | undefined;
19
17
  /**
20
18
  * DSM reports the CPU vendor, not the ISA. Every x86_64 platform Synology
21
19
  * ships is Intel or AMD; the aarch64 ones are Realtek (rtd1296, rtd1619b) and
@@ -26,4 +24,3 @@ export declare function homesPathFromDsmUserHomeRead(result: Pick<ReturnType<typ
26
24
  * unusable SPK.
27
25
  */
28
26
  export declare function archFamilyForCpuVendor(vendor: string | undefined): NativeArchFamily | undefined;
29
- export {};