@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,157 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { CtlError } from "../ctl/errors.js";
|
|
3
|
+
import { familyOfArchToken } from "../build-spk/dsm-arch.js";
|
|
4
|
+
const fail = (code, message, hint = "Check the package source and select a compatible package") => new CtlError(code, message, hint, 3);
|
|
5
|
+
export function compareVersions(a, b) {
|
|
6
|
+
const x = a.split(/[._-]/).map(Number), y = b.split(/[._-]/).map(Number);
|
|
7
|
+
if ([...x, ...y].some((n) => !Number.isSafeInteger(n) || n < 0))
|
|
8
|
+
throw fail("PACKAGE_VERSION_INVALID", "Package versions must contain numeric segments");
|
|
9
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++)
|
|
10
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0))
|
|
11
|
+
return (x[i] ?? 0) - (y[i] ?? 0);
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
export function archMatches(value, arch) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return true; // A DSM feed may omit arch after filtering its query.
|
|
17
|
+
const tokens = Array.isArray(value) ? value : value.split(/\s+/);
|
|
18
|
+
return tokens.some((token) => token === "noarch" || token === arch || token === familyOfArchToken(arch));
|
|
19
|
+
}
|
|
20
|
+
export function assertCompatible(info, target) {
|
|
21
|
+
if (!info.arch || !archMatches(info.arch, target.arch))
|
|
22
|
+
throw fail("PACKAGE_ARCH_MISMATCH", "SPK architecture does not match this NAS");
|
|
23
|
+
if (info.exclude_arch && archMatches(info.exclude_arch, target.arch))
|
|
24
|
+
throw fail("PACKAGE_ARCH_MISMATCH", "SPK excludes this NAS architecture");
|
|
25
|
+
for (const [key, side] of [
|
|
26
|
+
["os_min_ver", -1],
|
|
27
|
+
["os_max_ver", 1],
|
|
28
|
+
]) {
|
|
29
|
+
if (info[key] &&
|
|
30
|
+
Math.sign(compareVersions(target.version, info[key])) === side)
|
|
31
|
+
throw fail("PACKAGE_DSM_MISMATCH", `SPK ${key} is incompatible with this NAS`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function sourceUrl(source, target) {
|
|
35
|
+
const url = new URL(source);
|
|
36
|
+
if (url.protocol !== "https:" || url.username || url.password)
|
|
37
|
+
throw fail("PACKAGE_SOURCE_INVALID", "Package source must be HTTPS without URL credentials");
|
|
38
|
+
for (const [key, value] of Object.entries({
|
|
39
|
+
arch: target.arch,
|
|
40
|
+
build: target.build,
|
|
41
|
+
major: target.major,
|
|
42
|
+
minor: target.minor,
|
|
43
|
+
}))
|
|
44
|
+
url.searchParams.set(key, String(value));
|
|
45
|
+
return url;
|
|
46
|
+
}
|
|
47
|
+
export function selectEntry(entries, id, version, target) {
|
|
48
|
+
const matching = entries
|
|
49
|
+
.filter((e) => e.package === id &&
|
|
50
|
+
(!version || e.version === version) &&
|
|
51
|
+
archMatches(e.arch, target.arch) &&
|
|
52
|
+
(!e.os_min_ver || compareVersions(target.version, e.os_min_ver) >= 0) &&
|
|
53
|
+
(!e.os_max_ver || compareVersions(target.version, e.os_max_ver) <= 0))
|
|
54
|
+
.sort((a, b) => compareVersions(b.version, a.version));
|
|
55
|
+
if (!matching.length)
|
|
56
|
+
throw fail("PACKAGE_NOT_FOUND", `No compatible store release for ${id}${version ? "@" + version : ""}`);
|
|
57
|
+
const chosen = matching[0];
|
|
58
|
+
if (matching.filter((e) => e.version === chosen.version).length !== 1)
|
|
59
|
+
throw fail("PACKAGE_SOURCE_AMBIGUOUS", "Source offers overlapping package architectures");
|
|
60
|
+
if (!Number.isSafeInteger(chosen.size) ||
|
|
61
|
+
chosen.size <= 0 ||
|
|
62
|
+
(!chosen.md5 && !chosen.sha256))
|
|
63
|
+
throw fail("PACKAGE_PIN_INVALID", "Source must pin SPK size and MD5 or SHA-256");
|
|
64
|
+
const link = new URL(chosen.link);
|
|
65
|
+
if (link.protocol !== "https:" || link.username || link.password)
|
|
66
|
+
throw fail("PACKAGE_LINK_INVALID", "SPK download must be HTTPS without URL credentials");
|
|
67
|
+
return chosen;
|
|
68
|
+
}
|
|
69
|
+
export function dependencies(value) {
|
|
70
|
+
if (value === undefined || value === null || value === "")
|
|
71
|
+
return [];
|
|
72
|
+
if (typeof value === "string")
|
|
73
|
+
return value
|
|
74
|
+
.trim()
|
|
75
|
+
.split(/[:\s]+/)
|
|
76
|
+
.map((part) => {
|
|
77
|
+
const match = /^([A-Za-z][A-Za-z0-9_.+-]*)(?:(>=|<=|==|=|>|<)([0-9][0-9._-]*))?$/.exec(part);
|
|
78
|
+
if (!match)
|
|
79
|
+
throw fail("PACKAGE_DEPENDENCY_INVALID", "Unsupported package dependency syntax");
|
|
80
|
+
return {
|
|
81
|
+
id: match[1],
|
|
82
|
+
constraints: match[2] ? { [match[2]]: match[3] } : {},
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
if (typeof value === "object" && !Array.isArray(value))
|
|
86
|
+
return Object.entries(value).map(([id, rules]) => {
|
|
87
|
+
if (!/^[A-Za-z][A-Za-z0-9_.+-]*$/.test(id))
|
|
88
|
+
throw fail("PACKAGE_DEPENDENCY_INVALID", "Invalid dependency package ID");
|
|
89
|
+
if (rules === null)
|
|
90
|
+
return { id, constraints: {} };
|
|
91
|
+
if (typeof rules !== "object" ||
|
|
92
|
+
Array.isArray(rules) ||
|
|
93
|
+
Object.entries(rules).some(([op, v]) => ![">=", ">", "<=", "<", "==", "="].includes(op) ||
|
|
94
|
+
typeof v !== "string" ||
|
|
95
|
+
!/^\d[\d._-]*$/.test(v)))
|
|
96
|
+
throw fail("PACKAGE_DEPENDENCY_INVALID", "Unsupported package dependency rules");
|
|
97
|
+
return { id, constraints: rules };
|
|
98
|
+
});
|
|
99
|
+
throw fail("PACKAGE_DEPENDENCY_INVALID", "Unsupported package dependency format");
|
|
100
|
+
}
|
|
101
|
+
export function satisfies(version, dependency) {
|
|
102
|
+
return Object.entries(dependency.constraints).every(([op, want]) => {
|
|
103
|
+
const c = compareVersions(version, want);
|
|
104
|
+
return op === ">="
|
|
105
|
+
? c >= 0
|
|
106
|
+
: op === ">"
|
|
107
|
+
? c > 0
|
|
108
|
+
: op === "<="
|
|
109
|
+
? c <= 0
|
|
110
|
+
: op === "<"
|
|
111
|
+
? c < 0
|
|
112
|
+
: c === 0;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
export function resolveInstallPlan(root, entries, target, installed) {
|
|
116
|
+
const plan = [], visiting = new Set(), chosen = new Map();
|
|
117
|
+
const visit = (entry) => {
|
|
118
|
+
if (visiting.has(entry.package))
|
|
119
|
+
throw fail("PACKAGE_DEPENDENCY_CYCLE", "Package dependency cycle detected");
|
|
120
|
+
if (chosen.has(entry.package))
|
|
121
|
+
return;
|
|
122
|
+
visiting.add(entry.package);
|
|
123
|
+
for (const dep of dependencies(entry.deppkgs)) {
|
|
124
|
+
const existing = chosen.get(dep.id);
|
|
125
|
+
if (existing) {
|
|
126
|
+
if (!satisfies(existing.version, dep))
|
|
127
|
+
throw fail("PACKAGE_DEPENDENCY_CONFLICT", `Conflicting dependency ${dep.id}`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (installed.has(dep.id) && satisfies(installed.get(dep.id), dep))
|
|
131
|
+
continue;
|
|
132
|
+
const candidates = entries.filter((e) => e.package === dep.id && satisfies(e.version, dep));
|
|
133
|
+
if (!candidates.length)
|
|
134
|
+
throw fail("PACKAGE_DEPENDENCY_REQUIRED", `Install required Synology package ${dep.id}${Object.entries(dep.constraints)
|
|
135
|
+
.map(([k, v]) => k + v)
|
|
136
|
+
.join(",")} first`, "Install the dependency in DSM Package Center, then retry");
|
|
137
|
+
visit(selectEntry(candidates, dep.id, undefined, target));
|
|
138
|
+
}
|
|
139
|
+
visiting.delete(entry.package);
|
|
140
|
+
chosen.set(entry.package, entry);
|
|
141
|
+
plan.push(entry);
|
|
142
|
+
};
|
|
143
|
+
visit(root);
|
|
144
|
+
return plan;
|
|
145
|
+
}
|
|
146
|
+
export function verifyPins(bytes, entry) {
|
|
147
|
+
if (bytes.length !== entry.size)
|
|
148
|
+
throw fail("PACKAGE_SIZE_MISMATCH", "Downloaded SPK size differs from the feed pin");
|
|
149
|
+
for (const algorithm of ["md5", "sha256"]) {
|
|
150
|
+
const pin = entry[algorithm];
|
|
151
|
+
if (pin === undefined)
|
|
152
|
+
continue;
|
|
153
|
+
if (!new RegExp(`^[a-fA-F0-9]{${algorithm === "md5" ? 32 : 64}}$`).test(pin) ||
|
|
154
|
+
createHash(algorithm).update(bytes).digest("hex") !== pin.toLowerCase())
|
|
155
|
+
throw fail("PACKAGE_DIGEST_MISMATCH", `Downloaded SPK ${algorithm.toUpperCase()} differs from the feed pin`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type NasConnection } from "./connection.js";
|
|
2
|
+
import { type NasTarget, type StoreEntry } from "./install-plan.js";
|
|
3
|
+
export declare const DEFAULT_PACKAGE_SOURCE = "https://packages.synopkgland.com";
|
|
4
|
+
export declare function packageId(value: string): string;
|
|
5
|
+
export declare function nasTarget(nas: NasConnection): Promise<NasTarget>;
|
|
6
|
+
export declare function readFeed(source: string, target: NasTarget): Promise<StoreEntry[]>;
|
|
7
|
+
export interface InstallOptions {
|
|
8
|
+
host?: string;
|
|
9
|
+
source?: string;
|
|
10
|
+
param?: Record<string, string>;
|
|
11
|
+
yes?: boolean;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function installPackage(spec: string, options?: InstallOptions, connection?: NasConnection): Promise<{
|
|
15
|
+
host: string;
|
|
16
|
+
target: NasTarget;
|
|
17
|
+
source: string;
|
|
18
|
+
packages: {
|
|
19
|
+
signed?: boolean | undefined;
|
|
20
|
+
package: string;
|
|
21
|
+
version: string;
|
|
22
|
+
size: number;
|
|
23
|
+
action: string;
|
|
24
|
+
}[];
|
|
25
|
+
parameters: string[];
|
|
26
|
+
dryRun: boolean;
|
|
27
|
+
} | {
|
|
28
|
+
status: import("./catalog-install.js").InstalledPackageState | undefined;
|
|
29
|
+
host: string;
|
|
30
|
+
target: NasTarget;
|
|
31
|
+
source: string;
|
|
32
|
+
packages: {
|
|
33
|
+
signed?: boolean | undefined;
|
|
34
|
+
package: string;
|
|
35
|
+
version: string;
|
|
36
|
+
size: number;
|
|
37
|
+
action: string;
|
|
38
|
+
}[];
|
|
39
|
+
parameters: string[];
|
|
40
|
+
}>;
|
|
41
|
+
export declare function uninstallPackage(id: string, options?: {
|
|
42
|
+
host?: string;
|
|
43
|
+
yes?: boolean;
|
|
44
|
+
dryRun?: boolean;
|
|
45
|
+
}): Promise<{
|
|
46
|
+
dryRun: boolean;
|
|
47
|
+
host: string;
|
|
48
|
+
package: string;
|
|
49
|
+
action: string;
|
|
50
|
+
installed?: undefined;
|
|
51
|
+
} | {
|
|
52
|
+
host: string;
|
|
53
|
+
package: string;
|
|
54
|
+
installed: boolean;
|
|
55
|
+
dryRun?: undefined;
|
|
56
|
+
action?: undefined;
|
|
57
|
+
}>;
|
|
58
|
+
export declare function packageStatus(id: string, host?: string): Promise<{
|
|
59
|
+
installed: boolean;
|
|
60
|
+
version?: string;
|
|
61
|
+
status?: string;
|
|
62
|
+
error?: {
|
|
63
|
+
code?: number;
|
|
64
|
+
description?: string;
|
|
65
|
+
workerMsg?: unknown[];
|
|
66
|
+
};
|
|
67
|
+
brokenBy?: string;
|
|
68
|
+
stage?: string;
|
|
69
|
+
lastStage?: string;
|
|
70
|
+
statusCode?: number;
|
|
71
|
+
statusDescription?: string;
|
|
72
|
+
statusOrigin?: string;
|
|
73
|
+
host: string;
|
|
74
|
+
package: string;
|
|
75
|
+
}>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { CtlError } from "../ctl/errors.js";
|
|
6
|
+
import { inspectAnySpk } from "../spk/inspect.js";
|
|
7
|
+
import { connectNas, requireAdministrator, } from "./connection.js";
|
|
8
|
+
import { fetchPackageSource } from "../package-source.js";
|
|
9
|
+
import { fetchInstalledPackageState, installPackageFromLocalSpk, uninstallPackageFromDsm, } from "./catalog-install.js";
|
|
10
|
+
import { assertCompatible, sourceUrl, selectEntry, resolveInstallPlan, verifyPins, compareVersions, } from "./install-plan.js";
|
|
11
|
+
export const DEFAULT_PACKAGE_SOURCE = "https://packages.synopkgland.com";
|
|
12
|
+
const fail = (code, message, hint = "Check Package Center and retry") => new CtlError(code, message, hint, 3);
|
|
13
|
+
export function packageId(value) {
|
|
14
|
+
if (!/^[A-Za-z][A-Za-z0-9_.+-]{0,127}$/.test(value))
|
|
15
|
+
throw fail("PACKAGE_ID_INVALID", "Invalid package ID");
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export async function nasTarget(nas) {
|
|
19
|
+
const packageInfo = await nas.call("SYNO.Core.Package.Info", "get", 1);
|
|
20
|
+
const info = await nas.call("SYNO.DSM.Info", "getinfo", 2);
|
|
21
|
+
const config = packageInfo?.config;
|
|
22
|
+
const unique = config?.ds_unique;
|
|
23
|
+
const arch = typeof unique === "string" && unique.startsWith("synology_")
|
|
24
|
+
? unique.slice(9, unique.lastIndexOf("_"))
|
|
25
|
+
: undefined;
|
|
26
|
+
const build = Number(config?.ds_build);
|
|
27
|
+
const version = typeof info.version_string === "string"
|
|
28
|
+
? /\b(\d+\.\d+(?:\.\d+)?)-\d+/.exec(info.version_string)?.[1]
|
|
29
|
+
: undefined;
|
|
30
|
+
if (typeof arch !== "string" ||
|
|
31
|
+
!/^\w+$/.test(arch) ||
|
|
32
|
+
!Number.isSafeInteger(build) ||
|
|
33
|
+
build < 1 ||
|
|
34
|
+
typeof version !== "string" ||
|
|
35
|
+
!/^\d+\.\d+(?:\.\d+)?$/.test(version))
|
|
36
|
+
throw fail("NAS_TARGET_UNKNOWN", "DSM did not report a usable platform and build", "Check NAS system information; no architecture was guessed");
|
|
37
|
+
const [major, minor] = version.split(".").map(Number);
|
|
38
|
+
return { arch, version: `${version}-${build}`, major, minor, build };
|
|
39
|
+
}
|
|
40
|
+
export async function readFeed(source, target) {
|
|
41
|
+
const response = await fetchPackageSource(sourceUrl(source, target).href);
|
|
42
|
+
if (!response.ok)
|
|
43
|
+
throw fail("PACKAGE_SOURCE_FAILED", `Package source returned HTTP ${response.status}`);
|
|
44
|
+
const body = (await response.json());
|
|
45
|
+
if (!Array.isArray(body?.packages) || body.packages.length > 4096)
|
|
46
|
+
throw fail("PACKAGE_SOURCE_INVALID", "Package source returned an invalid package list");
|
|
47
|
+
return body.packages;
|
|
48
|
+
}
|
|
49
|
+
async function inventory(nas) {
|
|
50
|
+
const data = await nas.call("SYNO.Core.Package", "list", 2, {
|
|
51
|
+
additional: '["status"]',
|
|
52
|
+
ignore_hidden: "false",
|
|
53
|
+
});
|
|
54
|
+
if (!Array.isArray(data?.packages) ||
|
|
55
|
+
(data.total !== undefined && data.total !== data.packages.length))
|
|
56
|
+
throw fail("NAS_INVENTORY_INVALID", "Installed package inventory is incomplete");
|
|
57
|
+
return new Map(data.packages.map((p) => [p.id, p.version]));
|
|
58
|
+
}
|
|
59
|
+
async function confirm(action, yes) {
|
|
60
|
+
if (yes)
|
|
61
|
+
return;
|
|
62
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
63
|
+
throw fail("PACKAGE_CONFIRM_REQUIRED", "Package changes require confirmation", `Use --yes to ${action} non-interactively`);
|
|
64
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
65
|
+
try {
|
|
66
|
+
if (!/^y(es)?$/i.test(await rl.question(`${action}? [y/N] `)))
|
|
67
|
+
throw fail("PACKAGE_CANCELLED", "Package operation cancelled", "No changes were made");
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
rl.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function checkSpk(report, target) {
|
|
74
|
+
if (!report.dsm.ok ||
|
|
75
|
+
report.findings.length ||
|
|
76
|
+
!report.package ||
|
|
77
|
+
!report.version)
|
|
78
|
+
throw fail("PACKAGE_INSPECT_FAILED", "SPK inspection failed", "Run synopkgland inspect <file.spk> for the detailed checks");
|
|
79
|
+
assertCompatible(report.info ?? {}, target);
|
|
80
|
+
}
|
|
81
|
+
export async function installPackage(spec, options = {}, connection) {
|
|
82
|
+
const stage = mkdtempSync(join(tmpdir(), "synopkgland-install-"));
|
|
83
|
+
try {
|
|
84
|
+
const local = /\.spk$/i.test(spec) ||
|
|
85
|
+
spec.startsWith(".") ||
|
|
86
|
+
spec.startsWith("/") ||
|
|
87
|
+
spec.includes("\\");
|
|
88
|
+
let report;
|
|
89
|
+
const localFile = join(stage, "local.spk");
|
|
90
|
+
if (local) {
|
|
91
|
+
writeFileSync(localFile, readFileSync(resolve(spec)), { mode: 0o600 });
|
|
92
|
+
report = await inspectAnySpk(localFile);
|
|
93
|
+
if (!report.dsm.ok || report.findings.length)
|
|
94
|
+
throw fail("PACKAGE_INSPECT_FAILED", "SPK inspection failed", "Run synopkgland inspect <file.spk> for details");
|
|
95
|
+
}
|
|
96
|
+
const nas = connection ?? (await connectNas(options.host)), target = await nasTarget(nas), installed = await inventory(nas);
|
|
97
|
+
if (report)
|
|
98
|
+
checkSpk(report, target);
|
|
99
|
+
const source = options.source ?? DEFAULT_PACKAGE_SOURCE;
|
|
100
|
+
let entries = [];
|
|
101
|
+
if (!local || report?.info?.install_dep_packages)
|
|
102
|
+
entries = await readFeed(source, target);
|
|
103
|
+
let root;
|
|
104
|
+
if (report) {
|
|
105
|
+
root = {
|
|
106
|
+
package: packageId(report.package),
|
|
107
|
+
version: report.version,
|
|
108
|
+
link: "",
|
|
109
|
+
size: readFileSync(localFile).length,
|
|
110
|
+
deppkgs: report.info?.install_dep_packages,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const match = /^([^@]+)(?:@([0-9][0-9._-]*))?$/.exec(spec);
|
|
115
|
+
if (!match)
|
|
116
|
+
throw fail("PACKAGE_SPEC_INVALID", "Use <package-id>[@version] or a local .spk");
|
|
117
|
+
root = selectEntry(entries, packageId(match[1]), match[2], target);
|
|
118
|
+
}
|
|
119
|
+
const plan = resolveInstallPlan(root, entries, target, installed);
|
|
120
|
+
for (const item of plan)
|
|
121
|
+
if (installed.has(item.package) &&
|
|
122
|
+
compareVersions(item.version, installed.get(item.package)) < 0)
|
|
123
|
+
throw fail("PACKAGE_DOWNGRADE_REFUSED", `Refusing downgrade of ${item.package}`, "Choose the installed version or a newer release");
|
|
124
|
+
const publicPlan = {
|
|
125
|
+
host: nas.name,
|
|
126
|
+
target,
|
|
127
|
+
source: local ? "local" : source,
|
|
128
|
+
packages: plan.map((item) => ({
|
|
129
|
+
package: item.package,
|
|
130
|
+
version: item.version,
|
|
131
|
+
size: item.size,
|
|
132
|
+
action: installed.has(item.package) ? "upgrade" : "install",
|
|
133
|
+
...(item.package === root.package && report
|
|
134
|
+
? { signed: report.signed }
|
|
135
|
+
: {}),
|
|
136
|
+
})),
|
|
137
|
+
parameters: Object.keys(options.param ?? {}),
|
|
138
|
+
};
|
|
139
|
+
if (options.dryRun)
|
|
140
|
+
return { dryRun: true, ...publicPlan };
|
|
141
|
+
await requireAdministrator(nas);
|
|
142
|
+
await confirm(`Install ${plan.map((e) => e.package + "@" + e.version).join(", ")}`, options.yes);
|
|
143
|
+
// Download, verify and inspect the entire plan before the first NAS write.
|
|
144
|
+
const files = new Map();
|
|
145
|
+
for (const entry of plan) {
|
|
146
|
+
let file = localFile;
|
|
147
|
+
if (!local || entry.package !== root.package) {
|
|
148
|
+
const response = await fetchPackageSource(entry.link);
|
|
149
|
+
if (!response.ok)
|
|
150
|
+
throw fail("PACKAGE_DOWNLOAD_FAILED", `SPK download returned HTTP ${response.status}`);
|
|
151
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
152
|
+
verifyPins(bytes, entry);
|
|
153
|
+
file = join(stage, `${files.size}.spk`);
|
|
154
|
+
writeFileSync(file, bytes, { mode: 0o600 });
|
|
155
|
+
}
|
|
156
|
+
const checked = await inspectAnySpk(file);
|
|
157
|
+
checkSpk(checked, target);
|
|
158
|
+
if (checked.package !== entry.package ||
|
|
159
|
+
checked.version !== entry.version)
|
|
160
|
+
throw fail("PACKAGE_IDENTITY_MISMATCH", "Downloaded SPK identity differs from the selected release");
|
|
161
|
+
files.set(entry.package, file);
|
|
162
|
+
}
|
|
163
|
+
for (const entry of plan)
|
|
164
|
+
await installPackageFromLocalSpk({
|
|
165
|
+
dsmOrigin: nas.session.origin,
|
|
166
|
+
cookieHeader: nas.session.cookie,
|
|
167
|
+
packageId: entry.package,
|
|
168
|
+
version: entry.version,
|
|
169
|
+
spkPath: files.get(entry.package),
|
|
170
|
+
extraValues: entry.package === root.package ? options.param : undefined,
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
...publicPlan,
|
|
174
|
+
status: await fetchInstalledPackageState(nas.session.origin, nas.session.cookie, root.package),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
rmSync(stage, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export async function uninstallPackage(id, options = {}) {
|
|
182
|
+
packageId(id);
|
|
183
|
+
const nas = await connectNas(options.host);
|
|
184
|
+
const state = await fetchInstalledPackageState(nas.session.origin, nas.session.cookie, id);
|
|
185
|
+
if (!state?.installed)
|
|
186
|
+
throw fail("PACKAGE_NOT_INSTALLED", `Package ${id} is not installed`);
|
|
187
|
+
if (options.dryRun)
|
|
188
|
+
return { dryRun: true, host: nas.name, package: id, action: "uninstall" };
|
|
189
|
+
await requireAdministrator(nas);
|
|
190
|
+
await confirm(`Uninstall ${id} (package uninstall hooks may remove data)`, options.yes);
|
|
191
|
+
await uninstallPackageFromDsm({
|
|
192
|
+
dsmOrigin: nas.session.origin,
|
|
193
|
+
cookieHeader: nas.session.cookie,
|
|
194
|
+
packageId: id,
|
|
195
|
+
});
|
|
196
|
+
return { host: nas.name, package: id, installed: false };
|
|
197
|
+
}
|
|
198
|
+
export async function packageStatus(id, host) {
|
|
199
|
+
packageId(id);
|
|
200
|
+
const nas = await connectNas(host);
|
|
201
|
+
const state = await fetchInstalledPackageState(nas.session.origin, nas.session.cookie, id);
|
|
202
|
+
if (!state)
|
|
203
|
+
throw fail("PACKAGE_STATUS_UNKNOWN", "DSM did not return a verifiable package state");
|
|
204
|
+
return { host: nas.name, package: id, ...state };
|
|
205
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { PortInfoEntry } from "./port-info.js";
|
|
2
2
|
export type PortInfoHttpRequest = (url: string, init?: RequestInit) => Promise<Response>;
|
|
3
3
|
/**
|
|
4
|
-
* Parses the native DSM response, not a
|
|
4
|
+
* Parses the native DSM response, not a shared host operation envelope.
|
|
5
5
|
* `undefined` means "cannot tell" and deliberately allows deployment to
|
|
6
6
|
* continue. An empty `port_info` list has the same fail-open meaning.
|
|
7
7
|
*/
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CtlError } from '../ctl/errors.js';
|
|
1
2
|
import { dsmFetch } from "./dsm-http.js";
|
|
2
3
|
const PORT_INFO_API = "SYNO.Core.Service.PortInfo";
|
|
3
4
|
function asRecord(value) {
|
|
@@ -20,7 +21,7 @@ function validPortRange(value) {
|
|
|
20
21
|
end <= 65535);
|
|
21
22
|
}
|
|
22
23
|
/**
|
|
23
|
-
* Parses the native DSM response, not a
|
|
24
|
+
* Parses the native DSM response, not a shared host operation envelope.
|
|
24
25
|
* `undefined` means "cannot tell" and deliberately allows deployment to
|
|
25
26
|
* continue. An empty `port_info` list has the same fail-open meaning.
|
|
26
27
|
*/
|
|
@@ -81,7 +82,9 @@ export async function fetchPortInfo(dsmOrigin, cookieHeader, request = dsmFetch)
|
|
|
81
82
|
}
|
|
82
83
|
return parsePortInfo(await response.json());
|
|
83
84
|
}
|
|
84
|
-
catch {
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof CtlError)
|
|
87
|
+
throw error;
|
|
85
88
|
return undefined;
|
|
86
89
|
}
|
|
87
90
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare function shareRealPathFromEntry(entry: Record<string, unknown>, logicalName: string): string | undefined;
|
|
2
2
|
export declare function findShareRealPath(payload: unknown, logicalName: string): string | undefined;
|
|
3
3
|
export declare function findShareNames(payload: unknown): string[];
|
|
4
|
-
|
|
5
|
-
export declare function
|
|
4
|
+
/** Query shares through the caller's authenticated DSM transport. */
|
|
5
|
+
export declare function lookupSharePath(logicalName: string, call: (api: string, method: string, version: number, params?: Record<string, string>) => Promise<any>): Promise<string | undefined>;
|
|
6
|
+
export declare function listShareNames(call: (api: string, method: string, version: number, params?: Record<string, string>) => Promise<any>): Promise<string[]>;
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { resolveHostCredentials } from "./credentials.js";
|
|
3
1
|
function asRecord(value) {
|
|
4
2
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
5
3
|
return value;
|
|
@@ -69,93 +67,9 @@ export function findShareNames(payload) {
|
|
|
69
67
|
}
|
|
70
68
|
return [...names];
|
|
71
69
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
const result = spawnSync("synoagentcli", fullArgs, { encoding: "utf8" });
|
|
78
|
-
return {
|
|
79
|
-
status: result.status,
|
|
80
|
-
stdout: result.stdout ?? "",
|
|
81
|
-
stderr: result.stderr ?? "",
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
function parseJson(text) {
|
|
85
|
-
try {
|
|
86
|
-
return JSON.parse(text);
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
return undefined;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
function lookupSharePathWithDsmHttp(logicalName, profileName) {
|
|
93
|
-
const credentials = resolveHostCredentials(profileName);
|
|
94
|
-
if (!credentials) {
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
const script = `
|
|
98
|
-
import json, sys, urllib.parse, urllib.request
|
|
99
|
-
origin, account, passwd, name = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
|
100
|
-
def call(params):
|
|
101
|
-
url = origin.rstrip("/") + "/webapi/entry.cgi?" + urllib.parse.urlencode(params)
|
|
102
|
-
return json.loads(urllib.request.urlopen(url, timeout=8).read())
|
|
103
|
-
login = call({
|
|
104
|
-
"api": "SYNO.API.Auth",
|
|
105
|
-
"version": "3",
|
|
106
|
-
"method": "login",
|
|
107
|
-
"account": account,
|
|
108
|
-
"passwd": passwd,
|
|
109
|
-
"session": "FileStation",
|
|
110
|
-
"format": "sid",
|
|
111
|
-
})
|
|
112
|
-
sid = (login.get("data") or {}).get("sid")
|
|
113
|
-
if not sid:
|
|
114
|
-
sys.exit(2)
|
|
115
|
-
try:
|
|
116
|
-
listed = call({
|
|
117
|
-
"api": "SYNO.Core.Share",
|
|
118
|
-
"version": "1",
|
|
119
|
-
"method": "list",
|
|
120
|
-
"_sid": sid,
|
|
121
|
-
})
|
|
122
|
-
print(json.dumps(listed))
|
|
123
|
-
finally:
|
|
124
|
-
call({
|
|
125
|
-
"api": "SYNO.API.Auth",
|
|
126
|
-
"version": "3",
|
|
127
|
-
"method": "logout",
|
|
128
|
-
"session": "FileStation",
|
|
129
|
-
"_sid": sid,
|
|
130
|
-
})
|
|
131
|
-
`;
|
|
132
|
-
const result = spawnSync("python3", ["-c", script, credentials.dsmOrigin, credentials.username, credentials.password, logicalName], { encoding: "utf8" });
|
|
133
|
-
if (result.status !== 0) {
|
|
134
|
-
return undefined;
|
|
135
|
-
}
|
|
136
|
-
return findShareRealPath(parseJson(result.stdout), logicalName);
|
|
137
|
-
}
|
|
138
|
-
export function lookupSharePathWithSynoagentcli(logicalName, profileName) {
|
|
139
|
-
const get = synoagentcli(["dsm", "control-panel", "shared-folder", "get", logicalName], profileName);
|
|
140
|
-
if (get.status === 0) {
|
|
141
|
-
const fromGet = findShareRealPath(parseJson(get.stdout), logicalName);
|
|
142
|
-
if (fromGet) {
|
|
143
|
-
return fromGet;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
const list = synoagentcli(["dsm", "control-panel", "shared-folder", "list"], profileName);
|
|
147
|
-
if (list.status === 0) {
|
|
148
|
-
const fromList = findShareRealPath(parseJson(list.stdout), logicalName);
|
|
149
|
-
if (fromList) {
|
|
150
|
-
return fromList;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return lookupSharePathWithDsmHttp(logicalName, profileName);
|
|
154
|
-
}
|
|
155
|
-
export function listShareNamesWithSynoagentcli(profileName) {
|
|
156
|
-
const list = synoagentcli(["dsm", "control-panel", "shared-folder", "list"], profileName);
|
|
157
|
-
if (list.status !== 0) {
|
|
158
|
-
return [];
|
|
159
|
-
}
|
|
160
|
-
return findShareNames(parseJson(list.stdout));
|
|
70
|
+
/** Query shares through the caller's authenticated DSM transport. */
|
|
71
|
+
export async function lookupSharePath(logicalName, call) {
|
|
72
|
+
const body = await call('SYNO.Core.Share', 'list', 1, { additional: '["real_path"]' });
|
|
73
|
+
return findShareRealPath(body, logicalName);
|
|
161
74
|
}
|
|
75
|
+
export async function listShareNames(call) { return findShareNames(await call('SYNO.Core.Share', 'list', 1)); }
|
|
@@ -11,7 +11,7 @@ import type { SharePermissionRow } from "./share-permission.js";
|
|
|
11
11
|
* The APIs used were confirmed present on DSM 7.4 (the test NAS):
|
|
12
12
|
* `SYNO.Core.Share.Permission`, `SYNO.Core.Group`, `SYNO.Core.Group.Member`.
|
|
13
13
|
* `SYNO.Core.User.Group` also exists on the NAS but has no generated
|
|
14
|
-
*
|
|
14
|
+
* CLI operation, which is why membership is derived group-by-group.
|
|
15
15
|
*/
|
|
16
16
|
export type DsmApiCall = (operationId: string, params: Record<string, string>) => Promise<unknown>;
|
|
17
17
|
export declare function parseSharePermissionRows(payload: unknown): SharePermissionRow[] | undefined;
|
|
@@ -19,5 +19,5 @@ export declare function parseGroupNames(payload: unknown): string[] | undefined;
|
|
|
19
19
|
export declare function parseGroupMemberNames(payload: unknown): string[] | undefined;
|
|
20
20
|
export declare function fetchSharePermissions(shareName: string, call: DsmApiCall): Promise<SharePermissionListing | undefined>;
|
|
21
21
|
export declare function fetchUserGroups(userName: string, call: DsmApiCall): Promise<string[] | undefined>;
|
|
22
|
-
/**
|
|
23
|
-
export declare function
|
|
22
|
+
/** Adapt fixed operation IDs to direct DSM WebAPI requests. */
|
|
23
|
+
export declare function createDsmApiCall(call: (api: string, method: string, version: number, params: Record<string, string>) => Promise<unknown>): DsmApiCall;
|