@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.
- package/CHANGELOG.md +6 -6
- package/README.md +13 -3
- package/dist/cli.js +39 -11
- package/dist/ctl/cli.d.ts +1 -0
- package/dist/ctl/cli.js +73 -21
- package/dist/ctl/dsm-auth.d.ts +1 -10
- package/dist/ctl/dsm-auth.js +2 -30
- package/dist/ctl/errors.js +1 -1
- package/dist/ctl/input.js +3 -3
- package/dist/ctl/service.js +8 -8
- package/dist/ctl/store.d.ts +1 -51
- package/dist/ctl/store.js +2 -93
- package/dist/ctl/tls.d.ts +1 -0
- package/dist/ctl/tls.js +2 -0
- package/dist/ctl/transfers.d.ts +1 -14
- package/dist/ctl/transfers.js +2 -77
- package/dist/ctl/transport.d.ts +1 -12
- package/dist/ctl/transport.js +2 -83
- package/dist/e2e/dsm.d.ts +2 -2
- package/dist/e2e/dsm.js +33 -17
- package/dist/error-registry.js +10 -12
- package/dist/host/catalog-install.d.ts +0 -10
- package/dist/host/catalog-install.js +24 -140
- package/dist/host/connection.d.ts +1 -0
- package/dist/host/connection.js +2 -0
- package/dist/host/credentials.d.ts +1 -2
- package/dist/host/credentials.js +2 -67
- package/dist/host/dsm-client.d.ts +4 -7
- package/dist/host/dsm-client.js +44 -112
- package/dist/host/dsm-http.d.ts +1 -1
- package/dist/host/dsm-http.js +2 -124
- package/dist/host/dsm-port-profile.js +7 -2
- package/dist/host/install-plan.d.ts +32 -0
- package/dist/host/install-plan.js +157 -0
- package/dist/host/package-commands.d.ts +75 -0
- package/dist/host/package-commands.js +205 -0
- package/dist/host/port-info-lookup.d.ts +1 -1
- package/dist/host/port-info-lookup.js +5 -2
- package/dist/host/share-lookup.d.ts +3 -2
- package/dist/host/share-lookup.js +5 -91
- package/dist/host/share-permission-lookup.d.ts +3 -3
- package/dist/host/share-permission-lookup.js +18 -30
- package/dist/host/types.d.ts +6 -1
- package/dist/nas/auth.d.ts +10 -0
- package/dist/nas/auth.js +30 -0
- package/dist/nas/connection.d.ts +9 -0
- package/dist/nas/connection.js +46 -0
- package/dist/nas/credentials.d.ts +6 -0
- package/dist/nas/credentials.js +22 -0
- package/dist/nas/http.d.ts +5 -0
- package/dist/nas/http.js +25 -0
- package/dist/nas/options.d.ts +13 -0
- package/dist/nas/options.js +33 -0
- package/dist/nas/session.d.ts +2 -0
- package/dist/nas/session.js +6 -0
- package/dist/nas/store.d.ts +61 -0
- package/dist/nas/store.js +144 -0
- package/dist/nas/tls.d.ts +25 -0
- package/dist/nas/tls.js +129 -0
- package/dist/nas/transfers.d.ts +16 -0
- package/dist/nas/transfers.js +76 -0
- package/dist/nas/transport.d.ts +17 -0
- package/dist/nas/transport.js +143 -0
- package/dist/package-source.d.ts +2 -0
- package/dist/package-source.js +4 -0
- package/dist/skill.js +1 -0
- package/dist/spk/inspect.d.ts +1 -0
- package/dist/spk/inspect.js +2 -1
- package/dist/spk/validate-spk.d.ts +1 -0
- package/dist/spk/validate-spk.js +1 -0
- package/package.json +2 -2
- package/skills/SKILL.md +5 -1
- package/skills/reference/ctl.md +6 -4
- package/skills/reference/deploy.md +4 -0
- package/skills/reference/host-install.md +66 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../nas/tls.js";
|
package/dist/ctl/tls.js
ADDED
package/dist/ctl/transfers.d.ts
CHANGED
|
@@ -1,14 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function localFile(file: unknown): {
|
|
3
|
-
file: string;
|
|
4
|
-
size: number;
|
|
5
|
-
};
|
|
6
|
-
export declare function transferRequest(session: DsmSession, path: string, method: string, params: Record<string, unknown>, options?: {
|
|
7
|
-
file?: {
|
|
8
|
-
path: string;
|
|
9
|
-
parent: string;
|
|
10
|
-
name: string;
|
|
11
|
-
};
|
|
12
|
-
ca?: string;
|
|
13
|
-
timeoutMs?: number;
|
|
14
|
-
}): Promise<any>;
|
|
1
|
+
export * from "../nas/transfers.js";
|
package/dist/ctl/transfers.js
CHANGED
|
@@ -1,77 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import https from 'node:https';
|
|
4
|
-
import { CtlError } from './errors.js';
|
|
5
|
-
import { CTL_TIMEOUT_MS } from './transport.js';
|
|
6
|
-
export function localFile(file) { if (typeof file !== 'string' || !file)
|
|
7
|
-
throw new CtlError('CTL_INPUT_FILE', 'A local filename is required.', 'Use --file <path>.'); try {
|
|
8
|
-
const stat = statSync(file);
|
|
9
|
-
if (!stat.isFile() || stat.size > 16 * 1024 ** 3)
|
|
10
|
-
throw Error();
|
|
11
|
-
return { file, size: stat.size };
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
throw new CtlError('CTL_INPUT_FILE', 'Upload must be a readable regular file of at most 16 GiB.', 'Check --file <path>.');
|
|
15
|
-
} }
|
|
16
|
-
export async function transferRequest(session, path, method, params, options = {}) {
|
|
17
|
-
if (!/^\/webman\/3rdparty\/[A-Za-z][A-Za-z0-9_.+-]*\/cgi\/api\.cgi$/.test(path) || !/^([a-z][a-z0-9_]*\.)+[a-z][a-z0-9_]*$/.test(method))
|
|
18
|
-
throw new CtlError('CTL_PATH_INVALID', 'Invalid transfer binding.', 'Refresh the API description.', 4);
|
|
19
|
-
if (options.timeoutMs !== undefined && options.timeoutMs <= 0)
|
|
20
|
-
throw new CtlError('CTL_NETWORK', 'Transfer budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
|
|
21
|
-
const url = new URL(path, session.origin);
|
|
22
|
-
const file = options.file ? localFile(options.file.path) : undefined;
|
|
23
|
-
if (options.file) {
|
|
24
|
-
url.search = new URLSearchParams({ method, parent: options.file.parent, name: options.file.name }).toString();
|
|
25
|
-
}
|
|
26
|
-
const raw = file ? undefined : Buffer.from(JSON.stringify({ method, params }));
|
|
27
|
-
return new Promise((resolve, reject) => {
|
|
28
|
-
const headers = { 'content-type': file ? 'application/octet-stream' : 'application/json', cookie: session.cookie, 'X-SYNO-TOKEN': session.token, origin: session.origin, 'content-length': String(file?.size ?? raw.length) };
|
|
29
|
-
const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: options.timeoutMs ?? CTL_TIMEOUT_MS, ...(options.ca ? { ca: options.ca } : {}) }, res => {
|
|
30
|
-
const expected = file ? 'application/json' : 'application/x-ndjson';
|
|
31
|
-
if (res.statusCode !== 200 || !res.headers['content-type']?.includes(expected)) {
|
|
32
|
-
res.resume();
|
|
33
|
-
reject(new CtlError('CTL_TRANSFER_FAILED', 'Unexpected transfer response; redirects are refused.', 'Check App transfer support.', 5));
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
const chunks = [];
|
|
37
|
-
let total = 0;
|
|
38
|
-
res.on('data', (chunk) => { total += chunk.length; if (total > (file ? 1048576 : 8 * 1048576)) {
|
|
39
|
-
req.destroy();
|
|
40
|
-
reject(new CtlError('CTL_FLOW_LIMIT', 'Transfer result exceeds collection limit.', 'Narrow the query.', 5));
|
|
41
|
-
}
|
|
42
|
-
else
|
|
43
|
-
chunks.push(chunk); });
|
|
44
|
-
res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer response interrupted.', 'Check state before retrying writes.', 6)));
|
|
45
|
-
res.on('end', () => { try {
|
|
46
|
-
const text = Buffer.concat(chunks).toString('utf8');
|
|
47
|
-
if (file) {
|
|
48
|
-
const body = JSON.parse(text);
|
|
49
|
-
if (body.success !== true)
|
|
50
|
-
throw Error();
|
|
51
|
-
resolve(body.data);
|
|
52
|
-
}
|
|
53
|
-
else {
|
|
54
|
-
const lines = text.split('\n').filter(l => l.trim());
|
|
55
|
-
if (lines.length > 10000)
|
|
56
|
-
throw Error();
|
|
57
|
-
resolve({ items: lines.map(line => JSON.parse(line)) });
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
reject(new CtlError('CTL_TRANSFER_FAILED', 'Invalid or failed transfer response.', 'Inspect the App result; do not retry writes blindly.', 5));
|
|
62
|
-
} });
|
|
63
|
-
});
|
|
64
|
-
const wallTimer = setTimeout(() => req.destroy(), options.timeoutMs ?? CTL_TIMEOUT_MS);
|
|
65
|
-
req.once('close', () => clearTimeout(wallTimer));
|
|
66
|
-
req.on('timeout', () => req.destroy());
|
|
67
|
-
req.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer interrupted; completion may be uncertain.', 'Check App state before retrying.', 6)));
|
|
68
|
-
if (file) {
|
|
69
|
-
const source = createReadStream(file.file);
|
|
70
|
-
source.on('error', () => req.destroy());
|
|
71
|
-
req.on('close', () => source.destroy());
|
|
72
|
-
source.pipe(req);
|
|
73
|
-
}
|
|
74
|
-
else
|
|
75
|
-
req.end(raw);
|
|
76
|
-
});
|
|
77
|
-
}
|
|
1
|
+
// Compatibility export; NAS connection implementation lives in one module.
|
|
2
|
+
export * from "../nas/transfers.js";
|
package/dist/ctl/transport.d.ts
CHANGED
|
@@ -1,12 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
export declare const APP_COOKIE: unique symbol;
|
|
3
|
-
export interface DsmSession {
|
|
4
|
-
origin: string;
|
|
5
|
-
username: string;
|
|
6
|
-
cookie: string;
|
|
7
|
-
token: string;
|
|
8
|
-
deviceId?: string;
|
|
9
|
-
}
|
|
10
|
-
export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
|
|
11
|
-
export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
|
|
12
|
-
export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string): JsonRequest;
|
|
1
|
+
export * from "../nas/transport.js";
|
package/dist/ctl/transport.js
CHANGED
|
@@ -1,83 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import https from 'node:https';
|
|
4
|
-
import { CtlError } from './errors.js';
|
|
5
|
-
export const CTL_TIMEOUT_MS = 62000;
|
|
6
|
-
export const APP_COOKIE = Symbol("ctlAppCookie");
|
|
7
|
-
export function canonicalOrigin(input, tunnel = false) {
|
|
8
|
-
let url;
|
|
9
|
-
try {
|
|
10
|
-
url = new URL(input);
|
|
11
|
-
}
|
|
12
|
-
catch {
|
|
13
|
-
throw new CtlError('CTL_URL_INVALID', 'A NAS origin URL is required.', 'run: synopkgland ctl host add <name> --url https://<nas>:5001');
|
|
14
|
-
}
|
|
15
|
-
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || !(url.protocol === 'https:' || (tunnel && url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname))))
|
|
16
|
-
throw new CtlError('CTL_URL_INVALID', 'Use HTTPS, or explicit --ssh-tunnel for a loopback HTTP tunnel.', 'run: synopkgland ctl host add <name> --url https://<nas>:5001');
|
|
17
|
-
return url.origin;
|
|
18
|
-
}
|
|
19
|
-
export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca) {
|
|
20
|
-
return async (path, body, session) => {
|
|
21
|
-
if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
|
|
22
|
-
throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
|
|
23
|
-
const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs;
|
|
24
|
-
if (remaining <= 0)
|
|
25
|
-
throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
|
|
26
|
-
const url = new URL(path, origin);
|
|
27
|
-
if (url.origin !== origin || (session && session.origin !== origin))
|
|
28
|
-
throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins.', 'run: synopkgland ctl host list', 4);
|
|
29
|
-
const form = body instanceof URLSearchParams;
|
|
30
|
-
const payload = Buffer.from(form ? body.toString() : JSON.stringify(body));
|
|
31
|
-
if (payload.length > 1048576)
|
|
32
|
-
throw new CtlError('CTL_INPUT_TOO_LARGE', 'Request exceeds 1 MiB.', 'Reduce the input.');
|
|
33
|
-
return new Promise((resolve, reject) => {
|
|
34
|
-
const headers = { 'content-type': form ? 'application/x-www-form-urlencoded' : 'application/json', accept: 'application/json', 'content-length': String(payload.length), origin };
|
|
35
|
-
if (session) {
|
|
36
|
-
headers.cookie = session.cookie;
|
|
37
|
-
headers['X-SYNO-TOKEN'] = session.token;
|
|
38
|
-
}
|
|
39
|
-
const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: remaining, ...(ca ? { ca } : {}) }, res => {
|
|
40
|
-
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
41
|
-
res.resume();
|
|
42
|
-
reject(new CtlError('CTL_HTTP_ERROR', `HTTP ${res.statusCode}; redirects are never followed.`, 'Check the NAS URL and connection.', 6));
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
if (!res.headers['content-type']?.includes('application/json')) {
|
|
46
|
-
res.resume();
|
|
47
|
-
reject(new CtlError('CTL_RESPONSE_INVALID', 'NAS returned non-JSON data.', 'Check the endpoint and installed package.', 5));
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
const chunks = [];
|
|
51
|
-
let length = 0;
|
|
52
|
-
res.on('data', (c) => { length += c.length; if (length > 1048576) {
|
|
53
|
-
res.destroy();
|
|
54
|
-
reject(new CtlError('CTL_RESPONSE_TOO_LARGE', 'Response exceeds 1 MiB.', 'Use a smaller operation.', 5));
|
|
55
|
-
}
|
|
56
|
-
else
|
|
57
|
-
chunks.push(c); });
|
|
58
|
-
res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Response interrupted.', 'Check the NAS connection; state-changing requests are not replayed.', 6)));
|
|
59
|
-
res.on('end', () => {
|
|
60
|
-
try {
|
|
61
|
-
const body = strictJson(Buffer.concat(chunks));
|
|
62
|
-
if (!form && body?.success === true && body.data && path.startsWith('/webman/3rdparty/') && url.protocol === 'https:') {
|
|
63
|
-
const cookies = (res.headers['set-cookie'] ?? []).filter(line => /;\s*Secure(?:;|$)/i.test(line) && /;\s*HttpOnly(?:;|$)/i.test(line) && !/;\s*Domain=/i.test(line));
|
|
64
|
-
const own = path.slice(0, path.lastIndexOf('/cgi/')) + '/';
|
|
65
|
-
const valid = cookies.filter(line => { const match = /;\s*Path=([^;]+)/i.exec(line); return match?.[1] === own; }).map(line => line.split(';')[0]).filter(pair => !pair.startsWith('id='));
|
|
66
|
-
if (valid.length)
|
|
67
|
-
Object.defineProperty(body, APP_COOKIE, { value: valid.join('; '), enumerable: false });
|
|
68
|
-
}
|
|
69
|
-
resolve(body);
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
reject(new CtlError('CTL_RESPONSE_INVALID', 'Invalid JSON response.', 'Check the installed package.', 5));
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
const wallTimer = setTimeout(() => req.destroy(), remaining);
|
|
77
|
-
req.once('close', () => clearTimeout(wallTimer));
|
|
78
|
-
req.on('timeout', () => req.destroy());
|
|
79
|
-
req.on('error', () => reject(new CtlError('CTL_NETWORK', 'NAS request failed or timed out.', 'Check the NAS connection and trusted CA; requests are not replayed.', 6)));
|
|
80
|
-
req.end(payload);
|
|
81
|
-
});
|
|
82
|
-
};
|
|
83
|
-
}
|
|
1
|
+
// Compatibility export; NAS connection implementation lives in one module.
|
|
2
|
+
export * from "../nas/transport.js";
|
package/dist/e2e/dsm.d.ts
CHANGED
|
@@ -129,10 +129,10 @@ export declare function dismissNotifications(page?: Page, env?: Env): Promise<nu
|
|
|
129
129
|
/**
|
|
130
130
|
* Whether the signed-in desktop's session is usable: it holds a SynoToken
|
|
131
131
|
* (a restored `stay_login` cookie alone yields none, and the app's CGIs then
|
|
132
|
-
* answer 119) and DSM's login.cgi, asked
|
|
132
|
+
* answer 119) and DSM's login.cgi, asked through the shared NAS transport, still accepts the
|
|
133
133
|
* session. Any failure to ask counts as not live.
|
|
134
134
|
*/
|
|
135
|
-
export declare function dsmSessionLive(page: Pick<SessionPage, "evaluate"
|
|
135
|
+
export declare function dsmSessionLive(page: Pick<SessionPage, "evaluate">, check?: (token: string) => Promise<boolean>, env?: Env): Promise<boolean>;
|
|
136
136
|
/**
|
|
137
137
|
* `app.open(path)`, once more after net::ERR_ABORTED: about one first
|
|
138
138
|
* navigation in ten was aborted as DSM's desktop navigated on its own, and the
|
package/dist/e2e/dsm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// DSM helpers for package e2e suites on upstream `e2e` + `@e2e-dev/web`.
|
|
2
|
-
//
|
|
2
|
+
// Import through @synopackageland/cli/e2e to share the CLI NAS connection path.
|
|
3
3
|
// It signs in once per run (a saved, private profile is restored when still valid),
|
|
4
4
|
// opens DSM app windows and Application Portal pages (accepting their native
|
|
5
5
|
// dialogs when given the `browser` fixture), dismisses DSM notifications,
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// e2e output, which is made private (directories 700, files 600).
|
|
10
10
|
//
|
|
11
11
|
// node tests/e2e/support/dsm.ts tunnel -- npx e2e run
|
|
12
|
+
import { loginDsm } from "../nas/auth.js";
|
|
13
|
+
import { sessionFetch } from "../nas/transport.js";
|
|
14
|
+
import { canonicalOrigin } from "../nas/transport.js";
|
|
12
15
|
import { spawn } from "node:child_process";
|
|
13
16
|
import { randomUUID } from "node:crypto";
|
|
14
17
|
import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -389,16 +392,25 @@ export async function dismissNotifications(page = dsmPage(), env = process.env)
|
|
|
389
392
|
/**
|
|
390
393
|
* Whether the signed-in desktop's session is usable: it holds a SynoToken
|
|
391
394
|
* (a restored `stay_login` cookie alone yields none, and the app's CGIs then
|
|
392
|
-
* answer 119) and DSM's login.cgi, asked
|
|
395
|
+
* answer 119) and DSM's login.cgi, asked through the shared NAS transport, still accepts the
|
|
393
396
|
* session. Any failure to ask counts as not live.
|
|
394
397
|
*/
|
|
395
|
-
export async function dsmSessionLive(page) {
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
398
|
+
export async function dsmSessionLive(page, check, env = process.env) {
|
|
399
|
+
const token = await page.evaluate(() => window.SYNO?.SDS?.Session?.SynoToken).catch(() => undefined);
|
|
400
|
+
if (typeof token !== 'string' || !token)
|
|
401
|
+
return false;
|
|
402
|
+
try {
|
|
403
|
+
if (check)
|
|
404
|
+
return await check(token);
|
|
405
|
+
const state = await surface().context().storageState();
|
|
406
|
+
const cookie = state.cookies.filter(c => sessionCookieNames.includes(c.name)).map(c => `${c.name}=${c.value}`).join('; ');
|
|
407
|
+
const origin = canonicalOrigin(dsmUrl(env), true);
|
|
408
|
+
const response = await sessionFetch(origin + '/webman/login.cgi', {}, { origin, username: 'e2e', cookie, token });
|
|
409
|
+
return (await response.json())?.success === true;
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
402
414
|
}
|
|
403
415
|
function accountFile(user, env) {
|
|
404
416
|
const key = `DSM_E2E_ACCOUNT_${user.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
|
|
@@ -447,8 +459,8 @@ export async function openUntilLoaded(app, path, { timeoutMs = 30_000, pauseMs =
|
|
|
447
459
|
}
|
|
448
460
|
}
|
|
449
461
|
}
|
|
450
|
-
async function restoredSessionLive(page) {
|
|
451
|
-
return await desktopOrLogin(page) === "desktop" && await dsmSessionLive(page);
|
|
462
|
+
async function restoredSessionLive(page, env) {
|
|
463
|
+
return await desktopOrLogin(page) === "desktop" && await dsmSessionLive(page, undefined, env);
|
|
452
464
|
}
|
|
453
465
|
/**
|
|
454
466
|
* Restores the saved profile of `user`; signs in with the account file only
|
|
@@ -466,18 +478,22 @@ export async function signIn(app, user = "admin", env = process.env) {
|
|
|
466
478
|
await openPath(app, "/");
|
|
467
479
|
const page = dsmPage();
|
|
468
480
|
let outcome = "restored";
|
|
469
|
-
if (!profile || !await restoredSessionLive(page)) {
|
|
481
|
+
if (!profile || !await restoredSessionLive(page, env)) {
|
|
470
482
|
// A refused session still shows the desktop; dropping its cookies shows the login form.
|
|
471
483
|
if (profile) {
|
|
472
484
|
await context.clearCookies();
|
|
473
485
|
await openUntilLoaded(app, "/");
|
|
474
486
|
}
|
|
475
487
|
const account = readAccount(accountFile(user, env), user);
|
|
476
|
-
await
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
await
|
|
480
|
-
await
|
|
488
|
+
const session = await loginDsm(canonicalOrigin(url, true), account.username, account.password, {
|
|
489
|
+
remember: false, otp: async () => env.DSM_OTP ?? '',
|
|
490
|
+
});
|
|
491
|
+
const state = await context.storageState();
|
|
492
|
+
await context.setStorageState({ ...state, cookies: [...state.cookies, {
|
|
493
|
+
name: 'id', value: session.cookie.slice(3), domain: new URL(url).hostname, path: '/',
|
|
494
|
+
httpOnly: true, secure: new URL(url).protocol === 'https:', sameSite: 'Lax', expires: -1,
|
|
495
|
+
}] });
|
|
496
|
+
await openUntilLoaded(app, '/');
|
|
481
497
|
outcome = "signed-in";
|
|
482
498
|
}
|
|
483
499
|
await waitForDesktop(page);
|
package/dist/error-registry.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { COMPOSE_ERRORS } from "./standalone/compose-errors.js";
|
|
2
2
|
export const ERROR_REGISTRY = {
|
|
3
3
|
...COMPOSE_ERRORS,
|
|
4
|
+
HOST_CERT_CHANGED: { "code": "HOST_CERT_CHANGED", "description": "Strict TLS: the NAS certificate differs from the recorded SHA-256", "suggestion": "Verify old/new fingerprints, then run synopkgland host trust <name>" },
|
|
5
|
+
HOST_TLS_UNTRUSTED: { "code": "HOST_TLS_UNTRUSTED", "description": "Strict TLS: the NAS certificate failed CA, hostname or validity verification", "suggestion": "Check the CA file, NAS hostname and certificate validity, or omit strict TLS" },
|
|
6
|
+
HOST_DNS_FAILED: { "code": "HOST_DNS_FAILED", "description": "The NAS hostname could not be resolved", "suggestion": "Check the registered hostname and DNS" },
|
|
7
|
+
HOST_CONNECTION_REFUSED: { "code": "HOST_CONNECTION_REFUSED", "description": "The NAS refused the connection", "suggestion": "Check the DSM port and NAS availability" },
|
|
8
|
+
HOST_TIMEOUT: { "code": "HOST_TIMEOUT", "description": "The NAS request timed out", "suggestion": "Check connection and firewall; inspect state before retrying writes" },
|
|
9
|
+
CTL_HOST_REQUIRED: { "code": "CTL_HOST_REQUIRED", "description": "Select a registered NAS host", "suggestion": "Run synopkgland host add, select --host, or set host default" },
|
|
10
|
+
CTL_NOT_AUTHENTICATED: { "code": "CTL_NOT_AUTHENTICATED", "description": "The DSM session is absent or expired", "suggestion": "Run synopkgland host login <name>" },
|
|
11
|
+
NAS_ADMIN_REQUIRED: { "code": "NAS_ADMIN_REQUIRED", "description": "Package installation and removal require an administrator", "suggestion": "Register and log in with a DSM administrator account" },
|
|
4
12
|
INIT_UPSTREAM_INVALID: { code: "INIT_UPSTREAM_INVALID", description: "The upstream repository URL is invalid.", suggestion: "Supply --upstream with an HTTPS repository URL without credentials." },
|
|
5
13
|
INIT_PUBLISHER_INVALID: { code: "INIT_PUBLISHER_INVALID", description: "The attribution name or publisher URL is invalid.", suggestion: "Use plain single-line project/publisher names and an http(s) publisher URL without credentials." },
|
|
6
14
|
UPSTREAM_ATTRIBUTION: { code: "UPSTREAM_ATTRIBUTION", description: "Upstream App attribution is incomplete or names the distributor as maintainer.", suggestion: "Name the original project/team or owner as maintainer; set metadata.maintainer_url to the upstream repository and metadata.distributor/distributor_url to your publisher; resolve TODOs before release." },
|
|
@@ -725,16 +733,6 @@ export const ERROR_REGISTRY = {
|
|
|
725
733
|
description: "Writing a single file needs an architecture.",
|
|
726
734
|
suggestion: "Combine -o with a single --arch, or use --out-dir.",
|
|
727
735
|
},
|
|
728
|
-
HOST_REQUIRED: {
|
|
729
|
-
code: "HOST_REQUIRED",
|
|
730
|
-
description: "No NAS was specified.",
|
|
731
|
-
suggestion: "Use --host <profile>; for a local deploy check use --dry-run.",
|
|
732
|
-
},
|
|
733
|
-
NAS_CREDENTIALS_MISSING: {
|
|
734
|
-
code: "NAS_CREDENTIALS_MISSING",
|
|
735
|
-
description: "NAS credentials were not found.",
|
|
736
|
-
suggestion: "Set a synoagentcli profile or DSM_URL/DSM_PASSWORD.",
|
|
737
|
-
},
|
|
738
736
|
NATIVE_LAYOUT_SCRIPT_NOT_EXECUTABLE: {
|
|
739
737
|
code: "NATIVE_LAYOUT_SCRIPT_NOT_EXECUTABLE",
|
|
740
738
|
description: "A scripts/ lifecycle script in the native layout metadata is not executable (inspect's SPK_SCRIPT_NOT_EXECUTABLE).",
|
|
@@ -1002,8 +1000,8 @@ export const ERROR_REGISTRY = {
|
|
|
1002
1000
|
},
|
|
1003
1001
|
DSM_TLS_UNTRUSTED: {
|
|
1004
1002
|
code: "DSM_TLS_UNTRUSTED",
|
|
1005
|
-
description: "
|
|
1006
|
-
suggestion: "
|
|
1003
|
+
description: "Strict TLS: the DSM certificate failed CA, hostname or validity verification",
|
|
1004
|
+
suggestion: "Check the --ca PEM file, NAS hostname and certificate validity; NAS certificates are accepted by default without --strict-tls or --ca",
|
|
1007
1005
|
},
|
|
1008
1006
|
STANDALONE_ICON_INVALID: {
|
|
1009
1007
|
code: "STANDALONE_ICON_INVALID",
|
|
@@ -10,15 +10,6 @@ interface CatalogPackageEntry {
|
|
|
10
10
|
breakpkgs?: unknown;
|
|
11
11
|
replacepkgs?: unknown;
|
|
12
12
|
}
|
|
13
|
-
export interface CatalogInstallOptions {
|
|
14
|
-
dsmOrigin: string;
|
|
15
|
-
cookieHeader: string;
|
|
16
|
-
packageId: string;
|
|
17
|
-
profileName?: string;
|
|
18
|
-
expectedVersion?: string;
|
|
19
|
-
packageSourceOrigin?: string;
|
|
20
|
-
extraValues?: Record<string, string>;
|
|
21
|
-
}
|
|
22
13
|
export interface InstalledPackageState {
|
|
23
14
|
installed: boolean;
|
|
24
15
|
version?: string;
|
|
@@ -75,5 +66,4 @@ export interface LocalSpkInstallOptions {
|
|
|
75
66
|
extraValues?: Record<string, string>;
|
|
76
67
|
}
|
|
77
68
|
export declare function installPackageFromLocalSpk(options: LocalSpkInstallOptions): Promise<void>;
|
|
78
|
-
export declare function installPackageFromCatalog(options: CatalogInstallOptions): Promise<void>;
|
|
79
69
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { CtlError } from '../ctl/errors.js';
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { dsmFetch } from "./dsm-http.js";
|
|
4
4
|
import { assertDsmCompoundResponse } from "./dsm-compound.js";
|
|
5
5
|
function normalizeDeppkgs(deppkgs) {
|
|
@@ -31,81 +31,11 @@ async function dsmWebApi(dsmOrigin, cookieHeader, params, options = {}) {
|
|
|
31
31
|
if (!body.success &&
|
|
32
32
|
!options.allowFailure &&
|
|
33
33
|
!(errorCode && options.allowErrorCodes?.includes(errorCode))) {
|
|
34
|
-
throw new Error(`DSM ${params.api}/${params.method} failed: ${
|
|
34
|
+
throw new Error(`DSM ${params.api}/${params.method} failed: ${Number(body.error?.code) || "invalid response"}`);
|
|
35
35
|
}
|
|
36
36
|
return body;
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
const sid = extractSid(options.cookieHeader);
|
|
40
|
-
const list = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
41
|
-
api: "SYNO.Core.Package.Server",
|
|
42
|
-
version: "2",
|
|
43
|
-
method: "list",
|
|
44
|
-
blforcereload: "true",
|
|
45
|
-
blloadothers: "true",
|
|
46
|
-
_sid: sid,
|
|
47
|
-
});
|
|
48
|
-
const data = list.data;
|
|
49
|
-
const entries = [...(data.packages ?? []), ...(data.beta_packages ?? [])];
|
|
50
|
-
const entry = entries.find((item) => item.package === options.packageId);
|
|
51
|
-
if (!entry) {
|
|
52
|
-
throw new Error(`Package ${options.packageId} not found in Package Source catalog`);
|
|
53
|
-
}
|
|
54
|
-
return entry;
|
|
55
|
-
}
|
|
56
|
-
async function fetchCatalogEntryFromOrigin(origin, packageId) {
|
|
57
|
-
const catalogUrl = `${origin.replace(/\/$/, "")}/?arch=noarch&build=69057&major=7`;
|
|
58
|
-
try {
|
|
59
|
-
const response = await dsmFetch(catalogUrl);
|
|
60
|
-
if (!response.ok) {
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
const data = (await response.json());
|
|
64
|
-
const entries = [...(data.packages ?? []), ...(data.beta_packages ?? [])];
|
|
65
|
-
return entries.find((item) => item.package === packageId);
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
return undefined;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
async function waitForCatalogEntry(options, expectedVersion) {
|
|
72
|
-
const deadline = Date.now() + 5 * 60 * 1000;
|
|
73
|
-
let lastVersion;
|
|
74
|
-
while (Date.now() < deadline) {
|
|
75
|
-
const originEntry = options.packageSourceOrigin
|
|
76
|
-
? await fetchCatalogEntryFromOrigin(options.packageSourceOrigin, options.packageId)
|
|
77
|
-
: undefined;
|
|
78
|
-
const entry = originEntry ?? await fetchCatalogEntry(options);
|
|
79
|
-
lastVersion = entry.version;
|
|
80
|
-
if (entry.version === expectedVersion) {
|
|
81
|
-
return entry;
|
|
82
|
-
}
|
|
83
|
-
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
84
|
-
}
|
|
85
|
-
throw new Error(`Package ${options.packageId} catalog shows ${lastVersion ?? "unknown"}; expected ${expectedVersion}`);
|
|
86
|
-
}
|
|
87
|
-
function readInstalledPackageState(packageId, profileName) {
|
|
88
|
-
const args = ["dsm", "package-center", "get", packageId];
|
|
89
|
-
if (profileName) {
|
|
90
|
-
args.push("--host", profileName);
|
|
91
|
-
}
|
|
92
|
-
const result = spawnSync("synoagentcli", args, { encoding: "utf8" });
|
|
93
|
-
if (result.status !== 0) {
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
try {
|
|
97
|
-
const parsed = JSON.parse(result.stdout);
|
|
98
|
-
return packageStateFromRecord(parsed.data);
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
function record(value) {
|
|
105
|
-
return value && typeof value === "object" && !Array.isArray(value)
|
|
106
|
-
? value
|
|
107
|
-
: {};
|
|
108
|
-
}
|
|
38
|
+
function record(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
|
|
109
39
|
function stringValue(...values) {
|
|
110
40
|
return values.find((value) => typeof value === "string" && value.length > 0);
|
|
111
41
|
}
|
|
@@ -191,19 +121,17 @@ export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packag
|
|
|
191
121
|
});
|
|
192
122
|
return packageStateFromListPayload(body, packageId);
|
|
193
123
|
}
|
|
194
|
-
catch {
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error instanceof CtlError)
|
|
126
|
+
throw error;
|
|
195
127
|
return undefined;
|
|
196
128
|
}
|
|
197
129
|
}
|
|
198
|
-
function isPackageInstalled(packageId, profileName) {
|
|
199
|
-
const state = readInstalledPackageState(packageId, profileName);
|
|
200
|
-
return state?.installed === true;
|
|
201
|
-
}
|
|
202
130
|
function installStatusIsBroken(status) {
|
|
203
131
|
if (!status) {
|
|
204
132
|
return false;
|
|
205
133
|
}
|
|
206
|
-
return /(broken|error|fail|
|
|
134
|
+
return /(broken|error|fail|150)/i.test(status);
|
|
207
135
|
}
|
|
208
136
|
export async function waitForInstalledVersion(readState, options) {
|
|
209
137
|
const deadline = Date.now() + (options.deadlineMs ?? 15 * 60 * 1000);
|
|
@@ -213,7 +141,7 @@ export async function waitForInstalledVersion(readState, options) {
|
|
|
213
141
|
if (state?.installed && installStatusIsBroken(state.status)) {
|
|
214
142
|
throw new Error(`Package ${options.packageId} install did not complete (Package Center status ${state.status}). Check synopkg postinst / webservice acquire.`);
|
|
215
143
|
}
|
|
216
|
-
if (state?.installed) {
|
|
144
|
+
if (state?.installed && !/installing|upgrading/i.test(state.status ?? "")) {
|
|
217
145
|
if (!options.expectedVersion || state.version === options.expectedVersion) {
|
|
218
146
|
return;
|
|
219
147
|
}
|
|
@@ -262,14 +190,19 @@ export async function uninstallPackageFromDsm(options) {
|
|
|
262
190
|
try {
|
|
263
191
|
const sid = extractSid(options.cookieHeader);
|
|
264
192
|
const readState = () => fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId);
|
|
265
|
-
await
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
193
|
+
const before = await readState();
|
|
194
|
+
if (!before)
|
|
195
|
+
throw new Error("Cannot verify package state before removal");
|
|
196
|
+
if (!packageStatusIsStopped(before)) {
|
|
197
|
+
await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
198
|
+
api: "SYNO.Core.Package.Control",
|
|
199
|
+
version: "1",
|
|
200
|
+
method: "stop",
|
|
201
|
+
id: jsonApiParam(options.packageId),
|
|
202
|
+
_sid: sid,
|
|
203
|
+
});
|
|
204
|
+
await waitForPackageStopped(readState, options);
|
|
205
|
+
}
|
|
273
206
|
await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
274
207
|
api: "SYNO.Core.Package.Uninstallation",
|
|
275
208
|
version: "1",
|
|
@@ -379,14 +312,14 @@ async function uploadLocalSpk(options, sid) {
|
|
|
379
312
|
});
|
|
380
313
|
const body = (await uploadResponse.json());
|
|
381
314
|
if (!body.success || !body.data?.id || !body.data.task_id) {
|
|
382
|
-
throw new Error(`DSM SYNO.Core.Package.Installation/upload failed: ${
|
|
315
|
+
throw new Error(`DSM SYNO.Core.Package.Installation/upload failed: ${Number(body.error?.code) || "invalid response"}`);
|
|
383
316
|
}
|
|
384
317
|
return { id: body.data.id, taskId: body.data.task_id };
|
|
385
318
|
}
|
|
386
319
|
export async function installPackageFromLocalSpk(options) {
|
|
387
320
|
const sid = extractSid(options.cookieHeader);
|
|
388
321
|
const dsmState = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId);
|
|
389
|
-
const installed = dsmState?.installed === true
|
|
322
|
+
const installed = dsmState?.installed === true;
|
|
390
323
|
const uploaded = await uploadLocalSpk(options, sid);
|
|
391
324
|
const compound = JSON.stringify(catalogCompoundSteps(uploaded, { version: options.version, size: readFileSync(options.spkPath).byteLength }, { blupgrade: installed, extraValues: options.extraValues }));
|
|
392
325
|
const compoundResponse = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
@@ -399,54 +332,5 @@ export async function installPackageFromLocalSpk(options) {
|
|
|
399
332
|
_sid: sid,
|
|
400
333
|
}, { allowFailure: true });
|
|
401
334
|
assertDsmCompoundResponse(compoundResponse);
|
|
402
|
-
await waitForInstalledVersion(async () =>
|
|
403
|
-
readInstalledPackageState(options.packageId, options.profileName), { packageId: options.packageId, expectedVersion: options.version });
|
|
404
|
-
}
|
|
405
|
-
async function uploadCatalogSpk(options, entry, sid) {
|
|
406
|
-
const response = await dsmFetch(entry.link);
|
|
407
|
-
if (!response.ok) {
|
|
408
|
-
throw new Error(`Failed to download catalog SPK for ${entry.package}: HTTP ${response.status}`);
|
|
409
|
-
}
|
|
410
|
-
const bytes = Buffer.from(await response.arrayBuffer());
|
|
411
|
-
const form = new FormData();
|
|
412
|
-
form.append("api", "SYNO.Core.Package.Installation");
|
|
413
|
-
form.append("method", "upload");
|
|
414
|
-
form.append("version", "1");
|
|
415
|
-
form.append("_sid", sid);
|
|
416
|
-
form.append("file", new Blob([bytes]), `${entry.package}.spk`);
|
|
417
|
-
const uploadResponse = await dsmFetch(`${options.dsmOrigin}/webapi/entry.cgi`, {
|
|
418
|
-
method: "POST",
|
|
419
|
-
headers: { cookie: options.cookieHeader },
|
|
420
|
-
body: form,
|
|
421
|
-
});
|
|
422
|
-
const body = (await uploadResponse.json());
|
|
423
|
-
if (!body.success || !body.data?.id || !body.data.task_id) {
|
|
424
|
-
throw new Error(`DSM SYNO.Core.Package.Installation/upload failed: ${JSON.stringify(body.error ?? body)}`);
|
|
425
|
-
}
|
|
426
|
-
return { id: body.data.id, taskId: body.data.task_id };
|
|
427
|
-
}
|
|
428
|
-
async function installCatalogViaUpload(options, entry, sid, installed) {
|
|
429
|
-
const uploaded = await uploadCatalogSpk(options, entry, sid);
|
|
430
|
-
const compound = JSON.stringify(catalogCompoundSteps(uploaded, entry, { blupgrade: installed, extraValues: options.extraValues }));
|
|
431
|
-
const compoundResponse = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
432
|
-
api: "SYNO.Entry.Request",
|
|
433
|
-
version: "1",
|
|
434
|
-
method: "request",
|
|
435
|
-
stop_when_error: "true",
|
|
436
|
-
mode: jsonApiParam("sequential"),
|
|
437
|
-
compound,
|
|
438
|
-
_sid: sid,
|
|
439
|
-
}, { allowFailure: true });
|
|
440
|
-
assertDsmCompoundResponse(compoundResponse);
|
|
441
|
-
}
|
|
442
|
-
export async function installPackageFromCatalog(options) {
|
|
443
|
-
const entry = options.expectedVersion
|
|
444
|
-
? await waitForCatalogEntry(options, options.expectedVersion)
|
|
445
|
-
: await fetchCatalogEntry(options);
|
|
446
|
-
const sid = extractSid(options.cookieHeader);
|
|
447
|
-
const dsmState = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId);
|
|
448
|
-
const installed = dsmState?.installed === true || isPackageInstalled(options.packageId, options.profileName);
|
|
449
|
-
await installCatalogViaUpload(options, entry, sid, installed);
|
|
450
|
-
await waitForInstalledVersion(async () => (await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId)) ??
|
|
451
|
-
readInstalledPackageState(options.packageId, options.profileName), { packageId: options.packageId, expectedVersion: entry.version });
|
|
335
|
+
await waitForInstalledVersion(async () => await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId), { packageId: options.packageId, expectedVersion: options.version });
|
|
452
336
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../nas/connection.js";
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function resolveHostCredentials(hostName?: string): HostCredentials | null;
|
|
1
|
+
export * from "../nas/credentials.js";
|