@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
package/dist/host/credentials.js
CHANGED
|
@@ -1,67 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
function findProfile(settings, input) {
|
|
5
|
-
if (!input) {
|
|
6
|
-
return settings.hosts?.[settings.defaultHost ?? "default"];
|
|
7
|
-
}
|
|
8
|
-
// 1. Direct match by profile name
|
|
9
|
-
if (settings.hosts?.[input]) {
|
|
10
|
-
return settings.hosts[input];
|
|
11
|
-
}
|
|
12
|
-
// 2. Match by URL hostname or exact URL
|
|
13
|
-
for (const profile of Object.values(settings.hosts ?? {})) {
|
|
14
|
-
if (profile?.url) {
|
|
15
|
-
try {
|
|
16
|
-
const u = new URL(profile.url);
|
|
17
|
-
if (u.hostname === input ||
|
|
18
|
-
profile.url === input ||
|
|
19
|
-
profile.url.replace(/\/$/, "") === input.replace(/\/$/, "")) {
|
|
20
|
-
return profile;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
// ignore invalid URL
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return undefined;
|
|
29
|
-
}
|
|
30
|
-
export function resolveHostCredentials(hostName) {
|
|
31
|
-
const settingsPath = join(homedir(), ".synoagentcli", "setting.json");
|
|
32
|
-
let settings;
|
|
33
|
-
try {
|
|
34
|
-
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
return fromEnv(hostName);
|
|
38
|
-
}
|
|
39
|
-
const profile = findProfile(settings, hostName);
|
|
40
|
-
const username = profile?.username ?? profile?.auth?.username;
|
|
41
|
-
const password = profile?.password ?? profile?.auth?.password;
|
|
42
|
-
if (profile?.url && username && password) {
|
|
43
|
-
return {
|
|
44
|
-
dsmOrigin: profile.url,
|
|
45
|
-
username,
|
|
46
|
-
password,
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
if (profile?.url) {
|
|
50
|
-
return fromEnv(profile.url);
|
|
51
|
-
}
|
|
52
|
-
return fromEnv(hostName);
|
|
53
|
-
}
|
|
54
|
-
function fromEnv(urlOverride) {
|
|
55
|
-
let dsmOrigin = process.env.DSM_URL;
|
|
56
|
-
if (urlOverride) {
|
|
57
|
-
dsmOrigin = urlOverride.startsWith("http://") || urlOverride.startsWith("https://")
|
|
58
|
-
? urlOverride
|
|
59
|
-
: `https://${urlOverride}:5001`;
|
|
60
|
-
}
|
|
61
|
-
const password = process.env.DSM_PASSWORD;
|
|
62
|
-
const username = process.env.DSM_USERNAME ?? "admin";
|
|
63
|
-
if (!dsmOrigin || !password) {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
return { dsmOrigin, username, password };
|
|
67
|
-
}
|
|
1
|
+
// Compatibility export; NAS connection implementation lives in one module.
|
|
2
|
+
export * from "../nas/credentials.js";
|
|
@@ -5,17 +5,15 @@ export interface CreateNasHostOptions {
|
|
|
5
5
|
credentials?: HostCredentials;
|
|
6
6
|
}
|
|
7
7
|
export declare function createNasHost(options?: CreateNasHostOptions): NasHost | null;
|
|
8
|
-
declare function synoagentcli(args: string[], profileName?: string): {
|
|
9
|
-
status: number | null;
|
|
10
|
-
stdout: string;
|
|
11
|
-
stderr: string;
|
|
12
|
-
};
|
|
13
8
|
/**
|
|
14
9
|
* DSM's User Home read reports the selected volume, not the final share path.
|
|
15
10
|
* Keep the projection strict: an unexpected response must not become a host
|
|
16
11
|
* bind to an arbitrary path.
|
|
17
12
|
*/
|
|
18
|
-
export declare function homesPathFromDsmUserHomeRead(result:
|
|
13
|
+
export declare function homesPathFromDsmUserHomeRead(result: {
|
|
14
|
+
status: number | null;
|
|
15
|
+
stdout: string;
|
|
16
|
+
}): string | undefined;
|
|
19
17
|
/**
|
|
20
18
|
* DSM reports the CPU vendor, not the ISA. Every x86_64 platform Synology
|
|
21
19
|
* ships is Intel or AMD; the aarch64 ones are Realtek (rtd1296, rtd1619b) and
|
|
@@ -26,4 +24,3 @@ export declare function homesPathFromDsmUserHomeRead(result: Pick<ReturnType<typ
|
|
|
26
24
|
* unusable SPK.
|
|
27
25
|
*/
|
|
28
26
|
export declare function archFamilyForCpuVendor(vendor: string | undefined): NativeArchFamily | undefined;
|
|
29
|
-
export {};
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { sessionForCredentials } from "../nas/credentials.js";
|
|
2
|
+
import { CtlError } from '../ctl/errors.js';
|
|
3
|
+
import { listShareNames, lookupSharePath, } from "./share-lookup.js";
|
|
4
|
+
import { createDsmApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
|
|
4
5
|
import { fetchPortInfo } from "./port-info-lookup.js";
|
|
5
6
|
import { dsmFetch } from "./dsm-http.js";
|
|
6
7
|
import { versionUnsupported } from "../runtime/dsm-version.js";
|
|
7
8
|
import { fetchDsmPortProfileInventory } from "./dsm-port-profile.js";
|
|
8
|
-
import { installPackageFromLocalSpk, fetchInstalledPackageState,
|
|
9
|
+
import { installPackageFromLocalSpk, fetchInstalledPackageState, } from "./catalog-install.js";
|
|
9
10
|
import { resolveHostCredentials } from "./credentials.js";
|
|
10
11
|
export function createNasHost(options = {}) {
|
|
11
12
|
const credentials = options.credentials ?? resolveHostCredentials(options.host);
|
|
@@ -14,18 +15,6 @@ export function createNasHost(options = {}) {
|
|
|
14
15
|
}
|
|
15
16
|
return createDsmNasHost(credentials, options.host);
|
|
16
17
|
}
|
|
17
|
-
function synoagentcli(args, profileName) {
|
|
18
|
-
const fullArgs = [...args];
|
|
19
|
-
if (profileName) {
|
|
20
|
-
fullArgs.push("--host", profileName);
|
|
21
|
-
}
|
|
22
|
-
const result = spawnSync("synoagentcli", fullArgs, { encoding: "utf8" });
|
|
23
|
-
return {
|
|
24
|
-
status: result.status,
|
|
25
|
-
stdout: result.stdout ?? "",
|
|
26
|
-
stderr: result.stderr ?? "",
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
18
|
/**
|
|
30
19
|
* DSM's User Home read reports the selected volume, not the final share path.
|
|
31
20
|
* Keep the projection strict: an unexpected response must not become a host
|
|
@@ -68,29 +57,10 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
68
57
|
const dsmOrigin = credentials.dsmOrigin.replace(/\/$/, "");
|
|
69
58
|
let cookieHeader = credentials.cookieHeader;
|
|
70
59
|
const ensureLogin = async () => {
|
|
71
|
-
if (cookieHeader) {
|
|
72
|
-
|
|
60
|
+
if (!cookieHeader) {
|
|
61
|
+
const session = await sessionForCredentials(credentials);
|
|
62
|
+
cookieHeader = session.cookie;
|
|
73
63
|
}
|
|
74
|
-
const query = new URLSearchParams({
|
|
75
|
-
api: "SYNO.API.Auth",
|
|
76
|
-
version: "3",
|
|
77
|
-
method: "login",
|
|
78
|
-
account: credentials.username,
|
|
79
|
-
passwd: credentials.password,
|
|
80
|
-
session: "FileStation",
|
|
81
|
-
format: "cookie",
|
|
82
|
-
});
|
|
83
|
-
const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi`, {
|
|
84
|
-
method: "POST",
|
|
85
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
86
|
-
body: query,
|
|
87
|
-
});
|
|
88
|
-
const login = (await response.json());
|
|
89
|
-
if (!response.ok || login.success !== true)
|
|
90
|
-
throw new Error("DSM login failed");
|
|
91
|
-
cookieHeader = setCookiePairs(response.headers).join("; ");
|
|
92
|
-
if (!cookieHeader)
|
|
93
|
-
throw new Error("DSM login returned no session cookie");
|
|
94
64
|
};
|
|
95
65
|
/**
|
|
96
66
|
* One read-only call of the App's own CGI as the administrator. identity:
|
|
@@ -129,10 +99,21 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
129
99
|
await appCall("identity.logout", appCookie).catch(() => undefined);
|
|
130
100
|
}
|
|
131
101
|
};
|
|
102
|
+
const call = async (api, method, version, params = {}) => {
|
|
103
|
+
await ensureLogin();
|
|
104
|
+
const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi`, { method: 'POST', headers: { cookie: cookieHeader ?? '', 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ api, method, version: String(version), ...params }) });
|
|
105
|
+
const body = await response.json();
|
|
106
|
+
if (body?.success !== true)
|
|
107
|
+
throw Object.assign(new Error(`DSM ${api}/${method} failed (${Number(body?.error?.code) || 'invalid response'})`), { code: body?.error?.code });
|
|
108
|
+
return body;
|
|
109
|
+
};
|
|
132
110
|
return {
|
|
133
111
|
dsmOrigin,
|
|
134
112
|
async installFromLocalSpk(input) {
|
|
135
113
|
await ensureLogin();
|
|
114
|
+
const members = await call('SYNO.Core.Group.Member', 'list', 1, { group: 'administrators', offset: '0', limit: '-1' });
|
|
115
|
+
if (!Array.isArray(members.data?.users) || !members.data.users.some((u) => u.name === credentials.username))
|
|
116
|
+
throw new CtlError('NAS_ADMIN_REQUIRED', 'Package installation requires a DSM administrator account', 'Log in with a DSM administrator account', 4);
|
|
136
117
|
await installPackageFromLocalSpk({
|
|
137
118
|
dsmOrigin,
|
|
138
119
|
cookieHeader: cookieHeader ?? "",
|
|
@@ -154,7 +135,9 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
154
135
|
return { ready: false, executor_digest: "", policy_digest: "", error: read.error };
|
|
155
136
|
return explain(read.data);
|
|
156
137
|
}
|
|
157
|
-
catch {
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof CtlError)
|
|
140
|
+
throw error;
|
|
158
141
|
return undefined;
|
|
159
142
|
}
|
|
160
143
|
},
|
|
@@ -163,7 +146,9 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
163
146
|
const read = await readApp(packageIdentity, "portal.status");
|
|
164
147
|
return read && "data" in read ? read.data : undefined;
|
|
165
148
|
}
|
|
166
|
-
catch {
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error instanceof CtlError)
|
|
151
|
+
throw error;
|
|
167
152
|
return undefined;
|
|
168
153
|
}
|
|
169
154
|
},
|
|
@@ -218,110 +203,57 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
218
203
|
const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?api=SYNO.Core.System&version=1&method=info`, { headers: { cookie: cookieHeader ?? "" } });
|
|
219
204
|
const body = (await response.json());
|
|
220
205
|
if (body.success !== true) {
|
|
206
|
+
if (Number(body.error?.code) === 105)
|
|
207
|
+
throw new CtlError('NAS_ADMIN_REQUIRED', 'NAS deployment requires a DSM administrator account', 'Log in with a DSM administrator account', 4);
|
|
221
208
|
return undefined;
|
|
222
209
|
}
|
|
223
210
|
return archFamilyForCpuVendor(body.data?.cpu_vendor);
|
|
224
211
|
},
|
|
225
212
|
async resolveSharePath(logicalName) {
|
|
226
|
-
return
|
|
213
|
+
return lookupSharePath(logicalName, call);
|
|
227
214
|
},
|
|
228
215
|
async listShareNames() {
|
|
229
|
-
return
|
|
216
|
+
return listShareNames(call);
|
|
230
217
|
},
|
|
231
218
|
async listSharePermissions(logicalName) {
|
|
232
|
-
return fetchSharePermissions(logicalName,
|
|
219
|
+
return fetchSharePermissions(logicalName, createDsmApiCall(call));
|
|
233
220
|
},
|
|
234
221
|
async listUserGroups(userName) {
|
|
235
|
-
return fetchUserGroups(userName,
|
|
222
|
+
return fetchUserGroups(userName, createDsmApiCall(call));
|
|
236
223
|
},
|
|
237
224
|
async listPortInfo() {
|
|
238
225
|
try {
|
|
239
226
|
await ensureLogin();
|
|
240
227
|
return fetchPortInfo(dsmOrigin, cookieHeader ?? "");
|
|
241
228
|
}
|
|
242
|
-
catch {
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (error instanceof CtlError)
|
|
231
|
+
throw error;
|
|
243
232
|
return undefined;
|
|
244
233
|
}
|
|
245
234
|
},
|
|
246
235
|
async resolveHomesPath() {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
"dsm",
|
|
250
|
-
"control-panel",
|
|
251
|
-
"user-group",
|
|
252
|
-
"advanced",
|
|
253
|
-
"get",
|
|
254
|
-
"--section",
|
|
255
|
-
"home",
|
|
256
|
-
], profileName);
|
|
257
|
-
return homesPathFromDsmUserHomeRead(result);
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
return undefined;
|
|
261
|
-
}
|
|
236
|
+
const body = await call('SYNO.Core.User.Home', 'get', 1);
|
|
237
|
+
return homesPathFromDsmUserHomeRead({ status: 0, stdout: JSON.stringify(body) });
|
|
262
238
|
},
|
|
263
239
|
async getInstalledPackageState(packageIdentity) {
|
|
264
240
|
await ensureLogin();
|
|
265
|
-
|
|
266
|
-
if (viaDsm) {
|
|
267
|
-
return viaDsm;
|
|
268
|
-
}
|
|
269
|
-
const result = synoagentcli(["dsm", "package-center", "get", packageIdentity], profileName);
|
|
270
|
-
if (result.status !== 0) {
|
|
271
|
-
return undefined;
|
|
272
|
-
}
|
|
273
|
-
try {
|
|
274
|
-
const parsed = JSON.parse(result.stdout);
|
|
275
|
-
return packageStateFromRecord(parsed.data);
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
return undefined;
|
|
279
|
-
}
|
|
280
|
-
},
|
|
281
|
-
async getInstalledPackageVersion(packageIdentity) {
|
|
282
|
-
await ensureLogin();
|
|
283
|
-
const viaDsm = await fetchInstalledPackageState(dsmOrigin, cookieHeader ?? "", packageIdentity);
|
|
284
|
-
if (viaDsm?.version) {
|
|
285
|
-
return viaDsm.version;
|
|
286
|
-
}
|
|
287
|
-
const result = synoagentcli(["dsm", "package-center", "get", packageIdentity], profileName);
|
|
288
|
-
if (result.status !== 0) {
|
|
289
|
-
return undefined;
|
|
290
|
-
}
|
|
291
|
-
try {
|
|
292
|
-
const parsed = JSON.parse(result.stdout);
|
|
293
|
-
return parsed.data?.version;
|
|
294
|
-
}
|
|
295
|
-
catch {
|
|
296
|
-
return undefined;
|
|
297
|
-
}
|
|
298
|
-
},
|
|
299
|
-
async isPackageInstalled(packageIdentity) {
|
|
300
|
-
await ensureLogin();
|
|
301
|
-
const viaDsm = await fetchInstalledPackageState(dsmOrigin, cookieHeader ?? "", packageIdentity);
|
|
302
|
-
if (viaDsm) {
|
|
303
|
-
return viaDsm.installed;
|
|
304
|
-
}
|
|
305
|
-
const result = synoagentcli(["dsm", "package-center", "get", packageIdentity], profileName);
|
|
306
|
-
return result.status === 0;
|
|
241
|
+
return fetchInstalledPackageState(dsmOrigin, cookieHeader ?? '', packageIdentity);
|
|
307
242
|
},
|
|
243
|
+
async getInstalledPackageVersion(packageIdentity) { return (await this.getInstalledPackageState(packageIdentity))?.version; },
|
|
244
|
+
async isPackageInstalled(packageIdentity) { return (await this.getInstalledPackageState(packageIdentity))?.installed === true; },
|
|
308
245
|
async fetchContainerLogs(packageIdentity) {
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
return [];
|
|
312
|
-
}
|
|
313
|
-
return result.stdout
|
|
314
|
-
.split("\n")
|
|
315
|
-
.map((line) => line.trim())
|
|
316
|
-
.filter(Boolean)
|
|
317
|
-
.map((line) => `[status] ${line}`);
|
|
246
|
+
const state = await this.getInstalledPackageState(packageIdentity);
|
|
247
|
+
return state?.installed ? [`[status] ${state.status ?? 'unknown'} ${state.version ?? ''}`] : [];
|
|
318
248
|
},
|
|
319
249
|
async getDsmPortProfileInventory() {
|
|
320
250
|
try {
|
|
321
251
|
await ensureLogin();
|
|
322
252
|
return await fetchDsmPortProfileInventory(dsmOrigin, cookieHeader ?? "");
|
|
323
253
|
}
|
|
324
|
-
catch {
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (error instanceof CtlError)
|
|
256
|
+
throw error;
|
|
325
257
|
return undefined;
|
|
326
258
|
}
|
|
327
259
|
},
|
package/dist/host/dsm-http.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { dsmFetch, registerDsmSession } from '../nas/http.js';
|
package/dist/host/dsm-http.js
CHANGED
|
@@ -1,124 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
function errorCode(error) {
|
|
6
|
-
return error.cause?.code
|
|
7
|
-
?? error.code;
|
|
8
|
-
}
|
|
9
|
-
function isHostUnreachable(error) {
|
|
10
|
-
const code = errorCode(error);
|
|
11
|
-
return code === "EHOSTUNREACH" || code === "ENETUNREACH";
|
|
12
|
-
}
|
|
13
|
-
const UNTRUSTED_CERTIFICATE = new Set([
|
|
14
|
-
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
15
|
-
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
16
|
-
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
17
|
-
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
18
|
-
"CERT_HAS_EXPIRED",
|
|
19
|
-
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
20
|
-
]);
|
|
21
|
-
/** Node reports an untrusted certificate only as "fetch failed"; say what to do. Verification stays on. */
|
|
22
|
-
function untrustedCertificate(url, code) {
|
|
23
|
-
const origin = new URL(url).origin;
|
|
24
|
-
return Object.assign(new Error(`DSM at ${origin} presented a TLS certificate this computer does not trust (${code}). ` +
|
|
25
|
-
`Export the NAS certificate or its CA as PEM and run with NODE_EXTRA_CA_CERTS=<that file>, ` +
|
|
26
|
-
`or give DSM a certificate whose name matches the host profile. Certificate checks stay on.`), { code: "DSM_TLS_UNTRUSTED" });
|
|
27
|
-
}
|
|
28
|
-
function headerRecord(headers) {
|
|
29
|
-
if (!headers) {
|
|
30
|
-
return {};
|
|
31
|
-
}
|
|
32
|
-
if (headers instanceof Headers) {
|
|
33
|
-
return Object.fromEntries(headers.entries());
|
|
34
|
-
}
|
|
35
|
-
if (Array.isArray(headers)) {
|
|
36
|
-
return Object.fromEntries(headers);
|
|
37
|
-
}
|
|
38
|
-
return { ...headers };
|
|
39
|
-
}
|
|
40
|
-
export async function dsmFetch(url, init = {}) {
|
|
41
|
-
try {
|
|
42
|
-
return await fetch(url, init);
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
const code = errorCode(error);
|
|
46
|
-
if (code && UNTRUSTED_CERTIFICATE.has(code)) {
|
|
47
|
-
throw untrustedCertificate(url, code);
|
|
48
|
-
}
|
|
49
|
-
if (!isHostUnreachable(error)) {
|
|
50
|
-
throw error;
|
|
51
|
-
}
|
|
52
|
-
return curlFetch(url, init);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
function curlConfigString(value) {
|
|
56
|
-
const escapes = { "\\": "\\\\", '"': '\\"', "\t": "\\t", "\n": "\\n", "\r": "\\r", "\v": "\\v" };
|
|
57
|
-
return `"${value.replace(/[\\"\t\n\r\v]/g, (c) => escapes[c])}"`;
|
|
58
|
-
}
|
|
59
|
-
async function curlFetch(url, init) {
|
|
60
|
-
const staging = mkdtempSync(join(tmpdir(), "synopkgland-dsm-http-"));
|
|
61
|
-
const headerFile = join(staging, "headers");
|
|
62
|
-
const bodyFile = join(staging, "body");
|
|
63
|
-
try {
|
|
64
|
-
const configFile = join(staging, "config");
|
|
65
|
-
const args = ["-sS", "-D", headerFile, "-o", bodyFile, "-w", "%{http_code}", "-X", init.method ?? "GET", "-K", configFile];
|
|
66
|
-
// Headers and form text go through a private config file, not argv, and
|
|
67
|
-
// form text is sent literally (curl -F would read "@file" and "<file").
|
|
68
|
-
const config = [];
|
|
69
|
-
const headers = headerRecord(init.headers);
|
|
70
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
71
|
-
if (name.toLowerCase() === "content-type" && init.body instanceof FormData) {
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
if (/[\r\n\0]/.test(name + value)) {
|
|
75
|
-
throw new Error(`Invalid header value: ${name}`);
|
|
76
|
-
}
|
|
77
|
-
config.push(`header = ${curlConfigString(`${name}: ${value}`)}`);
|
|
78
|
-
}
|
|
79
|
-
let stdin;
|
|
80
|
-
if (init.body instanceof FormData) {
|
|
81
|
-
for (const [name, value] of init.body.entries()) {
|
|
82
|
-
if (typeof value === "string") {
|
|
83
|
-
if (/[=\0]/.test(name) || value.includes("\0")) {
|
|
84
|
-
throw new Error(`Invalid form field: ${name}`);
|
|
85
|
-
}
|
|
86
|
-
config.push(`form-string = ${curlConfigString(`${name}=${value}`)}`);
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
const filename = "name" in value && typeof value.name === "string" ? value.name : "upload.bin";
|
|
90
|
-
const partPath = join(staging, filename);
|
|
91
|
-
writeFileSync(partPath, Buffer.from(await value.arrayBuffer()));
|
|
92
|
-
config.push(`form = ${curlConfigString(`${name}=@${partPath};filename=${filename}`)}`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
else if (typeof init.body === "string") {
|
|
96
|
-
stdin = Buffer.from(init.body);
|
|
97
|
-
args.push("--data-binary", "@-");
|
|
98
|
-
}
|
|
99
|
-
else if (init.body) {
|
|
100
|
-
stdin = Buffer.from(await new Response(init.body).arrayBuffer());
|
|
101
|
-
args.push("--data-binary", "@-");
|
|
102
|
-
}
|
|
103
|
-
writeFileSync(configFile, config.map((line) => line + "\n").join(""), { mode: 0o600 });
|
|
104
|
-
args.push(url);
|
|
105
|
-
const result = spawnSync("curl", args, { encoding: "utf8", input: stdin });
|
|
106
|
-
if (result.status !== 0) {
|
|
107
|
-
throw new Error(result.stderr || `curl exit ${result.status}`);
|
|
108
|
-
}
|
|
109
|
-
const status = Number((result.stdout ?? "").trim().slice(-3));
|
|
110
|
-
const rawHeaders = readFileSync(headerFile, "utf8");
|
|
111
|
-
const body = readFileSync(bodyFile);
|
|
112
|
-
const responseHeaders = new Headers();
|
|
113
|
-
for (const line of rawHeaders.split(/\r?\n/)) {
|
|
114
|
-
const idx = line.indexOf(":");
|
|
115
|
-
if (idx > 0) {
|
|
116
|
-
responseHeaders.append(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return new Response(body, { status: Number.isInteger(status) ? status : 502, headers: responseHeaders });
|
|
120
|
-
}
|
|
121
|
-
finally {
|
|
122
|
-
rmSync(staging, { recursive: true, force: true });
|
|
123
|
-
}
|
|
124
|
-
}
|
|
1
|
+
// Compatibility export; every DSM request uses the shared NAS connection module.
|
|
2
|
+
export { dsmFetch, registerDsmSession } from '../nas/http.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CtlError } from '../ctl/errors.js';
|
|
1
2
|
import { basename } from "node:path";
|
|
2
3
|
import { composeProjectName } from "../build-spk/resource-worker.js";
|
|
3
4
|
import { dsmFetch } from "./dsm-http.js";
|
|
@@ -169,7 +170,9 @@ async function fetchJson(dsmOrigin, cookieHeader, params) {
|
|
|
169
170
|
try {
|
|
170
171
|
return await response.json();
|
|
171
172
|
}
|
|
172
|
-
catch {
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error instanceof CtlError)
|
|
175
|
+
throw error;
|
|
173
176
|
return undefined;
|
|
174
177
|
}
|
|
175
178
|
}
|
|
@@ -200,7 +203,9 @@ export async function fetchDsmPortProfileInventory(dsmOrigin, cookieHeader) {
|
|
|
200
203
|
});
|
|
201
204
|
return dsmPortProfileInventoryFromPayloads(portInfoPayload, packageListPayload);
|
|
202
205
|
}
|
|
203
|
-
catch {
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error instanceof CtlError)
|
|
208
|
+
throw error;
|
|
204
209
|
return undefined;
|
|
205
210
|
}
|
|
206
211
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface NasTarget {
|
|
2
|
+
arch: string;
|
|
3
|
+
version: string;
|
|
4
|
+
build: number;
|
|
5
|
+
major: number;
|
|
6
|
+
minor: number;
|
|
7
|
+
}
|
|
8
|
+
export interface StoreEntry {
|
|
9
|
+
package: string;
|
|
10
|
+
version: string;
|
|
11
|
+
link: string;
|
|
12
|
+
size: number;
|
|
13
|
+
md5?: string;
|
|
14
|
+
sha256?: string;
|
|
15
|
+
arch?: string | string[];
|
|
16
|
+
os_min_ver?: string;
|
|
17
|
+
os_max_ver?: string;
|
|
18
|
+
deppkgs?: unknown;
|
|
19
|
+
}
|
|
20
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
21
|
+
export declare function archMatches(value: string | string[] | undefined, arch: string): boolean;
|
|
22
|
+
export declare function assertCompatible(info: Record<string, string>, target: NasTarget): void;
|
|
23
|
+
export declare function sourceUrl(source: string, target: NasTarget): URL;
|
|
24
|
+
export declare function selectEntry(entries: StoreEntry[], id: string, version: string | undefined, target: NasTarget): StoreEntry;
|
|
25
|
+
export interface Dependency {
|
|
26
|
+
id: string;
|
|
27
|
+
constraints: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
export declare function dependencies(value: unknown): Dependency[];
|
|
30
|
+
export declare function satisfies(version: string, dependency: Dependency): boolean;
|
|
31
|
+
export declare function resolveInstallPlan(root: StoreEntry, entries: StoreEntry[], target: NasTarget, installed: Map<string, string>): StoreEntry[];
|
|
32
|
+
export declare function verifyPins(bytes: Buffer, entry: Pick<StoreEntry, "size" | "md5" | "sha256">): void;
|