@synopackageland/cli 0.32.1 → 0.33.1

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 (75) hide show
  1. package/CHANGELOG.md +6 -6
  2. package/README.md +13 -3
  3. package/dist/cli.js +39 -11
  4. package/dist/ctl/cli.d.ts +1 -0
  5. package/dist/ctl/cli.js +73 -21
  6. package/dist/ctl/dsm-auth.d.ts +1 -10
  7. package/dist/ctl/dsm-auth.js +2 -30
  8. package/dist/ctl/errors.js +1 -1
  9. package/dist/ctl/input.js +3 -3
  10. package/dist/ctl/service.js +8 -8
  11. package/dist/ctl/store.d.ts +1 -51
  12. package/dist/ctl/store.js +2 -93
  13. package/dist/ctl/tls.d.ts +1 -0
  14. package/dist/ctl/tls.js +2 -0
  15. package/dist/ctl/transfers.d.ts +1 -14
  16. package/dist/ctl/transfers.js +2 -77
  17. package/dist/ctl/transport.d.ts +1 -12
  18. package/dist/ctl/transport.js +2 -83
  19. package/dist/e2e/dsm.d.ts +2 -2
  20. package/dist/e2e/dsm.js +33 -17
  21. package/dist/error-registry.js +10 -12
  22. package/dist/host/catalog-install.d.ts +0 -10
  23. package/dist/host/catalog-install.js +24 -140
  24. package/dist/host/connection.d.ts +1 -0
  25. package/dist/host/connection.js +2 -0
  26. package/dist/host/credentials.d.ts +1 -2
  27. package/dist/host/credentials.js +2 -67
  28. package/dist/host/dsm-client.d.ts +4 -7
  29. package/dist/host/dsm-client.js +44 -112
  30. package/dist/host/dsm-http.d.ts +1 -1
  31. package/dist/host/dsm-http.js +2 -124
  32. package/dist/host/dsm-port-profile.js +7 -2
  33. package/dist/host/install-plan.d.ts +32 -0
  34. package/dist/host/install-plan.js +157 -0
  35. package/dist/host/package-commands.d.ts +75 -0
  36. package/dist/host/package-commands.js +205 -0
  37. package/dist/host/port-info-lookup.d.ts +1 -1
  38. package/dist/host/port-info-lookup.js +5 -2
  39. package/dist/host/share-lookup.d.ts +3 -2
  40. package/dist/host/share-lookup.js +5 -91
  41. package/dist/host/share-permission-lookup.d.ts +3 -3
  42. package/dist/host/share-permission-lookup.js +18 -30
  43. package/dist/host/types.d.ts +6 -1
  44. package/dist/nas/auth.d.ts +10 -0
  45. package/dist/nas/auth.js +30 -0
  46. package/dist/nas/connection.d.ts +9 -0
  47. package/dist/nas/connection.js +46 -0
  48. package/dist/nas/credentials.d.ts +6 -0
  49. package/dist/nas/credentials.js +22 -0
  50. package/dist/nas/http.d.ts +5 -0
  51. package/dist/nas/http.js +25 -0
  52. package/dist/nas/options.d.ts +13 -0
  53. package/dist/nas/options.js +33 -0
  54. package/dist/nas/session.d.ts +2 -0
  55. package/dist/nas/session.js +6 -0
  56. package/dist/nas/store.d.ts +61 -0
  57. package/dist/nas/store.js +144 -0
  58. package/dist/nas/tls.d.ts +25 -0
  59. package/dist/nas/tls.js +129 -0
  60. package/dist/nas/transfers.d.ts +16 -0
  61. package/dist/nas/transfers.js +76 -0
  62. package/dist/nas/transport.d.ts +17 -0
  63. package/dist/nas/transport.js +143 -0
  64. package/dist/package-source.d.ts +2 -0
  65. package/dist/package-source.js +4 -0
  66. package/dist/skill.js +1 -0
  67. package/dist/spk/inspect.d.ts +1 -0
  68. package/dist/spk/inspect.js +2 -1
  69. package/dist/spk/validate-spk.d.ts +1 -0
  70. package/dist/spk/validate-spk.js +1 -0
  71. package/package.json +2 -2
  72. package/skills/SKILL.md +5 -1
  73. package/skills/reference/ctl.md +6 -4
  74. package/skills/reference/deploy.md +4 -0
  75. package/skills/reference/host-install.md +66 -0
@@ -0,0 +1 @@
1
+ export * from "../nas/tls.js";
@@ -0,0 +1,2 @@
1
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/tls.js";
@@ -1,14 +1 @@
1
- import { type DsmSession } from './transport.js';
2
- export declare function localFile(file: unknown): {
3
- file: string;
4
- size: number;
5
- };
6
- export declare function transferRequest(session: DsmSession, path: string, method: string, params: Record<string, unknown>, options?: {
7
- file?: {
8
- path: string;
9
- parent: string;
10
- name: string;
11
- };
12
- ca?: string;
13
- timeoutMs?: number;
14
- }): Promise<any>;
1
+ export * from "../nas/transfers.js";
@@ -1,77 +1,2 @@
1
- import { createReadStream, statSync } from 'node:fs';
2
- import http from 'node:http';
3
- import https from 'node:https';
4
- import { CtlError } from './errors.js';
5
- import { CTL_TIMEOUT_MS } from './transport.js';
6
- export function localFile(file) { if (typeof file !== 'string' || !file)
7
- throw new CtlError('CTL_INPUT_FILE', 'A local filename is required.', 'Use --file <path>.'); try {
8
- const stat = statSync(file);
9
- if (!stat.isFile() || stat.size > 16 * 1024 ** 3)
10
- throw Error();
11
- return { file, size: stat.size };
12
- }
13
- catch {
14
- throw new CtlError('CTL_INPUT_FILE', 'Upload must be a readable regular file of at most 16 GiB.', 'Check --file <path>.');
15
- } }
16
- export async function transferRequest(session, path, method, params, options = {}) {
17
- if (!/^\/webman\/3rdparty\/[A-Za-z][A-Za-z0-9_.+-]*\/cgi\/api\.cgi$/.test(path) || !/^([a-z][a-z0-9_]*\.)+[a-z][a-z0-9_]*$/.test(method))
18
- throw new CtlError('CTL_PATH_INVALID', 'Invalid transfer binding.', 'Refresh the API description.', 4);
19
- if (options.timeoutMs !== undefined && options.timeoutMs <= 0)
20
- throw new CtlError('CTL_NETWORK', 'Transfer budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
21
- const url = new URL(path, session.origin);
22
- const file = options.file ? localFile(options.file.path) : undefined;
23
- if (options.file) {
24
- url.search = new URLSearchParams({ method, parent: options.file.parent, name: options.file.name }).toString();
25
- }
26
- const raw = file ? undefined : Buffer.from(JSON.stringify({ method, params }));
27
- return new Promise((resolve, reject) => {
28
- 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 expected = file ? 'application/json' : 'application/x-ndjson';
31
- if (res.statusCode !== 200 || !res.headers['content-type']?.includes(expected)) {
32
- res.resume();
33
- reject(new CtlError('CTL_TRANSFER_FAILED', 'Unexpected transfer response; redirects are refused.', 'Check App transfer support.', 5));
34
- return;
35
- }
36
- const chunks = [];
37
- let total = 0;
38
- res.on('data', (chunk) => { total += chunk.length; if (total > (file ? 1048576 : 8 * 1048576)) {
39
- req.destroy();
40
- reject(new CtlError('CTL_FLOW_LIMIT', 'Transfer result exceeds collection limit.', 'Narrow the query.', 5));
41
- }
42
- else
43
- chunks.push(chunk); });
44
- res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer response interrupted.', 'Check state before retrying writes.', 6)));
45
- res.on('end', () => { try {
46
- const text = Buffer.concat(chunks).toString('utf8');
47
- if (file) {
48
- const body = JSON.parse(text);
49
- if (body.success !== true)
50
- throw Error();
51
- resolve(body.data);
52
- }
53
- else {
54
- const lines = text.split('\n').filter(l => l.trim());
55
- if (lines.length > 10000)
56
- throw Error();
57
- resolve({ items: lines.map(line => JSON.parse(line)) });
58
- }
59
- }
60
- catch {
61
- reject(new CtlError('CTL_TRANSFER_FAILED', 'Invalid or failed transfer response.', 'Inspect the App result; do not retry writes blindly.', 5));
62
- } });
63
- });
64
- const wallTimer = setTimeout(() => req.destroy(), options.timeoutMs ?? CTL_TIMEOUT_MS);
65
- 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)));
68
- if (file) {
69
- const source = createReadStream(file.file);
70
- source.on('error', () => req.destroy());
71
- req.on('close', () => source.destroy());
72
- source.pipe(req);
73
- }
74
- else
75
- req.end(raw);
76
- });
77
- }
1
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/transfers.js";
@@ -1,12 +1 @@
1
- export declare const CTL_TIMEOUT_MS = 62000;
2
- export declare const APP_COOKIE: unique symbol;
3
- export interface DsmSession {
4
- origin: string;
5
- username: string;
6
- cookie: string;
7
- token: string;
8
- deviceId?: string;
9
- }
10
- export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
11
- export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
12
- export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string): JsonRequest;
1
+ export * from "../nas/transport.js";
@@ -1,83 +1,2 @@
1
- import { strictJson } from './json.js';
2
- import http from 'node:http';
3
- import https from 'node:https';
4
- import { CtlError } from './errors.js';
5
- export const CTL_TIMEOUT_MS = 62000;
6
- export const APP_COOKIE = Symbol("ctlAppCookie");
7
- export function canonicalOrigin(input, tunnel = false) {
8
- let url;
9
- try {
10
- url = new URL(input);
11
- }
12
- 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
- }
15
- 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
- return url.origin;
18
- }
19
- export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca) {
20
- return async (path, body, session) => {
21
- if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
22
- throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
23
- const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs;
24
- if (remaining <= 0)
25
- throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
26
- const url = new URL(path, origin);
27
- 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
- const form = body instanceof URLSearchParams;
30
- const payload = Buffer.from(form ? body.toString() : JSON.stringify(body));
31
- if (payload.length > 1048576)
32
- throw new CtlError('CTL_INPUT_TOO_LARGE', 'Request exceeds 1 MiB.', 'Reduce the input.');
33
- return new Promise((resolve, reject) => {
34
- const headers = { 'content-type': form ? 'application/x-www-form-urlencoded' : 'application/json', accept: 'application/json', 'content-length': String(payload.length), origin };
35
- if (session) {
36
- headers.cookie = session.cookie;
37
- headers['X-SYNO-TOKEN'] = session.token;
38
- }
39
- const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: remaining, ...(ca ? { ca } : {}) }, res => {
40
- if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
41
- res.resume();
42
- reject(new CtlError('CTL_HTTP_ERROR', `HTTP ${res.statusCode}; redirects are never followed.`, 'Check the NAS URL and connection.', 6));
43
- return;
44
- }
45
- if (!res.headers['content-type']?.includes('application/json')) {
46
- res.resume();
47
- reject(new CtlError('CTL_RESPONSE_INVALID', 'NAS returned non-JSON data.', 'Check the endpoint and installed package.', 5));
48
- return;
49
- }
50
- const chunks = [];
51
- let length = 0;
52
- res.on('data', (c) => { length += c.length; if (length > 1048576) {
53
- res.destroy();
54
- reject(new CtlError('CTL_RESPONSE_TOO_LARGE', 'Response exceeds 1 MiB.', 'Use a smaller operation.', 5));
55
- }
56
- else
57
- chunks.push(c); });
58
- res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Response interrupted.', 'Check the NAS connection; state-changing requests are not replayed.', 6)));
59
- res.on('end', () => {
60
- try {
61
- const body = strictJson(Buffer.concat(chunks));
62
- if (!form && body?.success === true && body.data && path.startsWith('/webman/3rdparty/') && url.protocol === 'https:') {
63
- const cookies = (res.headers['set-cookie'] ?? []).filter(line => /;\s*Secure(?:;|$)/i.test(line) && /;\s*HttpOnly(?:;|$)/i.test(line) && !/;\s*Domain=/i.test(line));
64
- const own = path.slice(0, path.lastIndexOf('/cgi/')) + '/';
65
- const valid = cookies.filter(line => { const match = /;\s*Path=([^;]+)/i.exec(line); return match?.[1] === own; }).map(line => line.split(';')[0]).filter(pair => !pair.startsWith('id='));
66
- if (valid.length)
67
- Object.defineProperty(body, APP_COOKIE, { value: valid.join('; '), enumerable: false });
68
- }
69
- resolve(body);
70
- }
71
- catch {
72
- reject(new CtlError('CTL_RESPONSE_INVALID', 'Invalid JSON response.', 'Check the installed package.', 5));
73
- }
74
- });
75
- });
76
- const wallTimer = setTimeout(() => req.destroy(), remaining);
77
- 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)));
80
- req.end(payload);
81
- });
82
- };
83
- }
1
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/transport.js";
package/dist/e2e/dsm.d.ts CHANGED
@@ -129,10 +129,10 @@ export declare function dismissNotifications(page?: Page, env?: Env): Promise<nu
129
129
  /**
130
130
  * Whether the signed-in desktop's session is usable: it holds a SynoToken
131
131
  * (a restored `stay_login` cookie alone yields none, and the app's CGIs then
132
- * answer 119) and DSM's login.cgi, asked from the page, still accepts the
132
+ * answer 119) and DSM's login.cgi, asked through the shared NAS transport, still accepts the
133
133
  * session. Any failure to ask counts as not live.
134
134
  */
135
- export declare function dsmSessionLive(page: Pick<SessionPage, "evaluate">): Promise<boolean>;
135
+ export declare function dsmSessionLive(page: Pick<SessionPage, "evaluate">, check?: (token: string) => Promise<boolean>, env?: Env): Promise<boolean>;
136
136
  /**
137
137
  * `app.open(path)`, once more after net::ERR_ABORTED: about one first
138
138
  * navigation in ten was aborted as DSM's desktop navigated on its own, and the
package/dist/e2e/dsm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // DSM helpers for package e2e suites on upstream `e2e` + `@e2e-dev/web`.
2
- // One self-contained file: a package may copy it verbatim to tests/e2e/support/dsm.ts.
2
+ // Import through @synopackageland/cli/e2e to share the CLI NAS connection path.
3
3
  // It signs in once per run (a saved, private profile is restored when still valid),
4
4
  // opens DSM app windows and Application Portal pages (accepting their native
5
5
  // dialogs when given the `browser` fixture), dismisses DSM notifications,
@@ -9,6 +9,9 @@
9
9
  // e2e output, which is made private (directories 700, files 600).
10
10
  //
11
11
  // node tests/e2e/support/dsm.ts tunnel -- npx e2e run
12
+ import { loginDsm } from "../nas/auth.js";
13
+ import { sessionFetch } from "../nas/transport.js";
14
+ import { canonicalOrigin } from "../nas/transport.js";
12
15
  import { spawn } from "node:child_process";
13
16
  import { randomUUID } from "node:crypto";
14
17
  import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
@@ -389,16 +392,25 @@ export async function dismissNotifications(page = dsmPage(), env = process.env)
389
392
  /**
390
393
  * Whether the signed-in desktop's session is usable: it holds a SynoToken
391
394
  * (a restored `stay_login` cookie alone yields none, and the app's CGIs then
392
- * answer 119) and DSM's login.cgi, asked from the page, still accepts the
395
+ * answer 119) and DSM's login.cgi, asked through the shared NAS transport, still accepts the
393
396
  * session. Any failure to ask counts as not live.
394
397
  */
395
- export async function dsmSessionLive(page) {
396
- const state = await page.evaluate(async () => {
397
- const token = window.SYNO?.SDS?.Session?.SynoToken;
398
- const reply = await fetch("/webman/login.cgi", { credentials: "same-origin" }).then(response => response.json());
399
- return { token: typeof token === "string" && token !== "", success: reply?.success === true };
400
- }).catch(() => undefined);
401
- return !!state?.token && state.success;
398
+ export async function dsmSessionLive(page, check, env = process.env) {
399
+ const token = await page.evaluate(() => window.SYNO?.SDS?.Session?.SynoToken).catch(() => undefined);
400
+ if (typeof token !== 'string' || !token)
401
+ return false;
402
+ try {
403
+ if (check)
404
+ return await check(token);
405
+ const state = await surface().context().storageState();
406
+ const cookie = state.cookies.filter(c => sessionCookieNames.includes(c.name)).map(c => `${c.name}=${c.value}`).join('; ');
407
+ const origin = canonicalOrigin(dsmUrl(env), true);
408
+ const response = await sessionFetch(origin + '/webman/login.cgi', {}, { origin, username: 'e2e', cookie, token });
409
+ return (await response.json())?.success === true;
410
+ }
411
+ catch {
412
+ return false;
413
+ }
402
414
  }
403
415
  function accountFile(user, env) {
404
416
  const key = `DSM_E2E_ACCOUNT_${user.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
@@ -447,8 +459,8 @@ export async function openUntilLoaded(app, path, { timeoutMs = 30_000, pauseMs =
447
459
  }
448
460
  }
449
461
  }
450
- async function restoredSessionLive(page) {
451
- return await desktopOrLogin(page) === "desktop" && await dsmSessionLive(page);
462
+ async function restoredSessionLive(page, env) {
463
+ return await desktopOrLogin(page) === "desktop" && await dsmSessionLive(page, undefined, env);
452
464
  }
453
465
  /**
454
466
  * Restores the saved profile of `user`; signs in with the account file only
@@ -466,18 +478,22 @@ export async function signIn(app, user = "admin", env = process.env) {
466
478
  await openPath(app, "/");
467
479
  const page = dsmPage();
468
480
  let outcome = "restored";
469
- if (!profile || !await restoredSessionLive(page)) {
481
+ if (!profile || !await restoredSessionLive(page, env)) {
470
482
  // A refused session still shows the desktop; dropping its cookies shows the login form.
471
483
  if (profile) {
472
484
  await context.clearCookies();
473
485
  await openUntilLoaded(app, "/");
474
486
  }
475
487
  const account = readAccount(accountFile(user, env), user);
476
- await page.locator(loginSelector).fill(account.username);
477
- await page.keyboard.press("Enter");
478
- const password = page.locator('input[type="password"]:visible');
479
- await password.fill(account.password);
480
- await password.press("Enter");
488
+ const session = await loginDsm(canonicalOrigin(url, true), account.username, account.password, {
489
+ remember: false, otp: async () => env.DSM_OTP ?? '',
490
+ });
491
+ const state = await context.storageState();
492
+ await context.setStorageState({ ...state, cookies: [...state.cookies, {
493
+ name: 'id', value: session.cookie.slice(3), domain: new URL(url).hostname, path: '/',
494
+ httpOnly: true, secure: new URL(url).protocol === 'https:', sameSite: 'Lax', expires: -1,
495
+ }] });
496
+ await openUntilLoaded(app, '/');
481
497
  outcome = "signed-in";
482
498
  }
483
499
  await waitForDesktop(page);
@@ -1,6 +1,14 @@
1
1
  import { COMPOSE_ERRORS } from "./standalone/compose-errors.js";
2
2
  export const ERROR_REGISTRY = {
3
3
  ...COMPOSE_ERRORS,
4
+ HOST_CERT_CHANGED: { "code": "HOST_CERT_CHANGED", "description": "Strict TLS: the NAS certificate differs from the recorded SHA-256", "suggestion": "Verify old/new fingerprints, then run synopkgland host trust <name>" },
5
+ HOST_TLS_UNTRUSTED: { "code": "HOST_TLS_UNTRUSTED", "description": "Strict TLS: the NAS certificate failed CA, hostname or validity verification", "suggestion": "Check the CA file, NAS hostname and certificate validity, or omit strict TLS" },
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" },
4
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." },
@@ -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).",
@@ -1002,8 +1000,8 @@ export const ERROR_REGISTRY = {
1002
1000
  },
1003
1001
  DSM_TLS_UNTRUSTED: {
1004
1002
  code: "DSM_TLS_UNTRUSTED",
1005
- description: "The DSM TLS certificate is not trusted by this computer (self-signed, expired or a name mismatch).",
1006
- suggestion: "Export the NAS certificate or its CA as PEM and run with NODE_EXTRA_CA_CERTS=<file>; or give DSM a trusted certificate whose name matches the host profile. Do not disable certificate verification.",
1003
+ description: "Strict TLS: the DSM certificate failed CA, hostname or validity verification",
1004
+ suggestion: "Check the --ca PEM file, NAS hostname and certificate validity; NAS certificates are accepted by default without --strict-tls or --ca",
1007
1005
  },
1008
1006
  STANDALONE_ICON_INVALID: {
1009
1007
  code: "STANDALONE_ICON_INVALID",
@@ -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 @@
1
+ export * from "../nas/connection.js";
@@ -0,0 +1,2 @@
1
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/connection.js";
@@ -1,2 +1 @@
1
- import type { HostCredentials } from "./types.js";
2
- export declare function resolveHostCredentials(hostName?: string): HostCredentials | null;
1
+ export * from "../nas/credentials.js";