@synopackageland/cli 0.32.0 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +7 -5
  2. package/README.md +4 -4
  3. package/dist/cli.js +33 -12
  4. package/dist/ctl/cli.d.ts +1 -0
  5. package/dist/ctl/cli.js +61 -19
  6. package/dist/ctl/dsm-auth.js +2 -2
  7. package/dist/ctl/errors.js +1 -1
  8. package/dist/ctl/input.js +2 -2
  9. package/dist/ctl/service.js +8 -6
  10. package/dist/ctl/store.d.ts +4 -0
  11. package/dist/ctl/store.js +33 -5
  12. package/dist/ctl/tls.d.ts +22 -0
  13. package/dist/ctl/tls.js +118 -0
  14. package/dist/ctl/transfers.d.ts +2 -0
  15. package/dist/ctl/transfers.js +5 -4
  16. package/dist/ctl/transport.d.ts +3 -1
  17. package/dist/ctl/transport.js +38 -8
  18. package/dist/error-registry.js +9 -11
  19. package/dist/host/catalog-install.d.ts +0 -10
  20. package/dist/host/catalog-install.js +24 -140
  21. package/dist/host/connection.d.ts +9 -0
  22. package/dist/host/connection.js +49 -0
  23. package/dist/host/credentials.d.ts +3 -1
  24. package/dist/host/credentials.js +27 -62
  25. package/dist/host/dsm-client.d.ts +4 -7
  26. package/dist/host/dsm-client.js +47 -92
  27. package/dist/host/dsm-http.d.ts +2 -0
  28. package/dist/host/dsm-http.js +21 -1
  29. package/dist/host/dsm-port-profile.js +7 -2
  30. package/dist/host/install-plan.d.ts +32 -0
  31. package/dist/host/install-plan.js +157 -0
  32. package/dist/host/package-commands.d.ts +75 -0
  33. package/dist/host/package-commands.js +205 -0
  34. package/dist/host/port-info-lookup.d.ts +1 -1
  35. package/dist/host/port-info-lookup.js +5 -2
  36. package/dist/host/share-lookup.d.ts +3 -2
  37. package/dist/host/share-lookup.js +5 -91
  38. package/dist/host/share-permission-lookup.d.ts +3 -3
  39. package/dist/host/share-permission-lookup.js +18 -30
  40. package/dist/host/types.d.ts +5 -1
  41. package/dist/init-upstream.js +6 -6
  42. package/dist/package-metadata.d.ts +8 -1
  43. package/dist/package-metadata.js +28 -1
  44. package/dist/skill.js +1 -0
  45. package/dist/spk/inspect.d.ts +1 -0
  46. package/dist/spk/inspect.js +2 -1
  47. package/dist/spk/validate-spk.d.ts +1 -0
  48. package/dist/spk/validate-spk.js +1 -0
  49. package/dist/upstream-attribution.js +4 -3
  50. package/package.json +1 -1
  51. package/schema/package-metadata.schema.json +32 -3
  52. package/schema/synology-app.schema.json +32 -3
  53. package/skills/SKILL.md +3 -1
  54. package/skills/reference/compose.md +1 -1
  55. package/skills/reference/ctl.md +4 -4
  56. package/skills/reference/deploy.md +2 -0
  57. package/skills/reference/host-install.md +55 -0
  58. package/skills/reference/upstream-fork.md +2 -1
@@ -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 synoagentcli operation envelope.
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 synoagentcli operation envelope.
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
- export declare function lookupSharePathWithSynoagentcli(logicalName: string, profileName?: string): string | undefined;
5
- export declare function listShareNamesWithSynoagentcli(profileName?: string): string[];
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
- function synoagentcli(args, profileName) {
73
- const fullArgs = [...args];
74
- if (profileName) {
75
- fullArgs.push("--host", profileName);
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
- * synoagentcli operation, which is why membership is derived group-by-group.
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
- /** synoagentcli-backed `DsmApiCall`, matching how the rest of the host client talks to DSM. */
23
- export declare function createSynoagentcliApiCall(profileName?: string): DsmApiCall;
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 { spawnSync } from "node:child_process";
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 synoagentcli envelope down to the DSM `data` object. */
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
- /** synoagentcli-backed `DsmApiCall`, matching how the rest of the host client talks to DSM. */
124
- export function createSynoagentcliApiCall(profileName) {
125
- return async (operationId, params) => {
126
- const args = ["api", "call", "--operation-id", operationId];
127
- for (const [key, value] of Object.entries(params)) {
128
- args.push("--param", `${key}=${value}`);
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
  }
@@ -65,6 +65,10 @@ export interface NasHost {
65
65
  export interface HostCredentials {
66
66
  dsmOrigin: string;
67
67
  username: string;
68
- password: string;
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
  }
@@ -3,16 +3,16 @@ import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { parseDocument } from 'yaml';
5
5
  import { API_PERMISSIONS_FILE } from './standalone/api-permissions.js';
6
- import { safeMetadataUrl } from './package-metadata.js';
6
+ import { safeMetadataUrl, upstreamMetadata } from './package-metadata.js';
7
7
  import { parseImageReference } from './images/pin.js';
8
8
  export function validateForkRecipe(template, options) {
9
- if (options.upstream && !safeMetadataUrl(options.upstream))
10
- throw new Error('[INIT_UPSTREAM_INVALID] Supply an http(s) upstream repository URL without credentials.');
9
+ if (options.upstream && (!safeMetadataUrl(options.upstream) || !options.upstream.startsWith("https:")))
10
+ throw new Error('[INIT_UPSTREAM_INVALID] Supply an HTTPS upstream repository URL without credentials.');
11
11
  if (options.publisherUrl && !safeMetadataUrl(options.publisherUrl))
12
12
  throw new Error('[INIT_PUBLISHER_INVALID] Supply an http(s) publisher URL without credentials.');
13
13
  for (const value of [options.publisher, options.upstreamMaintainer]) {
14
- if (value !== undefined && (!value.trim() || /[\x00-\x1f\x7f"\\]/.test(value) || value.length > 200))
15
- throw new Error('[INIT_PUBLISHER_INVALID] Attribution names must be plain, non-empty single-line text.');
14
+ if (value !== undefined && (!value.trim() || /[\x00-\x1f\x7f"\\]/.test(value) || value.length > 80))
15
+ throw new Error('[INIT_PUBLISHER_INVALID] Attribution names must be plain, non-empty single-line text of at most 80 characters.');
16
16
  }
17
17
  if (template === 'native-go' && options.upstreamImage)
18
18
  throw new Error('[INIT_OPTIONS_UNSUPPORTED] Native forks stage official release binaries or use a source build hook; image options apply only to compose.');
@@ -40,7 +40,7 @@ export function writeForkScaffold(packaging, template, options) {
40
40
  doc.set('maintainer', options.upstreamMaintainer ?? 'TODO: original upstream project or owner');
41
41
  doc.setIn(['metadata', 'distributor'], options.publisher ?? 'TODO: your publisher name');
42
42
  if (options.upstream) {
43
- doc.setIn(['metadata', 'upstream'], options.upstream);
43
+ doc.setIn(['metadata', 'upstream'], upstreamMetadata(options.upstream));
44
44
  doc.setIn(['metadata', 'maintainer_url'], options.upstream);
45
45
  }
46
46
  if (options.publisherUrl)
@@ -1,5 +1,12 @@
1
1
  /** Plain text, or a map from a BCP-47 tag (en, zh-TW, ...) to plain text. */
2
2
  export type LocalizedText = string | Record<string, string>;
3
+ export interface UpstreamMetadata {
4
+ name: string;
5
+ url: string;
6
+ license?: string;
7
+ }
8
+ /** Normalize the URL shorthand to the catalog's existing structured contract. */
9
+ export declare function upstreamMetadata(raw: unknown): UpstreamMetadata;
3
10
  export interface PackageMetadata {
4
11
  long_description?: LocalizedText;
5
12
  changelog?: LocalizedText;
@@ -7,7 +14,7 @@ export interface PackageMetadata {
7
14
  support_email?: string;
8
15
  homepage?: string;
9
16
  source_url?: string;
10
- upstream?: string;
17
+ upstream?: UpstreamMetadata;
11
18
  issues_url?: string;
12
19
  license?: string;
13
20
  maintainer_url?: string;
@@ -1,3 +1,26 @@
1
+ /** Normalize the URL shorthand to the catalog's existing structured contract. */
2
+ export function upstreamMetadata(raw) {
3
+ const value = typeof raw === "string" ? { name: upstreamName(raw), url: raw } : raw;
4
+ if (!value || typeof value !== "object" || Array.isArray(value))
5
+ throw new Error("invalid upstream");
6
+ const v = value;
7
+ const text = (t) => typeof t === "string" && !!t.trim() && t.length <= 80 && !/[\x00-\x1f\x7f]/.test(t);
8
+ if (Object.keys(v).some(k => !["name", "url", "license"].includes(k)) ||
9
+ !text(v.name) || !safeMetadataUrl(v.url) || !v.url.startsWith("https:") ||
10
+ (v.license !== undefined && !text(v.license)))
11
+ throw new Error("invalid upstream");
12
+ return value;
13
+ }
14
+ function upstreamName(url) {
15
+ try {
16
+ const u = new URL(url);
17
+ const name = decodeURIComponent(u.pathname.split("/").filter(Boolean).at(-1) ?? u.hostname).replace(/\.git$/, "");
18
+ return name.trim() && name.length <= 80 && !/[\x00-\x1f\x7f]/.test(name) ? name : "TODO: original upstream project";
19
+ }
20
+ catch {
21
+ return "TODO: original upstream project";
22
+ }
23
+ }
1
24
  export function safeMetadataUrl(value, mail = false) {
2
25
  if (typeof value !== "string" || value.length > 2048)
3
26
  return false;
@@ -40,6 +63,10 @@ export function validateMetadata(raw) {
40
63
  for (const [k, v] of Object.entries(raw)) {
41
64
  if (!allowed.includes(k))
42
65
  throw new Error("unknown metadata");
66
+ if (k === "upstream") {
67
+ upstreamMetadata(v);
68
+ continue;
69
+ }
43
70
  if (k === "screenshots") {
44
71
  if (!Array.isArray(v) ||
45
72
  v.length > MAX_SCREENSHOTS ||
@@ -79,7 +106,7 @@ export function validateMetadata(raw) {
79
106
  }
80
107
  if (JSON.stringify(raw).length > 32000)
81
108
  throw new Error("metadata too large");
82
- return raw;
109
+ return { ...raw, ...("upstream" in raw ? { upstream: upstreamMetadata(raw.upstream) } : {}) };
83
110
  }
84
111
  export function metadataInfoFields(metadata) {
85
112
  if (!metadata)
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",
@@ -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;
@@ -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
  }
@@ -3,6 +3,7 @@ export type { SpkFinding } from "./findings.js";
3
3
  export interface SpkValidation {
4
4
  /** No errors; warnings do not fail validation. */
5
5
  ok: boolean;
6
+ info?: Record<string, string>;
6
7
  package?: string;
7
8
  version?: string;
8
9
  arch?: string;
@@ -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 } : {}),
@@ -11,7 +11,7 @@ export function upstreamRepository(root, explicit) {
11
11
  if (!raw)
12
12
  return undefined;
13
13
  const url = raw.replace(/^git@github\.com:/, 'https://github.com/').replace(/\.git$/, '');
14
- return safeMetadataUrl(url) ? url : undefined;
14
+ return safeMetadataUrl(url) && url.startsWith("https:") ? url : undefined;
15
15
  }
16
16
  /** Best effort, bounded and public: offline/rate-limited lookups retain an explicit TODO. */
17
17
  export async function upstreamMaintainer(url, fetcher = fetch) {
@@ -28,7 +28,7 @@ export async function upstreamMaintainer(url, fetcher = fetch) {
28
28
  return undefined;
29
29
  const owner = await response.json();
30
30
  const name = typeof owner.name === 'string' && owner.name.trim() ? owner.name.trim() : owner.login;
31
- return typeof name === 'string' && name.length <= 200 && !/[\x00-\x1f\x7f"\\]/.test(name) ? name : undefined;
31
+ return typeof name === 'string' && name.length <= 80 && !/[\x00-\x1f\x7f"\\]/.test(name) ? name : undefined;
32
32
  }
33
33
  catch {
34
34
  return undefined;
@@ -45,7 +45,8 @@ export function upstreamAttributionWarnings(root) {
45
45
  const todo = (v) => typeof v !== 'string' || !v.trim() || /^TODO\b/i.test(v.trim());
46
46
  if (!todo(raw.maintainer) && !todo(m.distributor) &&
47
47
  raw.maintainer.trim().toLowerCase() !== m.distributor.trim().toLowerCase() &&
48
- !todo(m.maintainer_url) && !todo(m.distributor_url))
48
+ !todo(m.maintainer_url) && !todo(m.distributor_url) &&
49
+ !(typeof m.upstream === "object" && m.upstream && todo(m.upstream.name)))
49
50
  return [];
50
51
  return [{
51
52
  code: 'UPSTREAM_ATTRIBUTION', severity: 'warning',