@synopackageland/cli 0.33.0 → 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 (55) hide show
  1. package/CHANGELOG.md +6 -7
  2. package/README.md +10 -0
  3. package/dist/cli.js +7 -0
  4. package/dist/ctl/cli.js +27 -17
  5. package/dist/ctl/dsm-auth.d.ts +1 -10
  6. package/dist/ctl/dsm-auth.js +2 -30
  7. package/dist/ctl/input.js +1 -1
  8. package/dist/ctl/service.js +3 -5
  9. package/dist/ctl/store.d.ts +1 -55
  10. package/dist/ctl/store.js +2 -121
  11. package/dist/ctl/tls.d.ts +1 -22
  12. package/dist/ctl/tls.js +2 -118
  13. package/dist/ctl/transfers.d.ts +1 -16
  14. package/dist/ctl/transfers.js +2 -78
  15. package/dist/ctl/transport.d.ts +1 -14
  16. package/dist/ctl/transport.js +2 -113
  17. package/dist/e2e/dsm.d.ts +2 -2
  18. package/dist/e2e/dsm.js +33 -17
  19. package/dist/error-registry.js +4 -4
  20. package/dist/host/connection.d.ts +1 -9
  21. package/dist/host/connection.js +2 -49
  22. package/dist/host/credentials.d.ts +1 -4
  23. package/dist/host/credentials.js +2 -32
  24. package/dist/host/dsm-client.js +4 -27
  25. package/dist/host/dsm-http.d.ts +1 -3
  26. package/dist/host/dsm-http.js +2 -144
  27. package/dist/host/package-commands.js +3 -3
  28. package/dist/host/types.d.ts +1 -0
  29. package/dist/nas/auth.d.ts +10 -0
  30. package/dist/nas/auth.js +30 -0
  31. package/dist/nas/connection.d.ts +9 -0
  32. package/dist/nas/connection.js +46 -0
  33. package/dist/nas/credentials.d.ts +6 -0
  34. package/dist/nas/credentials.js +22 -0
  35. package/dist/nas/http.d.ts +5 -0
  36. package/dist/nas/http.js +25 -0
  37. package/dist/nas/options.d.ts +13 -0
  38. package/dist/nas/options.js +33 -0
  39. package/dist/nas/session.d.ts +2 -0
  40. package/dist/nas/session.js +6 -0
  41. package/dist/nas/store.d.ts +61 -0
  42. package/dist/nas/store.js +144 -0
  43. package/dist/nas/tls.d.ts +25 -0
  44. package/dist/nas/tls.js +129 -0
  45. package/dist/nas/transfers.d.ts +16 -0
  46. package/dist/nas/transfers.js +76 -0
  47. package/dist/nas/transport.d.ts +17 -0
  48. package/dist/nas/transport.js +143 -0
  49. package/dist/package-source.d.ts +2 -0
  50. package/dist/package-source.js +4 -0
  51. package/package.json +2 -2
  52. package/skills/SKILL.md +3 -1
  53. package/skills/reference/ctl.md +2 -0
  54. package/skills/reference/deploy.md +2 -0
  55. package/skills/reference/host-install.md +21 -10
@@ -1,78 +1,2 @@
1
- import { pinnedAgent, networkError, timeoutError } from './tls.js';
2
- import { createReadStream, statSync } from 'node:fs';
3
- import http from 'node:http';
4
- import https from 'node:https';
5
- import { CtlError } from './errors.js';
6
- import { CTL_TIMEOUT_MS } from './transport.js';
7
- export function localFile(file) { if (typeof file !== 'string' || !file)
8
- throw new CtlError('CTL_INPUT_FILE', 'A local filename is required.', 'Use --file <path>.'); try {
9
- const stat = statSync(file);
10
- if (!stat.isFile() || stat.size > 16 * 1024 ** 3)
11
- throw Error();
12
- return { file, size: stat.size };
13
- }
14
- catch {
15
- throw new CtlError('CTL_INPUT_FILE', 'Upload must be a readable regular file of at most 16 GiB.', 'Check --file <path>.');
16
- } }
17
- export async function transferRequest(session, path, method, params, options = {}) {
18
- 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))
19
- throw new CtlError('CTL_PATH_INVALID', 'Invalid transfer binding.', 'Refresh the API description.', 4);
20
- if (options.timeoutMs !== undefined && options.timeoutMs <= 0)
21
- throw new CtlError('CTL_NETWORK', 'Transfer budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
22
- const url = new URL(path, session.origin);
23
- const file = options.file ? localFile(options.file.path) : undefined;
24
- if (options.file) {
25
- url.search = new URLSearchParams({ method, parent: options.file.parent, name: options.file.name }).toString();
26
- }
27
- const raw = file ? undefined : Buffer.from(JSON.stringify({ method, params }));
28
- return new Promise((resolve, reject) => {
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) };
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 => {
31
- const expected = file ? 'application/json' : 'application/x-ndjson';
32
- if (res.statusCode !== 200 || !res.headers['content-type']?.includes(expected)) {
33
- res.resume();
34
- reject(new CtlError('CTL_TRANSFER_FAILED', 'Unexpected transfer response; redirects are refused.', 'Check App transfer support.', 5));
35
- return;
36
- }
37
- const chunks = [];
38
- let total = 0;
39
- res.on('data', (chunk) => { total += chunk.length; if (total > (file ? 1048576 : 8 * 1048576)) {
40
- req.destroy();
41
- reject(new CtlError('CTL_FLOW_LIMIT', 'Transfer result exceeds collection limit.', 'Narrow the query.', 5));
42
- }
43
- else
44
- chunks.push(chunk); });
45
- res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer response interrupted.', 'Check state before retrying writes.', 6)));
46
- res.on('end', () => { try {
47
- const text = Buffer.concat(chunks).toString('utf8');
48
- if (file) {
49
- const body = JSON.parse(text);
50
- if (body.success !== true)
51
- throw Error();
52
- resolve(body.data);
53
- }
54
- else {
55
- const lines = text.split('\n').filter(l => l.trim());
56
- if (lines.length > 10000)
57
- throw Error();
58
- resolve({ items: lines.map(line => JSON.parse(line)) });
59
- }
60
- }
61
- catch {
62
- reject(new CtlError('CTL_TRANSFER_FAILED', 'Invalid or failed transfer response.', 'Inspect the App result; do not retry writes blindly.', 5));
63
- } });
64
- });
65
- const wallTimer = setTimeout(() => req.destroy(timeoutError()), options.timeoutMs ?? CTL_TIMEOUT_MS);
66
- req.once('close', () => clearTimeout(wallTimer));
67
- req.on('timeout', () => req.destroy(timeoutError()));
68
- req.on('error', e => reject(networkError(e)));
69
- if (file) {
70
- const source = createReadStream(file.file);
71
- source.on('error', () => req.destroy());
72
- req.on('close', () => source.destroy());
73
- source.pipe(req);
74
- }
75
- else
76
- req.end(raw);
77
- });
78
- }
1
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/transfers.js";
@@ -1,14 +1 @@
1
- import { type HostTls } from './tls.js';
2
- export declare const CTL_TIMEOUT_MS = 62000;
3
- export declare const APP_COOKIE: unique symbol;
4
- export interface DsmSession {
5
- origin: string;
6
- username: string;
7
- cookie: string;
8
- token: string;
9
- deviceId?: string;
10
- }
11
- export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
12
- export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
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
+ export * from "../nas/transport.js";
@@ -1,113 +1,2 @@
1
- import { pinnedAgent, networkError, timeoutError } from './tls.js';
2
- import { strictJson } from './json.js';
3
- import http from 'node:http';
4
- import https from 'node:https';
5
- import { CtlError } from './errors.js';
6
- export const CTL_TIMEOUT_MS = 62000;
7
- export const APP_COOKIE = Symbol("ctlAppCookie");
8
- export function canonicalOrigin(input, tunnel = false) {
9
- let url;
10
- try {
11
- url = new URL(input);
12
- }
13
- catch {
14
- throw new CtlError('CTL_URL_INVALID', 'A NAS origin URL is required.', 'run: synopkgland host add <name> --url https://<nas>:5001');
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))))
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');
18
- return url.origin;
19
- }
20
- export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca, trust) {
21
- return async (path, body, session) => {
22
- if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
23
- throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
24
- const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs;
25
- if (remaining <= 0)
26
- throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
27
- const url = new URL(path, origin);
28
- if (url.origin !== origin || (session && session.origin !== origin))
29
- throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins.', 'run: synopkgland host list', 4);
30
- const form = body instanceof URLSearchParams;
31
- const payload = Buffer.from(form ? body.toString() : JSON.stringify(body));
32
- if (payload.length > 1048576)
33
- throw new CtlError('CTL_INPUT_TOO_LARGE', 'Request exceeds 1 MiB.', 'Reduce the input.');
34
- return new Promise((resolve, reject) => {
35
- const headers = { 'content-type': form ? 'application/x-www-form-urlencoded' : 'application/json', accept: 'application/json', 'content-length': String(payload.length), origin };
36
- if (session) {
37
- headers.cookie = session.cookie;
38
- headers['X-SYNO-TOKEN'] = session.token;
39
- }
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 => {
41
- if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
42
- res.resume();
43
- reject(new CtlError('CTL_HTTP_ERROR', `HTTP ${res.statusCode}; redirects are never followed.`, 'Check the NAS URL and connection.', 6));
44
- return;
45
- }
46
- if (!res.headers['content-type']?.includes('application/json')) {
47
- res.resume();
48
- reject(new CtlError('CTL_RESPONSE_INVALID', 'NAS returned non-JSON data.', 'Check the endpoint and installed package.', 5));
49
- return;
50
- }
51
- const chunks = [];
52
- let length = 0;
53
- res.on('data', (c) => { length += c.length; if (length > 1048576) {
54
- res.destroy();
55
- reject(new CtlError('CTL_RESPONSE_TOO_LARGE', 'Response exceeds 1 MiB.', 'Use a smaller operation.', 5));
56
- }
57
- else
58
- chunks.push(c); });
59
- res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Response interrupted.', 'Check the NAS connection; state-changing requests are not replayed.', 6)));
60
- res.on('end', () => {
61
- try {
62
- const body = strictJson(Buffer.concat(chunks));
63
- if (!form && body?.success === true && body.data && path.startsWith('/webman/3rdparty/') && url.protocol === 'https:') {
64
- const cookies = (res.headers['set-cookie'] ?? []).filter(line => /;\s*Secure(?:;|$)/i.test(line) && /;\s*HttpOnly(?:;|$)/i.test(line) && !/;\s*Domain=/i.test(line));
65
- const own = path.slice(0, path.lastIndexOf('/cgi/')) + '/';
66
- 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='));
67
- if (valid.length)
68
- Object.defineProperty(body, APP_COOKIE, { value: valid.join('; '), enumerable: false });
69
- }
70
- resolve(body);
71
- }
72
- catch {
73
- reject(new CtlError('CTL_RESPONSE_INVALID', 'Invalid JSON response.', 'Check the installed package.', 5));
74
- }
75
- });
76
- });
77
- const wallTimer = setTimeout(() => req.destroy(timeoutError()), remaining);
78
- req.once('close', () => clearTimeout(wallTimer));
79
- req.on('timeout', () => req.destroy(timeoutError()));
80
- req.on('error', error => reject(networkError(error)));
81
- req.end(payload);
82
- });
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
+ // 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,8 +1,8 @@
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": "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" },
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
6
  HOST_DNS_FAILED: { "code": "HOST_DNS_FAILED", "description": "The NAS hostname could not be resolved", "suggestion": "Check the registered hostname and DNS" },
7
7
  HOST_CONNECTION_REFUSED: { "code": "HOST_CONNECTION_REFUSED", "description": "The NAS refused the connection", "suggestion": "Check the DSM port and NAS availability" },
8
8
  HOST_TIMEOUT: { "code": "HOST_TIMEOUT", "description": "The NAS request timed out", "suggestion": "Check connection and firewall; inspect state before retrying writes" },
@@ -1000,8 +1000,8 @@ export const ERROR_REGISTRY = {
1000
1000
  },
1001
1001
  DSM_TLS_UNTRUSTED: {
1002
1002
  code: "DSM_TLS_UNTRUSTED",
1003
- description: "The DSM TLS certificate is not trusted by this computer (self-signed, expired or a name mismatch).",
1004
- 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",
1005
1005
  },
1006
1006
  STANDALONE_ICON_INVALID: {
1007
1007
  code: "STANDALONE_ICON_INVALID",
@@ -1,9 +1 @@
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>;
1
+ export * from "../nas/connection.js";
@@ -1,49 +1,2 @@
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
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/connection.js";
@@ -1,4 +1 @@
1
- import { CtlStore } from "../ctl/store.js";
2
- import type { HostCredentials } from "./types.js";
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
+ export * from "../nas/credentials.js";
@@ -1,32 +1,2 @@
1
- import { readFileSync } from "node:fs";
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);
13
- return {
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
- }),
21
- };
22
- }
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
- };
32
- }
1
+ // Compatibility export; NAS connection implementation lives in one module.
2
+ export * from "../nas/credentials.js";
@@ -1,8 +1,9 @@
1
+ import { sessionForCredentials } from "../nas/credentials.js";
1
2
  import { CtlError } from '../ctl/errors.js';
2
3
  import { listShareNames, lookupSharePath, } from "./share-lookup.js";
3
4
  import { createDsmApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
4
5
  import { fetchPortInfo } from "./port-info-lookup.js";
5
- import { dsmFetch, registerDsmSession } from "./dsm-http.js";
6
+ import { dsmFetch } from "./dsm-http.js";
6
7
  import { versionUnsupported } from "../runtime/dsm-version.js";
7
8
  import { fetchDsmPortProfileInventory } from "./dsm-port-profile.js";
8
9
  import { installPackageFromLocalSpk, fetchInstalledPackageState, } from "./catalog-install.js";
@@ -56,34 +57,10 @@ function createDsmNasHost(credentials, profileName) {
56
57
  const dsmOrigin = credentials.dsmOrigin.replace(/\/$/, "");
57
58
  let cookieHeader = credentials.cookieHeader;
58
59
  const ensureLogin = async () => {
59
- if (credentials.loadSession && !cookieHeader) {
60
- const session = await credentials.loadSession();
60
+ if (!cookieHeader) {
61
+ const session = await sessionForCredentials(credentials);
61
62
  cookieHeader = session.cookie;
62
- registerDsmSession(session, credentials.hostName ?? profileName ?? 'env', credentials.ca, credentials.fingerprint);
63
63
  }
64
- if (cookieHeader) {
65
- return;
66
- }
67
- const query = new URLSearchParams({
68
- api: "SYNO.API.Auth",
69
- version: "3",
70
- method: "login",
71
- account: credentials.username,
72
- passwd: credentials.password ?? "",
73
- session: "FileStation",
74
- format: "cookie",
75
- });
76
- const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi`, {
77
- method: "POST",
78
- headers: { "content-type": "application/x-www-form-urlencoded" },
79
- body: query,
80
- });
81
- const login = (await response.json());
82
- if (!response.ok || login.success !== true)
83
- throw new Error("DSM login failed");
84
- cookieHeader = setCookiePairs(response.headers).join("; ");
85
- if (!cookieHeader)
86
- throw new Error("DSM login returned no session cookie");
87
64
  };
88
65
  /**
89
66
  * One read-only call of the App's own CGI as the administrator. identity:
@@ -1,3 +1 @@
1
- import { type DsmSession } from '../ctl/transport.js';
2
- export declare function registerDsmSession(session: DsmSession, name: string, ca?: string, fingerprint?: string): void;
3
- export declare function dsmFetch(url: string, init?: RequestInit): Promise<Response>;
1
+ export { dsmFetch, registerDsmSession } from '../nas/http.js';