@synopackageland/cli 0.33.0 → 0.33.2

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 (60) hide show
  1. package/CHANGELOG.md +3 -7
  2. package/README.md +14 -2
  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/dist/standalone/dsm-window.d.ts +2 -0
  52. package/dist/standalone/dsm-window.js +30 -3
  53. package/package.json +1 -1
  54. package/skills/SKILL.md +4 -2
  55. package/skills/reference/create.md +1 -1
  56. package/skills/reference/ctl.md +2 -0
  57. package/skills/reference/deploy.md +2 -0
  58. package/skills/reference/host-install.md +21 -10
  59. package/skills/reference/portal.md +1 -1
  60. package/skills/reference/ui-vue.md +1 -1
@@ -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
+ }
@@ -49,6 +49,8 @@ export declare function renderUiConfig(manifest: StandaloneManifest, icons?: Pac
49
49
  * (and add a device-width viewport), and `ctx.appIcon` is the package icon a
50
50
  * full-page shell shows beside the name, where a DSM window has a title bar.
51
51
  * A portal App's `ctx.portal` shows `portalText`, the SDK's panel text.
52
+ * `ctx.uiUpdate.check()` answers whether the installed package replaced this
53
+ * window's UI (the SDK's AppShell then offers a reload).
52
54
  * `ctx.launchParams` holds the App's own values from the launch that opened
53
55
  * the window (a `launchParam` desktop URL or `SYNO.SDS.AppLaunch(id, param)`),
54
56
  * and `ctx.onLaunch(fn)` hands fn those of each later launch with values.
@@ -213,6 +213,8 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), lo
213
213
  * (and add a device-width viewport), and `ctx.appIcon` is the package icon a
214
214
  * full-page shell shows beside the name, where a DSM window has a title bar.
215
215
  * A portal App's `ctx.portal` shows `portalText`, the SDK's panel text.
216
+ * `ctx.uiUpdate.check()` answers whether the installed package replaced this
217
+ * window's UI (the SDK's AppShell then offers a reload).
216
218
  * `ctx.launchParams` holds the App's own values from the launch that opened
217
219
  * the window (a `launchParam` desktop URL or `SYNO.SDS.AppLaunch(id, param)`),
218
220
  * and `ctx.onLaunch(fn)` hands fn those of each later launch with values.
@@ -394,9 +396,14 @@ ${ns}.sessionUser = function () {
394
396
  return { name: typeof session.user === "string" ? session.user : "", isAdmin: session.is_admin === true };
395
397
  };
396
398
 
397
- ${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns, portalText) : ""}${ns}.loadApp = function (done) {
399
+ ${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns, portalText) : ""}// DSM loads the installed build's window script when a window opens after a
400
+ // package upgrade, but keeps the page and the app module the previous build
401
+ // registered. Reuse it only when this build loaded it; an older loader records
402
+ // no build, so its app is replaced as well.
403
+ ${ns}.loadApp = function (done) {
398
404
  var apps = window.DsmPackageApps || {};
399
- if (apps[${p}]) {
405
+ var versions = window.DsmPackageAppVersions = window.DsmPackageAppVersions || {};
406
+ if (apps[${p}] && versions[${p}] === "${assetVersion}") {
400
407
  done(apps[${p}]);
401
408
  return;
402
409
  }
@@ -408,10 +415,29 @@ ${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns, po
408
415
  var js = document.createElement("script");
409
416
  js.src = base + "app.js?v=${assetVersion}";
410
417
  js.dataset.package = ${p};
411
- js.onload = function () { done((window.DsmPackageApps || {})[${p}]); };
418
+ js.onload = function () { versions[${p}] = "${assetVersion}"; done((window.DsmPackageApps || {})[${p}]); };
412
419
  document.head.appendChild(js);
413
420
  };
414
421
 
422
+ // DSM keeps a loaded window script after a package upgrade until the page
423
+ // reloads, so a window opened again runs the old UI. ui/config names the
424
+ // installed build's window file: once it no longer names this one, the
425
+ // installed UI replaced this page's. no-store, not no-cache: DSM tags
426
+ // ui/config by size alone, so a revalidation can keep an older build's copy.
427
+ // An uninstalled package or a failed read answers false.
428
+ ${ns}.uiUpdate = {
429
+ check: function (signal) {
430
+ if (typeof fetch !== "function") return Promise.resolve(false);
431
+ return fetch("webman/3rdparty/${pkg}/config", { cache: "no-store", credentials: "same-origin", signal: signal })
432
+ .then(function (response) { return response.ok ? response.json() : null; })
433
+ .then(function (config) {
434
+ return !!config && typeof config === "object" && !Array.isArray(config) &&
435
+ !Object.prototype.hasOwnProperty.call(config, ${JSON.stringify(windowScriptName(manifest))});
436
+ })
437
+ .catch(function () { return false; });
438
+ }
439
+ };
440
+
415
441
  // A Portal/standalone launch is the whole page, on a phone as well: no desktop
416
442
  // minimum width, and the app lays itself out full-page.
417
443
  ${ns}.fullPage = function () {
@@ -501,6 +527,7 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
501
527
  appWindow: me,
502
528
  fullPage: ${ns}.fullPage(),
503
529
  appIcon: ${ns}.appIcon,
530
+ uiUpdate: ${ns}.uiUpdate,
504
531
  lang: lang,
505
532
  locale: i18n.locale,
506
533
  t: i18n.t${root ? `,\n executor: access` : ""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.33.0",
3
+ "version": "0.33.2",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
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,禁止發明新的身分平台。
@@ -182,7 +184,7 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
182
184
  預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
183
185
 
184
186
  `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
185
- 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
187
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、`ctx.uiUpdate`、SynoToken/session、
186
188
  `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
187
189
  favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
188
190
 
@@ -231,7 +231,7 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
231
231
  預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
232
232
 
233
233
  `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
234
- 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
234
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、`ctx.uiUpdate`、SynoToken/session、
235
235
  `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
236
236
  favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
237
237
 
@@ -1,5 +1,7 @@
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
7
  1. `synopkgland host list --json` checks registered machines and sessions.
@@ -1,5 +1,7 @@
1
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
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
  # Build and deploy
4
6
 
5
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`.
@@ -4,7 +4,7 @@ Register once with `synopkgland host add nas --url https://nas.example:5001 --us
4
4
  The CLI prompts for the password and OTP when DSM requires it. Session cookies,
5
5
  SynoToken and remembered-device tokens stay in the OS credential store; host
6
6
  metadata and App approvals stay under `~/.synopkgland/ctl`. Passwords are not saved.
7
- Use `--ca <PEM>` when adding a host with a private CA. Certificate verification stays on.
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
8
 
9
9
  Use `synopkgland host login nas` to renew an expired session. `host list`,
10
10
  `host show nas`, `host logout nas`, `host remove nas --yes` and
@@ -41,15 +41,26 @@ For automation, provide `DSM_USERNAME` and `DSM_PASSWORD` in the process environ
41
41
  (and `DSM_OTP` if required), with a registered `--host` using that same account.
42
42
  These values create an in-memory session and are never written to disk. Without a
43
43
  registration, install/deploy/status/logs also accept `DSM_URL` with both credentials.
44
- Use an HTTPS URL and a trusted certificate. Supply secrets through your CI secret
44
+ Use an HTTPS URL. Supply secrets through your CI secret
45
45
  store, never literal command arguments, committed files or command output.
46
46
  `host add/login --password-stdin` supports a password from standard input.
47
47
 
48
- Default NAS HTTPS certificates work through trust on first use: before sending a
49
- password, the CLI pins an untrusted leaf certificate's SHA-256 and prints
50
- `Trusting the NAS certificate <short fingerprint> (self-signed)`. Subsequent
51
- requests check the exact leaf. `HOST_CERT_CHANGED` shows old/new fingerprints;
52
- verify them with the user, then use `synopkgland host trust <name>` only after
53
- confirmation. Never disable TLS checks. `host add --fingerprint <sha256>` pins an
54
- expected leaf; `--ca <PEM>` uses a CA instead. Network errors distinguish
55
- HOST_TLS_UNTRUSTED, HOST_DNS_FAILED, HOST_CONNECTION_REFUSED and HOST_TIMEOUT.
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.
@@ -49,7 +49,7 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
49
49
  預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
50
50
 
51
51
  `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
52
- 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
52
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、`ctx.uiUpdate`、SynoToken/session、
53
53
  `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
54
54
  favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
55
55
 
@@ -173,7 +173,7 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
173
173
  預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
174
174
 
175
175
  `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
176
- 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
176
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、`ctx.uiUpdate`、SynoToken/session、
177
177
  `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
178
178
  favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
179
179