@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,129 @@
1
+ import tls from "node:tls";
2
+ import https from "node:https";
3
+ import { isIP } from "node:net";
4
+ import { CtlError } from "../ctl/errors.js";
5
+ export function normalizeFingerprint(value) {
6
+ const normalized = value.replaceAll(":", "").toLowerCase();
7
+ if (!/^[a-f0-9]{64}$/.test(normalized))
8
+ throw new CtlError("HOST_FINGERPRINT_INVALID", "A SHA-256 fingerprint must contain 64 hexadecimal digits", "Use the SHA-256 leaf certificate fingerprint", 3);
9
+ return normalized;
10
+ }
11
+ export function networkError(error) {
12
+ if (error instanceof CtlError)
13
+ return error;
14
+ const code = error?.code;
15
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN")
16
+ return new CtlError("HOST_DNS_FAILED", "NAS hostname could not be resolved", "Check the NAS hostname and DNS", 6);
17
+ if (code === "ECONNREFUSED")
18
+ return new CtlError("HOST_CONNECTION_REFUSED", "NAS refused the connection", "Check the DSM port and NAS availability", 6);
19
+ if (code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT")
20
+ return new CtlError("HOST_TIMEOUT", "NAS connection timed out", "Check the NAS connection and firewall", 6);
21
+ if (code &&
22
+ (code === "DSM_TLS_UNTRUSTED" || /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(code)))
23
+ return new CtlError("HOST_TLS_UNTRUSTED", "NAS TLS certificate is not trusted", "Check the CA file, NAS hostname and certificate validity, or omit strict TLS", 6);
24
+ return new CtlError("CTL_NETWORK", "NAS connection failed", "Check the NAS connection; state-changing requests are not replayed", 6);
25
+ }
26
+ export function timeoutError() {
27
+ return new CtlError("HOST_TIMEOUT", "NAS connection timed out", "Check the NAS connection and firewall", 6);
28
+ }
29
+ function changed(old, current, name) {
30
+ return new CtlError("HOST_CERT_CHANGED", `NAS certificate changed: expected SHA-256 ${old}; received ${current}`, `Verify the new certificate, then run: synopkgland host trust ${name ?? "<name>"}`, 6);
31
+ }
32
+ function socketOptions(origin, ca) {
33
+ const url = new URL(origin), host = url.hostname.replace(/^\[|\]$/g, "");
34
+ return {
35
+ host,
36
+ port: Number(url.port || 443),
37
+ ...(isIP(host) ? {} : { servername: host }),
38
+ ...(ca ? { ca } : {}),
39
+ };
40
+ }
41
+ /** TLS-only handshake. No HTTP headers, password or session is sent during discovery. */
42
+ export function probeCertificate(origin, options = {}) {
43
+ if (new URL(origin).protocol !== "https:")
44
+ return Promise.resolve({ trusted: true });
45
+ return new Promise((resolve, reject) => {
46
+ const socket = tls.connect({
47
+ ...socketOptions(origin, options.ca),
48
+ rejectUnauthorized: false,
49
+ });
50
+ const timer = setTimeout(() => socket.destroy(timeoutError()), options.timeoutMs ?? 30000);
51
+ socket.once("error", (e) => reject(networkError(e)));
52
+ socket.once("close", () => clearTimeout(timer));
53
+ socket.once("secureConnect", () => {
54
+ try {
55
+ const raw = socket.getPeerCertificate().fingerprint256;
56
+ if (!raw)
57
+ throw new CtlError("HOST_TLS_UNTRUSTED", "NAS did not provide a leaf certificate", "Check the NAS HTTPS endpoint", 6);
58
+ const fingerprint = normalizeFingerprint(raw);
59
+ checkCertificate(socket, { ...options, expectedFingerprint: options.expectedFingerprint });
60
+ resolve({
61
+ trusted: socket.authorized,
62
+ leafFingerprint: fingerprint,
63
+ fingerprint: fingerprint,
64
+ });
65
+ }
66
+ catch (e) {
67
+ reject(e);
68
+ }
69
+ finally {
70
+ socket.destroy();
71
+ }
72
+ });
73
+ });
74
+ }
75
+ /** Inspect the certificate and apply NAS policy before releasing this exact socket to HTTP. */
76
+ export function pinnedAgent(origin, trust, timeoutMs) {
77
+ const agent = new https.Agent({ keepAlive: false });
78
+ agent.createConnection = (_options, callback) => {
79
+ let done = false;
80
+ const socket = tls.connect({
81
+ ...socketOptions(origin, trust.ca),
82
+ rejectUnauthorized: false,
83
+ });
84
+ const timer = setTimeout(() => socket.destroy(timeoutError()), timeoutMs);
85
+ const finish = (error) => {
86
+ if (done)
87
+ return;
88
+ done = true;
89
+ clearTimeout(timer);
90
+ if (error) {
91
+ socket.destroy();
92
+ callback?.(error, null);
93
+ }
94
+ else
95
+ callback?.(null, socket);
96
+ };
97
+ socket.once("error", (e) => finish(networkError(e)));
98
+ socket.once("secureConnect", () => {
99
+ try {
100
+ checkCertificate(socket, trust);
101
+ finish();
102
+ }
103
+ catch (e) {
104
+ finish(e);
105
+ }
106
+ });
107
+ return undefined;
108
+ };
109
+ return agent;
110
+ }
111
+ /** Runs before HTTP can write headers or a password on this very socket. */
112
+ function checkCertificate(socket, trust) {
113
+ const current = normalizeFingerprint(socket.getPeerCertificate().fingerprint256 ?? '');
114
+ const recorded = trust.readFingerprint?.() ?? trust.fingerprint;
115
+ const old = recorded && normalizeFingerprint(recorded);
116
+ const expected = trust.expectedFingerprint && normalizeFingerprint(trust.expectedFingerprint);
117
+ if (expected && expected !== current)
118
+ throw changed(expected, current, trust.name);
119
+ if (old && old !== current && (trust.strictTls || trust.ca))
120
+ throw changed(old, current, trust.name);
121
+ if ((trust.strictTls || trust.ca) && !socket.authorized)
122
+ throw new CtlError('HOST_TLS_UNTRUSTED', 'NAS certificate failed CA, hostname or validity verification', 'Check the CA file, NAS hostname and certificate validity', 6);
123
+ if (old && old !== current)
124
+ (trust.warn ?? console.error)(`NAS certificate changed: ${old.slice(0, 16)} -> ${current.slice(0, 16)}, now trusting the new one`);
125
+ if (old !== current) {
126
+ trust.record?.(current);
127
+ trust.fingerprint = current;
128
+ }
129
+ }
@@ -0,0 +1,16 @@
1
+ import { type HostTls } from './tls.js';
2
+ import { type DsmSession } from './transport.js';
3
+ export declare function localFile(file: unknown): {
4
+ file: string;
5
+ size: number;
6
+ };
7
+ export declare function transferRequest(session: DsmSession, path: string, method: string, params: Record<string, unknown>, options?: {
8
+ file?: {
9
+ path: string;
10
+ parent: string;
11
+ name: string;
12
+ };
13
+ ca?: string;
14
+ timeoutMs?: number;
15
+ trust?: HostTls;
16
+ }): Promise<any>;
@@ -0,0 +1,76 @@
1
+ import { networkError, timeoutError } from './tls.js';
2
+ import { createReadStream, statSync } from 'node:fs';
3
+ import { CtlError } from '../ctl/errors.js';
4
+ import { nasHttpRequest, CTL_TIMEOUT_MS } from './transport.js';
5
+ export function localFile(file) { if (typeof file !== 'string' || !file)
6
+ throw new CtlError('CTL_INPUT_FILE', 'A local filename is required.', 'Use --file <path>.'); try {
7
+ const stat = statSync(file);
8
+ if (!stat.isFile() || stat.size > 16 * 1024 ** 3)
9
+ throw Error();
10
+ return { file, size: stat.size };
11
+ }
12
+ catch {
13
+ throw new CtlError('CTL_INPUT_FILE', 'Upload must be a readable regular file of at most 16 GiB.', 'Check --file <path>.');
14
+ } }
15
+ export async function transferRequest(session, path, method, params, options = {}) {
16
+ 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))
17
+ throw new CtlError('CTL_PATH_INVALID', 'Invalid transfer binding.', 'Refresh the API description.', 4);
18
+ if (options.timeoutMs !== undefined && options.timeoutMs <= 0)
19
+ throw new CtlError('CTL_NETWORK', 'Transfer budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
20
+ const url = new URL(path, session.origin);
21
+ const file = options.file ? localFile(options.file.path) : undefined;
22
+ if (options.file) {
23
+ url.search = new URLSearchParams({ method, parent: options.file.parent, name: options.file.name }).toString();
24
+ }
25
+ const raw = file ? undefined : Buffer.from(JSON.stringify({ method, params }));
26
+ return new Promise((resolve, reject) => {
27
+ 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) };
28
+ const req = nasHttpRequest(url, { method: 'POST', headers }, options.timeoutMs ?? CTL_TIMEOUT_MS, { ...options.trust, ca: options.ca ?? options.trust?.ca }, res => {
29
+ const expected = file ? 'application/json' : 'application/x-ndjson';
30
+ if (res.statusCode !== 200 || !res.headers['content-type']?.includes(expected)) {
31
+ res.resume();
32
+ reject(new CtlError('CTL_TRANSFER_FAILED', 'Unexpected transfer response; redirects are refused.', 'Check App transfer support.', 5));
33
+ return;
34
+ }
35
+ const chunks = [];
36
+ let total = 0;
37
+ res.on('data', (chunk) => { total += chunk.length; if (total > (file ? 1048576 : 8 * 1048576)) {
38
+ req.destroy();
39
+ reject(new CtlError('CTL_FLOW_LIMIT', 'Transfer result exceeds collection limit.', 'Narrow the query.', 5));
40
+ }
41
+ else
42
+ chunks.push(chunk); });
43
+ res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer response interrupted.', 'Check state before retrying writes.', 6)));
44
+ res.on('end', () => { try {
45
+ const text = Buffer.concat(chunks).toString('utf8');
46
+ if (file) {
47
+ const body = JSON.parse(text);
48
+ if (body.success !== true)
49
+ throw Error();
50
+ resolve(body.data);
51
+ }
52
+ else {
53
+ const lines = text.split('\n').filter(l => l.trim());
54
+ if (lines.length > 10000)
55
+ throw Error();
56
+ resolve({ items: lines.map(line => JSON.parse(line)) });
57
+ }
58
+ }
59
+ catch {
60
+ reject(new CtlError('CTL_TRANSFER_FAILED', 'Invalid or failed transfer response.', 'Inspect the App result; do not retry writes blindly.', 5));
61
+ } });
62
+ });
63
+ const wallTimer = setTimeout(() => req.destroy(timeoutError()), options.timeoutMs ?? CTL_TIMEOUT_MS);
64
+ req.once('close', () => clearTimeout(wallTimer));
65
+ req.on('timeout', () => req.destroy(timeoutError()));
66
+ req.on('error', e => reject(networkError(e)));
67
+ if (file) {
68
+ const source = createReadStream(file.file);
69
+ source.on('error', () => req.destroy());
70
+ req.on('close', () => source.destroy());
71
+ source.pipe(req);
72
+ }
73
+ else
74
+ req.end(raw);
75
+ });
76
+ }
@@ -0,0 +1,17 @@
1
+ import { type HostTls } from './tls.js';
2
+ import http from 'node:http';
3
+ export declare const CTL_TIMEOUT_MS = 62000;
4
+ export declare const APP_COOKIE: unique symbol;
5
+ export interface DsmSession {
6
+ origin: string;
7
+ username: string;
8
+ cookie: string;
9
+ token: string;
10
+ deviceId?: string;
11
+ }
12
+ export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
13
+ export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
14
+ export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string, trust?: HostTls): JsonRequest;
15
+ export declare function sessionFetch(url: string, init: RequestInit, session: DsmSession | undefined, ca?: string, trust?: HostTls): Promise<Response>;
16
+ /** The only Node HTTP dispatch for NAS endpoints, including streaming transfers. */
17
+ export declare function nasHttpRequest(url: URL, options: http.RequestOptions, timeoutMs: number, trust: HostTls | undefined, listener: (res: http.IncomingMessage) => void): http.ClientRequest;
@@ -0,0 +1,143 @@
1
+ import { pinnedAgent, networkError, timeoutError } from './tls.js';
2
+ import { strictJson } from '../ctl/json.js';
3
+ import http from 'node:http';
4
+ import https from 'node:https';
5
+ import { assertSessionActive } from './session.js';
6
+ import { nasOptions } from './options.js';
7
+ import { readFileSync } from 'node:fs';
8
+ import { CtlError } from '../ctl/errors.js';
9
+ export const CTL_TIMEOUT_MS = 62000;
10
+ export const APP_COOKIE = Symbol("ctlAppCookie");
11
+ export function canonicalOrigin(input, tunnel = false) {
12
+ let url;
13
+ try {
14
+ url = new URL(input);
15
+ }
16
+ catch {
17
+ throw new CtlError('CTL_URL_INVALID', 'A NAS origin URL is required.', 'run: synopkgland host add <name> --url https://<nas>:5001');
18
+ }
19
+ 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))))
20
+ 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');
21
+ return url.origin;
22
+ }
23
+ export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca, trust) {
24
+ return async (path, body, session) => {
25
+ if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
26
+ throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
27
+ const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs;
28
+ if (remaining <= 0)
29
+ throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
30
+ const url = new URL(path, origin);
31
+ if (url.origin !== origin || (session && session.origin !== origin))
32
+ throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins.', 'run: synopkgland host list', 4);
33
+ const form = body instanceof URLSearchParams;
34
+ const payload = Buffer.from(form ? body.toString() : JSON.stringify(body));
35
+ if (payload.length > 1048576)
36
+ throw new CtlError('CTL_INPUT_TOO_LARGE', 'Request exceeds 1 MiB.', 'Reduce the input.');
37
+ return new Promise((resolve, reject) => {
38
+ const headers = { 'content-type': form ? 'application/x-www-form-urlencoded' : 'application/json', accept: 'application/json', 'content-length': String(payload.length), origin };
39
+ if (session) {
40
+ headers.cookie = session.cookie;
41
+ headers['X-SYNO-TOKEN'] = session.token;
42
+ }
43
+ const req = nasHttpRequest(url, { method: 'POST', headers }, remaining, trustWithCa(trust, ca), res => {
44
+ if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
45
+ res.resume();
46
+ reject(new CtlError('CTL_HTTP_ERROR', `HTTP ${res.statusCode}; redirects are never followed.`, 'Check the NAS URL and connection.', 6));
47
+ return;
48
+ }
49
+ if (!res.headers['content-type']?.includes('application/json')) {
50
+ res.resume();
51
+ reject(new CtlError('CTL_RESPONSE_INVALID', 'NAS returned non-JSON data.', 'Check the endpoint and installed package.', 5));
52
+ return;
53
+ }
54
+ const chunks = [];
55
+ let length = 0;
56
+ res.on('data', (c) => { length += c.length; if (length > 1048576) {
57
+ res.destroy();
58
+ reject(new CtlError('CTL_RESPONSE_TOO_LARGE', 'Response exceeds 1 MiB.', 'Use a smaller operation.', 5));
59
+ }
60
+ else
61
+ chunks.push(c); });
62
+ res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Response interrupted.', 'Check the NAS connection; state-changing requests are not replayed.', 6)));
63
+ res.on('end', () => {
64
+ try {
65
+ const body = strictJson(Buffer.concat(chunks));
66
+ if (session && path === '/webapi/entry.cgi')
67
+ assertSessionActive(body, trust?.name);
68
+ if (!form && body?.success === true && body.data && path.startsWith('/webman/3rdparty/') && url.protocol === 'https:') {
69
+ const cookies = (res.headers['set-cookie'] ?? []).filter(line => /;\s*Secure(?:;|$)/i.test(line) && /;\s*HttpOnly(?:;|$)/i.test(line) && !/;\s*Domain=/i.test(line));
70
+ const own = path.slice(0, path.lastIndexOf('/cgi/')) + '/';
71
+ 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='));
72
+ if (valid.length)
73
+ Object.defineProperty(body, APP_COOKIE, { value: valid.join('; '), enumerable: false });
74
+ }
75
+ resolve(body);
76
+ }
77
+ catch (error) {
78
+ if (error instanceof CtlError) {
79
+ reject(error);
80
+ return;
81
+ }
82
+ reject(new CtlError('CTL_RESPONSE_INVALID', 'Invalid JSON response.', 'Check the installed package.', 5));
83
+ }
84
+ });
85
+ });
86
+ const wallTimer = setTimeout(() => req.destroy(timeoutError()), remaining);
87
+ req.once('close', () => clearTimeout(wallTimer));
88
+ req.on('timeout', () => req.destroy(timeoutError()));
89
+ req.on('error', error => reject(networkError(error)));
90
+ req.end(payload);
91
+ });
92
+ };
93
+ }
94
+ export async function sessionFetch(url, init, session, ca, trust) {
95
+ if (session && new URL(url).origin !== session.origin)
96
+ throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins', 'Check the registered NAS origin', 4);
97
+ const headers = new Headers(init.headers);
98
+ if (session) {
99
+ headers.set('cookie', session.cookie);
100
+ headers.set('X-SYNO-TOKEN', session.token);
101
+ }
102
+ const request = new Request(url, { ...init, headers }), bytes = init.body ? Buffer.from(await request.arrayBuffer()) : undefined;
103
+ return new Promise((resolve, reject) => {
104
+ const target = new URL(url);
105
+ const req = nasHttpRequest(target, { method: request.method, headers: Object.fromEntries(request.headers) }, 62000, trustWithCa(trust, ca), res => {
106
+ const chunks = [];
107
+ let length = 0;
108
+ res.on('data', c => { length += c.length; if (length > 16 * 1024 * 1024)
109
+ res.destroy(new CtlError('CTL_RESPONSE_TOO_LARGE', 'DSM response exceeds 16 MiB', 'Use a narrower operation', 5));
110
+ else
111
+ chunks.push(c); });
112
+ res.on('error', () => reject(new CtlError('DSM_NETWORK', 'DSM response interrupted', 'Check the NAS connection', 6)));
113
+ res.on('end', () => { const headers = new Headers(); for (const [k, v] of Object.entries(res.headers))
114
+ for (const x of Array.isArray(v) ? v : [v])
115
+ if (x !== undefined)
116
+ headers.append(k, x); resolve(new Response([204, 205, 304].includes(res.statusCode ?? 0) || request.method === 'HEAD' ? null : Buffer.concat(chunks), { status: res.statusCode, headers })); });
117
+ });
118
+ const abort = () => req.destroy(request.signal.reason instanceof Error ? request.signal.reason : new Error('Request aborted'));
119
+ if (request.signal.aborted)
120
+ abort();
121
+ else
122
+ request.signal.addEventListener('abort', abort, { once: true });
123
+ req.once('close', () => request.signal.removeEventListener('abort', abort));
124
+ const timer = setTimeout(() => req.destroy(timeoutError()), 62000);
125
+ req.once('close', () => clearTimeout(timer));
126
+ req.on('timeout', () => req.destroy(timeoutError()));
127
+ req.on('error', e => reject(networkError(e)));
128
+ req.end(bytes);
129
+ });
130
+ }
131
+ /** The only Node HTTP dispatch for NAS endpoints, including streaming transfers. */
132
+ export function nasHttpRequest(url, options, timeoutMs, trust = {}, listener) {
133
+ const override = nasOptions();
134
+ const policy = { ...trust, get fingerprint() { return trust.fingerprint; }, set fingerprint(value) { trust.fingerprint = value; }, strictTls: trust.strictTls || override.strictTls, ca: override.ca ? readFileSync(override.ca, 'utf8') : trust.ca, warn: trust.warn ?? override.warn };
135
+ const agent = url.protocol === 'https:' ? pinnedAgent(url.origin, policy, timeoutMs) : undefined;
136
+ // Share observations with this request factory, so parallel requests warn once.
137
+ policy.record = (fingerprint) => { trust.record?.(fingerprint); trust.fingerprint = fingerprint; };
138
+ const req = (url.protocol === 'https:' ? https : http).request(url, { ...options, timeout: timeoutMs, ...(agent ? { agent } : {}) }, listener);
139
+ req.once('close', () => agent?.destroy());
140
+ return req;
141
+ }
142
+ function trustWithCa(trust, ca) { const policy = trust ?? {}; if (ca)
143
+ policy.ca = ca; return policy; }
@@ -0,0 +1,2 @@
1
+ /** Public package feeds and SPK downloads use normal public HTTPS verification. */
2
+ export declare function fetchPackageSource(url: string): Promise<Response>;
@@ -0,0 +1,4 @@
1
+ /** Public package feeds and SPK downloads use normal public HTTPS verification. */
2
+ export function fetchPackageSource(url) {
3
+ return fetch(url, { redirect: 'error', signal: AbortSignal.timeout(62000) });
4
+ }
package/dist/skill.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  const SKILL_CATALOG = [
5
5
  { id: "orchestrator", file: "SKILL.md", when: "Read first" },
6
+ { id: "host-install", file: "reference/host-install.md", when: "Shared NAS login, store/local SPK installation and removal" },
6
7
  { id: "ctl", file: "reference/ctl.md", when: "Operate registered NAS Apps or design AI commands" },
7
8
  {
8
9
  id: "create",
@@ -3,6 +3,7 @@ import { type SpkFinding } from "./validate-spk.js";
3
3
  export interface SpkReport extends SpkInspection {
4
4
  /** Built by synopkgland, so its build contract (`findings`) was checked too. */
5
5
  synopkgland: boolean;
6
+ info?: Record<string, string>;
6
7
  /** The DSM 7 package format rules, checked on every SPK. */
7
8
  dsm: {
8
9
  ok: boolean;
@@ -19,7 +19,7 @@ export async function inspectAnySpk(spkPath) {
19
19
  const dsm = { ok: validation.ok, errors: validation.errors, warnings: validation.warnings };
20
20
  const facts = await readFacts(spkPath);
21
21
  if (BUILD_MARKERS.some((name) => facts.top.has(name)) || facts.payload.some((entry) => entry.path === CONTAINER_MARKER)) {
22
- return { ...(await inspectSpk(spkPath)), synopkgland: true, dsm };
22
+ return { ...(await inspectSpk(spkPath)), synopkgland: true, info: validation.info, dsm };
23
23
  }
24
24
  return {
25
25
  ...(validation.package ? { package: validation.package } : {}),
@@ -30,6 +30,7 @@ export async function inspectAnySpk(spkPath) {
30
30
  findings: [],
31
31
  capabilities: [],
32
32
  synopkgland: false,
33
+ info: validation.info,
33
34
  dsm,
34
35
  };
35
36
  }
@@ -3,6 +3,7 @@ export type { SpkFinding } from "./findings.js";
3
3
  export interface SpkValidation {
4
4
  /** No errors; warnings do not fail validation. */
5
5
  ok: boolean;
6
+ info?: Record<string, string>;
6
7
  package?: string;
7
8
  version?: string;
8
9
  arch?: string;
@@ -34,6 +34,7 @@ export async function validateSpk(spkPath) {
34
34
  const errors = findings.list.filter((finding) => finding.severity === "error");
35
35
  return {
36
36
  ok: errors.length === 0,
37
+ info,
37
38
  ...(info.package ? { package: info.package } : {}),
38
39
  ...(info.version ? { version: info.version } : {}),
39
40
  ...(info.arch ? { arch: info.arch } : {}),
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.32.1",
3
+ "version": "0.33.1",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
- "synopkgland": "./dist/bin.js"
7
+ "synopkgland": "dist/bin.js"
8
8
  },
9
9
  "engines": {
10
10
  "node": ">=22.12"
package/skills/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
5
5
 
6
6
  # Independent Synology SPKs
7
7
 
8
- CLI 0.32.1 installs templates through `~0.31.0`. A templates patch needs only a
8
+ CLI 0.33.1 installs templates through `~0.31.0`. A templates patch needs only a
9
9
  templates release; npm install/update selects a patch in that minor line. The CLI
10
10
  uses the locally installed templates and reads their SDK pin for defaults; it
11
11
  never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
@@ -16,6 +16,8 @@ The CLI verifies bytes before packaging and never fetches a manifest at NAS runt
16
16
  Unsupported formats require a CLI update; invalid manifests fail closed. Existing
17
17
  SDK 0.26.0/0.27.0 use the frozen legacy inventory.
18
18
 
19
+ All CLI NAS requests share one connection path. NAS certificates are accepted by default, including self-signed, wrong-name and expired certificates. A changed certificate warns once, updates its displayed SHA-256 and continues. `host add --strict-tls` stores strict verification; `--strict-tls` or `--ca <file>` on a command opts in for that command. Read [reference/host-install.md](reference/host-install.md) for host trust and error codes.
20
+
19
21
  Before releasing an executor App, read [reference/approval-upgrades.md](reference/approval-upgrades.md). Run build/inspect with the previous released SPK as `--baseline` and include the approval difference in release notes. Without a comparable baseline, retention is unknown.
20
22
 
21
23
  Use this guide to initialize, build and inspect own applications or upstream OSS as independent packages. 依已實作能力區分 DSM session 檔案、私有 jobs 與 App 登入;delegate 已實作並隨 CLI 提供固定操作的離線委託(預設 `files.moveFromStaging`);共用 delegate/rootOps executor 的完整裝置生命週期驗收仍待完成,不得宣稱已通過;支援邊界依 delegate guide/support matrix,禁止發明新的身分平台。
@@ -48,6 +50,8 @@ Buttons follow DSM's policy ([reference/ui-vue.md](reference/ui-vue.md#buttons),
48
50
 
49
51
  Read [reference/ui-json.md](reference/ui-json.md) when generating or editing JSON UI; discover the installed catalog, validate the spec, then render and fix layout issues.
50
52
 
53
+ Read [reference/host-install.md](reference/host-install.md) before NAS authentication, store/local-SPK installation or package removal. All DSM commands share the registered host session.
54
+
51
55
  ## UI copywriting
52
56
 
53
57
  For UI/locale strings we author in the SDK, UI package, templates and our own
@@ -1,16 +1,18 @@
1
1
  # Control registered NAS Apps with ctl
2
2
 
3
+ NAS HTTPS accepts any certificate by default. A changed leaf warns and updates the recorded SHA-256. Use `--strict-tls` or `--ca <file>` to opt into CA, hostname, expiry and change checks; host add stores that policy. See [host login](host-install.md).
4
+
3
5
  Use this for operating installed Apps, rather than building/deploying projects.
4
6
 
5
- 1. `synopkgland ctl host list --json` checks registered machines and sessions.
6
- 2. Register with `ctl host add nas --url https://nas.example:5001 --username alice`, or renew with `ctl host login nas`. Credentials are hidden interactive input; `--password-stdin` is available. Do not put passwords/OTP in argv. Sessions and remembered-device tokens use OS secure storage, never a plaintext fallback.
7
+ 1. `synopkgland host list --json` checks registered machines and sessions.
8
+ 2. Register with `host add nas --url https://nas.example:5001 --username alice`, or renew with `host login nas`. Credentials are hidden interactive input; `--password-stdin` is available. Do not put passwords/OTP in argv. Sessions and remembered-device tokens use OS secure storage, never a plaintext fallback.
7
9
  3. Run `synopkgland ctl list --host nas --json` for all registered Apps, commands, schemas, effects, permissions and executable examples. If description access needs approval, ask the user to approve the whole candidate batch; do not self-approve. An explicit `--approve-installed` records permission to send the DSM session to the displayed inventory's fixed description endpoints. Use `--app <package>` to narrow the batch. No description endpoint is anonymous.
8
10
  4. Use the discovered alias, CLI path and typed flags with an explicit `--host nas`; inspect `--help` and `--dry-run` before execution. For example, `synopkgland ctl notes list --status pending --host nas --dry-run`. Use the real command/schema from discovery, not this illustrative name.
9
11
  5. Reads need no write confirmation. For authorized write/destructive commands, add `--yes`; without it the CLI returns a plan and confirmation error. A --yes flag does not replace the user's authorization or the App's backend checks.
10
12
 
11
- There is no shared current context: with multiple hosts, always specify --host. `--host a,b` and --all-hosts return per-host results, with exit 8 on partial failure. Do not retry a write merely because one host failed; query its state first. --refresh rereads descriptions; unchanged package versions use the host/account/package/version/hash cache. Never assume cached metadata grants resource permission.
13
+ With multiple hosts, select a default with `synopkgland host default <name>` or specify --host. `--host a,b` and --all-hosts return per-host results, with exit 8 on partial failure. Do not retry a write merely because one host failed; query its state first. --refresh rereads descriptions; unchanged package versions use the host/account/package/version/hash cache. Never assume cached metadata grants resource permission.
12
14
 
13
- Use `ctl host show nas` to inspect approvals/pins, `ctl host revoke nas PackageID` to remove an App approval, `ctl host logout nas` to retain registration, and `ctl host remove nas --yes` to delete sessions, device token, approvals and cache. A failed NAS logout is reported independently from successful local removal.
15
+ Use `host show nas` to inspect approvals/pins, `host revoke nas PackageID` to remove an App approval, `host logout nas` to retain registration, and `host remove nas --yes` to delete sessions, device token, approvals and cache. A failed NAS logout is reported independently from successful local removal.
14
16
 
15
17
  Stdout defaults to JSON without a TTY. Success is {ok:true,data}; failure is {ok:false,error:{code,message,hint,allowed?}}. Multi-host output uses {ok,results:[{host,ok,data|error}]}. Follow the concrete hint and complete allowed list; do not guess another method or bypass ctl with raw HTTP. Exit codes: 2 authentication, 3 parameters/names, 4 permissions/trust, 5 App/contract, 6 network, 7 confirmation, 8 partial failure.
16
18
 
@@ -1,3 +1,7 @@
1
+ Before deployment, register a NAS with `synopkgland host add nas --url https://nas.example:5001`, then renew it with `synopkgland host login nas`. See [host-install.md](host-install.md) for shared sessions, automation, install and uninstall.
2
+
3
+ NAS HTTPS accepts any certificate by default. A changed leaf warns and updates the recorded SHA-256. Use `--strict-tls` or `--ca <file>` to opt into CA, hostname, expiry and change checks; host add stores that policy. See [host login](host-install.md).
4
+
1
5
  # Build and deploy
2
6
 
3
7
  1. Run `synopkgland check` and `synopkgland build --arch x86_64` (use aarch64 or armv7 for those targets; armv7 must be listed in `arches`; its jobs, daemon and executor are built but not NAS-verified). deploy detects the NAS arch, except on Marvell CPUs, where it asks for `--arch`.
@@ -0,0 +1,66 @@
1
+ # Shared NAS login and package installation
2
+
3
+ Register once with `synopkgland host add nas --url https://nas.example:5001 --username alice`.
4
+ The CLI prompts for the password and OTP when DSM requires it. Session cookies,
5
+ SynoToken and remembered-device tokens stay in the OS credential store; host
6
+ metadata and App approvals stay under `~/.synopkgland/ctl`. Passwords are not saved.
7
+ NAS HTTPS accepts every certificate by default, including self-signed, untrusted CA, wrong-name and expired certificates. Use `--strict-tls` or `--ca <PEM>` to opt into verification.
8
+
9
+ Use `synopkgland host login nas` to renew an expired session. `host list`,
10
+ `host show nas`, `host logout nas`, `host remove nas --yes` and
11
+ `host revoke nas <package-id>` manage the registration, login and App approvals.
12
+ `host default nas` selects a default; with one host it is selected automatically.
13
+ Every DSM command accepts `--host nas`, including ctl, deploy, status, logs,
14
+ install and uninstall. ctl still requires explicit App description approval.
15
+
16
+ For authorized package changes:
17
+
18
+ ```sh
19
+ synopkgland install SynoReclaim --host nas --dry-run --json
20
+ synopkgland install SynoReclaim --host nas --yes --json
21
+ synopkgland install SynoReclaim@0.1.1-1591 --host nas --yes
22
+ synopkgland install ./MyApp.spk --host nas --param NAME=value --yes
23
+ synopkgland status SynoReclaim --host nas --json
24
+ synopkgland uninstall SynoReclaim --host nas --yes --json
25
+ ```
26
+
27
+ Store installs default to `https://packages.synopkgland.com`; `--source <HTTPS URL>`
28
+ selects another source. The source is queried for the NAS platform and DSM build.
29
+ The CLI plans dependencies from the same feed, reports missing Synology packages,
30
+ checks every feed size/MD5/SHA-256 pin and inspects SPKs before uploading.
31
+ `<package-id>@version` selects an exact available release; downgrades are refused.
32
+ `--dry-run` reads NAS/source metadata and prints the plan without uploading or changing DSM.
33
+ Local SPKs pass the same inspect, architecture and DSM checks before installation.
34
+ The inspection reports signature presence; it does not cryptographically authenticate a publisher.
35
+ Install wizard values use repeated `--param NAME=value`; values are private and
36
+ not printed in plans. DSM package hooks validate their meaning. Install/removal
37
+ require administrator rights. Uninstall hooks can remove package data; review the App's policy.
38
+ A failed multi-package install may leave earlier dependencies installed; inspect status before retrying.
39
+
40
+ For automation, provide `DSM_USERNAME` and `DSM_PASSWORD` in the process environment
41
+ (and `DSM_OTP` if required), with a registered `--host` using that same account.
42
+ These values create an in-memory session and are never written to disk. Without a
43
+ registration, install/deploy/status/logs also accept `DSM_URL` with both credentials.
44
+ Use an HTTPS URL. Supply secrets through your CI secret
45
+ store, never literal command arguments, committed files or command output.
46
+ `host add/login --password-stdin` supports a password from standard input.
47
+
48
+ Every NAS request uses one shared connection module for host lookup, login,
49
+ OS session storage, expiry handling, TLS and HTTP, including ctl transfers,
50
+ install, uninstall, deploy, dev, status, logs and e2e login helpers. Passwords
51
+ are sent only after inspecting the certificate on that exact TLS connection.
52
+ The leaf SHA-256 is recorded for display. An unverified first connection says
53
+ `Trusting the NAS certificate <short fingerprint> (not verified by a trusted CA)`.
54
+ A changed certificate prints one stderr line, updates the recorded fingerprint
55
+ and continues: `NAS certificate changed: <old short> -> <new short>, now trusting the new one`.
56
+
57
+ `host add --strict-tls` stores strict mode in the host profile. `--strict-tls`
58
+ on another command applies to that invocation only. Strict mode checks the CA,
59
+ hostname, expiry and recorded fingerprint: untrusted certificates fail with
60
+ `HOST_TLS_UNTRUSTED`; changed certificates fail with `HOST_CERT_CHANGED`.
61
+ `--ca <PEM>` also opts into real CA and hostname verification and is stored on
62
+ host add, or applies to one command. `host trust <name>` still replaces a recorded
63
+ fingerprint after confirmation. `host add --fingerprint <sha256>` checks an
64
+ explicit expected leaf during registration; it does not turn on strict mode.
65
+ No global TLS override is used. Public package feeds and downloads keep normal
66
+ HTTPS verification. Network errors distinguish DNS, refusal and timeout.