@synopackageland/cli 0.32.1 → 0.33.0
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 +7 -6
- package/README.md +3 -3
- package/dist/cli.js +32 -11
- package/dist/ctl/cli.d.ts +1 -0
- package/dist/ctl/cli.js +61 -19
- package/dist/ctl/dsm-auth.js +2 -2
- package/dist/ctl/errors.js +1 -1
- package/dist/ctl/input.js +2 -2
- package/dist/ctl/service.js +8 -6
- package/dist/ctl/store.d.ts +4 -0
- package/dist/ctl/store.js +33 -5
- package/dist/ctl/tls.d.ts +22 -0
- package/dist/ctl/tls.js +118 -0
- package/dist/ctl/transfers.d.ts +2 -0
- package/dist/ctl/transfers.js +5 -4
- package/dist/ctl/transport.d.ts +3 -1
- package/dist/ctl/transport.js +38 -8
- package/dist/error-registry.js +8 -10
- package/dist/host/catalog-install.d.ts +0 -10
- package/dist/host/catalog-install.js +24 -140
- package/dist/host/connection.d.ts +9 -0
- package/dist/host/connection.js +49 -0
- package/dist/host/credentials.d.ts +3 -1
- package/dist/host/credentials.js +27 -62
- package/dist/host/dsm-client.d.ts +4 -7
- package/dist/host/dsm-client.js +47 -92
- package/dist/host/dsm-http.d.ts +2 -0
- package/dist/host/dsm-http.js +21 -1
- 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 +5 -1
- 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 +1 -1
- package/skills/SKILL.md +2 -0
- package/skills/reference/ctl.md +4 -4
- package/skills/reference/deploy.md +2 -0
- package/skills/reference/host-install.md +55 -0
|
@@ -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 { dsmFetch } from "./dsm-http.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 dsmFetch(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 dsmFetch(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;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CtlError } from '../ctl/errors.js';
|
|
2
2
|
const SHARE_PERMISSION_OPERATION = "SYNO_Core_Share_Permission_list_v1";
|
|
3
3
|
const GROUP_LIST_OPERATION = "SYNO_Core_Group_list_v1";
|
|
4
4
|
const GROUP_MEMBER_OPERATION = "SYNO_Core_Group_Member_list_v1";
|
|
@@ -8,7 +8,7 @@ function asRecord(value) {
|
|
|
8
8
|
}
|
|
9
9
|
return undefined;
|
|
10
10
|
}
|
|
11
|
-
/** Unwrap the
|
|
11
|
+
/** Unwrap the CLI envelope down to the DSM `data` object. */
|
|
12
12
|
function dsmData(payload) {
|
|
13
13
|
const outer = asRecord(payload);
|
|
14
14
|
if (!outer || outer.success === false) {
|
|
@@ -77,7 +77,9 @@ export async function fetchSharePermissions(shareName, call) {
|
|
|
77
77
|
limit: "-1",
|
|
78
78
|
}));
|
|
79
79
|
}
|
|
80
|
-
catch {
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof CtlError)
|
|
82
|
+
throw error;
|
|
81
83
|
return undefined;
|
|
82
84
|
}
|
|
83
85
|
};
|
|
@@ -94,7 +96,9 @@ export async function fetchUserGroups(userName, call) {
|
|
|
94
96
|
try {
|
|
95
97
|
groupNames = parseGroupNames(await call(GROUP_LIST_OPERATION, { offset: "0", limit: "-1" }));
|
|
96
98
|
}
|
|
97
|
-
catch {
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error instanceof CtlError)
|
|
101
|
+
throw error;
|
|
98
102
|
return undefined;
|
|
99
103
|
}
|
|
100
104
|
if (!groupNames) {
|
|
@@ -106,7 +110,9 @@ export async function fetchUserGroups(userName, call) {
|
|
|
106
110
|
try {
|
|
107
111
|
members = parseGroupMemberNames(await call(GROUP_MEMBER_OPERATION, { group: groupName, offset: "0", limit: "-1" }));
|
|
108
112
|
}
|
|
109
|
-
catch {
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof CtlError)
|
|
115
|
+
throw error;
|
|
110
116
|
return undefined;
|
|
111
117
|
}
|
|
112
118
|
// One unreadable group could be the one that grants access, so a partial
|
|
@@ -120,30 +126,12 @@ export async function fetchUserGroups(userName, call) {
|
|
|
120
126
|
}
|
|
121
127
|
return memberships;
|
|
122
128
|
}
|
|
123
|
-
/**
|
|
124
|
-
export function
|
|
125
|
-
return async (
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
if (profileName) {
|
|
131
|
-
args.push("--host", profileName);
|
|
132
|
-
}
|
|
133
|
-
const result = spawnSync("synoagentcli", args, { encoding: "utf8" });
|
|
134
|
-
if (result.status !== 0) {
|
|
135
|
-
return undefined;
|
|
136
|
-
}
|
|
137
|
-
// synoagentcli may prepend an update notice before the JSON body.
|
|
138
|
-
const match = /\{[\s\S]*\}/.exec(result.stdout ?? "");
|
|
139
|
-
if (!match) {
|
|
140
|
-
return undefined;
|
|
141
|
-
}
|
|
142
|
-
try {
|
|
143
|
-
return JSON.parse(match[0]);
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
return undefined;
|
|
147
|
-
}
|
|
129
|
+
/** Adapt fixed operation IDs to direct DSM WebAPI requests. */
|
|
130
|
+
export function createDsmApiCall(call) {
|
|
131
|
+
return async (operation, params) => {
|
|
132
|
+
const match = /^(SYNO_Core_(?:Share_Permission|Group|Group_Member))_(list)_v(1)$/.exec(operation);
|
|
133
|
+
if (!match)
|
|
134
|
+
throw new Error('Unsupported DSM read operation');
|
|
135
|
+
return { success: true, data: { response: await call(match[1].replaceAll('_', '.'), match[2], Number(match[3]), params) } };
|
|
148
136
|
};
|
|
149
137
|
}
|
package/dist/host/types.d.ts
CHANGED
|
@@ -65,6 +65,10 @@ export interface NasHost {
|
|
|
65
65
|
export interface HostCredentials {
|
|
66
66
|
dsmOrigin: string;
|
|
67
67
|
username: string;
|
|
68
|
-
password
|
|
68
|
+
password?: string;
|
|
69
|
+
hostName?: string;
|
|
70
|
+
ca?: string;
|
|
71
|
+
fingerprint?: string;
|
|
72
|
+
loadSession?: () => Promise<import("../ctl/transport.js").DsmSession>;
|
|
69
73
|
cookieHeader?: string;
|
|
70
74
|
}
|
package/dist/skill.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
const SKILL_CATALOG = [
|
|
5
5
|
{ id: "orchestrator", file: "SKILL.md", when: "Read first" },
|
|
6
|
+
{ id: "host-install", file: "reference/host-install.md", when: "Shared NAS login, store/local SPK installation and removal" },
|
|
6
7
|
{ id: "ctl", file: "reference/ctl.md", when: "Operate registered NAS Apps or design AI commands" },
|
|
7
8
|
{
|
|
8
9
|
id: "create",
|
package/dist/spk/inspect.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type SpkFinding } from "./validate-spk.js";
|
|
|
3
3
|
export interface SpkReport extends SpkInspection {
|
|
4
4
|
/** Built by synopkgland, so its build contract (`findings`) was checked too. */
|
|
5
5
|
synopkgland: boolean;
|
|
6
|
+
info?: Record<string, string>;
|
|
6
7
|
/** The DSM 7 package format rules, checked on every SPK. */
|
|
7
8
|
dsm: {
|
|
8
9
|
ok: boolean;
|
package/dist/spk/inspect.js
CHANGED
|
@@ -19,7 +19,7 @@ export async function inspectAnySpk(spkPath) {
|
|
|
19
19
|
const dsm = { ok: validation.ok, errors: validation.errors, warnings: validation.warnings };
|
|
20
20
|
const facts = await readFacts(spkPath);
|
|
21
21
|
if (BUILD_MARKERS.some((name) => facts.top.has(name)) || facts.payload.some((entry) => entry.path === CONTAINER_MARKER)) {
|
|
22
|
-
return { ...(await inspectSpk(spkPath)), synopkgland: true, dsm };
|
|
22
|
+
return { ...(await inspectSpk(spkPath)), synopkgland: true, info: validation.info, dsm };
|
|
23
23
|
}
|
|
24
24
|
return {
|
|
25
25
|
...(validation.package ? { package: validation.package } : {}),
|
|
@@ -30,6 +30,7 @@ export async function inspectAnySpk(spkPath) {
|
|
|
30
30
|
findings: [],
|
|
31
31
|
capabilities: [],
|
|
32
32
|
synopkgland: false,
|
|
33
|
+
info: validation.info,
|
|
33
34
|
dsm,
|
|
34
35
|
};
|
|
35
36
|
}
|
package/dist/spk/validate-spk.js
CHANGED
|
@@ -34,6 +34,7 @@ export async function validateSpk(spkPath) {
|
|
|
34
34
|
const errors = findings.list.filter((finding) => finding.severity === "error");
|
|
35
35
|
return {
|
|
36
36
|
ok: errors.length === 0,
|
|
37
|
+
info,
|
|
37
38
|
...(info.package ? { package: info.package } : {}),
|
|
38
39
|
...(info.version ? { version: info.version } : {}),
|
|
39
40
|
...(info.arch ? { arch: info.arch } : {}),
|
package/package.json
CHANGED
package/skills/SKILL.md
CHANGED
|
@@ -48,6 +48,8 @@ Buttons follow DSM's policy ([reference/ui-vue.md](reference/ui-vue.md#buttons),
|
|
|
48
48
|
|
|
49
49
|
Read [reference/ui-json.md](reference/ui-json.md) when generating or editing JSON UI; discover the installed catalog, validate the spec, then render and fix layout issues.
|
|
50
50
|
|
|
51
|
+
Read [reference/host-install.md](reference/host-install.md) before NAS authentication, store/local-SPK installation or package removal. All DSM commands share the registered host session.
|
|
52
|
+
|
|
51
53
|
## UI copywriting
|
|
52
54
|
|
|
53
55
|
For UI/locale strings we author in the SDK, UI package, templates and our own
|
package/skills/reference/ctl.md
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Use this for operating installed Apps, rather than building/deploying projects.
|
|
4
4
|
|
|
5
|
-
1. `synopkgland
|
|
6
|
-
2. Register with `
|
|
5
|
+
1. `synopkgland host list --json` checks registered machines and sessions.
|
|
6
|
+
2. Register with `host add nas --url https://nas.example:5001 --username alice`, or renew with `host login nas`. Credentials are hidden interactive input; `--password-stdin` is available. Do not put passwords/OTP in argv. Sessions and remembered-device tokens use OS secure storage, never a plaintext fallback.
|
|
7
7
|
3. Run `synopkgland ctl list --host nas --json` for all registered Apps, commands, schemas, effects, permissions and executable examples. If description access needs approval, ask the user to approve the whole candidate batch; do not self-approve. An explicit `--approve-installed` records permission to send the DSM session to the displayed inventory's fixed description endpoints. Use `--app <package>` to narrow the batch. No description endpoint is anonymous.
|
|
8
8
|
4. Use the discovered alias, CLI path and typed flags with an explicit `--host nas`; inspect `--help` and `--dry-run` before execution. For example, `synopkgland ctl notes list --status pending --host nas --dry-run`. Use the real command/schema from discovery, not this illustrative name.
|
|
9
9
|
5. Reads need no write confirmation. For authorized write/destructive commands, add `--yes`; without it the CLI returns a plan and confirmation error. A --yes flag does not replace the user's authorization or the App's backend checks.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
With multiple hosts, select a default with `synopkgland host default <name>` or specify --host. `--host a,b` and --all-hosts return per-host results, with exit 8 on partial failure. Do not retry a write merely because one host failed; query its state first. --refresh rereads descriptions; unchanged package versions use the host/account/package/version/hash cache. Never assume cached metadata grants resource permission.
|
|
12
12
|
|
|
13
|
-
Use `
|
|
13
|
+
Use `host show nas` to inspect approvals/pins, `host revoke nas PackageID` to remove an App approval, `host logout nas` to retain registration, and `host remove nas --yes` to delete sessions, device token, approvals and cache. A failed NAS logout is reported independently from successful local removal.
|
|
14
14
|
|
|
15
15
|
Stdout defaults to JSON without a TTY. Success is {ok:true,data}; failure is {ok:false,error:{code,message,hint,allowed?}}. Multi-host output uses {ok,results:[{host,ok,data|error}]}. Follow the concrete hint and complete allowed list; do not guess another method or bypass ctl with raw HTTP. Exit codes: 2 authentication, 3 parameters/names, 4 permissions/trust, 5 App/contract, 6 network, 7 confirmation, 8 partial failure.
|
|
16
16
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
Before deployment, register a NAS with `synopkgland host add nas --url https://nas.example:5001`, then renew it with `synopkgland host login nas`. See [host-install.md](host-install.md) for shared sessions, automation, install and uninstall.
|
|
2
|
+
|
|
1
3
|
# Build and deploy
|
|
2
4
|
|
|
3
5
|
1. Run `synopkgland check` and `synopkgland build --arch x86_64` (use aarch64 or armv7 for those targets; armv7 must be listed in `arches`; its jobs, daemon and executor are built but not NAS-verified). deploy detects the NAS arch, except on Marvell CPUs, where it asks for `--arch`.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Shared NAS login and package installation
|
|
2
|
+
|
|
3
|
+
Register once with `synopkgland host add nas --url https://nas.example:5001 --username alice`.
|
|
4
|
+
The CLI prompts for the password and OTP when DSM requires it. Session cookies,
|
|
5
|
+
SynoToken and remembered-device tokens stay in the OS credential store; host
|
|
6
|
+
metadata and App approvals stay under `~/.synopkgland/ctl`. Passwords are not saved.
|
|
7
|
+
Use `--ca <PEM>` when adding a host with a private CA. Certificate verification stays on.
|
|
8
|
+
|
|
9
|
+
Use `synopkgland host login nas` to renew an expired session. `host list`,
|
|
10
|
+
`host show nas`, `host logout nas`, `host remove nas --yes` and
|
|
11
|
+
`host revoke nas <package-id>` manage the registration, login and App approvals.
|
|
12
|
+
`host default nas` selects a default; with one host it is selected automatically.
|
|
13
|
+
Every DSM command accepts `--host nas`, including ctl, deploy, status, logs,
|
|
14
|
+
install and uninstall. ctl still requires explicit App description approval.
|
|
15
|
+
|
|
16
|
+
For authorized package changes:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
synopkgland install SynoReclaim --host nas --dry-run --json
|
|
20
|
+
synopkgland install SynoReclaim --host nas --yes --json
|
|
21
|
+
synopkgland install SynoReclaim@0.1.1-1591 --host nas --yes
|
|
22
|
+
synopkgland install ./MyApp.spk --host nas --param NAME=value --yes
|
|
23
|
+
synopkgland status SynoReclaim --host nas --json
|
|
24
|
+
synopkgland uninstall SynoReclaim --host nas --yes --json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Store installs default to `https://packages.synopkgland.com`; `--source <HTTPS URL>`
|
|
28
|
+
selects another source. The source is queried for the NAS platform and DSM build.
|
|
29
|
+
The CLI plans dependencies from the same feed, reports missing Synology packages,
|
|
30
|
+
checks every feed size/MD5/SHA-256 pin and inspects SPKs before uploading.
|
|
31
|
+
`<package-id>@version` selects an exact available release; downgrades are refused.
|
|
32
|
+
`--dry-run` reads NAS/source metadata and prints the plan without uploading or changing DSM.
|
|
33
|
+
Local SPKs pass the same inspect, architecture and DSM checks before installation.
|
|
34
|
+
The inspection reports signature presence; it does not cryptographically authenticate a publisher.
|
|
35
|
+
Install wizard values use repeated `--param NAME=value`; values are private and
|
|
36
|
+
not printed in plans. DSM package hooks validate their meaning. Install/removal
|
|
37
|
+
require administrator rights. Uninstall hooks can remove package data; review the App's policy.
|
|
38
|
+
A failed multi-package install may leave earlier dependencies installed; inspect status before retrying.
|
|
39
|
+
|
|
40
|
+
For automation, provide `DSM_USERNAME` and `DSM_PASSWORD` in the process environment
|
|
41
|
+
(and `DSM_OTP` if required), with a registered `--host` using that same account.
|
|
42
|
+
These values create an in-memory session and are never written to disk. Without a
|
|
43
|
+
registration, install/deploy/status/logs also accept `DSM_URL` with both credentials.
|
|
44
|
+
Use an HTTPS URL and a trusted certificate. Supply secrets through your CI secret
|
|
45
|
+
store, never literal command arguments, committed files or command output.
|
|
46
|
+
`host add/login --password-stdin` supports a password from standard input.
|
|
47
|
+
|
|
48
|
+
Default NAS HTTPS certificates work through trust on first use: before sending a
|
|
49
|
+
password, the CLI pins an untrusted leaf certificate's SHA-256 and prints
|
|
50
|
+
`Trusting the NAS certificate <short fingerprint> (self-signed)`. Subsequent
|
|
51
|
+
requests check the exact leaf. `HOST_CERT_CHANGED` shows old/new fingerprints;
|
|
52
|
+
verify them with the user, then use `synopkgland host trust <name>` only after
|
|
53
|
+
confirmation. Never disable TLS checks. `host add --fingerprint <sha256>` pins an
|
|
54
|
+
expected leaf; `--ca <PEM>` uses a CA instead. Network errors distinguish
|
|
55
|
+
HOST_TLS_UNTRUSTED, HOST_DNS_FAILED, HOST_CONNECTION_REFUSED and HOST_TIMEOUT.
|