@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
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { CtlError } from '../ctl/errors.js';
2
2
  const SHARE_PERMISSION_OPERATION = "SYNO_Core_Share_Permission_list_v1";
3
3
  const GROUP_LIST_OPERATION = "SYNO_Core_Group_list_v1";
4
4
  const GROUP_MEMBER_OPERATION = "SYNO_Core_Group_Member_list_v1";
@@ -8,7 +8,7 @@ function asRecord(value) {
8
8
  }
9
9
  return undefined;
10
10
  }
11
- /** Unwrap the synoagentcli envelope down to the DSM `data` object. */
11
+ /** Unwrap the CLI envelope down to the DSM `data` object. */
12
12
  function dsmData(payload) {
13
13
  const outer = asRecord(payload);
14
14
  if (!outer || outer.success === false) {
@@ -77,7 +77,9 @@ export async function fetchSharePermissions(shareName, call) {
77
77
  limit: "-1",
78
78
  }));
79
79
  }
80
- catch {
80
+ catch (error) {
81
+ if (error instanceof CtlError)
82
+ throw error;
81
83
  return undefined;
82
84
  }
83
85
  };
@@ -94,7 +96,9 @@ export async function fetchUserGroups(userName, call) {
94
96
  try {
95
97
  groupNames = parseGroupNames(await call(GROUP_LIST_OPERATION, { offset: "0", limit: "-1" }));
96
98
  }
97
- catch {
99
+ catch (error) {
100
+ if (error instanceof CtlError)
101
+ throw error;
98
102
  return undefined;
99
103
  }
100
104
  if (!groupNames) {
@@ -106,7 +110,9 @@ export async function fetchUserGroups(userName, call) {
106
110
  try {
107
111
  members = parseGroupMemberNames(await call(GROUP_MEMBER_OPERATION, { group: groupName, offset: "0", limit: "-1" }));
108
112
  }
109
- catch {
113
+ catch (error) {
114
+ if (error instanceof CtlError)
115
+ throw error;
110
116
  return undefined;
111
117
  }
112
118
  // One unreadable group could be the one that grants access, so a partial
@@ -120,30 +126,12 @@ export async function fetchUserGroups(userName, call) {
120
126
  }
121
127
  return memberships;
122
128
  }
123
- /** synoagentcli-backed `DsmApiCall`, matching how the rest of the host client talks to DSM. */
124
- export function createSynoagentcliApiCall(profileName) {
125
- return async (operationId, params) => {
126
- const args = ["api", "call", "--operation-id", operationId];
127
- for (const [key, value] of Object.entries(params)) {
128
- args.push("--param", `${key}=${value}`);
129
- }
130
- if (profileName) {
131
- args.push("--host", profileName);
132
- }
133
- const result = spawnSync("synoagentcli", args, { encoding: "utf8" });
134
- if (result.status !== 0) {
135
- return undefined;
136
- }
137
- // synoagentcli may prepend an update notice before the JSON body.
138
- const match = /\{[\s\S]*\}/.exec(result.stdout ?? "");
139
- if (!match) {
140
- return undefined;
141
- }
142
- try {
143
- return JSON.parse(match[0]);
144
- }
145
- catch {
146
- return undefined;
147
- }
129
+ /** Adapt fixed operation IDs to direct DSM WebAPI requests. */
130
+ export function createDsmApiCall(call) {
131
+ return async (operation, params) => {
132
+ const match = /^(SYNO_Core_(?:Share_Permission|Group|Group_Member))_(list)_v(1)$/.exec(operation);
133
+ if (!match)
134
+ throw new Error('Unsupported DSM read operation');
135
+ return { success: true, data: { response: await call(match[1].replaceAll('_', '.'), match[2], Number(match[3]), params) } };
148
136
  };
149
137
  }
@@ -65,6 +65,11 @@ export interface NasHost {
65
65
  export interface HostCredentials {
66
66
  dsmOrigin: string;
67
67
  username: string;
68
- password: string;
68
+ password?: string;
69
+ hostName?: string;
70
+ ca?: string;
71
+ fingerprint?: string;
72
+ tls?: import("../nas/tls.js").HostTls;
73
+ loadSession?: () => Promise<import("../ctl/transport.js").DsmSession>;
69
74
  cookieHeader?: string;
70
75
  }
@@ -0,0 +1,10 @@
1
+ import { type DsmSession, type JsonRequest } from './transport.js';
2
+ export interface LoginOptions {
3
+ remember?: boolean;
4
+ deviceId?: string;
5
+ otp: () => Promise<string>;
6
+ request?: JsonRequest;
7
+ ca?: string;
8
+ }
9
+ export declare function loginDsm(origin: string, username: string, password: string, options: LoginOptions): Promise<DsmSession>;
10
+ export declare function logoutDsm(session: DsmSession, request?: JsonRequest): Promise<void>;
@@ -0,0 +1,30 @@
1
+ import { CtlError } from '../ctl/errors.js';
2
+ import { createRequest } from './transport.js';
3
+ export async function loginDsm(origin, username, password, options) {
4
+ const request = options.request ?? createRequest(origin, 30000, options.ca);
5
+ const form = new URLSearchParams({ api: 'SYNO.API.Auth', version: '7', method: 'login', account: username, passwd: password, session: 'DSM', format: 'cookie', enable_syno_token: 'yes', enable_device_token: options.remember === false ? 'no' : 'yes', device_name: 'synopkgland', device_id: options.deviceId ?? '' });
6
+ let result = await request('/webapi/entry.cgi', form);
7
+ if (result?.error?.code === 402) {
8
+ form.delete('session');
9
+ result = await request('/webapi/entry.cgi', form);
10
+ }
11
+ if (result?.error?.code === 403) {
12
+ const otp = await options.otp();
13
+ if (!/^\d{6}$/.test(otp))
14
+ throw new CtlError('CTL_OTP_INVALID', 'OTP must be six digits.', 'run: synopkgland host login <name>', 2);
15
+ form.set('otp_code', otp);
16
+ result = await request('/webapi/entry.cgi', form);
17
+ }
18
+ if (result?.success !== true || typeof result.data?.sid !== 'string' || !result.data.sid || typeof result.data.synotoken !== 'string' || !result.data.synotoken)
19
+ throw new CtlError('CTL_AUTH_FAILED', `DSM login failed (${Number(result?.error?.code) || 'invalid response'}).`, 'run: synopkgland host login <name>', 2);
20
+ if (/[;\r\n\x00]/.test(result.data.sid) || /[\r\n\x00]/.test(result.data.synotoken) || result.data.sid.length > 4096 || result.data.synotoken.length > 4096)
21
+ throw new CtlError('CTL_AUTH_FAILED', 'DSM returned invalid session carriers.', 'Check the DSM endpoint.', 2);
22
+ const returnedDevice = result.data.device_id || result.data.did || options.deviceId;
23
+ const deviceId = options.remember === false ? undefined : typeof returnedDevice === 'string' && returnedDevice.length <= 4096 && !/[\r\n\x00]/.test(returnedDevice) ? returnedDevice : undefined;
24
+ return { origin, username, cookie: `id=${result.data.sid}`, token: result.data.synotoken, ...(deviceId ? { deviceId } : {}) };
25
+ }
26
+ export async function logoutDsm(session, request = createRequest(session.origin)) {
27
+ const body = await request('/webapi/entry.cgi', new URLSearchParams({ api: 'SYNO.API.Auth', version: '7', method: 'logout', SynoToken: session.token }), session);
28
+ if (body?.success !== true)
29
+ throw new CtlError('CTL_LOGOUT_FAILED', 'DSM did not confirm logout.', 'Retry logout or revoke the NAS session.', 2);
30
+ }
@@ -0,0 +1,9 @@
1
+ import { type DsmSession, type JsonRequest } from "./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,46 @@
1
+ import { CtlError } from "../ctl/errors.js";
2
+ import { createRequest, } from "./transport.js";
3
+ import { resolveHostCredentials } from "./credentials.js";
4
+ import { registerDsmSession } from "./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, credentials.tls);
9
+ const request = createRequest(session.origin, 62000, credentials.ca, credentials.tls);
10
+ return {
11
+ name: credentials.hostName,
12
+ session,
13
+ request,
14
+ call: async (api, method, version, params = {}) => {
15
+ const body = await request("/webapi/entry.cgi", new URLSearchParams({
16
+ api,
17
+ method,
18
+ version: String(version),
19
+ ...params,
20
+ }), session);
21
+ const code = Number(body?.error?.code);
22
+ if (body?.success !== true)
23
+ throw new CtlError([106, 107, 119].includes(code)
24
+ ? "CTL_NOT_AUTHENTICATED"
25
+ : code === 105
26
+ ? "NAS_ADMIN_REQUIRED"
27
+ : "DSM_API_FAILED", `DSM ${api}/${method} failed (${code || "invalid response"})`, [106, 107, 119].includes(code)
28
+ ? `run: synopkgland host login ${credentials.hostName}`
29
+ : code === 105
30
+ ? "Log in with a DSM administrator account"
31
+ : "Check DSM permissions and Package Center", 2);
32
+ return body.data;
33
+ },
34
+ };
35
+ }
36
+ export async function requireAdministrator(nas) {
37
+ const data = await nas.call("SYNO.Core.Group.Member", "list", 1, {
38
+ group: "administrators",
39
+ offset: "0",
40
+ limit: "-1",
41
+ });
42
+ const members = data?.users;
43
+ if (!Array.isArray(members) ||
44
+ !members.some((u) => u.name === nas.session.username))
45
+ 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);
46
+ }
@@ -0,0 +1,6 @@
1
+ import { CtlStore } from "./store.js";
2
+ import type { HostCredentials } from "../host/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;
5
+ /** Legacy embedded callers supply credentials here too; there is only one DSM login implementation. */
6
+ export declare function sessionForCredentials(credentials: HostCredentials): Promise<import("./transport.js").DsmSession>;
@@ -0,0 +1,22 @@
1
+ import { registerDsmSession } from "./http.js";
2
+ import { CtlStore } from "./store.js";
3
+ import { loginDsm } from "./auth.js";
4
+ /** Host metadata is public; session carriers are loaded only from secure storage. */
5
+ export function resolveHostCredentials(name, store = new CtlStore()) {
6
+ const host = store.select(name);
7
+ return {
8
+ dsmOrigin: host.origin,
9
+ username: host.username,
10
+ hostName: host.name,
11
+ fingerprint: host.fingerprint,
12
+ ca: store.trust(host).ca,
13
+ tls: store.trust(host),
14
+ loadSession: async () => { const session = await store.session(host); registerDsmSession(session, host.name, undefined, undefined, store.trust(host)); return session; },
15
+ };
16
+ }
17
+ /** Legacy embedded callers supply credentials here too; there is only one DSM login implementation. */
18
+ export async function sessionForCredentials(credentials) {
19
+ const session = credentials.loadSession ? await credentials.loadSession() : await loginDsm(credentials.dsmOrigin, credentials.username, credentials.password ?? '', { remember: false, otp: async () => process.env.DSM_OTP ?? '' });
20
+ registerDsmSession(session, credentials.hostName ?? 'env', credentials.ca, credentials.fingerprint, credentials.tls);
21
+ return session;
22
+ }
@@ -0,0 +1,5 @@
1
+ import { type DsmSession } from './transport.js';
2
+ import type { HostTls } from './tls.js';
3
+ export declare function registerDsmSession(session: DsmSession, name: string, ca?: string, fingerprint?: string, policy?: HostTls): void;
4
+ /** All DSM fetch callers, including session-only automation, use the NAS socket policy. */
5
+ export declare function dsmFetch(url: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,25 @@
1
+ import { sessionFetch } from './transport.js';
2
+ import { assertSessionActive } from './session.js';
3
+ import { CtlError } from '../ctl/errors.js';
4
+ const bindings = new Map();
5
+ const policies = new Map();
6
+ export function registerDsmSession(session, name, ca, fingerprint, policy) {
7
+ const trust = policy ?? { name, ca, fingerprint };
8
+ bindings.set(session.origin + '\0' + session.cookie, { session, name, trust });
9
+ policies.set(session.origin, trust);
10
+ }
11
+ /** All DSM fetch callers, including session-only automation, use the NAS socket policy. */
12
+ export async function dsmFetch(url, init = {}) {
13
+ const headers = new Headers(init.headers), origin = new URL(url).origin;
14
+ const cookie = headers.get('cookie') ?? '';
15
+ // App cookies can accompany a DSM cookie; keep its token and TLS policy.
16
+ const binding = [...bindings.values()].find(b => b.session.origin === origin && cookie.split(/;\s*/).includes(b.session.cookie));
17
+ const response = await sessionFetch(url, { ...init, headers }, binding ? { ...binding.session, cookie } : undefined, undefined, binding?.trust ?? policies.get(origin));
18
+ if (response.status >= 300 && response.status < 400)
19
+ throw new CtlError('CTL_HTTP_ERROR', `HTTP ${response.status}; redirects are never followed`, 'Check the NAS URL', 6);
20
+ if (binding && new URL(url).pathname === '/webapi/entry.cgi') {
21
+ const body = await response.clone().json().catch(() => null);
22
+ assertSessionActive(body, binding.name);
23
+ }
24
+ return response;
25
+ }
@@ -0,0 +1,13 @@
1
+ export interface NasOptions {
2
+ strictTls?: boolean;
3
+ ca?: string;
4
+ url?: string;
5
+ warn?: (line: string) => void;
6
+ }
7
+ export declare const nasOptions: () => NasOptions;
8
+ export declare function withNasOptions<T>(options: NasOptions, run: () => T): T;
9
+ /** Shared flag extraction for ctl and ordinary commands; overrides last for this invocation only. */
10
+ export declare function nasFlags(argv: string[], allowUrl?: boolean): {
11
+ args: string[];
12
+ options: NasOptions;
13
+ };
@@ -0,0 +1,33 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { CtlError } from '../ctl/errors.js';
3
+ const context = new AsyncLocalStorage();
4
+ export const nasOptions = () => context.getStore() ?? {};
5
+ export function withNasOptions(options, run) {
6
+ return context.run({ ...nasOptions(), ...options }, run);
7
+ }
8
+ /** Shared flag extraction for ctl and ordinary commands; overrides last for this invocation only. */
9
+ export function nasFlags(argv, allowUrl = false) {
10
+ const args = [...argv], options = {};
11
+ for (let i = 0; i < args.length; i++) {
12
+ const flag = args[i];
13
+ if (flag === '--strict-tls') {
14
+ options.strictTls = true;
15
+ args.splice(i--, 1);
16
+ }
17
+ else if (allowUrl && (flag === '--url' || flag.startsWith('--url=')) && args[0] !== 'host' && args[2] !== 'host') {
18
+ const value = flag.includes('=') ? flag.slice(6) : args[i + 1];
19
+ if (!value || value.startsWith('--'))
20
+ throw new CtlError('CTL_ARGUMENT_INVALID', '--url requires a NAS origin', 'Use --url https://<nas>:5001');
21
+ options.url = value;
22
+ args.splice(i--, flag.includes('=') ? 1 : 2);
23
+ }
24
+ else if (flag === '--ca' || flag.startsWith('--ca=')) {
25
+ const value = flag.includes('=') ? flag.slice(5) : args[i + 1];
26
+ if (!value || value.startsWith('--'))
27
+ throw new CtlError('CTL_ARGUMENT_INVALID', '--ca requires a PEM file', 'Use --ca <file>');
28
+ options.ca = value;
29
+ args.splice(i--, flag.includes('=') ? 1 : 2);
30
+ }
31
+ }
32
+ return { args, options };
33
+ }
@@ -0,0 +1,2 @@
1
+ /** Passwords are never persisted. Expired sessions require login; writes are never replayed. */
2
+ export declare function assertSessionActive(body: any, name?: string): void;
@@ -0,0 +1,6 @@
1
+ import { CtlError } from '../ctl/errors.js';
2
+ /** Passwords are never persisted. Expired sessions require login; writes are never replayed. */
3
+ export function assertSessionActive(body, name = '<name>') {
4
+ if (body?.success === false && [106, 107, 119].includes(Number(body.error?.code)))
5
+ throw new CtlError('CTL_NOT_AUTHENTICATED', 'DSM session expired', `run: synopkgland host login ${name}`, 2);
6
+ }
@@ -0,0 +1,61 @@
1
+ import type { DsmSession } from './transport.js';
2
+ import type { HostTls } from './tls.js';
3
+ export interface Approval {
4
+ version: string;
5
+ hash?: string;
6
+ }
7
+ export interface Host {
8
+ name: string;
9
+ origin: string;
10
+ username: string;
11
+ fingerprint?: string;
12
+ strictTls?: boolean;
13
+ ephemeral?: boolean;
14
+ tunnel?: boolean;
15
+ ca?: string;
16
+ lastUsed?: string;
17
+ approved: Record<string, Approval>;
18
+ }
19
+ export interface CredentialStore {
20
+ get(key: string): Promise<DsmSession | null>;
21
+ set(key: string, value: DsmSession): Promise<void>;
22
+ delete(key: string): Promise<void>;
23
+ }
24
+ export declare class OsCredentialStore implements CredentialStore {
25
+ private entry;
26
+ get(key: string): Promise<DsmSession | null>;
27
+ set(key: string, value: DsmSession): Promise<void>;
28
+ delete(key: string): Promise<void>;
29
+ }
30
+ export declare class CtlStore {
31
+ root: string;
32
+ credentials: CredentialStore;
33
+ constructor(root?: string, credentials?: CredentialStore);
34
+ private safe;
35
+ private file;
36
+ key(host: Host): string;
37
+ defaultName(): string | undefined;
38
+ setDefault(name: string): void;
39
+ automation(): Host | undefined;
40
+ targets(): Host[];
41
+ select(name?: string): Host;
42
+ list(): Host[];
43
+ get(name: string): Host;
44
+ save(host: Host): void;
45
+ add(host: Host, session: DsmSession): Promise<void>;
46
+ trust(host: Host): HostTls;
47
+ session(host: Host): Promise<DsmSession>;
48
+ cacheRoot(host: Host): string;
49
+ cacheFile(host: Host, pkg: string, version: string): string;
50
+ cache(host: Host, pkg: string, version: string): any | null;
51
+ putCache(host: Host, pkg: string, version: string, description: any): void;
52
+ putFailure(host: Host, pkg: string, version: string, failure: unknown): void;
53
+ revoke(name: string, pkg: string): {
54
+ revoked: string;
55
+ cacheCleared: boolean;
56
+ };
57
+ remove(name: string): Promise<{
58
+ removed: string;
59
+ deleted: string[];
60
+ }>;
61
+ }
@@ -0,0 +1,144 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, rmSync, existsSync, chmodSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
+ import { CtlError, choose } from '../ctl/errors.js';
6
+ import { loginDsm } from './auth.js';
7
+ import { createRequest, canonicalOrigin } from './transport.js';
8
+ import { nasOptions } from './options.js';
9
+ import { publicDescriptor } from '../ctl/redaction.js';
10
+ export class OsCredentialStore {
11
+ async entry(key) { try {
12
+ const { AsyncEntry } = await import('@napi-rs/keyring');
13
+ return new AsyncEntry('synopkgland.ctl', key, { linux: { store: 'secret-service' } });
14
+ }
15
+ catch {
16
+ throw new CtlError('CTL_CREDENTIAL_STORE', 'OS secure credential store unavailable.', 'Unlock the OS credential store and retry.', 2);
17
+ } }
18
+ async get(key) { try {
19
+ const raw = await (await this.entry(key)).getPassword();
20
+ return raw ? JSON.parse(raw) : null;
21
+ }
22
+ catch (e) {
23
+ if (e instanceof CtlError)
24
+ throw e;
25
+ throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot read OS credentials.', 'Unlock the OS credential store and retry.', 2);
26
+ } }
27
+ async set(key, value) { try {
28
+ await (await this.entry(key)).setPassword(JSON.stringify(value));
29
+ }
30
+ catch {
31
+ throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot save session in OS secure storage.', 'Unlock the OS credential store and retry.', 2);
32
+ } }
33
+ async delete(key) { try {
34
+ await (await this.entry(key)).deleteCredential();
35
+ }
36
+ catch {
37
+ throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot remove OS credentials.', 'Unlock the OS credential store and retry removal.', 2);
38
+ } }
39
+ }
40
+ export class CtlStore {
41
+ root;
42
+ credentials;
43
+ constructor(root = process.env.SYNOPKGLAND_CTL_DIR ?? join(homedir(), '.synopkgland', 'ctl'), credentials = new OsCredentialStore()) {
44
+ this.root = root;
45
+ this.credentials = credentials;
46
+ }
47
+ safe(name) { if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name))
48
+ throw new CtlError('CTL_HOST_INVALID', 'Invalid host name.', 'Use letters, digits, dots, dashes or underscores.'); return name; }
49
+ file(name) { return join(this.root, 'hosts', this.safe(name) + '.json'); }
50
+ key(host) { return createHash('sha256').update(host.name + '\0' + host.origin + '\0' + host.username).digest('hex'); }
51
+ defaultName() { try {
52
+ return JSON.parse(readFileSync(join(this.root, 'default.json'), 'utf8')).name;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ } }
57
+ setDefault(name) { this.get(name); mkdirSync(this.root, { recursive: true, mode: 0o700 }); const file = join(this.root, 'default.json'), temp = file + '.' + randomUUID(); writeFileSync(temp, JSON.stringify({ name }), { mode: 0o600 }); renameSync(temp, file); }
58
+ automation() {
59
+ const url = nasOptions().url ?? process.env.DSM_URL;
60
+ if (url && process.env.DSM_USERNAME && process.env.DSM_PASSWORD)
61
+ return { name: 'env', origin: canonicalOrigin(url), username: process.env.DSM_USERNAME, approved: {}, ephemeral: true };
62
+ }
63
+ targets() { const registered = this.list(); const ephemeral = this.automation(); return ephemeral && (nasOptions().url || !registered.length) ? [ephemeral] : registered; }
64
+ select(name) { if (!name) {
65
+ const ephemeral = this.automation();
66
+ if (ephemeral && (nasOptions().url || !this.list().length))
67
+ return ephemeral;
68
+ } if (name)
69
+ return this.get(name); const hosts = this.list(); const selected = this.defaultName(); if (selected)
70
+ return this.get(selected); if (hosts.length === 1)
71
+ return hosts[0]; throw new CtlError('CTL_HOST_REQUIRED', hosts.length ? 'Choose a NAS with --host.' : 'No NAS hosts registered.', hosts.length ? 'run: synopkgland host default <name>' : 'run: synopkgland host add <name> --url https://<nas>:5001', 2); }
72
+ list() { const dir = join(this.root, 'hosts'); if (!existsSync(dir))
73
+ return []; return readdirSync(dir).filter(n => n.endsWith('.json')).map(n => JSON.parse(readFileSync(join(dir, n), 'utf8'))).sort((a, b) => a.name.localeCompare(b.name)); }
74
+ get(name) { this.safe(name); const host = this.list().find(h => h.name === name); if (!host)
75
+ return choose(name, this.list().map(h => h.name), 'host'); return host; }
76
+ save(host) { if (host.ephemeral)
77
+ return; const file = this.file(host.name); mkdirSync(join(this.root, 'hosts'), { recursive: true, mode: 0o700 }); const temp = file + '.' + randomUUID(); writeFileSync(temp, JSON.stringify(host), { mode: 0o600 }); renameSync(temp, file); chmodSync(file, 0o600); }
78
+ async add(host, session) { if (this.list().some(h => h.name === host.name))
79
+ throw new CtlError('CTL_HOST_EXISTS', 'Host name already registered.', `run: synopkgland host login ${host.name}`); await this.credentials.set(this.key(host), session); try {
80
+ this.save(host);
81
+ }
82
+ catch (e) {
83
+ await this.credentials.delete(this.key(host));
84
+ throw e;
85
+ } }
86
+ trust(host) {
87
+ const override = nasOptions();
88
+ return { name: host.name, fingerprint: host.fingerprint, strictTls: host.strictTls || override.strictTls, ca: (override.ca ?? host.ca) ? readFileSync((override.ca ?? host.ca), 'utf8') : undefined,
89
+ readFingerprint: () => { try {
90
+ return JSON.parse(readFileSync(this.file(host.name), 'utf8')).fingerprint;
91
+ }
92
+ catch {
93
+ return host.fingerprint;
94
+ } },
95
+ record: (fingerprint) => { host.fingerprint = fingerprint; this.save(host); }, warn: override.warn };
96
+ }
97
+ async session(host) {
98
+ let session;
99
+ if (process.env.DSM_USERNAME && process.env.DSM_PASSWORD) {
100
+ if (process.env.DSM_USERNAME !== host.username)
101
+ throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Automation account differs from the registered host.', 'Use the registered account or a separately named host.', 2);
102
+ session = await loginDsm(host.origin, host.username, process.env.DSM_PASSWORD, { remember: false, otp: async () => process.env.DSM_OTP ?? '', request: createRequest(host.origin, 30000, undefined, this.trust(host)) });
103
+ }
104
+ else
105
+ session = await this.credentials.get(this.key(host));
106
+ if (!session?.cookie || !session.token)
107
+ throw new CtlError('CTL_NOT_AUTHENTICATED', `Host ${host.name} is not logged in.`, `run: synopkgland host login ${host.name}`, 2);
108
+ if (/[\r\n\0]/.test(session.cookie + session.token))
109
+ throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Stored session carriers are invalid', `run: synopkgland host login ${host.name}`, 2);
110
+ if (session.origin !== host.origin || session.username !== host.username)
111
+ throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Stored credentials do not match host.', 'Run synopkgland host login again.', 2);
112
+ return session;
113
+ }
114
+ cacheRoot(host) { return join(this.root, 'cache', this.key(host)); }
115
+ cacheFile(host, pkg, version) { return join(this.cacheRoot(host), createHash('sha256').update(pkg + '\0' + version).digest('hex') + '.json'); }
116
+ cache(host, pkg, version) {
117
+ try {
118
+ const entry = JSON.parse(readFileSync(this.cacheFile(host, pkg, version), 'utf8'));
119
+ if (entry.description && !entry.redacted) {
120
+ const safe = publicDescriptor(entry.description);
121
+ if (JSON.stringify(safe) !== JSON.stringify(entry.description)) {
122
+ this.putCache(host, pkg, version, entry.description);
123
+ return { ...entry, description: safe, redacted: true };
124
+ }
125
+ }
126
+ return entry;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ putCache(host, pkg, version, description) {
133
+ const safe = publicDescriptor(description);
134
+ const redacted = JSON.stringify(safe) !== JSON.stringify(description);
135
+ mkdirSync(this.cacheRoot(host), { recursive: true, mode: 0o700 });
136
+ const file = this.cacheFile(host, pkg, version), tmp = file + '.' + randomUUID();
137
+ writeFileSync(tmp, JSON.stringify({ hash: description.sha256, description: safe, ...(redacted ? { redacted: true } : {}) }), { mode: 0o600 });
138
+ renameSync(tmp, file);
139
+ }
140
+ putFailure(host, pkg, version, failure) { mkdirSync(this.cacheRoot(host), { recursive: true, mode: 0o700 }); const file = this.cacheFile(host, pkg, version), tmp = file + '.' + randomUUID(); writeFileSync(tmp, JSON.stringify({ failure }), { mode: 0o600 }); renameSync(tmp, file); }
141
+ revoke(name, pkg) { const host = this.get(name); delete host.approved[pkg]; this.save(host); rmSync(this.cacheRoot(host), { recursive: true, force: true }); return { revoked: pkg, cacheCleared: true }; }
142
+ async remove(name) { const host = this.get(name); await this.credentials.delete(this.key(host)); rmSync(this.cacheRoot(host), { recursive: true, force: true }); rmSync(this.file(name)); if (this.defaultName() === name)
143
+ rmSync(join(this.root, 'default.json'), { force: true }); return { removed: name, deleted: ['session', 'SynoToken', 'device token', 'App approvals', 'hash pins', 'list cache', 'host registration'] }; }
144
+ }
@@ -0,0 +1,25 @@
1
+ import https from "node:https";
2
+ import { CtlError } from "../ctl/errors.js";
3
+ export interface HostTls {
4
+ fingerprint?: string;
5
+ name?: string;
6
+ ca?: string;
7
+ strictTls?: boolean;
8
+ expectedFingerprint?: string;
9
+ readFingerprint?: () => string | undefined;
10
+ record?: (fingerprint: string) => void;
11
+ warn?: (line: string) => void;
12
+ }
13
+ export declare function normalizeFingerprint(value: string): string;
14
+ export declare function networkError(error: unknown): CtlError;
15
+ export declare function timeoutError(): CtlError;
16
+ /** TLS-only handshake. No HTTP headers, password or session is sent during discovery. */
17
+ export declare function probeCertificate(origin: string, options?: HostTls & {
18
+ timeoutMs?: number;
19
+ }): Promise<{
20
+ fingerprint?: string;
21
+ leafFingerprint?: string;
22
+ trusted: boolean;
23
+ }>;
24
+ /** Inspect the certificate and apply NAS policy before releasing this exact socket to HTTP. */
25
+ export declare function pinnedAgent(origin: string, trust: HostTls, timeoutMs: number): https.Agent;