@synopackageland/cli 0.1.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/README.md +13 -0
- package/dist/attestation/app-package.d.ts +86 -0
- package/dist/attestation/app-package.js +199 -0
- package/dist/attestation/install-on-host.d.ts +14 -0
- package/dist/attestation/install-on-host.js +52 -0
- package/dist/attestation/keyring.d.ts +21 -0
- package/dist/attestation/keyring.js +168 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +14 -0
- package/dist/build-spk/dsm-arch.d.ts +4 -0
- package/dist/build-spk/dsm-arch.js +32 -0
- package/dist/build-spk/dsm-icons.d.ts +2 -0
- package/dist/build-spk/dsm-icons.js +67 -0
- package/dist/build-spk/generator.d.ts +43 -0
- package/dist/build-spk/generator.js +653 -0
- package/dist/build-spk/native-payload.d.ts +22 -0
- package/dist/build-spk/native-payload.js +654 -0
- package/dist/build-spk/reproducible-archive.d.ts +8 -0
- package/dist/build-spk/reproducible-archive.js +34 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +345 -0
- package/dist/compose/analyze.d.ts +25 -0
- package/dist/compose/analyze.js +346 -0
- package/dist/compose/env.d.ts +7 -0
- package/dist/compose/env.js +36 -0
- package/dist/compose/privilege.d.ts +14 -0
- package/dist/compose/privilege.js +102 -0
- package/dist/compose/runner.d.ts +32 -0
- package/dist/compose/runner.js +84 -0
- package/dist/contract/load.d.ts +25 -0
- package/dist/contract/load.js +87 -0
- package/dist/contract/schema-validate.d.ts +3 -0
- package/dist/contract/schema-validate.js +50 -0
- package/dist/contract/validate.d.ts +3 -0
- package/dist/contract/validate.js +80 -0
- package/dist/contract/web-launch.d.ts +19 -0
- package/dist/contract/web-launch.js +94 -0
- package/dist/deploy/catalog-publish.d.ts +3 -0
- package/dist/deploy/catalog-publish.js +7 -0
- package/dist/deploy/deploy.d.ts +22 -0
- package/dist/deploy/deploy.js +136 -0
- package/dist/deploy/identity-shared.d.ts +3 -0
- package/dist/deploy/identity-shared.js +14 -0
- package/dist/deploy/identity.d.ts +7 -0
- package/dist/deploy/identity.js +20 -0
- package/dist/deploy/package-source.d.ts +7 -0
- package/dist/deploy/package-source.js +17 -0
- package/dist/deploy/run-as-values.d.ts +18 -0
- package/dist/deploy/run-as-values.js +48 -0
- package/dist/deploy/share-values.d.ts +20 -0
- package/dist/deploy/share-values.js +60 -0
- package/dist/deploy/spk-info.d.ts +12 -0
- package/dist/deploy/spk-info.js +41 -0
- package/dist/deploy/spk-version.d.ts +4 -0
- package/dist/deploy/spk-version.js +64 -0
- package/dist/dev.d.ts +13 -0
- package/dist/dev.js +31 -0
- package/dist/discovery.d.ts +8 -0
- package/dist/discovery.js +22 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +18 -0
- package/dist/host/broker-paths.d.ts +1 -0
- package/dist/host/broker-paths.js +1 -0
- package/dist/host/catalog-install.d.ts +59 -0
- package/dist/host/catalog-install.js +326 -0
- package/dist/host/credentials.d.ts +2 -0
- package/dist/host/credentials.js +37 -0
- package/dist/host/digests.d.ts +5 -0
- package/dist/host/digests.js +55 -0
- package/dist/host/dsm-client.d.ts +6 -0
- package/dist/host/dsm-client.js +290 -0
- package/dist/host/dsm-http.d.ts +1 -0
- package/dist/host/dsm-http.js +87 -0
- package/dist/host/fake-nas.d.ts +16 -0
- package/dist/host/fake-nas.js +116 -0
- package/dist/host/open-url.d.ts +5 -0
- package/dist/host/open-url.js +13 -0
- package/dist/host/share-lookup.d.ts +3 -0
- package/dist/host/share-lookup.js +144 -0
- package/dist/host/types.d.ts +67 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +14 -0
- package/dist/info.d.ts +17 -0
- package/dist/info.js +34 -0
- package/dist/init.d.ts +4 -0
- package/dist/init.js +31 -0
- package/dist/inspect.d.ts +13 -0
- package/dist/inspect.js +102 -0
- package/dist/load-env.d.ts +1 -0
- package/dist/load-env.js +31 -0
- package/dist/log.d.ts +13 -0
- package/dist/log.js +20 -0
- package/dist/mock-broker/bootstrap.d.ts +2 -0
- package/dist/mock-broker/bootstrap.js +8 -0
- package/dist/mock-broker/files.d.ts +60 -0
- package/dist/mock-broker/files.js +408 -0
- package/dist/mock-broker/oidc.d.ts +31 -0
- package/dist/mock-broker/oidc.js +161 -0
- package/dist/mock-broker/path.d.ts +9 -0
- package/dist/mock-broker/path.js +50 -0
- package/dist/mock-broker/server.d.ts +19 -0
- package/dist/mock-broker/server.js +350 -0
- package/dist/publish/build-ledger.d.ts +22 -0
- package/dist/publish/build-ledger.js +26 -0
- package/dist/publish/community-path.d.ts +6 -0
- package/dist/publish/community-path.js +41 -0
- package/dist/publish/curated-release.d.ts +78 -0
- package/dist/publish/curated-release.js +693 -0
- package/dist/publish/file-build-ledger.d.ts +18 -0
- package/dist/publish/file-build-ledger.js +268 -0
- package/dist/publish/github-pr.d.ts +25 -0
- package/dist/publish/github-pr.js +85 -0
- package/dist/publish/github-repo.d.ts +8 -0
- package/dist/publish/github-repo.js +21 -0
- package/dist/publish/publish.d.ts +23 -0
- package/dist/publish/publish.js +128 -0
- package/dist/publish/sync-definition.d.ts +4 -0
- package/dist/publish/sync-definition.js +48 -0
- package/dist/skill.d.ts +10 -0
- package/dist/skill.js +50 -0
- package/dist/templates/compose-import/compose.yaml +11 -0
- package/dist/templates/compose-import/synology-app.yaml +15 -0
- package/dist/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
- package/dist/templates/hello-files/compose.yaml +8 -0
- package/dist/templates/hello-files/synology-app.yaml +13 -0
- package/dist/templates/hello-web/compose.yaml +7 -0
- package/dist/templates/hello-web/html/app.js +23 -0
- package/dist/templates/hello-web/html/index.html +15 -0
- package/dist/templates/hello-web/html/sdk.js +68 -0
- package/dist/templates/hello-web/synology-app.yaml +13 -0
- package/dist/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
- package/dist/templates/hello-web-page/compose.yaml +7 -0
- package/dist/templates/hello-web-page/html/app.js +23 -0
- package/dist/templates/hello-web-page/html/index.html +15 -0
- package/dist/templates/hello-web-page/html/sdk.js +68 -0
- package/dist/templates/hello-web-page/synology-app.yaml +13 -0
- package/dist/test-command.d.ts +13 -0
- package/dist/test-command.js +26 -0
- package/dist/test-host.d.ts +13 -0
- package/dist/test-host.js +24 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/dist/validate.d.ts +8 -0
- package/dist/validate.js +103 -0
- package/package.json +38 -0
- package/schema/synology-app.schema.json +137 -0
- package/skills/SKILL.md +51 -0
- package/skills/reference/create.md +34 -0
- package/skills/reference/deploy.md +41 -0
- package/skills/reference/diagnose.md +42 -0
- package/skills/reference/files.md +37 -0
- package/skills/reference/identity.md +31 -0
- package/skills/reference/wrap.md +35 -0
- package/templates/compose-import/compose.yaml +11 -0
- package/templates/compose-import/synology-app.yaml +15 -0
- package/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
- package/templates/hello-files/compose.yaml +8 -0
- package/templates/hello-files/synology-app.yaml +13 -0
- package/templates/hello-web/compose.yaml +7 -0
- package/templates/hello-web/html/app.js +23 -0
- package/templates/hello-web/html/index.html +15 -0
- package/templates/hello-web/html/sdk.js +68 -0
- package/templates/hello-web/synology-app.yaml +13 -0
- package/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
- package/templates/hello-web-page/compose.yaml +7 -0
- package/templates/hello-web-page/html/app.js +23 -0
- package/templates/hello-web-page/html/index.html +15 -0
- package/templates/hello-web-page/html/sdk.js +68 -0
- package/templates/hello-web-page/synology-app.yaml +13 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { IDENTITY_CGI_PATH } from "./broker-paths.js";
|
|
6
|
+
import { packageSourceOrigin } from "../deploy/catalog-publish.js";
|
|
7
|
+
import { lookupSharePathWithSynoagentcli } from "./share-lookup.js";
|
|
8
|
+
import { dsmFetch } from "./dsm-http.js";
|
|
9
|
+
import { installPackageFromCatalog, installPackageFromLocalSpk, fetchInstalledPackageState } from "./catalog-install.js";
|
|
10
|
+
import { resolveHostCredentials } from "./credentials.js";
|
|
11
|
+
export function createNasHost(options = {}) {
|
|
12
|
+
const credentials = options.credentials ?? resolveHostCredentials(options.host);
|
|
13
|
+
if (!credentials) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return createDsmNasHost(credentials, options.host);
|
|
17
|
+
}
|
|
18
|
+
function synoagentcli(args, profileName) {
|
|
19
|
+
const fullArgs = [...args];
|
|
20
|
+
if (profileName) {
|
|
21
|
+
fullArgs.push("--host", profileName);
|
|
22
|
+
}
|
|
23
|
+
const result = spawnSync("synoagentcli", fullArgs, { encoding: "utf8" });
|
|
24
|
+
return {
|
|
25
|
+
status: result.status,
|
|
26
|
+
stdout: result.stdout ?? "",
|
|
27
|
+
stderr: result.stderr ?? "",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function createDsmNasHost(credentials, profileName) {
|
|
31
|
+
const dsmOrigin = credentials.dsmOrigin.replace(/\/$/, "");
|
|
32
|
+
const brokerBaseUrl = `${dsmOrigin}${IDENTITY_CGI_PATH}`;
|
|
33
|
+
let cookieHeader = credentials.cookieHeader;
|
|
34
|
+
const projectEnvCache = new Map();
|
|
35
|
+
const ensureLogin = async () => {
|
|
36
|
+
if (cookieHeader) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const query = new URLSearchParams({
|
|
40
|
+
api: "SYNO.API.Auth",
|
|
41
|
+
version: "3",
|
|
42
|
+
method: "login",
|
|
43
|
+
account: credentials.username,
|
|
44
|
+
passwd: credentials.password,
|
|
45
|
+
session: "FileStation",
|
|
46
|
+
format: "cookie",
|
|
47
|
+
});
|
|
48
|
+
const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?${query.toString()}`);
|
|
49
|
+
const setCookie = typeof response.headers.getSetCookie === "function"
|
|
50
|
+
? response.headers.getSetCookie()
|
|
51
|
+
: response.headers.get("set-cookie")
|
|
52
|
+
? [response.headers.get("set-cookie")]
|
|
53
|
+
: [];
|
|
54
|
+
const pairs = setCookie
|
|
55
|
+
.map((line) => line.split(";")[0]?.trim())
|
|
56
|
+
.filter((pair) => Boolean(pair));
|
|
57
|
+
cookieHeader = pairs.join("; ");
|
|
58
|
+
};
|
|
59
|
+
const ensureInnerLoopApi = async () => {
|
|
60
|
+
await ensureLogin();
|
|
61
|
+
const response = await dsmFetch(`${brokerBaseUrl}/api/v1/inner-loop/diagnostics?package=probe`, { headers: { cookie: cookieHeader ?? "" } });
|
|
62
|
+
const text = await response.text();
|
|
63
|
+
if (response.status === 404 || text.trimStart().startsWith("<")) {
|
|
64
|
+
const error = new Error("Broker inner-loop API 不可用(收到 HTML 404)。請將 SynoPkgLandService 升級到 >=0.1.0-0016。");
|
|
65
|
+
error.code = "INNER_LOOP_API_MISSING";
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
JSON.parse(text);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
const error = new Error("Broker inner-loop API 回傳非 JSON 內容。");
|
|
73
|
+
error.code = "INNER_LOOP_API_MISSING";
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const ensurePackageSourceFeed = (origin) => {
|
|
78
|
+
const feed = origin.replace(/\/$/, "") + "/";
|
|
79
|
+
const list = synoagentcli(["dsm", "package-center", "source", "list"], profileName);
|
|
80
|
+
if (list.status !== 0) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const parsed = JSON.parse(list.stdout);
|
|
84
|
+
const items = parsed.data?.items ?? [];
|
|
85
|
+
const normalizedFeed = feed.replace(/\/$/, "");
|
|
86
|
+
const existing = items.find((item) => item.feed?.replace(/\/$/, "") === normalizedFeed);
|
|
87
|
+
if (existing) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const stale = items.find((item) => item.name === "package-land-throwaway");
|
|
91
|
+
const payload = { name: "package-land-throwaway", feed };
|
|
92
|
+
const staging = mkdtempSync(join(tmpdir(), "syno-package-source-feed-"));
|
|
93
|
+
const jsonPath = join(staging, "source.json");
|
|
94
|
+
writeFileSync(jsonPath, JSON.stringify(payload));
|
|
95
|
+
try {
|
|
96
|
+
if (stale?.feed) {
|
|
97
|
+
synoagentcli(["dsm", "package-center", "source", "update", stale.feed, "--json-file", jsonPath], profileName);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
synoagentcli(["dsm", "package-center", "source", "add", "--json-file", jsonPath], profileName);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
rmSync(staging, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
dsmOrigin,
|
|
108
|
+
installationId: "live-nas",
|
|
109
|
+
async requestAttestation(input) {
|
|
110
|
+
await ensureInnerLoopApi();
|
|
111
|
+
await ensureLogin();
|
|
112
|
+
const response = await dsmFetch(`${brokerBaseUrl}/api/v1/inner-loop/attest`, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: {
|
|
115
|
+
cookie: cookieHeader ?? "",
|
|
116
|
+
"content-type": "application/json",
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify(input),
|
|
119
|
+
});
|
|
120
|
+
const text = await response.text();
|
|
121
|
+
let body;
|
|
122
|
+
try {
|
|
123
|
+
body = JSON.parse(text);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
const error = new Error("Broker inner-loop attest 回傳非 JSON 內容。");
|
|
127
|
+
error.code = "INNER_LOOP_API_MISSING";
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
const error = new Error(body.error?.message ?? `Attestation failed (${response.status})`);
|
|
132
|
+
error.code = body.error?.code;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
return { kind: "local", attestationDigest: body.attestationDigest ?? "" };
|
|
136
|
+
},
|
|
137
|
+
async ensurePackageSource(origin) {
|
|
138
|
+
ensurePackageSourceFeed(origin);
|
|
139
|
+
},
|
|
140
|
+
async installFromCatalog(input) {
|
|
141
|
+
try {
|
|
142
|
+
ensurePackageSourceFeed(packageSourceOrigin());
|
|
143
|
+
await ensureLogin();
|
|
144
|
+
await installPackageFromCatalog({
|
|
145
|
+
dsmOrigin,
|
|
146
|
+
cookieHeader: cookieHeader ?? "",
|
|
147
|
+
packageId: input.packageId,
|
|
148
|
+
profileName,
|
|
149
|
+
expectedVersion: input.version,
|
|
150
|
+
packageSourceOrigin: packageSourceOrigin(),
|
|
151
|
+
extraValues: input.extraValues,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
projectEnvCache.delete(input.packageId);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
async installFromLocalSpk(input) {
|
|
159
|
+
try {
|
|
160
|
+
await ensureLogin();
|
|
161
|
+
await installPackageFromLocalSpk({
|
|
162
|
+
dsmOrigin,
|
|
163
|
+
cookieHeader: cookieHeader ?? "",
|
|
164
|
+
packageId: input.packageId,
|
|
165
|
+
spkPath: input.spkPath,
|
|
166
|
+
version: input.version,
|
|
167
|
+
profileName,
|
|
168
|
+
extraValues: input.extraValues,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
projectEnvCache.delete(input.packageId);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
async resolveSharePath(logicalName) {
|
|
176
|
+
return lookupSharePathWithSynoagentcli(logicalName, profileName);
|
|
177
|
+
},
|
|
178
|
+
async getInstalledProjectEnv(packageIdentity) {
|
|
179
|
+
const cached = projectEnvCache.get(packageIdentity);
|
|
180
|
+
if (cached) {
|
|
181
|
+
return cached;
|
|
182
|
+
}
|
|
183
|
+
const lookup = (async () => {
|
|
184
|
+
await ensureLogin();
|
|
185
|
+
const response = await dsmFetch(`${brokerBaseUrl}/api/v1/inner-loop/project-env/${encodeURIComponent(packageIdentity)}`, { headers: { cookie: cookieHeader ?? "" } });
|
|
186
|
+
if (response.ok) {
|
|
187
|
+
const payload = (await response.json());
|
|
188
|
+
if (!payload.env || typeof payload.env !== "object" || Array.isArray(payload.env)) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
return Object.fromEntries(Object.entries(payload.env).filter((entry) => typeof entry[1] === "string"));
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
})();
|
|
195
|
+
projectEnvCache.set(packageIdentity, lookup);
|
|
196
|
+
return lookup;
|
|
197
|
+
},
|
|
198
|
+
async getAttestationInfo(packageIdentity) {
|
|
199
|
+
await ensureLogin();
|
|
200
|
+
const response = await dsmFetch(`${brokerBaseUrl}/api/v1/inner-loop/info/${encodeURIComponent(packageIdentity)}`, { headers: { cookie: cookieHeader ?? "" } });
|
|
201
|
+
const text = await response.text();
|
|
202
|
+
if (response.status === 404 || text.trimStart().startsWith("<")) {
|
|
203
|
+
const error = new Error("Broker inner-loop info 不可用(HTML 404)。");
|
|
204
|
+
error.code = "INNER_LOOP_API_MISSING";
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(text);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
const error = new Error("Broker inner-loop info 回傳非 JSON 內容。");
|
|
212
|
+
error.code = "INNER_LOOP_API_MISSING";
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
async getInstalledPackageVersion(packageIdentity) {
|
|
217
|
+
await ensureLogin();
|
|
218
|
+
const viaDsm = await fetchInstalledPackageState(dsmOrigin, cookieHeader ?? "", packageIdentity);
|
|
219
|
+
if (viaDsm?.version) {
|
|
220
|
+
return viaDsm.version;
|
|
221
|
+
}
|
|
222
|
+
const result = synoagentcli(["dsm", "package-center", "get", packageIdentity], profileName);
|
|
223
|
+
if (result.status !== 0) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(result.stdout);
|
|
228
|
+
return parsed.data?.version;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
async isPackageInstalled(packageIdentity) {
|
|
235
|
+
await ensureLogin();
|
|
236
|
+
const viaDsm = await fetchInstalledPackageState(dsmOrigin, cookieHeader ?? "", packageIdentity);
|
|
237
|
+
if (viaDsm) {
|
|
238
|
+
return viaDsm.installed;
|
|
239
|
+
}
|
|
240
|
+
const result = synoagentcli(["dsm", "package-center", "get", packageIdentity], profileName);
|
|
241
|
+
return result.status === 0;
|
|
242
|
+
},
|
|
243
|
+
async fetchContainerLogs(packageIdentity) {
|
|
244
|
+
const result = synoagentcli(["dsm", "package-center", "status", packageIdentity], profileName);
|
|
245
|
+
if (result.status !== 0) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
return result.stdout
|
|
249
|
+
.split("\n")
|
|
250
|
+
.map((line) => line.trim())
|
|
251
|
+
.filter(Boolean)
|
|
252
|
+
.map((line) => `[status] ${line}`);
|
|
253
|
+
},
|
|
254
|
+
async smokeRequireLogin(input) {
|
|
255
|
+
await ensureLogin();
|
|
256
|
+
const info = await this.getAttestationInfo(input.packageIdentity);
|
|
257
|
+
let openUrl = info.openUrl;
|
|
258
|
+
if (!openUrl) {
|
|
259
|
+
const pkg = synoagentcli(["dsm", "package-center", "get", input.packageIdentity], profileName);
|
|
260
|
+
if (pkg.status === 0) {
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(pkg.stdout);
|
|
263
|
+
openUrl = parsed.data?.url?.[0];
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// ignore parse errors
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!openUrl) {
|
|
271
|
+
return { ok: false };
|
|
272
|
+
}
|
|
273
|
+
const origin = new URL(openUrl).origin;
|
|
274
|
+
const response = await dsmFetch(`${brokerBaseUrl}/api/v1/auth/login`, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: {
|
|
277
|
+
cookie: cookieHeader ?? input.cookieHeader,
|
|
278
|
+
origin,
|
|
279
|
+
"content-type": "application/json",
|
|
280
|
+
},
|
|
281
|
+
body: JSON.stringify({ packageId: input.packageIdentity }),
|
|
282
|
+
});
|
|
283
|
+
if (!response.ok) {
|
|
284
|
+
return { ok: false };
|
|
285
|
+
}
|
|
286
|
+
const body = (await response.json());
|
|
287
|
+
return { ok: Boolean(body.user?.username), username: body.user?.username };
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function dsmFetch(url: string, init?: RequestInit): Promise<Response>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
function isHostUnreachable(error) {
|
|
6
|
+
const code = error.cause?.code
|
|
7
|
+
?? error.code;
|
|
8
|
+
return code === "EHOSTUNREACH" || code === "ENETUNREACH";
|
|
9
|
+
}
|
|
10
|
+
function headerRecord(headers) {
|
|
11
|
+
if (!headers) {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
if (headers instanceof Headers) {
|
|
15
|
+
return Object.fromEntries(headers.entries());
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(headers)) {
|
|
18
|
+
return Object.fromEntries(headers);
|
|
19
|
+
}
|
|
20
|
+
return { ...headers };
|
|
21
|
+
}
|
|
22
|
+
export async function dsmFetch(url, init = {}) {
|
|
23
|
+
try {
|
|
24
|
+
return await fetch(url, init);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!isHostUnreachable(error)) {
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
return curlFetch(url, init);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function curlFetch(url, init) {
|
|
34
|
+
const staging = mkdtempSync(join(tmpdir(), "synopkgland-dsm-http-"));
|
|
35
|
+
const headerFile = join(staging, "headers");
|
|
36
|
+
const bodyFile = join(staging, "body");
|
|
37
|
+
try {
|
|
38
|
+
const args = ["-sS", "-D", headerFile, "-o", bodyFile, "-w", "%{http_code}", "-X", init.method ?? "GET"];
|
|
39
|
+
const headers = headerRecord(init.headers);
|
|
40
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
41
|
+
if (name.toLowerCase() === "content-type" && init.body instanceof FormData) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
args.push("-H", `${name}: ${value}`);
|
|
45
|
+
}
|
|
46
|
+
let stdin;
|
|
47
|
+
if (init.body instanceof FormData) {
|
|
48
|
+
for (const [name, value] of init.body.entries()) {
|
|
49
|
+
if (typeof value === "string") {
|
|
50
|
+
args.push("-F", `${name}=${value}`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const filename = "name" in value && typeof value.name === "string" ? value.name : "upload.bin";
|
|
54
|
+
const partPath = join(staging, filename);
|
|
55
|
+
writeFileSync(partPath, Buffer.from(await value.arrayBuffer()));
|
|
56
|
+
args.push("-F", `${name}=@${partPath};filename=${filename}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (typeof init.body === "string") {
|
|
60
|
+
stdin = Buffer.from(init.body);
|
|
61
|
+
args.push("--data-binary", "@-");
|
|
62
|
+
}
|
|
63
|
+
else if (init.body) {
|
|
64
|
+
stdin = Buffer.from(await new Response(init.body).arrayBuffer());
|
|
65
|
+
args.push("--data-binary", "@-");
|
|
66
|
+
}
|
|
67
|
+
args.push(url);
|
|
68
|
+
const result = spawnSync("curl", args, { encoding: "utf8", input: stdin });
|
|
69
|
+
if (result.status !== 0) {
|
|
70
|
+
throw new Error(result.stderr || `curl exit ${result.status}`);
|
|
71
|
+
}
|
|
72
|
+
const status = Number((result.stdout ?? "").trim().slice(-3));
|
|
73
|
+
const rawHeaders = readFileSync(headerFile, "utf8");
|
|
74
|
+
const body = readFileSync(bodyFile);
|
|
75
|
+
const responseHeaders = new Headers();
|
|
76
|
+
for (const line of rawHeaders.split(/\r?\n/)) {
|
|
77
|
+
const idx = line.indexOf(":");
|
|
78
|
+
if (idx > 0) {
|
|
79
|
+
responseHeaders.append(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return new Response(body, { status: Number.isInteger(status) ? status : 502, headers: responseHeaders });
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rmSync(staging, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AttestationKind, HostInstallRecord, NasHost } from "./types.js";
|
|
2
|
+
export interface FakeNasHostOptions {
|
|
3
|
+
curated?: string[];
|
|
4
|
+
dsmOrigin?: string;
|
|
5
|
+
installationId?: string;
|
|
6
|
+
shares?: Record<string, string>;
|
|
7
|
+
installedEnv?: Record<string, Record<string, string>>;
|
|
8
|
+
}
|
|
9
|
+
export declare function createFakeNasHost(options?: FakeNasHostOptions): NasHost & {
|
|
10
|
+
installs: HostInstallRecord[];
|
|
11
|
+
attestations: Set<string>;
|
|
12
|
+
attestationInfo(packageIdentity: string): {
|
|
13
|
+
attestationKind: AttestationKind;
|
|
14
|
+
} | undefined;
|
|
15
|
+
seedInstalled(packageIdentity: string): void;
|
|
16
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { webStationAlias } from "../contract/web-launch.js";
|
|
2
|
+
export function createFakeNasHost(options = {}) {
|
|
3
|
+
const dsmOrigin = options.dsmOrigin ?? "http://nas.example:5000";
|
|
4
|
+
const installationId = options.installationId ?? "fake-installation";
|
|
5
|
+
const curated = new Set(options.curated ?? []);
|
|
6
|
+
const installs = [];
|
|
7
|
+
const localAttestations = new Map();
|
|
8
|
+
const installed = new Set();
|
|
9
|
+
const openUrls = new Map();
|
|
10
|
+
const shares = { ...(options.shares ?? {}) };
|
|
11
|
+
const installedEnv = Object.fromEntries(Object.entries(options.installedEnv ?? {}).map(([packageId, env]) => [packageId, { ...env }]));
|
|
12
|
+
const host = {
|
|
13
|
+
dsmOrigin,
|
|
14
|
+
installationId,
|
|
15
|
+
installs,
|
|
16
|
+
attestations: new Set(),
|
|
17
|
+
markCurated(packageIdentity) {
|
|
18
|
+
curated.add(packageIdentity);
|
|
19
|
+
localAttestations.set(packageIdentity, "app-package");
|
|
20
|
+
},
|
|
21
|
+
seedInstalled(packageIdentity) {
|
|
22
|
+
installed.add(packageIdentity);
|
|
23
|
+
},
|
|
24
|
+
async resolveSharePath(logicalName) {
|
|
25
|
+
return shares[logicalName];
|
|
26
|
+
},
|
|
27
|
+
async getInstalledProjectEnv(packageIdentity) {
|
|
28
|
+
const env = installedEnv[packageIdentity];
|
|
29
|
+
return env ? { ...env } : undefined;
|
|
30
|
+
},
|
|
31
|
+
attestationInfo(packageIdentity) {
|
|
32
|
+
const kind = localAttestations.get(packageIdentity) ?? (installed.has(packageIdentity) ? "local" : "none");
|
|
33
|
+
return kind === "none" ? undefined : { attestationKind: kind };
|
|
34
|
+
},
|
|
35
|
+
async requestAttestation(input) {
|
|
36
|
+
if (curated.has(input.packageIdentity) && !input.allowCuratedDowngrade) {
|
|
37
|
+
const error = new Error("Curated deploy refused");
|
|
38
|
+
error.code = "CURATED_DEPLOY_REFUSED";
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
if (curated.has(input.packageIdentity) && input.allowCuratedDowngrade) {
|
|
42
|
+
localAttestations.set(input.packageIdentity, "local");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
localAttestations.set(input.packageIdentity, "local");
|
|
46
|
+
}
|
|
47
|
+
const digest = `${input.packageIdentity}:${input.bundleDigest}:${input.contractDigest}`;
|
|
48
|
+
host.attestations.add(input.packageIdentity);
|
|
49
|
+
return { kind: "local", attestationDigest: digest };
|
|
50
|
+
},
|
|
51
|
+
async ensurePackageSource() { },
|
|
52
|
+
async installFromCatalog(input) {
|
|
53
|
+
installs.push({
|
|
54
|
+
packageId: input.packageId,
|
|
55
|
+
version: input.version,
|
|
56
|
+
source: "package-center",
|
|
57
|
+
extraValues: { ...(input.extraValues ?? {}) },
|
|
58
|
+
});
|
|
59
|
+
installed.add(input.packageId);
|
|
60
|
+
installedEnv[input.packageId] = {
|
|
61
|
+
...(installedEnv[input.packageId] ?? {}),
|
|
62
|
+
...(input.extraValues ?? {}),
|
|
63
|
+
};
|
|
64
|
+
openUrls.set(input.packageId, `${new URL(dsmOrigin).protocol}//${new URL(dsmOrigin).hostname}/${webStationAlias(input.packageId)}/`);
|
|
65
|
+
if (!localAttestations.has(input.packageId)) {
|
|
66
|
+
localAttestations.set(input.packageId, "local");
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async installFromLocalSpk(input) {
|
|
70
|
+
installs.push({
|
|
71
|
+
packageId: input.packageId,
|
|
72
|
+
version: input.version,
|
|
73
|
+
source: "local-spk",
|
|
74
|
+
extraValues: { ...(input.extraValues ?? {}) },
|
|
75
|
+
});
|
|
76
|
+
installed.add(input.packageId);
|
|
77
|
+
installedEnv[input.packageId] = {
|
|
78
|
+
...(installedEnv[input.packageId] ?? {}),
|
|
79
|
+
...(input.extraValues ?? {}),
|
|
80
|
+
};
|
|
81
|
+
openUrls.set(input.packageId, `${new URL(dsmOrigin).protocol}//${new URL(dsmOrigin).hostname}/${webStationAlias(input.packageId)}/`);
|
|
82
|
+
if (!localAttestations.has(input.packageId)) {
|
|
83
|
+
localAttestations.set(input.packageId, "local");
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
async getInstalledPackageVersion(packageIdentity) {
|
|
87
|
+
const latest = [...installs].reverse().find((entry) => entry.packageId === packageIdentity);
|
|
88
|
+
return latest?.version;
|
|
89
|
+
},
|
|
90
|
+
async getAttestationInfo(packageIdentity) {
|
|
91
|
+
return {
|
|
92
|
+
packageIdentity,
|
|
93
|
+
attestationKind: localAttestations.get(packageIdentity) ?? (installed.has(packageIdentity) ? "local" : "none"),
|
|
94
|
+
developmentInstance: packageIdentity.endsWith(".dev"),
|
|
95
|
+
openUrl: openUrls.get(packageIdentity),
|
|
96
|
+
readiness: installed.has(packageIdentity) ? "RUNNING" : "NOT_INSTALLED",
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
async isPackageInstalled(packageIdentity) {
|
|
100
|
+
return installed.has(packageIdentity);
|
|
101
|
+
},
|
|
102
|
+
async fetchContainerLogs(packageIdentity) {
|
|
103
|
+
if (!installed.has(packageIdentity)) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
return [`[${packageIdentity}] container ready`];
|
|
107
|
+
},
|
|
108
|
+
async smokeRequireLogin() {
|
|
109
|
+
return { ok: true, username: "admin" };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
for (const packageId of curated) {
|
|
113
|
+
localAttestations.set(packageId, "app-package");
|
|
114
|
+
}
|
|
115
|
+
return host;
|
|
116
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { webStationAlias } from "../contract/web-launch.js";
|
|
2
|
+
export function appOriginFromContract(dsmOrigin, contract) {
|
|
3
|
+
if (contract.adminport === undefined || contract.adminport === null || contract.adminport === "") {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
const packageId = String(contract.package ?? "");
|
|
7
|
+
if (!packageId) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const dsm = new URL(dsmOrigin);
|
|
11
|
+
const protocol = String(contract.adminprotocol ?? "http");
|
|
12
|
+
return `${protocol}://${dsm.hostname}/${webStationAlias(packageId)}/`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function shareRealPathFromEntry(entry: Record<string, unknown>, logicalName: string): string | undefined;
|
|
2
|
+
export declare function findShareRealPath(payload: unknown, logicalName: string): string | undefined;
|
|
3
|
+
export declare function lookupSharePathWithSynoagentcli(logicalName: string, profileName?: string): string | undefined;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { resolveHostCredentials } from "./credentials.js";
|
|
3
|
+
function asRecord(value) {
|
|
4
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
function collectRecords(payload) {
|
|
10
|
+
const records = [];
|
|
11
|
+
const visit = (value) => {
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
for (const item of value) {
|
|
14
|
+
visit(item);
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const record = asRecord(value);
|
|
19
|
+
if (!record) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (typeof record.name === "string" || typeof record.share_name === "string") {
|
|
23
|
+
records.push(record);
|
|
24
|
+
}
|
|
25
|
+
for (const child of Object.values(record)) {
|
|
26
|
+
if (child && typeof child === "object") {
|
|
27
|
+
visit(child);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
visit(payload);
|
|
32
|
+
return records;
|
|
33
|
+
}
|
|
34
|
+
export function shareRealPathFromEntry(entry, logicalName) {
|
|
35
|
+
const name = String(entry.name ?? entry.share_name ?? "");
|
|
36
|
+
if (name !== logicalName) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
for (const key of ["path", "real_path", "share_path", "mountpoint"]) {
|
|
40
|
+
const value = entry[key];
|
|
41
|
+
if (typeof value === "string" && value.startsWith("/")) {
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const key of ["vol_path", "volumePath", "volume_path"]) {
|
|
46
|
+
const value = entry[key];
|
|
47
|
+
if (typeof value === "string" && value.startsWith("/")) {
|
|
48
|
+
return `${value.replace(/\/$/, "")}/${logicalName}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
export function findShareRealPath(payload, logicalName) {
|
|
54
|
+
for (const record of collectRecords(payload)) {
|
|
55
|
+
const path = shareRealPathFromEntry(record, logicalName);
|
|
56
|
+
if (path) {
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function synoagentcli(args, profileName) {
|
|
63
|
+
const fullArgs = [...args];
|
|
64
|
+
if (profileName) {
|
|
65
|
+
fullArgs.push("--host", profileName);
|
|
66
|
+
}
|
|
67
|
+
const result = spawnSync("synoagentcli", fullArgs, { encoding: "utf8" });
|
|
68
|
+
return {
|
|
69
|
+
status: result.status,
|
|
70
|
+
stdout: result.stdout ?? "",
|
|
71
|
+
stderr: result.stderr ?? "",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function parseJson(text) {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(text);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function lookupSharePathWithDsmHttp(logicalName, profileName) {
|
|
83
|
+
const credentials = resolveHostCredentials(profileName);
|
|
84
|
+
if (!credentials) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const script = `
|
|
88
|
+
import json, sys, urllib.parse, urllib.request
|
|
89
|
+
origin, account, passwd, name = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
|
90
|
+
def call(params):
|
|
91
|
+
url = origin.rstrip("/") + "/webapi/entry.cgi?" + urllib.parse.urlencode(params)
|
|
92
|
+
return json.loads(urllib.request.urlopen(url, timeout=8).read())
|
|
93
|
+
login = call({
|
|
94
|
+
"api": "SYNO.API.Auth",
|
|
95
|
+
"version": "3",
|
|
96
|
+
"method": "login",
|
|
97
|
+
"account": account,
|
|
98
|
+
"passwd": passwd,
|
|
99
|
+
"session": "FileStation",
|
|
100
|
+
"format": "sid",
|
|
101
|
+
})
|
|
102
|
+
sid = (login.get("data") or {}).get("sid")
|
|
103
|
+
if not sid:
|
|
104
|
+
sys.exit(2)
|
|
105
|
+
try:
|
|
106
|
+
listed = call({
|
|
107
|
+
"api": "SYNO.Core.Share",
|
|
108
|
+
"version": "1",
|
|
109
|
+
"method": "list",
|
|
110
|
+
"_sid": sid,
|
|
111
|
+
})
|
|
112
|
+
print(json.dumps(listed))
|
|
113
|
+
finally:
|
|
114
|
+
call({
|
|
115
|
+
"api": "SYNO.API.Auth",
|
|
116
|
+
"version": "3",
|
|
117
|
+
"method": "logout",
|
|
118
|
+
"session": "FileStation",
|
|
119
|
+
"_sid": sid,
|
|
120
|
+
})
|
|
121
|
+
`;
|
|
122
|
+
const result = spawnSync("python3", ["-c", script, credentials.dsmOrigin, credentials.username, credentials.password, logicalName], { encoding: "utf8" });
|
|
123
|
+
if (result.status !== 0) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return findShareRealPath(parseJson(result.stdout), logicalName);
|
|
127
|
+
}
|
|
128
|
+
export function lookupSharePathWithSynoagentcli(logicalName, profileName) {
|
|
129
|
+
const get = synoagentcli(["dsm", "control-panel", "shared-folder", "get", logicalName], profileName);
|
|
130
|
+
if (get.status === 0) {
|
|
131
|
+
const fromGet = findShareRealPath(parseJson(get.stdout), logicalName);
|
|
132
|
+
if (fromGet) {
|
|
133
|
+
return fromGet;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const list = synoagentcli(["dsm", "control-panel", "shared-folder", "list"], profileName);
|
|
137
|
+
if (list.status === 0) {
|
|
138
|
+
const fromList = findShareRealPath(parseJson(list.stdout), logicalName);
|
|
139
|
+
if (fromList) {
|
|
140
|
+
return fromList;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return lookupSharePathWithDsmHttp(logicalName, profileName);
|
|
144
|
+
}
|