@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,87 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseDocument, isMap, isScalar } from "yaml";
|
|
3
|
+
export function loadContract(contractPath) {
|
|
4
|
+
const source = readFileSync(contractPath, "utf8");
|
|
5
|
+
const doc = parseDocument(source);
|
|
6
|
+
const contract = (doc.toJSON() ?? {});
|
|
7
|
+
return { contract, doc };
|
|
8
|
+
}
|
|
9
|
+
export function getScalarPosition(doc, keyPath) {
|
|
10
|
+
const node = findNode(doc.contents, keyPath);
|
|
11
|
+
if (!node || !isScalar(node.key)) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const range = node.key.range;
|
|
15
|
+
if (!range) {
|
|
16
|
+
return { line: 1, column: 1 };
|
|
17
|
+
}
|
|
18
|
+
return lineColumnFromOffset(doc, range[0]);
|
|
19
|
+
}
|
|
20
|
+
export function getValuePosition(doc, keyPath) {
|
|
21
|
+
const node = findNode(doc.contents, keyPath);
|
|
22
|
+
if (!node) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const valueNode = node.value;
|
|
26
|
+
const keyNode = node.key;
|
|
27
|
+
const range = valueNode?.range ?? keyNode?.range;
|
|
28
|
+
if (!range) {
|
|
29
|
+
return { line: 1, column: 1 };
|
|
30
|
+
}
|
|
31
|
+
return lineColumnFromOffset(doc, range[0]);
|
|
32
|
+
}
|
|
33
|
+
function findNode(node, keyPath) {
|
|
34
|
+
if (!node || keyPath.length === 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const [head, ...rest] = keyPath;
|
|
38
|
+
if (!isMap(node)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
for (const item of node.items) {
|
|
42
|
+
if (!isScalar(item.key) || String(item.key.value) !== head) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (rest.length === 0) {
|
|
46
|
+
return item;
|
|
47
|
+
}
|
|
48
|
+
return findNode(item.value, rest);
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function lineColumnFromOffset(doc, offset) {
|
|
53
|
+
const source = String(doc.toString());
|
|
54
|
+
let line = 1;
|
|
55
|
+
let column = 1;
|
|
56
|
+
for (let i = 0; i < offset && i < source.length; i++) {
|
|
57
|
+
if (source[i] === "\n") {
|
|
58
|
+
line++;
|
|
59
|
+
column = 1;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
column++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { line, column };
|
|
66
|
+
}
|
|
67
|
+
export function collectInstallParameters(contract) {
|
|
68
|
+
if (!contract.install || typeof contract.install !== "object") {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
return Object.entries(contract.install).map(([name, param]) => ({
|
|
72
|
+
name,
|
|
73
|
+
param: param ?? {},
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
export function isDeveloperAuthoredString(value) {
|
|
77
|
+
return typeof value === "string";
|
|
78
|
+
}
|
|
79
|
+
const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
|
|
80
|
+
const INTERPOLATION_PATTERN = /\$\{/;
|
|
81
|
+
export function stringViolations(value) {
|
|
82
|
+
return {
|
|
83
|
+
hasControlChars: CONTROL_CHAR_PATTERN.test(value),
|
|
84
|
+
hasInterpolation: INTERPOLATION_PATTERN.test(value),
|
|
85
|
+
tooLong: value.length > 256,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Ajv } from "ajv";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { contractSpan, makeError } from "../errors.js";
|
|
6
|
+
import { getValuePosition } from "./load.js";
|
|
7
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
8
|
+
const schemaPath = join(pkgRoot, "schema", "synology-app.schema.json");
|
|
9
|
+
let compiledValidator = null;
|
|
10
|
+
function getValidator() {
|
|
11
|
+
if (!compiledValidator) {
|
|
12
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
13
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
|
|
14
|
+
compiledValidator = ajv.compile(schema);
|
|
15
|
+
}
|
|
16
|
+
return compiledValidator;
|
|
17
|
+
}
|
|
18
|
+
function mapSchemaError(error, contractFile, parsed) {
|
|
19
|
+
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".") || "schema";
|
|
20
|
+
const pos = getValuePosition(parsed.doc, path.split(".").filter(Boolean)) ?? { line: 1, column: 1 };
|
|
21
|
+
if (error.keyword === "const" && path === "schema") {
|
|
22
|
+
return makeError("CONTRACT_SCHEMA_UNSUPPORTED", contractSpan(contractFile, pos.line, pos.column, "schema"), "不支援的 App Contract schema 版本。", "請將 schema 設為整數 1。");
|
|
23
|
+
}
|
|
24
|
+
if (error.keyword === "required") {
|
|
25
|
+
const missing = String(error.params.missingProperty);
|
|
26
|
+
const missingPos = getValuePosition(parsed.doc, [missing]) ?? { line: 1, column: 1 };
|
|
27
|
+
return makeError("CONTRACT_MISSING_FIELD", contractSpan(contractFile, missingPos.line, missingPos.column, missing), `缺少必填欄位「${missing}」。`, `請在 synology-app.yaml 補上 ${missing}。`);
|
|
28
|
+
}
|
|
29
|
+
return makeError("CONTRACT_SCHEMA_INVALID", contractSpan(contractFile, pos.line, pos.column, path), `App Contract 不符合 JSON Schema:${error.message ?? error.keyword}。`, "請修正 synology-app.yaml 的欄位格式。");
|
|
30
|
+
}
|
|
31
|
+
export function validateContractSchema(parsed, contractFile) {
|
|
32
|
+
const validate = getValidator();
|
|
33
|
+
const data = parsed.doc.toJSON();
|
|
34
|
+
const valid = validate(data);
|
|
35
|
+
if (valid) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const errors = validate.errors ?? [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const result = [];
|
|
41
|
+
for (const error of errors) {
|
|
42
|
+
const key = `${error.keyword}:${error.instancePath}:${error.params?.missingProperty ?? ""}`;
|
|
43
|
+
if (seen.has(key)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
seen.add(key);
|
|
47
|
+
result.push(mapSchemaError(error, contractFile, parsed));
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { contractSpan, makeError } from "../errors.js";
|
|
2
|
+
import { collectInstallParameters, getValuePosition, stringViolations, } from "./load.js";
|
|
3
|
+
import { validateContractSchema } from "./schema-validate.js";
|
|
4
|
+
import { isWebLaunchMode, readWebIntegration } from "./web-launch.js";
|
|
5
|
+
const METADATA_STRING_FIELDS = [
|
|
6
|
+
"package",
|
|
7
|
+
"version",
|
|
8
|
+
"displayname",
|
|
9
|
+
"description",
|
|
10
|
+
"maintainer",
|
|
11
|
+
"os_min_ver",
|
|
12
|
+
];
|
|
13
|
+
export function validateContract(parsed, contractFile) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
const { contract, doc } = parsed;
|
|
16
|
+
errors.push(...validateContractSchema(parsed, contractFile));
|
|
17
|
+
for (const field of METADATA_STRING_FIELDS) {
|
|
18
|
+
const value = contract[field];
|
|
19
|
+
if (typeof value !== "string") {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
errors.push(...validateDeveloperString(value, contractFile, field, getValuePosition(doc, [field]) ?? { line: 1, column: 1 }));
|
|
23
|
+
}
|
|
24
|
+
if (contract.adminport !== undefined) {
|
|
25
|
+
if (typeof contract.adminport !== "number" || !Number.isInteger(contract.adminport)) {
|
|
26
|
+
const pos = getValuePosition(doc, ["adminport"]) ?? { line: 1, column: 1 };
|
|
27
|
+
errors.push(makeError("CONTRACT_ADMINPORT_INVALID", contractSpan(contractFile, pos.line, pos.column, "adminport"), "adminport 必須是整數。", "請提供與 Compose published port 對應的數字 adminport。"));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
errors.push(...validateInstallParameters(parsed, contractFile));
|
|
31
|
+
errors.push(...validateWebLaunch(parsed, contractFile));
|
|
32
|
+
return errors;
|
|
33
|
+
}
|
|
34
|
+
function validateWebLaunch(parsed, contractFile) {
|
|
35
|
+
const errors = [];
|
|
36
|
+
const { contract, doc } = parsed;
|
|
37
|
+
const web = readWebIntegration(contract);
|
|
38
|
+
if (!web || web.launch === undefined) {
|
|
39
|
+
return errors;
|
|
40
|
+
}
|
|
41
|
+
const pos = getValuePosition(doc, ["integration", "web", "launch"]) ?? { line: 1, column: 1 };
|
|
42
|
+
if (!isWebLaunchMode(web.launch)) {
|
|
43
|
+
errors.push(makeError("CONTRACT_LAUNCH_INVALID", contractSpan(contractFile, pos.line, pos.column, "integration.web.launch"), "integration.web.launch 只能是 dsm-window 或 new-page。", "請改為 dsm-window(DSM 視窗,預設)或 new-page(新分頁),Headless App 請省略此欄位。"));
|
|
44
|
+
return errors;
|
|
45
|
+
}
|
|
46
|
+
if (contract.adminport === undefined) {
|
|
47
|
+
errors.push(makeError("CONTRACT_LAUNCH_REQUIRES_WEB", contractSpan(contractFile, pos.line, pos.column, "integration.web.launch"), "Headless App 不需要 Launch Mode。", "請省略 integration.web.launch,或補上對應 Compose published port 的 adminport。"));
|
|
48
|
+
}
|
|
49
|
+
return errors;
|
|
50
|
+
}
|
|
51
|
+
function validateDeveloperString(value, contractFile, path, pos) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
const violations = stringViolations(value);
|
|
54
|
+
if (violations.hasControlChars) {
|
|
55
|
+
errors.push(makeError("CONTRACT_INVALID_STRING", contractSpan(contractFile, pos.line, pos.column, path), "合約字串含有不允許的控制字元。", "請移除控制字元後再驗證。"));
|
|
56
|
+
}
|
|
57
|
+
if (violations.hasInterpolation) {
|
|
58
|
+
errors.push(makeError("CONTRACT_INVALID_STRING", contractSpan(contractFile, pos.line, pos.column, path), "合約字串不可包含 ${ 插值語法。", "請改為固定字面值,安裝參數請使用 install 區塊宣告。"));
|
|
59
|
+
}
|
|
60
|
+
return errors;
|
|
61
|
+
}
|
|
62
|
+
function validateInstallParameters(parsed, contractFile) {
|
|
63
|
+
const errors = [];
|
|
64
|
+
const { doc } = parsed;
|
|
65
|
+
for (const { name, param } of collectInstallParameters(parsed.contract)) {
|
|
66
|
+
const basePath = `install.${name}`;
|
|
67
|
+
const type = param.type;
|
|
68
|
+
if (type === "string" && typeof param.default === "string") {
|
|
69
|
+
const pos = getValuePosition(doc, ["install", name, "default"]) ?? { line: 1, column: 1 };
|
|
70
|
+
const violations = stringViolations(param.default);
|
|
71
|
+
if (violations.tooLong) {
|
|
72
|
+
errors.push(makeError("INSTALL_STRING_TOO_LONG", contractSpan(contractFile, pos.line, pos.column, `${basePath}.default`), `Install Parameter「${name}」的預設值超過 256 字元。`, "請縮短字串預設值。"));
|
|
73
|
+
}
|
|
74
|
+
if (violations.hasControlChars || violations.hasInterpolation) {
|
|
75
|
+
errors.push(makeError("INSTALL_STRING_INVALID", contractSpan(contractFile, pos.line, pos.column, `${basePath}.default`), `Install Parameter「${name}」的字串值不合法。`, "請移除控制字元與 ${ 插值語法。"));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return errors;
|
|
80
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AppContract } from "../types.js";
|
|
2
|
+
/** DSM Web Station portal alias max length (error 1311 above this). */
|
|
3
|
+
export declare const WEB_STATION_ALIAS_MAX = 20;
|
|
4
|
+
export declare const WEB_LAUNCH_MODES: readonly ["dsm-window", "new-page"];
|
|
5
|
+
export type WebLaunchMode = (typeof WEB_LAUNCH_MODES)[number];
|
|
6
|
+
export declare const DEFAULT_WEB_LAUNCH_MODE: WebLaunchMode;
|
|
7
|
+
export interface WebIntegration {
|
|
8
|
+
launch?: unknown;
|
|
9
|
+
cookies?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare function readWebIntegration(contract: AppContract): WebIntegration | undefined;
|
|
12
|
+
export declare function isWebLaunchMode(value: unknown): value is WebLaunchMode;
|
|
13
|
+
export declare function resolvedWebLaunch(contract: AppContract): WebLaunchMode | undefined;
|
|
14
|
+
export declare function webStationAlias(packageId: string): string;
|
|
15
|
+
export declare function trustedAppEntryPath(contract: AppContract): string;
|
|
16
|
+
export declare function webStationServiceName(alias: string): string;
|
|
17
|
+
export declare function webStationServiceRoot(alias: string): string;
|
|
18
|
+
export declare function renderWebStationResource(contract: AppContract): string;
|
|
19
|
+
export declare function renderDsmWindowConfig(contract: AppContract): string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** DSM Web Station portal alias max length (error 1311 above this). */
|
|
3
|
+
export const WEB_STATION_ALIAS_MAX = 20;
|
|
4
|
+
export const WEB_LAUNCH_MODES = ["dsm-window", "new-page"];
|
|
5
|
+
export const DEFAULT_WEB_LAUNCH_MODE = "dsm-window";
|
|
6
|
+
export function readWebIntegration(contract) {
|
|
7
|
+
const integration = contract.integration;
|
|
8
|
+
if (!integration || typeof integration !== "object") {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
const web = integration.web;
|
|
12
|
+
if (!web || typeof web !== "object") {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return web;
|
|
16
|
+
}
|
|
17
|
+
export function isWebLaunchMode(value) {
|
|
18
|
+
return value === "dsm-window" || value === "new-page";
|
|
19
|
+
}
|
|
20
|
+
export function resolvedWebLaunch(contract) {
|
|
21
|
+
if (contract.adminport === undefined) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const launch = readWebIntegration(contract)?.launch;
|
|
25
|
+
if (launch === undefined) {
|
|
26
|
+
return DEFAULT_WEB_LAUNCH_MODE;
|
|
27
|
+
}
|
|
28
|
+
return isWebLaunchMode(launch) ? launch : undefined;
|
|
29
|
+
}
|
|
30
|
+
export function webStationAlias(packageId) {
|
|
31
|
+
const slug = packageId
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
34
|
+
.replace(/^-+|-+$/g, "");
|
|
35
|
+
if (slug.length > 0 && slug.length <= WEB_STATION_ALIAS_MAX) {
|
|
36
|
+
return slug;
|
|
37
|
+
}
|
|
38
|
+
return `p${createHash("sha256").update(packageId).digest("hex").slice(0, 16)}`;
|
|
39
|
+
}
|
|
40
|
+
export function trustedAppEntryPath(contract) {
|
|
41
|
+
const alias = webStationAlias(String(contract.package ?? ""));
|
|
42
|
+
const adminurl = String(contract.adminurl ?? "/").trim() || "/";
|
|
43
|
+
const extra = adminurl.startsWith("/") ? adminurl : `/${adminurl}`;
|
|
44
|
+
if (extra === "/") {
|
|
45
|
+
return `/${alias}/`;
|
|
46
|
+
}
|
|
47
|
+
return `/${alias}${extra}`;
|
|
48
|
+
}
|
|
49
|
+
export function webStationServiceName(alias) {
|
|
50
|
+
return `PROXY-${alias}`;
|
|
51
|
+
}
|
|
52
|
+
export function webStationServiceRoot(alias) {
|
|
53
|
+
return `web/synopkgland-${alias}`;
|
|
54
|
+
}
|
|
55
|
+
export function renderWebStationResource(contract) {
|
|
56
|
+
const alias = webStationAlias(String(contract.package));
|
|
57
|
+
const launch = resolvedWebLaunch(contract);
|
|
58
|
+
// DSM synowebapi crashes on type=reverse_proxy. Package acquire of reverse_proxy
|
|
59
|
+
// also creates a preserve portal that Broker cannot rebind (error 1300).
|
|
60
|
+
// Static + alias matches Broker; cookie strip / proxy_pass live in nginx user.conf.
|
|
61
|
+
const service = {
|
|
62
|
+
service: webStationServiceName(alias),
|
|
63
|
+
display_name: String(contract.displayname ?? contract.package),
|
|
64
|
+
type: "static",
|
|
65
|
+
support_alias: true,
|
|
66
|
+
support_server: false,
|
|
67
|
+
root: webStationServiceRoot(alias),
|
|
68
|
+
};
|
|
69
|
+
const portal = {
|
|
70
|
+
service: webStationServiceName(alias),
|
|
71
|
+
name: alias,
|
|
72
|
+
type: "alias",
|
|
73
|
+
alias,
|
|
74
|
+
};
|
|
75
|
+
if (launch === "dsm-window") {
|
|
76
|
+
portal.app = String(contract.package);
|
|
77
|
+
}
|
|
78
|
+
return `${JSON.stringify({ webservice: { services: [service], portals: [portal] } }, null, 2)}\n`;
|
|
79
|
+
}
|
|
80
|
+
export function renderDsmWindowConfig(contract) {
|
|
81
|
+
const packageId = String(contract.package);
|
|
82
|
+
return `${JSON.stringify({
|
|
83
|
+
".url": {
|
|
84
|
+
[packageId]: {
|
|
85
|
+
type: "url",
|
|
86
|
+
allUsers: true,
|
|
87
|
+
title: String(contract.displayname ?? packageId),
|
|
88
|
+
desc: String(contract.description ?? ""),
|
|
89
|
+
icon: "images/app_{0}.png",
|
|
90
|
+
url: trustedAppEntryPath(contract),
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
}, null, 2)}\n`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { publishSpkToSource, resolvePublishTarget, } from "@synopackageland/package-source/publish-spk";
|
|
2
|
+
export async function publishBuiltSpk(spkPath) {
|
|
3
|
+
return publishSpkToSource(spkPath, resolvePublishTarget());
|
|
4
|
+
}
|
|
5
|
+
export function packageSourceOrigin() {
|
|
6
|
+
return resolvePublishTarget().origin;
|
|
7
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ProjectRoot } from "../discovery.js";
|
|
2
|
+
import type { NasHost } from "../host/types.js";
|
|
3
|
+
import type { PublishSpkResult } from "@synopackageland/package-source/publish-spk";
|
|
4
|
+
export interface DeployProjectOptions {
|
|
5
|
+
project: ProjectRoot;
|
|
6
|
+
host: NasHost;
|
|
7
|
+
dev?: boolean;
|
|
8
|
+
replaceCurated?: boolean;
|
|
9
|
+
catalogPublish?: (spkPath: string) => Promise<PublishSpkResult>;
|
|
10
|
+
shareValues?: Record<string, string>;
|
|
11
|
+
runAsValues?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
export interface DeployProjectResult {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
code?: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
packageId?: string;
|
|
18
|
+
spkPath?: string;
|
|
19
|
+
openUrl?: string;
|
|
20
|
+
catalogVersion?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function deployProject(options: DeployProjectOptions): Promise<DeployProjectResult>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { buildSpk } from "../build-spk/generator.js";
|
|
2
|
+
import { loadContract } from "../contract/load.js";
|
|
3
|
+
import { digestProjectBundle, digestProjectContract } from "../host/digests.js";
|
|
4
|
+
import { appOriginFromContract } from "../host/open-url.js";
|
|
5
|
+
import { validateProject } from "../validate.js";
|
|
6
|
+
import { publishBuiltSpk, packageSourceOrigin } from "./catalog-publish.js";
|
|
7
|
+
import { resolveDeployIdentity } from "./identity.js";
|
|
8
|
+
import { resolveDeploySpkVersion } from "./spk-version.js";
|
|
9
|
+
import { resolveSharedFolderExtraValues, sharedFolderParameterNames } from "./share-values.js";
|
|
10
|
+
import { resolveDsmUserExtraValues, dsmUserParameterNames } from "./run-as-values.js";
|
|
11
|
+
const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
|
|
12
|
+
function brokerCapabilitiesFromContract(project) {
|
|
13
|
+
const contract = loadContract(project.contractPath).contract;
|
|
14
|
+
const capabilities = ["identity"];
|
|
15
|
+
const integration = contract.integration;
|
|
16
|
+
if (integration?.files) {
|
|
17
|
+
capabilities.push("files");
|
|
18
|
+
}
|
|
19
|
+
return capabilities;
|
|
20
|
+
}
|
|
21
|
+
export async function deployProject(options) {
|
|
22
|
+
const validation = validateProject(options.project);
|
|
23
|
+
if (!validation.ok) {
|
|
24
|
+
return { ok: false, code: "VALIDATION_FAILED", message: validation.errors.map((e) => e.code).join(", ") };
|
|
25
|
+
}
|
|
26
|
+
const identity = resolveDeployIdentity(options.project, { dev: options.dev });
|
|
27
|
+
const contract = loadContract(options.project.contractPath).contract;
|
|
28
|
+
const shareResolved = await resolveSharedFolderExtraValues({
|
|
29
|
+
requiredKeys: sharedFolderParameterNames(contract),
|
|
30
|
+
provided: options.shareValues ?? {},
|
|
31
|
+
existingEnv: (await options.host.getInstalledProjectEnv?.(identity.packageId)) ?? {},
|
|
32
|
+
lookup: async (logicalName) => options.host.resolveSharePath?.(logicalName),
|
|
33
|
+
});
|
|
34
|
+
if (!shareResolved.ok) {
|
|
35
|
+
return { ok: false, code: shareResolved.code, message: shareResolved.message };
|
|
36
|
+
}
|
|
37
|
+
const runAsResolved = resolveDsmUserExtraValues({
|
|
38
|
+
requiredKeys: dsmUserParameterNames(contract),
|
|
39
|
+
provided: options.runAsValues ?? {},
|
|
40
|
+
existingEnv: (await options.host.getInstalledProjectEnv?.(identity.packageId)) ?? {},
|
|
41
|
+
});
|
|
42
|
+
if (!runAsResolved.ok) {
|
|
43
|
+
return { ok: false, code: runAsResolved.code, message: runAsResolved.message };
|
|
44
|
+
}
|
|
45
|
+
const extraValues = { ...shareResolved.extraValues, ...runAsResolved.extraValues };
|
|
46
|
+
const contractDigest = digestProjectContract(options.project);
|
|
47
|
+
const bundleDigest = digestProjectBundle(options.project);
|
|
48
|
+
try {
|
|
49
|
+
await options.host.requestAttestation({
|
|
50
|
+
packageIdentity: identity.packageId,
|
|
51
|
+
contractDigest,
|
|
52
|
+
bundleDigest,
|
|
53
|
+
capabilities: brokerCapabilitiesFromContract(options.project),
|
|
54
|
+
allowCuratedDowngrade: options.replaceCurated,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const code = error.code ?? "ATTESTATION_FAILED";
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
code,
|
|
62
|
+
message: code === "CURATED_DEPLOY_REFUSED"
|
|
63
|
+
? CURATED_DEPLOY_REFUSED_MESSAGE
|
|
64
|
+
: error instanceof Error
|
|
65
|
+
? error.message
|
|
66
|
+
: String(error),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const spkVersion = await resolveDeploySpkVersion(options.project, options.host, identity.packageId);
|
|
70
|
+
const build = await buildSpk({
|
|
71
|
+
project: options.project,
|
|
72
|
+
packageIdentity: identity.packageId,
|
|
73
|
+
displayname: identity.displayname,
|
|
74
|
+
spkVersion,
|
|
75
|
+
});
|
|
76
|
+
if (!build.ok || !build.outputPath) {
|
|
77
|
+
return { ok: false, code: "BUILD_SPK_FAILED", message: build.errors.join(", ") };
|
|
78
|
+
}
|
|
79
|
+
let catalogVersion = spkVersion;
|
|
80
|
+
const publishToCatalog = options.catalogPublish ?? publishBuiltSpk;
|
|
81
|
+
if (options.host.installationId === "live-nas" && !identity.developmentInstance) {
|
|
82
|
+
try {
|
|
83
|
+
const published = await publishToCatalog(build.outputPath);
|
|
84
|
+
catalogVersion = published.version;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
code: "CATALOG_PUBLISH_FAILED",
|
|
90
|
+
message: error instanceof Error ? error.message : String(error),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
if (identity.developmentInstance) {
|
|
96
|
+
if (!options.host.installFromLocalSpk) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
code: "LOCAL_SPK_INSTALL_UNSUPPORTED",
|
|
100
|
+
message: "Development deploy requires installFromLocalSpk on the host adapter.",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
await options.host.installFromLocalSpk({
|
|
104
|
+
packageId: identity.packageId,
|
|
105
|
+
version: catalogVersion,
|
|
106
|
+
spkPath: build.outputPath,
|
|
107
|
+
extraValues,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
if (options.host.ensurePackageSource) {
|
|
112
|
+
await options.host.ensurePackageSource(packageSourceOrigin());
|
|
113
|
+
}
|
|
114
|
+
await options.host.installFromCatalog({
|
|
115
|
+
packageId: identity.packageId,
|
|
116
|
+
version: catalogVersion,
|
|
117
|
+
extraValues,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
code: "CATALOG_INSTALL_FAILED",
|
|
125
|
+
message: error instanceof Error ? error.message : String(error),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const info = await options.host.getAttestationInfo(identity.packageId);
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
packageId: identity.packageId,
|
|
132
|
+
spkPath: build.outputPath,
|
|
133
|
+
openUrl: info.openUrl ?? appOriginFromContract(options.host.dsmOrigin, contract),
|
|
134
|
+
catalogVersion,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const DEV_SUFFIX = ".dev";
|
|
2
|
+
const DEV_DISPLAY_SUFFIX = "(開發中)";
|
|
3
|
+
export function developmentPackageIdentity(packageIdentity) {
|
|
4
|
+
return `${packageIdentity}${DEV_SUFFIX}`;
|
|
5
|
+
}
|
|
6
|
+
export function isDevelopmentPackageIdentity(packageIdentity) {
|
|
7
|
+
return packageIdentity.endsWith(DEV_SUFFIX) && packageIdentity.length > DEV_SUFFIX.length;
|
|
8
|
+
}
|
|
9
|
+
export function developmentDisplayName(displayname) {
|
|
10
|
+
if (displayname.includes(DEV_DISPLAY_SUFFIX)) {
|
|
11
|
+
return displayname;
|
|
12
|
+
}
|
|
13
|
+
return `${displayname}${DEV_DISPLAY_SUFFIX}`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ProjectRoot } from "../discovery.js";
|
|
2
|
+
import type { DeployIdentity } from "../host/types.js";
|
|
3
|
+
export { developmentDisplayName, developmentPackageIdentity, isDevelopmentPackageIdentity, } from "./identity-shared.js";
|
|
4
|
+
export interface ResolveDeployIdentityOptions {
|
|
5
|
+
dev?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function resolveDeployIdentity(project: ProjectRoot, options: ResolveDeployIdentityOptions): DeployIdentity;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { developmentDisplayName, developmentPackageIdentity, } from "./identity-shared.js";
|
|
2
|
+
import { loadContract } from "../contract/load.js";
|
|
3
|
+
export { developmentDisplayName, developmentPackageIdentity, isDevelopmentPackageIdentity, } from "./identity-shared.js";
|
|
4
|
+
export function resolveDeployIdentity(project, options) {
|
|
5
|
+
const parsed = loadContract(project.contractPath);
|
|
6
|
+
const basePackage = String(parsed.contract.package ?? "");
|
|
7
|
+
const baseDisplay = String(parsed.contract.displayname ?? basePackage);
|
|
8
|
+
if (options.dev) {
|
|
9
|
+
return {
|
|
10
|
+
packageId: developmentPackageIdentity(basePackage),
|
|
11
|
+
displayname: developmentDisplayName(baseDisplay),
|
|
12
|
+
developmentInstance: true,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
packageId: basePackage,
|
|
17
|
+
displayname: baseDisplay,
|
|
18
|
+
developmentInstance: false,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { DEFAULT_PACKAGE_SOURCE_ORIGIN, fetchPackageSourceManifest, publishSpkToSource, resolvePublishTarget, type PublishTarget, type PublishSpkResult } from "@synology/package-source/publish-spk";
|
|
2
|
+
import { resolveNextSpkVersion } from "@synology/package-source/next-version";
|
|
3
|
+
import type { IndexManifest } from "@synology/package-source/index-manifest";
|
|
4
|
+
export { DEFAULT_PACKAGE_SOURCE_ORIGIN, fetchPackageSourceManifest, publishSpkToSource, resolvePublishTarget, resolveNextSpkVersion, };
|
|
5
|
+
export type { PublishTarget, PublishSpkResult, IndexManifest };
|
|
6
|
+
export declare function resolvePackageSourceManifest(origin?: string): Promise<IndexManifest | null>;
|
|
7
|
+
export declare function publishBuiltSpk(spkPath: string, target?: PublishTarget): Promise<PublishSpkResult>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DEFAULT_PACKAGE_SOURCE_ORIGIN, fetchPackageSourceManifest, publishSpkToSource, resolvePublishTarget, } from "@synology/package-source/publish-spk";
|
|
2
|
+
import { resolveNextSpkVersion } from "@synology/package-source/next-version";
|
|
3
|
+
export { DEFAULT_PACKAGE_SOURCE_ORIGIN, fetchPackageSourceManifest, publishSpkToSource, resolvePublishTarget, resolveNextSpkVersion, };
|
|
4
|
+
const LIVE_TEST_FALLBACK_ORIGIN = "https://syno-package-source-throwaway.bidacumen.workers.dev";
|
|
5
|
+
export async function resolvePackageSourceManifest(origin = resolvePublishTarget().origin) {
|
|
6
|
+
const manifest = await fetchPackageSourceManifest(origin);
|
|
7
|
+
if (manifest) {
|
|
8
|
+
return manifest;
|
|
9
|
+
}
|
|
10
|
+
if (origin !== LIVE_TEST_FALLBACK_ORIGIN) {
|
|
11
|
+
return fetchPackageSourceManifest(LIVE_TEST_FALLBACK_ORIGIN);
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
export async function publishBuiltSpk(spkPath, target = resolvePublishTarget()) {
|
|
16
|
+
return publishSpkToSource(spkPath, target);
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AppContract } from "../types.js";
|
|
2
|
+
export declare const DSM_USER_REQUIRED = "DSM_USER_REQUIRED";
|
|
3
|
+
export declare const DSM_USER_LOGICAL_NAME_REQUIRED = "DSM_USER_LOGICAL_NAME_REQUIRED";
|
|
4
|
+
export declare function dsmUserParameterNames(contract: AppContract): string[];
|
|
5
|
+
export declare function isNumericUidInput(value: string): boolean;
|
|
6
|
+
export type RunAsResolveResult = {
|
|
7
|
+
ok: true;
|
|
8
|
+
extraValues: Record<string, string>;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function resolveDsmUserExtraValues(input: {
|
|
15
|
+
requiredKeys: string[];
|
|
16
|
+
provided: Record<string, string>;
|
|
17
|
+
existingEnv?: Record<string, string>;
|
|
18
|
+
}): RunAsResolveResult;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { collectInstallParameters } from "../contract/load.js";
|
|
2
|
+
export const DSM_USER_REQUIRED = "DSM_USER_REQUIRED";
|
|
3
|
+
export const DSM_USER_LOGICAL_NAME_REQUIRED = "DSM_USER_LOGICAL_NAME_REQUIRED";
|
|
4
|
+
export function dsmUserParameterNames(contract) {
|
|
5
|
+
return collectInstallParameters(contract)
|
|
6
|
+
.filter((item) => item.param.type === "dsm_user")
|
|
7
|
+
.map((item) => item.name);
|
|
8
|
+
}
|
|
9
|
+
export function isNumericUidInput(value) {
|
|
10
|
+
return /^(0|[1-9][0-9]*)(:(0|[1-9][0-9]*))?$/.test(value.trim());
|
|
11
|
+
}
|
|
12
|
+
export function resolveDsmUserExtraValues(input) {
|
|
13
|
+
if (input.requiredKeys.length === 0) {
|
|
14
|
+
return { ok: true, extraValues: {} };
|
|
15
|
+
}
|
|
16
|
+
const extraValues = {};
|
|
17
|
+
const missing = [];
|
|
18
|
+
for (const key of input.requiredKeys) {
|
|
19
|
+
const raw = input.provided[key];
|
|
20
|
+
if (raw === undefined || raw.trim() === "") {
|
|
21
|
+
const existing = input.existingEnv?.[key];
|
|
22
|
+
if (typeof existing === "string" && existing.trim() !== "" && !isNumericUidInput(existing)) {
|
|
23
|
+
extraValues[key] = existing.trim();
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
missing.push(key);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const logical = raw.trim();
|
|
30
|
+
if (isNumericUidInput(logical) || logical === "root") {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
code: DSM_USER_LOGICAL_NAME_REQUIRED,
|
|
34
|
+
message: `Container Run-as「${key}」必須是這台 NAS 上的 DSM 使用者邏輯名,不能傳數字 uid。`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
extraValues[key] = logical;
|
|
38
|
+
}
|
|
39
|
+
if (missing.length > 0) {
|
|
40
|
+
const example = `--run-as ${missing[0]}=alice`;
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
code: DSM_USER_REQUIRED,
|
|
44
|
+
message: `契約有 Container Run-as「${missing.join("、")}」。請傳這台 NAS 上的 DSM 使用者邏輯名(例如 ${example})。禁止把數字 uid 寫進 extra_values。`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, extraValues };
|
|
48
|
+
}
|