@synopackageland/cli 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +174 -1
- package/README.md +1 -1
- package/dist/add.js +12 -0
- package/dist/build-spk/dsm-version.d.ts +0 -2
- package/dist/build-spk/dsm-version.js +0 -6
- package/dist/build-spk/generator.js +4 -23
- package/dist/build-spk/resource-worker.js +0 -3
- package/dist/build-spk/uninstall-wizard.d.ts +8 -3
- package/dist/build-spk/uninstall-wizard.js +4 -2
- package/dist/build-spk/wizard-messages.d.ts +33 -0
- package/dist/build-spk/wizard-messages.js +33 -0
- package/dist/cli.js +7 -6
- package/dist/contract/validate.js +4 -21
- package/dist/error-registry.js +11 -12
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +5 -0
- package/dist/host/dsm-client.js +51 -30
- package/dist/host/types.d.ts +5 -0
- package/dist/images/pin.js +0 -1
- package/dist/info.js +14 -1
- package/dist/init.js +1 -2
- package/dist/portal/alias.d.ts +6 -4
- package/dist/portal/alias.js +6 -11
- package/dist/portal/package.d.ts +0 -3
- package/dist/portal/package.js +1 -7
- package/dist/runtime/executor-digests.json +33 -0
- package/dist/runtime/uninstall-cleanup.d.ts +7 -1
- package/dist/runtime/uninstall-cleanup.js +7 -1
- package/dist/standalone/dsm-window.js +8 -11
- package/dist/standalone/inspect-spk.js +11 -10
- package/dist/standalone/manifest.js +12 -3
- package/dist/standalone/root-window.d.ts +10 -3
- package/dist/standalone/root-window.js +81 -41
- package/dist/standalone/spk.js +3 -2
- package/dist/validate.js +2 -1
- package/package.json +3 -3
- package/schema/synology-app.schema.json +1 -10
- package/skills/SKILL.md +1 -1
- package/skills/reference/create.md +7 -2
- package/skills/reference/deploy.md +1 -1
- package/skills/reference/identity.md +1 -1
- package/skills/reference/portal.md +7 -3
package/dist/error-registry.js
CHANGED
|
@@ -59,15 +59,10 @@ export const ERROR_REGISTRY = {
|
|
|
59
59
|
"description": "Headless App 不需要宣告 Web Launch Mode。",
|
|
60
60
|
"suggestion": "請省略 integration.web.launch,或補上對應 Compose published port 的 adminport。"
|
|
61
61
|
},
|
|
62
|
-
"
|
|
63
|
-
"code": "
|
|
64
|
-
"description": "integration.web.portal
|
|
65
|
-
"suggestion": "
|
|
66
|
-
},
|
|
67
|
-
"CONTRACT_PORTAL_UNSUPPORTED": {
|
|
68
|
-
"code": "CONTRACT_PORTAL_UNSUPPORTED",
|
|
69
|
-
"description": "這個 App 的網頁入口無法加入 DSM Application Portal。",
|
|
70
|
-
"suggestion": "Application Portal 需要 adminport、launch: dsm-window 與非 public 的 access;否則請移除 integration.web.portal。"
|
|
62
|
+
"CONTRACT_PORTAL_REQUIRES_EXECUTOR": {
|
|
63
|
+
"code": "CONTRACT_PORTAL_REQUIRES_EXECUTOR",
|
|
64
|
+
"description": "Compose App 宣告了 integration.web.portal;Compose App 沒有 executor,登入入口別名在解除安裝後要 root 才能讓 nginx 停止提供。",
|
|
65
|
+
"suggestion": "請移除 integration.web.portal;需要登入入口別名時,由管理員在 DSM 手動設定反向代理。"
|
|
71
66
|
},
|
|
72
67
|
"CONTRACT_ACCESS_INVALID": {
|
|
73
68
|
"code": "CONTRACT_ACCESS_INVALID",
|
|
@@ -594,6 +589,11 @@ export const ERROR_REGISTRY = {
|
|
|
594
589
|
"description": "dsm-package.yaml 的 portal 格式或 alias 不正確。",
|
|
595
590
|
"suggestion": "請使用 portal: {} 或 portal: { alias: my-app };alias 為 1–64 個小寫英數字或 -,且不是 DSM 保留路徑。"
|
|
596
591
|
},
|
|
592
|
+
"STANDALONE_PORTAL_REQUIRES_EXECUTOR": {
|
|
593
|
+
"code": "STANDALONE_PORTAL_REQUIRES_EXECUTOR",
|
|
594
|
+
"description": "沒有 executor(capabilities.rootOps 或 delegate)的 App 宣告了 portal。",
|
|
595
|
+
"suggestion": "登入入口別名在解除安裝後要 root 才能讓 nginx 停止提供,而無升權 App 任何階段都不使用 root。請加 capabilities.rootOps 或 delegate(executor 層級),或移除 portal,改由管理員在 DSM 手動設定反向代理。"
|
|
596
|
+
},
|
|
597
597
|
"STANDALONE_I18N_INVALID": {
|
|
598
598
|
"code": "STANDALONE_I18N_INVALID",
|
|
599
599
|
"description": "dsm-package.yaml 的 i18n 設定不合法。",
|
|
@@ -649,8 +649,7 @@ for (const [code, description, suggestion] of [
|
|
|
649
649
|
ERROR_REGISTRY[code] = { code, description, suggestion };
|
|
650
650
|
ERROR_REGISTRY["ADD_LAYOUT_UNSUPPORTED"] = { "code": "ADD_LAYOUT_UNSUPPORTED", "description": "SDK overlay 需要標準 backend 目錄。", "suggestion": "使用 backend.dir: backend,或手動合併 SDK overlay。" };
|
|
651
651
|
ERROR_REGISTRY["MIN_DSM_CONTAINER_FLOOR"] = { "code": "MIN_DSM_CONTAINER_FLOOR", "description": "Compose 最低 DSM 版本過低。", "suggestion": "Compose 需要 DSM 7.2.1-69057 或以上。" };
|
|
652
|
-
|
|
653
|
-
ERROR_REGISTRY[code] = { code, "description": "宣告 portal 的套件 os_min_ver 低於 DSM 7.3.2-86009。", "suggestion": "DSM 7.1.1 以 320 拒裝帶 portal 的未簽章套件、7.2 未驗證;把 os_min_ver 提高到 7.3.2-86009 以上,或移除 portal。" };
|
|
652
|
+
ERROR_REGISTRY.STANDALONE_PORTAL_MIN_DSM = { "code": "STANDALONE_PORTAL_MIN_DSM", "description": "宣告 portal 的套件 os_min_ver 低於 DSM 7.3.2-86009。", "suggestion": "DSM 7.1.1 以 320 拒裝帶 portal 的未簽章套件、7.2 未驗證;把 os_min_ver 提高到 7.3.2-86009 以上,或移除 portal。" };
|
|
654
653
|
ERROR_REGISTRY["MIN_DSM_INVALID"] = { "code": "MIN_DSM_INVALID", "description": "最低 DSM 版本格式不合法。", "suggestion": "指定 7.x release 或完整 7.x-build。" };
|
|
655
654
|
ERROR_REGISTRY["NOTICE_UNSAFE"] = { "code": "NOTICE_UNSAFE", "description": "授權檔案不符合一般檔案限制。", "suggestion": "移除 symlink、特殊檔與超大授權檔案。" };
|
|
656
655
|
ERROR_REGISTRY["NOTICE_MISSING"] = { "code": "NOTICE_MISSING", "description": "缺少第三方授權資訊。", "suggestion": "重新安裝模板依賴並產生 third-party notices。" };
|
|
@@ -714,7 +713,7 @@ for (const [code, description, suggestion] of [
|
|
|
714
713
|
ERROR_REGISTRY[code] = { code, description, suggestion };
|
|
715
714
|
ERROR_REGISTRY["STANDALONE_ENTRY_INVALID"] = { "code": "STANDALONE_ENTRY_INVALID", "description": "entry 設定不合法。", "suggestion": "entry 填 admin-only 或 app-privilege;app-privilege 需要至少一個 authenticated route。" };
|
|
716
715
|
ERROR_REGISTRY["STANDALONE_ENTRY_WIDENED"] = { "code": "STANDALONE_ENTRY_WIDENED", "description": "entry: admin-only 與放寬權限的 route 設定衝突。", "suggestion": "把 permissions.json 中 authenticated 或 public 的 route 改為 administrators;要開放給一般使用者請改用 entry: app-privilege 或省略 entry。" };
|
|
717
|
-
ERROR_REGISTRY["STANDALONE_ENTRY_UNSUPPORTED"] = { "code": "STANDALONE_ENTRY_UNSUPPORTED", "description": "backend 無法強制 entry 模式。", "suggestion": "使用 Go backend:backend/go.mod require github.com/synopackageland/syno-pkg-land-sdk,並在 cmd/app/main.go
|
|
716
|
+
ERROR_REGISTRY["STANDALONE_ENTRY_UNSUPPORTED"] = { "code": "STANDALONE_ENTRY_UNSUPPORTED", "description": "backend 無法強制 entry 模式。", "suggestion": "使用 Go backend:backend/go.mod require github.com/synopackageland/syno-pkg-land-sdk,並在 backend.main 的 main.go(init 預設為 cmd/app/main.go)以 dsm.NewEntryGate 包住 authenticator。" };
|
|
718
717
|
ERROR_REGISTRY["DSM_TLS_UNTRUSTED"] = { "code": "DSM_TLS_UNTRUSTED", "description": "DSM 的 TLS 憑證不受這台電腦信任(自簽、過期或名稱不符)。", "suggestion": "把 NAS 憑證或其 CA 匯出成 PEM,用 NODE_EXTRA_CA_CERTS=<該檔> 執行;或讓 DSM 使用與 host profile 名稱相符的受信任憑證。不要關閉憑證驗證。" };
|
|
719
718
|
ERROR_REGISTRY["STANDALONE_ICON_INVALID"] = { "code": "STANDALONE_ICON_INVALID", "description": "dsm-package.yaml 的 icon 不是可用的套件圖示。", "suggestion": "icon 填專案內的正方形 PNG(至少 256×256),或含 icon_256.png 的 icon_<size>.png 目錄;省略則使用 SDK 預設圖示。" };
|
|
720
719
|
ERROR_REGISTRY["CONTRACT_ICON_INVALID"] = { "code": "CONTRACT_ICON_INVALID", "description": "synology-app.yaml 的 icon 不是可用的套件圖示。", "suggestion": "icon 填專案內的正方形 PNG(至少 256×256),或含 icon_256.png 的 icon_<size>.png 目錄;省略則使用 SDK 預設圖示。" };
|
package/dist/errors.d.ts
CHANGED
|
@@ -3,4 +3,6 @@ export declare function makeError(code: string, span: SourceSpan, message: strin
|
|
|
3
3
|
export declare function contractSpan(file: string, line: number, column: number, path?: string): SourceSpan;
|
|
4
4
|
export declare function composeSpan(file: string, line: number, column: number, path?: string): SourceSpan;
|
|
5
5
|
export declare function formatError(error: ValidationError): string;
|
|
6
|
+
/** The message of an error thrown with a code: the code in brackets, then the message; the code only once. */
|
|
7
|
+
export declare function codedMessage(code: string, message: string): string;
|
|
6
8
|
export declare function formatErrors(errors: ValidationError[]): string;
|
package/dist/errors.js
CHANGED
|
@@ -13,6 +13,11 @@ export function formatError(error) {
|
|
|
13
13
|
: `${error.span.file}:${error.span.line}:${error.span.column}`;
|
|
14
14
|
return `[${error.code}] ${location}\n${error.message}\n建議:${error.suggestion}`;
|
|
15
15
|
}
|
|
16
|
+
/** The message of an error thrown with a code: the code in brackets, then the message; the code only once. */
|
|
17
|
+
export function codedMessage(code, message) {
|
|
18
|
+
const prefix = `[${code}] `;
|
|
19
|
+
return message.startsWith(prefix) ? message : prefix + message;
|
|
20
|
+
}
|
|
16
21
|
export function formatErrors(errors) {
|
|
17
22
|
return errors.map(formatError).join("\n\n");
|
|
18
23
|
}
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -92,6 +92,43 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
92
92
|
if (!cookieHeader)
|
|
93
93
|
throw new Error("DSM login returned no session cookie");
|
|
94
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* One read-only call of the App's own CGI as the administrator. identity:
|
|
97
|
+
* standalone wraps business methods in an App session: trade the DSM cookie
|
|
98
|
+
* for one (same Origin), read once, then end that session. Undefined when
|
|
99
|
+
* the App refuses; `error` when no App session could be opened.
|
|
100
|
+
*/
|
|
101
|
+
const readApp = async (packageIdentity, method) => {
|
|
102
|
+
await ensureLogin();
|
|
103
|
+
const appCall = async (name, cookie) => {
|
|
104
|
+
const response = await dsmFetch(`${dsmOrigin}/webman/3rdparty/${encodeURIComponent(packageIdentity)}/cgi/api.cgi`, {
|
|
105
|
+
method: "POST", headers: { cookie, origin: dsmOrigin, "content-type": "application/json" },
|
|
106
|
+
body: JSON.stringify({ method: name, params: name === "identity.session" ? { bootstrap: true } : {} }),
|
|
107
|
+
});
|
|
108
|
+
const body = await response.json();
|
|
109
|
+
return { body, setCookie: setCookiePairs(response.headers) };
|
|
110
|
+
};
|
|
111
|
+
const direct = await appCall(method, cookieHeader ?? "");
|
|
112
|
+
if (direct.body.success)
|
|
113
|
+
return { data: direct.body.data };
|
|
114
|
+
if (direct.body.error?.code !== "session_required")
|
|
115
|
+
return undefined;
|
|
116
|
+
const session = await appCall("identity.session", cookieHeader ?? "");
|
|
117
|
+
if (!session.body.success) {
|
|
118
|
+
const code = session.body.error?.code;
|
|
119
|
+
return { error: code === "https_required"
|
|
120
|
+
? "identity: standalone status needs an https:// DSM host profile to open an App session."
|
|
121
|
+
: `identity: standalone App session bootstrap failed (${code ?? "error"}).` };
|
|
122
|
+
}
|
|
123
|
+
const appCookie = [cookieHeader ?? "", ...session.setCookie].filter(Boolean).join("; ");
|
|
124
|
+
try {
|
|
125
|
+
const status = await appCall(method, appCookie);
|
|
126
|
+
return status.body.success ? { data: status.body.data } : undefined;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await appCall("identity.logout", appCookie).catch(() => undefined);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
95
132
|
return {
|
|
96
133
|
dsmOrigin,
|
|
97
134
|
async installFromLocalSpk(input) {
|
|
@@ -108,39 +145,23 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
108
145
|
},
|
|
109
146
|
async getExecutorStatus(packageIdentity) {
|
|
110
147
|
try {
|
|
111
|
-
await ensureLogin();
|
|
112
|
-
const appCall = async (method, cookie) => {
|
|
113
|
-
const response = await dsmFetch(`${dsmOrigin}/webman/3rdparty/${encodeURIComponent(packageIdentity)}/cgi/api.cgi`, {
|
|
114
|
-
method: "POST", headers: { cookie, origin: dsmOrigin, "content-type": "application/json" },
|
|
115
|
-
body: JSON.stringify({ method, params: method === "identity.session" ? { bootstrap: true } : {} }),
|
|
116
|
-
});
|
|
117
|
-
const body = await response.json();
|
|
118
|
-
return { body, setCookie: setCookiePairs(response.headers) };
|
|
119
|
-
};
|
|
120
148
|
// A paused executor answers with why and what to do next.
|
|
121
149
|
const explain = (status) => status.state === "paused" ? { ...status, error: `Executor paused (${status.reason}). ${status.next}` } : status;
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
return explain(direct.body.data);
|
|
125
|
-
// identity: standalone wraps business methods in an App session; trade the
|
|
126
|
-
// administrator's DSM cookie for one, read once, then end that session.
|
|
127
|
-
if (direct.body.error?.code !== "session_required")
|
|
150
|
+
const read = await readApp(packageIdentity, "executor.status");
|
|
151
|
+
if (!read)
|
|
128
152
|
return undefined;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
finally {
|
|
142
|
-
await appCall("identity.logout", appCookie).catch(() => undefined);
|
|
143
|
-
}
|
|
153
|
+
if ("error" in read)
|
|
154
|
+
return { ready: false, executor_digest: "", policy_digest: "", error: read.error };
|
|
155
|
+
return explain(read.data);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
async getPortalStatus(packageIdentity) {
|
|
162
|
+
try {
|
|
163
|
+
const read = await readApp(packageIdentity, "portal.status");
|
|
164
|
+
return read && "data" in read ? read.data : undefined;
|
|
144
165
|
}
|
|
145
166
|
catch {
|
|
146
167
|
return undefined;
|
package/dist/host/types.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface NasHost {
|
|
|
35
35
|
policy_digest: string;
|
|
36
36
|
error?: string;
|
|
37
37
|
} | undefined>;
|
|
38
|
+
/** The App's `portal.status`: whether an administrator set its Application Portal alias. */
|
|
39
|
+
getPortalStatus?(packageIdentity: string): Promise<{
|
|
40
|
+
state: string;
|
|
41
|
+
alias?: string;
|
|
42
|
+
} | undefined>;
|
|
38
43
|
getArchFamily?(): Promise<NativeArchFamily | undefined>;
|
|
39
44
|
/** List the current logical Shared Folder names using an administrator session. */
|
|
40
45
|
listShareNames?(): Promise<string[]>;
|
package/dist/images/pin.js
CHANGED
|
@@ -9,7 +9,6 @@ import { isScalar, parseDocument } from "yaml";
|
|
|
9
9
|
* (Docker Hub and ghcr both issue an anonymous Bearer token for public repos).
|
|
10
10
|
*/
|
|
11
11
|
const DIGEST_VALUE = /^sha256:[0-9a-f]{64}$/i;
|
|
12
|
-
const DIGEST_AT_END = /@sha256:([0-9a-f]{64})$/i;
|
|
13
12
|
/** A registry returns the index (manifest list) digest when we ask for it. */
|
|
14
13
|
const MANIFEST_ACCEPT = [
|
|
15
14
|
"application/vnd.oci.image.index.v1+json",
|
package/dist/info.js
CHANGED
|
@@ -161,13 +161,26 @@ export async function standaloneInfo(options) {
|
|
|
161
161
|
reason: "Installed package supports this capability without administrator approval.",
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
|
+
if (m.portal)
|
|
165
|
+
capabilities.push(installed ? await portalCapability(m.package, m.portal.alias, options.host) : {
|
|
166
|
+
name: "portal", state: "blocked", reason: "Package is not installed; deploy first.",
|
|
167
|
+
});
|
|
164
168
|
return {
|
|
165
169
|
...state,
|
|
166
170
|
capabilities,
|
|
167
171
|
...(executor ? { executor } : {}),
|
|
168
|
-
uninstallCleanup: uninstallCleanup(m.package, Boolean(m.capabilities.rootOps || m.capabilities.delegate)),
|
|
172
|
+
uninstallCleanup: uninstallCleanup(m.package, { executor: Boolean(m.capabilities.rootOps || m.capabilities.delegate), portal: Boolean(m.portal) }),
|
|
169
173
|
};
|
|
170
174
|
}
|
|
175
|
+
/** What the installed App's portal.status says; DSM owns the alias, an administrator sets it. */
|
|
176
|
+
async function portalCapability(pkg, defaultAlias, host) {
|
|
177
|
+
const status = await host.getPortalStatus?.(pkg);
|
|
178
|
+
if (status?.state === "active" && status.alias)
|
|
179
|
+
return { name: "portal", state: "ready", reason: `Users open the App at ${host.dsmOrigin.replace(/\/$/, "")}/${encodeURIComponent(status.alias)}/ after DSM sign-in.` };
|
|
180
|
+
if (status?.state === "inactive")
|
|
181
|
+
return { name: "portal", state: "needs-admin-approval", reason: `No alias is set; an administrator applies one (default ${defaultAlias}) in the App's Application Portal panel.` };
|
|
182
|
+
return { name: "portal", state: "blocked", reason: "The App's portal.status could not be read; open the App window to check the Application Portal." };
|
|
183
|
+
}
|
|
171
184
|
/** A native layout has no SDK capabilities; report package state only. */
|
|
172
185
|
async function nativeLayoutInfo(root, host) {
|
|
173
186
|
const loaded = loadNativeLayout(root);
|
package/dist/init.js
CHANGED
|
@@ -119,7 +119,7 @@ export function initFromTemplate(template, targetDir, options = {}) {
|
|
|
119
119
|
const shared = join(templatesRoot(), "_shared");
|
|
120
120
|
const sharedFiles = ["LICENSES/TEMPLATE.txt", "AGENTS.md", "CLAUDE.md"];
|
|
121
121
|
const files = [...templateFiles(source), ...sharedFiles, ...overlays.flatMap(root => templateFiles(root)),
|
|
122
|
-
...(ui === "vue" ? ["frontend/third-party-notices.mjs"] : [])];
|
|
122
|
+
...capability.borrowed.map(borrowedTarget), ...(ui === "vue" ? ["frontend/third-party-notices.mjs"] : [])];
|
|
123
123
|
const collisions = files.map(targetName).filter((file) => existsSync(join(targetDir, file)));
|
|
124
124
|
if (collisions.length > 0) {
|
|
125
125
|
throw new Error(`[INIT_TARGET_NOT_EMPTY] ${targetDir} 已有 ${collisions.join(", ")};init 不會覆蓋既有檔案。請改用空目錄。`);
|
|
@@ -257,7 +257,6 @@ function substitute(path, substitutions) {
|
|
|
257
257
|
writeFileSync(path, text);
|
|
258
258
|
}
|
|
259
259
|
function substitutionsFor(template, targetDir, options) {
|
|
260
|
-
const ui = resolvedUi(template, options);
|
|
261
260
|
const caps = capabilityNames(options.with ?? []);
|
|
262
261
|
if (options.ui !== undefined && template !== "native-go")
|
|
263
262
|
throw new Error("[INIT_OPTIONS_UNSUPPORTED] --ui 只適用於 native-go。");
|
package/dist/portal/alias.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { AppContract } from "../types.js";
|
|
2
1
|
/**
|
|
3
2
|
* DSM Application Portal alias: `https://<nas>/<alias>/` opens the App after
|
|
4
3
|
* DSM sign-in. DSM itself only refuses empty aliases (4100), synoman
|
|
@@ -12,6 +11,12 @@ export declare const PORTAL_ALIAS_MAX = 64;
|
|
|
12
11
|
* of Synology Apps, as observed on DSM 7.4-90069. DSM stays the authority.
|
|
13
12
|
*/
|
|
14
13
|
export declare const RESERVED_PORTAL_ALIASES: readonly string[];
|
|
14
|
+
/**
|
|
15
|
+
* A portal needs the executor tier: DSM removes the alias files on uninstall
|
|
16
|
+
* but leaves nginx serving the alias, and only the root executor purge can
|
|
17
|
+
* reload it. A no-privilege App never runs anything as root.
|
|
18
|
+
*/
|
|
19
|
+
export declare const PORTAL_REQUIRES_EXECUTOR = "portal \u9700\u8981 executor \u5C64\u7D1A\uFF08capabilities.rootOps \u6216 delegate\uFF09\uFF1A\u89E3\u9664\u5B89\u88DD\u5F8C DSM \u4E0D\u6703 reload nginx\uFF0C\u767B\u5165\u5165\u53E3\u5225\u540D\u8981\u7531 root executor \u6E05\u9664\u3002\u6C92\u6709 executor \u7684 App \u8ACB\u79FB\u9664 portal\uFF0C\u6539\u7531\u7BA1\u7406\u54E1\u5728 DSM \u624B\u52D5\u8A2D\u5B9A\u53CD\u5411\u4EE3\u7406\u3002";
|
|
15
20
|
export interface PortalDeclaration {
|
|
16
21
|
alias: string;
|
|
17
22
|
}
|
|
@@ -20,6 +25,3 @@ export declare function isPortalAlias(value: unknown): value is string;
|
|
|
20
25
|
export declare function defaultPortalAlias(packageId: string): string;
|
|
21
26
|
/** `{}` or `{alias}`; undefined for any other shape. */
|
|
22
27
|
export declare function readPortalDeclaration(value: unknown, packageId: string): PortalDeclaration | undefined;
|
|
23
|
-
/** A Compose App reaches the portal only as a DSM window behind the DSM-authenticated gateway. */
|
|
24
|
-
export declare function contractPortalSupported(contract: AppContract): boolean;
|
|
25
|
-
export declare function resolvedContractPortal(contract: AppContract): PortalDeclaration | undefined;
|
package/dist/portal/alias.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { readWebIntegration, resolvedWebLaunch, usesAuthGateway } from "../contract/web-launch.js";
|
|
3
2
|
/**
|
|
4
3
|
* DSM Application Portal alias: `https://<nas>/<alias>/` opens the App after
|
|
5
4
|
* DSM sign-in. DSM itself only refuses empty aliases (4100), synoman
|
|
@@ -16,6 +15,12 @@ export const RESERVED_PORTAL_ALIASES = [
|
|
|
16
15
|
"dsfile", "indexdb", "mobile", "oauth", "scripts", "sharing", "ssdp", "synosdsjslib", "synohdpack", "webapi", "webman",
|
|
17
16
|
"calendar", "chatplus", "contacts", "drive", "file", "photo", "sso", "synologymeet",
|
|
18
17
|
];
|
|
18
|
+
/**
|
|
19
|
+
* A portal needs the executor tier: DSM removes the alias files on uninstall
|
|
20
|
+
* but leaves nginx serving the alias, and only the root executor purge can
|
|
21
|
+
* reload it. A no-privilege App never runs anything as root.
|
|
22
|
+
*/
|
|
23
|
+
export const PORTAL_REQUIRES_EXECUTOR = "portal 需要 executor 層級(capabilities.rootOps 或 delegate):解除安裝後 DSM 不會 reload nginx,登入入口別名要由 root executor 清除。沒有 executor 的 App 請移除 portal,改由管理員在 DSM 手動設定反向代理。";
|
|
19
24
|
export function isPortalAlias(value) {
|
|
20
25
|
return typeof value === "string" && PORTAL_ALIAS_PATTERN.test(value) && !RESERVED_PORTAL_ALIASES.includes(value);
|
|
21
26
|
}
|
|
@@ -36,13 +41,3 @@ export function readPortalDeclaration(value, packageId) {
|
|
|
36
41
|
return { alias: defaultPortalAlias(packageId) };
|
|
37
42
|
return isPortalAlias(alias) ? { alias } : undefined;
|
|
38
43
|
}
|
|
39
|
-
/** A Compose App reaches the portal only as a DSM window behind the DSM-authenticated gateway. */
|
|
40
|
-
export function contractPortalSupported(contract) {
|
|
41
|
-
return resolvedWebLaunch(contract) === "dsm-window" && usesAuthGateway(contract);
|
|
42
|
-
}
|
|
43
|
-
export function resolvedContractPortal(contract) {
|
|
44
|
-
const portal = readWebIntegration(contract)?.portal;
|
|
45
|
-
if (portal === undefined || !contractPortalSupported(contract))
|
|
46
|
-
return undefined;
|
|
47
|
-
return readPortalDeclaration(portal, String(contract.package ?? ""));
|
|
48
|
-
}
|
package/dist/portal/package.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Translate } from "../build-spk/wizard-i18n.js";
|
|
2
2
|
import type { InstallWizardStep } from "../build-spk/install-wizard.js";
|
|
3
|
-
import type { AppContract } from "../types.js";
|
|
4
3
|
/** Package payload paths DSM's app-portal worker copies to /usr/syno/etc/www/app.d at start. */
|
|
5
4
|
export declare const PORTAL_JSON_RELPATH = "etc/portal.json";
|
|
6
5
|
export declare const PORTAL_MUSTACHE_RELPATH = "etc/portal.mustache";
|
|
@@ -16,8 +15,6 @@ export interface PortalPackage {
|
|
|
16
15
|
/** Default alias; the wizard answer replaces it. */
|
|
17
16
|
alias: string;
|
|
18
17
|
}
|
|
19
|
-
/** A Compose portal opens the App's `.url` entry of ui/config, keyed by the package id. */
|
|
20
|
-
export declare function contractPortal(contract: AppContract): PortalPackage | undefined;
|
|
21
18
|
export declare function portalResource(): Record<string, unknown>;
|
|
22
19
|
/** Every field of DSM's own app.d files; the worker refuses a partial file (synopkg start 272). */
|
|
23
20
|
export declare function renderPortalJson(portal: PortalPackage): string;
|
package/dist/portal/package.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PORTAL_ALIAS_MAX, PORTAL_ALIAS_PATTERN, RESERVED_PORTAL_ALIASES
|
|
1
|
+
import { PORTAL_ALIAS_MAX, PORTAL_ALIAS_PATTERN, RESERVED_PORTAL_ALIASES } from "./alias.js";
|
|
2
2
|
/** Package payload paths DSM's app-portal worker copies to /usr/syno/etc/www/app.d at start. */
|
|
3
3
|
export const PORTAL_JSON_RELPATH = "etc/portal.json";
|
|
4
4
|
export const PORTAL_MUSTACHE_RELPATH = "etc/portal.mustache";
|
|
@@ -6,12 +6,6 @@ export const PORTAL_MUSTACHE_RELPATH = "etc/portal.mustache";
|
|
|
6
6
|
export const PORTAL_INSTALL_KEY = "portal_alias";
|
|
7
7
|
/** Package-owned install draft, never DSM's saved portal configuration. */
|
|
8
8
|
export const PORTAL_DRAFT = "portal-install.json";
|
|
9
|
-
/** A Compose portal opens the App's `.url` entry of ui/config, keyed by the package id. */
|
|
10
|
-
export function contractPortal(contract) {
|
|
11
|
-
const portal = resolvedContractPortal(contract);
|
|
12
|
-
const packageId = String(contract.package);
|
|
13
|
-
return portal && { appId: packageId, application: String(contract.displayname ?? packageId), alias: portal.alias };
|
|
14
|
-
}
|
|
15
9
|
export function portalResource() {
|
|
16
10
|
return { "web-config": { "app-portal": { "json-relpath": PORTAL_JSON_RELPATH, "mustache-relpath": PORTAL_MUSTACHE_RELPATH } } };
|
|
17
11
|
}
|
|
@@ -9,5 +9,38 @@
|
|
|
9
9
|
"armv7": "b65a14f0fc77a1d3221c7dc6781eb66059aa200956b094d58a62bd1cad10f116"
|
|
10
10
|
},
|
|
11
11
|
"goToolchain": "go1.26.9"
|
|
12
|
+
},
|
|
13
|
+
"0.9.1": {
|
|
14
|
+
"x86_64": "5d75af3ce1de5b1d3ef5d89e062be1651a8e8b84edbca9903eb1a0b81d122fb5",
|
|
15
|
+
"aarch64": "b0e1f8174bf53eb435616ae0faf39ac929b515056f0b2fad5a6c264a1ff7f6e2",
|
|
16
|
+
"armv7": "17db613a4bdb181e0aa0f127822bfc13d7473fa846aadfbac3344b7c17700a84",
|
|
17
|
+
"lifecycle": {
|
|
18
|
+
"x86_64": "819459580135e016184ad80c258350d57194d6134e8aa4cf1caf5237085b1004",
|
|
19
|
+
"aarch64": "5abcfc21403e84a0af15d972bcb819ac6fedfd9aaf5898ddc8c4bb06130f9f59",
|
|
20
|
+
"armv7": "b65a14f0fc77a1d3221c7dc6781eb66059aa200956b094d58a62bd1cad10f116"
|
|
21
|
+
},
|
|
22
|
+
"goToolchain": "go1.26.9"
|
|
23
|
+
},
|
|
24
|
+
"0.9.2": {
|
|
25
|
+
"x86_64": "89c26f18ef05b601963341da6cce43f588bdf2a74039feaf3dfcffdd1295207f",
|
|
26
|
+
"aarch64": "6616953ac1721da64a134898ed1bec93d8e5a81fc044f8bc822751df1a871bdd",
|
|
27
|
+
"armv7": "d9c62b601454b8806a6eeef18a503385b508a8a3b947afa9b433a092979cc6da",
|
|
28
|
+
"lifecycle": {
|
|
29
|
+
"x86_64": "819459580135e016184ad80c258350d57194d6134e8aa4cf1caf5237085b1004",
|
|
30
|
+
"aarch64": "5abcfc21403e84a0af15d972bcb819ac6fedfd9aaf5898ddc8c4bb06130f9f59",
|
|
31
|
+
"armv7": "b65a14f0fc77a1d3221c7dc6781eb66059aa200956b094d58a62bd1cad10f116"
|
|
32
|
+
},
|
|
33
|
+
"goToolchain": "go1.26.9"
|
|
34
|
+
},
|
|
35
|
+
"0.9.3": {
|
|
36
|
+
"x86_64": "89c26f18ef05b601963341da6cce43f588bdf2a74039feaf3dfcffdd1295207f",
|
|
37
|
+
"aarch64": "6616953ac1721da64a134898ed1bec93d8e5a81fc044f8bc822751df1a871bdd",
|
|
38
|
+
"armv7": "d9c62b601454b8806a6eeef18a503385b508a8a3b947afa9b433a092979cc6da",
|
|
39
|
+
"lifecycle": {
|
|
40
|
+
"x86_64": "819459580135e016184ad80c258350d57194d6134e8aa4cf1caf5237085b1004",
|
|
41
|
+
"aarch64": "5abcfc21403e84a0af15d972bcb819ac6fedfd9aaf5898ddc8c4bb06130f9f59",
|
|
42
|
+
"armv7": "b65a14f0fc77a1d3221c7dc6781eb66059aa200956b094d58a62bd1cad10f116"
|
|
43
|
+
},
|
|
44
|
+
"goToolchain": "go1.26.9"
|
|
12
45
|
}
|
|
13
46
|
}
|
|
@@ -4,4 +4,10 @@ export interface UninstallCleanupItem {
|
|
|
4
4
|
condition: string;
|
|
5
5
|
action: string;
|
|
6
6
|
}
|
|
7
|
-
export
|
|
7
|
+
export interface UninstallCleanupOptions {
|
|
8
|
+
/** The App runs the root executor, which purges DSM's leftovers on uninstall. */
|
|
9
|
+
executor?: boolean;
|
|
10
|
+
/** The App declares a Login Portal alias; only the executor tier can. */
|
|
11
|
+
portal?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function uninstallCleanup(pkg: string, { executor, portal }?: UninstallCleanupOptions): UninstallCleanupItem[];
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
const AUTOMATIC_FAILED = 'Only if automatic cleanup failed after uninstall; see the system log entry synopkgland-executor.';
|
|
2
|
+
export function uninstallCleanup(pkg, { executor = false, portal = false } = {}) {
|
|
2
3
|
const items = [
|
|
3
4
|
{ target: `@appdata/${pkg}, @appconf/${pkg}, @apphome/${pkg}`, condition: 'After uninstall, if retained data or empty DSM directories are no longer needed.', action: 'Back up retained data; an administrator may remove only these package-specific directories. The delete-data checkbox empties their contents and preserves DSM directory roots.' },
|
|
4
5
|
{ target: `@appshare/${pkg}, /usr/syno/etc/packages/${pkg}`, condition: 'After uninstall, if DSM leaves package infrastructure or a package-config symlink.', action: 'Administrator: inspect these package-specific paths and remove unused infrastructure; do not follow the symlink to delete shared data.' },
|
|
6
|
+
// Package scripts run as the package account; only the root executor can edit these DSM files.
|
|
7
|
+
executor
|
|
8
|
+
? { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: AUTOMATIC_FAILED, action: 'The root executor removes these rules automatically through the Application Privileges API on uninstall. Administrator: remove surviving rules in Control Panel > Application Privileges; DSM would apply them again to a later install under the same name.' }
|
|
9
|
+
: { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: 'After uninstall, if this package will not be reinstalled.', action: 'DSM keeps these rules in /etc/synoappprivilege.db and applies them again to a later install under the same name. Administrator: remove them in Control Panel > Application Privileges.' },
|
|
10
|
+
...(executor && portal ? [{ target: `${pkg} Login Portal alias`, condition: AUTOMATIC_FAILED, action: 'DSM removes the alias configuration without reloading its web server; the root executor then reloads the web server automatically. Administrator: if the alias still opens a DSM page, save an application alias in Control Panel > Login Portal > Applications to reload it, or restart the NAS.' }] : []),
|
|
5
11
|
{ target: `${pkg} package account/group`, condition: 'After uninstall, only if this package will not be reinstalled and the identity is unused.', action: 'Administrator: review DSM internal account/group and associated ACLs before cleanup. Package scripts do not delete system identities.' },
|
|
6
12
|
];
|
|
7
13
|
if (executor)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { REOPEN_EVENTS_RUNTIME } from "./reopen-events.js";
|
|
2
2
|
import { DSM_LANGS } from "./i18n.js";
|
|
3
|
-
import { PASSWORD_CONFIRM, ROOT_PANEL, ROOT_RUNTIME } from "./root-window.js";
|
|
3
|
+
import { PASSWORD_CONFIRM, ROOT_PANEL, ROOT_RUNTIME, rootPanelCatalogs } from "./root-window.js";
|
|
4
4
|
import { HOST_ADAPTER_RUNTIME } from "./host-adapter.js";
|
|
5
5
|
import { permitsOrdinaryUsers } from "./api-permissions.js";
|
|
6
6
|
import { PORTAL_RUNTIME } from "./portal-window.js";
|
|
@@ -297,21 +297,18 @@ ${ns}.webApi = function (win) {
|
|
|
297
297
|
}
|
|
298
298
|
const ROOT_PANEL_CSS = [
|
|
299
299
|
".dsmapp-rootpanel{margin:16px 20px;padding:12px 16px;border:1px solid #d5dce5;border-radius:4px;background:#f7f9fb;font-size:13px;line-height:1.5}",
|
|
300
|
-
".dsmapp-rootpanel h2{margin:0 0 6px;font-size:14px;font-weight:600}",
|
|
301
|
-
".dsmapp-rootpanel
|
|
302
|
-
".dsmapp-rootpanel dt{color:#526070}.dsmapp-rootpanel dd{margin:0}",
|
|
303
|
-
".dsmapp-rootpanel pre{margin:4px 0 8px;padding:6px 8px;background:#fff;border:1px solid #e1e6ec;white-space:pre-wrap;max-height:160px;overflow:auto}",
|
|
300
|
+
".dsmapp-rootpanel h2{margin:0 0 6px;font-size:14px;font-weight:600}.dsmapp-rootpanel p{margin:4px 0}",
|
|
301
|
+
".dsmapp-rootpanel label{display:block;margin:8px 0}.dsmapp-rootpanel input{margin-left:8px;padding:3px 6px}",
|
|
304
302
|
".dsmapp-rootpanel-actions button{margin-right:8px;padding:4px 14px}",
|
|
305
303
|
".dsmapp-rootpanel-primary{background:#057feb;color:#fff;border:1px solid #057feb;border-radius:3px}",
|
|
306
|
-
".dsmapp-rootpanel-
|
|
304
|
+
".dsmapp-rootpanel-hint{color:#526070;font-size:12px}.dsmapp-rootpanel [hidden]{display:none}",
|
|
307
305
|
].join("");
|
|
308
306
|
/**
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
* has no scope picker.
|
|
307
|
+
* A Vue app approves in the SDK's ExecutorSettings, which also carries an
|
|
308
|
+
* install-selected scope; the window panel serves plain-UI apps only.
|
|
312
309
|
*/
|
|
313
310
|
function settingsOnlyApproval(manifest) {
|
|
314
|
-
return manifest.ui.build !== undefined
|
|
311
|
+
return manifest.ui.build !== undefined;
|
|
315
312
|
}
|
|
316
313
|
/**
|
|
317
314
|
* The capabilities.rootOps: true additions to the window: the exact tasks and panel
|
|
@@ -352,7 +349,7 @@ ${PASSWORD_CONFIRM}
|
|
|
352
349
|
});
|
|
353
350
|
${settingsOnlyApproval(manifest) ? "" : ` var panels = me.body && me.body.dom.querySelector(".dsmapp-rootpanels");
|
|
354
351
|
if (panels) {
|
|
355
|
-
${ns}.mountRootPanel(panels, access,
|
|
352
|
+
${ns}.mountRootPanel(panels, access, ${ns}.createI18n(lang, ${JSON.stringify(rootPanelCatalogs())}).t, session().admin);
|
|
356
353
|
}
|
|
357
354
|
`}`,
|
|
358
355
|
};
|
|
@@ -19,7 +19,7 @@ import { renderUninstallWizardFiles } from "../build-spk/uninstall-wizard.js";
|
|
|
19
19
|
import { renderStandaloneScript } from "./lifecycle.js";
|
|
20
20
|
import { listPortExposures, loadCompose } from "../compose/analyze.js";
|
|
21
21
|
import { uninstallCleanup } from "../runtime/uninstall-cleanup.js";
|
|
22
|
-
import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH,
|
|
22
|
+
import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep } from "../portal/package.js";
|
|
23
23
|
import { isPortalAlias } from "../portal/alias.js";
|
|
24
24
|
import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
|
|
25
25
|
import { decodePng } from "../build-spk/png.js";
|
|
@@ -70,7 +70,8 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
70
70
|
await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
|
|
71
71
|
const background = kind === "native-go" && paths.has(BACKGROUND_WORKER_PAYLOAD);
|
|
72
72
|
const sharedFolder = standalone ? recoverSharedFolder(readOptional(join(dir, "scripts/postinst"))) : undefined;
|
|
73
|
-
|
|
73
|
+
// A Compose App has no executor, so its SPK never carries a portal.
|
|
74
|
+
const portal = standalone ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings) : undefined;
|
|
74
75
|
if (portal && info.os_min_ver && OS_MIN_VER.test(info.os_min_ver) && !isDsmAtLeast(info.os_min_ver, PORTAL_MIN_DSM)) {
|
|
75
76
|
findings.push(`INFO os_min_ver「${info.os_min_ver}」低於 portal 需要的 DSM ${PORTAL_MIN_DSM};DSM 7.1.1 以錯誤 320 拒裝帶 web-config app-portal 的未簽章套件,7.2 未驗證`);
|
|
76
77
|
}
|
|
@@ -80,6 +81,8 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
80
81
|
if (kind === "container")
|
|
81
82
|
await checkContainerExposure(dir, join(dir, "package.tgz"), findings);
|
|
82
83
|
const hasExecutor = (metadata.capabilities ?? []).some(name => name === "rootOps" || name === "delegate") || paths.has("executor/executor");
|
|
84
|
+
if (standalone && portal && !hasExecutor)
|
|
85
|
+
findings.push("PORTAL_REQUIRES_EXECUTOR portal 需要 executor 層級(rootOps 或 delegate):解除安裝後只有 root purge 能讓 nginx 停止提供別名");
|
|
83
86
|
const daemon = standalone ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background) : undefined;
|
|
84
87
|
if (background)
|
|
85
88
|
checkBackground(dir, info, payload, findings, hasExecutor, daemon);
|
|
@@ -104,7 +107,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
104
107
|
findings.push(`Executor scripts/${hook} differs from fixed lifecycle.`);
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
|
-
for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", { executor: true }))) {
|
|
110
|
+
for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", { executor: true, portal: Boolean(portal) }))) {
|
|
108
111
|
if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
|
|
109
112
|
findings.push(`Executor WIZARD_UIFILES/${name} is missing cleanup guidance.`);
|
|
110
113
|
}
|
|
@@ -115,7 +118,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
115
118
|
if (metadata.entry)
|
|
116
119
|
await checkEntryLauncher(dir, info.package, metadata.entry, findings);
|
|
117
120
|
if (standalone)
|
|
118
|
-
checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === 'install-selected', portal);
|
|
121
|
+
checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === 'install-selected', portal, hasExecutor);
|
|
119
122
|
const icon = inspectPackageIcons(dir, findings);
|
|
120
123
|
const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
|
|
121
124
|
if (standalone && !cgiEntry) {
|
|
@@ -130,7 +133,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
130
133
|
...(executor ? { executor } : {}),
|
|
131
134
|
...(portal ? { portal: { appId: portal.appId, alias: portal.alias } } : {}),
|
|
132
135
|
...(metadata.entry ? { entry: metadata.entry } : {}),
|
|
133
|
-
...(standalone ? { uninstallCleanup: uninstallCleanup(info.package, hasExecutor) } : {}),
|
|
136
|
+
...(standalone ? { uninstallCleanup: uninstallCleanup(info.package, { executor: hasExecutor, portal: Boolean(portal) }) } : {}),
|
|
134
137
|
package: info.package,
|
|
135
138
|
version: info.version,
|
|
136
139
|
arch: info.arch,
|
|
@@ -324,11 +327,9 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal) {
|
|
|
324
327
|
if (!resource["docker-project"])
|
|
325
328
|
findings.push("container 缺少 docker-project resource");
|
|
326
329
|
for (const key of Object.keys(resource)) {
|
|
327
|
-
if (!["docker-project", "webservice"
|
|
330
|
+
if (!["docker-project", "webservice"].includes(key))
|
|
328
331
|
findings.push(`未知 container resource: ${key}`);
|
|
329
332
|
}
|
|
330
|
-
if (portal && JSON.stringify(resource["web-config"]) !== JSON.stringify(portalResource()["web-config"]))
|
|
331
|
-
findings.push("container web-config 必須只有固定的 app-portal 註冊");
|
|
332
333
|
}
|
|
333
334
|
catch {
|
|
334
335
|
findings.push("container 缺少有效的 conf/resource");
|
|
@@ -380,7 +381,7 @@ function checkBackground(dir, info, payload, findings, executor, daemon) {
|
|
|
380
381
|
* declaration (without one, any present file must be the default native
|
|
381
382
|
* install wizard), and no other install/upgrade wizard file may exist.
|
|
382
383
|
*/
|
|
383
|
-
function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal) {
|
|
384
|
+
function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false) {
|
|
384
385
|
const wizardDir = join(dir, "WIZARD_UIFILES");
|
|
385
386
|
const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : []).filter(name => /^(install|upgrade)_uifile(_|\.|$)/.test(name)).sort();
|
|
386
387
|
const postinst = readOptional(join(dir, "scripts/postinst"));
|
|
@@ -406,7 +407,7 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal)
|
|
|
406
407
|
if (!folder)
|
|
407
408
|
return;
|
|
408
409
|
for (const hook of ["postinst", "postupgrade"]) {
|
|
409
|
-
if (readOptional(join(dir, "scripts", hook)) !== renderStandaloneScript(hook, { package: pkg, sharedFolder: folder, ...(selected ? { capabilities: { delegate: { scope: "install-selected" } } } : {}), ...portalManifest(portal) })) {
|
|
410
|
+
if (readOptional(join(dir, "scripts", hook)) !== renderStandaloneScript(hook, { package: pkg, sharedFolder: folder, ...(selected ? { capabilities: { delegate: { scope: "install-selected" } } } : executor ? { capabilities: { rootOps: true } } : {}), ...portalManifest(portal) })) {
|
|
410
411
|
findings.push(`scripts/${hook} 與 install.sharedFolder 宣告的固定腳本不符`);
|
|
411
412
|
}
|
|
412
413
|
}
|
|
@@ -11,7 +11,7 @@ import { readBackground } from "./background.js";
|
|
|
11
11
|
import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_MIN_DSM, portalMinDsmMessage } from "../build-spk/dsm-version.js";
|
|
12
12
|
import { readSharedFolder } from "./shared-folder.js";
|
|
13
13
|
import { floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions } from "./api-permissions.js";
|
|
14
|
-
import { readPortalDeclaration } from "../portal/alias.js";
|
|
14
|
+
import { PORTAL_REQUIRES_EXECUTOR, readPortalDeclaration } from "../portal/alias.js";
|
|
15
15
|
import { renderPackageIcons } from "../build-spk/dsm-icons.js";
|
|
16
16
|
import { validateReproducible } from "../reproducible-config.js";
|
|
17
17
|
import { SDK_MODULE, sdkRequirement } from "../runtime/sdk-source.js";
|
|
@@ -137,7 +137,12 @@ function readManifest(raw, c) {
|
|
|
137
137
|
permissions = readApiPermissions(backend.dir, capabilities);
|
|
138
138
|
}
|
|
139
139
|
catch (error) {
|
|
140
|
-
|
|
140
|
+
// The SDK's own routes come from its module; a failed download is not a policy problem.
|
|
141
|
+
const download = /^\[SDK_DOWNLOAD_FAILED\] ([\s\S]*)$/.exec(error.message);
|
|
142
|
+
if (download)
|
|
143
|
+
c.fail("SDK_DOWNLOAD_FAILED", download[1]);
|
|
144
|
+
else
|
|
145
|
+
c.fail("STANDALONE_API_PERMISSIONS_INVALID", `backend/internal/api/permissions.json must define the centralized API policy: ${String(error)}`);
|
|
141
146
|
}
|
|
142
147
|
}
|
|
143
148
|
const daemon = arches && readDaemon(raw.daemon, c.root, arches, e => c.fail(e.code, e.message));
|
|
@@ -172,6 +177,9 @@ function readManifest(raw, c) {
|
|
|
172
177
|
const icon = raw.icon === undefined ? undefined : readIcon(raw.icon, c);
|
|
173
178
|
const portal = raw.portal === undefined || !packageId ? undefined : readPortalDeclaration(raw.portal, packageId)
|
|
174
179
|
?? c.fail("STANDALONE_PORTAL_INVALID", "portal 必須是 {} 或 { alias: <小寫英數與 -,1–64 字,不可為 DSM 保留路徑> }。");
|
|
180
|
+
if (portal && !capabilities.rootOps && !capabilities.delegate) {
|
|
181
|
+
c.fail("STANDALONE_PORTAL_REQUIRES_EXECUTOR", PORTAL_REQUIRES_EXECUTOR);
|
|
182
|
+
}
|
|
175
183
|
if (portal && osMinVer && !isDsmAtLeast(osMinVer, PORTAL_MIN_DSM)) {
|
|
176
184
|
c.fail("STANDALONE_PORTAL_MIN_DSM", portalMinDsmMessage(osMinVer, "portal"));
|
|
177
185
|
}
|
|
@@ -230,7 +238,8 @@ function readEntry(value, backend, policy, c) {
|
|
|
230
238
|
const main = backend.kind === "go" ? join(backend.dir, backend.main, "main.go") : "";
|
|
231
239
|
if (backend.kind !== "go" || !sdkRequirement(backend.dir)
|
|
232
240
|
|| !existsSync(main) || !readFileSync(main, "utf8").includes("dsm.NewEntryGate(")) {
|
|
233
|
-
|
|
241
|
+
const shown = main ? relative(c.root, main) : "backend.main 的 main.go";
|
|
242
|
+
return c.fail("STANDALONE_ENTRY_UNSUPPORTED", `entry 需要 SDK 的 entry gate:backend/go.mod 要 require ${SDK_MODULE},並在 ${shown} 以 dsm.NewEntryGate 包住 authenticator。`);
|
|
234
243
|
}
|
|
235
244
|
if (value === "app-privilege" && !permitsOrdinaryUsers(policy)) {
|
|
236
245
|
return c.fail("STANDALONE_ENTRY_INVALID", "entry: app-privilege 需要至少一個 permissions.json 標為 authenticated 的 route,一般使用者才有功能可用。");
|