@synopackageland/cli 0.9.7 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +5 -0
  3. package/dist/add.js +7 -8
  4. package/dist/build-spk/dsm-arch.js +1 -1
  5. package/dist/build-spk/dsm-icons.d.ts +8 -1
  6. package/dist/build-spk/dsm-icons.js +26 -12
  7. package/dist/build-spk/dsm-version.d.ts +1 -1
  8. package/dist/build-spk/dsm-version.js +4 -4
  9. package/dist/build-spk/gateway.js +2 -2
  10. package/dist/build-spk/generator.js +1 -1
  11. package/dist/build-spk/png.js +16 -16
  12. package/dist/capabilities.js +1 -1
  13. package/dist/capability-overlays.d.ts +16 -5
  14. package/dist/capability-overlays.js +30 -16
  15. package/dist/cli.js +107 -107
  16. package/dist/compose/analyze.js +23 -23
  17. package/dist/compose/privilege.js +12 -12
  18. package/dist/contract/i18n.js +3 -3
  19. package/dist/contract/icon.js +2 -2
  20. package/dist/contract/posix-all-shares.js +2 -2
  21. package/dist/contract/schema-validate.js +3 -3
  22. package/dist/contract/validate.js +16 -16
  23. package/dist/contract/web-launch.d.ts +2 -1
  24. package/dist/contract/web-launch.js +3 -2
  25. package/dist/deploy/deploy.js +8 -8
  26. package/dist/deploy/homes-values.js +1 -1
  27. package/dist/deploy/identity-shared.js +1 -1
  28. package/dist/deploy/native-install-values.js +1 -1
  29. package/dist/deploy/param-values.js +4 -4
  30. package/dist/deploy/plan.d.ts +1 -1
  31. package/dist/deploy/port-preflight.js +3 -3
  32. package/dist/deploy/run-as-values.js +2 -2
  33. package/dist/deploy/share-permission-preflight.js +6 -6
  34. package/dist/deploy/share-values.js +3 -3
  35. package/dist/error-registry.js +369 -346
  36. package/dist/errors.js +1 -1
  37. package/dist/host/dsm-port-profile.d.ts +1 -1
  38. package/dist/host/dsm-port-profile.js +1 -1
  39. package/dist/info.js +11 -11
  40. package/dist/init.d.ts +2 -0
  41. package/dist/init.js +85 -38
  42. package/dist/inspect.js +5 -5
  43. package/dist/portal/alias.d.ts +1 -1
  44. package/dist/portal/alias.js +4 -4
  45. package/dist/runtime/executor-build.js +12 -1
  46. package/dist/runtime/executor-digests.json +8 -96
  47. package/dist/runtime/executor-inspect.js +11 -4
  48. package/dist/runtime/executor-policy.d.ts +9 -2
  49. package/dist/runtime/executor-policy.js +27 -8
  50. package/dist/runtime/root-programs.d.ts +47 -0
  51. package/dist/runtime/root-programs.js +153 -0
  52. package/dist/runtime/sdk-source.d.ts +5 -4
  53. package/dist/runtime/sdk-source.js +8 -4
  54. package/dist/skill.js +14 -14
  55. package/dist/spk/check-info.js +17 -17
  56. package/dist/spk/check-metadata.js +18 -18
  57. package/dist/spk/check-payload.js +10 -10
  58. package/dist/spk/rules.d.ts +57 -57
  59. package/dist/spk/rules.js +50 -50
  60. package/dist/spk/validate-spk.js +5 -5
  61. package/dist/standalone/api-permissions.js +1 -1
  62. package/dist/standalone/background.d.ts +3 -1
  63. package/dist/standalone/background.js +8 -4
  64. package/dist/standalone/binary.js +7 -7
  65. package/dist/standalone/bridge-app.d.ts +23 -0
  66. package/dist/standalone/bridge-app.js +141 -0
  67. package/dist/standalone/bridge-backend.d.ts +40 -0
  68. package/dist/standalone/bridge-backend.js +95 -0
  69. package/dist/standalone/bridge-build.d.ts +21 -0
  70. package/dist/standalone/bridge-build.js +62 -0
  71. package/dist/standalone/bridge-json.d.ts +16 -0
  72. package/dist/standalone/bridge-json.js +59 -0
  73. package/dist/standalone/bridge-runtime.d.ts +16 -0
  74. package/dist/standalone/bridge-runtime.js +46 -0
  75. package/dist/standalone/bridge-rust.d.ts +16 -0
  76. package/dist/standalone/bridge-rust.js +89 -0
  77. package/dist/standalone/build.js +18 -14
  78. package/dist/standalone/daemon.js +6 -6
  79. package/dist/standalone/dsm-window.d.ts +10 -5
  80. package/dist/standalone/dsm-window.js +64 -16
  81. package/dist/standalone/i18n.js +18 -18
  82. package/dist/standalone/inspect-bridge.d.ts +31 -0
  83. package/dist/standalone/inspect-bridge.js +111 -0
  84. package/dist/standalone/inspect-spk.d.ts +1 -1
  85. package/dist/standalone/inspect-spk.js +77 -69
  86. package/dist/standalone/lifecycle.js +22 -3
  87. package/dist/standalone/manifest.d.ts +17 -9
  88. package/dist/standalone/manifest.js +143 -65
  89. package/dist/standalone/portal-window.js +14 -7
  90. package/dist/standalone/request-timeout.d.ts +9 -0
  91. package/dist/standalone/request-timeout.js +13 -0
  92. package/dist/standalone/root-ops.d.ts +23 -2
  93. package/dist/standalone/root-ops.js +118 -13
  94. package/dist/standalone/sdk-check.js +4 -4
  95. package/dist/standalone/shared-folder.js +7 -7
  96. package/dist/standalone/spk.d.ts +2 -0
  97. package/dist/standalone/spk.js +30 -11
  98. package/dist/standalone/uninstall-hook.js +22 -4
  99. package/dist/standalone/uninstall.js +7 -7
  100. package/dist/standalone/window-size.d.ts +17 -0
  101. package/dist/standalone/window-size.js +31 -0
  102. package/dist/types.d.ts +1 -1
  103. package/dist/validate.js +13 -13
  104. package/package.json +2 -2
  105. package/schema/bridge-manifest.v1.schema.json +86 -0
  106. package/skills/reference/create.md +76 -2
  107. package/skills/reference/ui-json.md +2 -1
  108. package/skills/reference/ui-vue.md +9 -3
  109. package/skills/reference/wrap.md +1 -1
  110. package/dist/standalone/rust-build.d.ts +0 -35
  111. package/dist/standalone/rust-build.js +0 -127
@@ -0,0 +1,111 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import * as tar from "tar";
5
+ import { familyOfArchToken, NATIVE_ARCH_FAMILIES } from "../build-spk/dsm-arch.js";
6
+ import { ELF_MACHINE } from "./binary.js";
7
+ import { NODE_SDK_PACKAGE } from "./bridge-app.js";
8
+ import { BRIDGE_JSON, bridgeManifestProblems, PYTHON_VENDOR_DIR } from "./bridge-json.js";
9
+ import { CGI_PAYLOAD_PATH } from "./dsm-window.js";
10
+ /** Files that belong in target/app only: DSM serves everything under ui/ to the browser. */
11
+ const APP_ONLY = [`/${BRIDGE_JSON}`, "/permissions.json", `/${PYTHON_VENDOR_DIR}/synopkgland_app/`, `/node_modules/${NODE_SDK_PACKAGE}/`];
12
+ /**
13
+ * A Python, Node or Rust App: the runtime CGI binds bridge.json and
14
+ * permissions.json by digest, the App lives in target/app (never under ui/),
15
+ * and its entry, SDK and dependencies are in the package.
16
+ */
17
+ export async function checkBridgeApp(dir, backend, info, payload, findings) {
18
+ const paths = new Set(payload.map(entry => entry.path));
19
+ for (const path of payload.map(entry => `/${entry.path}`).filter(path => path.startsWith("/ui/") && APP_ONLY.some(name => path.includes(name)))) {
20
+ findings.push(`BRIDGE_APP_IN_UI ${path.slice(1)} is an App file and must not be placed in ui/ (DSM serves it directly to the browser)`);
21
+ }
22
+ const target = join(dir, "bridge-inspection");
23
+ mkdirSync(target);
24
+ // Only read, never run.
25
+ await tar.x({ file: join(dir, "package.tgz"), cwd: target, filter: path => { const p = path.replace(/^\.\//, ""); return p.startsWith("app/") || p === CGI_PAYLOAD_PATH; } });
26
+ const app = join(target, "app");
27
+ const text = existsSync(join(app, BRIDGE_JSON)) ? readFileSync(join(app, BRIDGE_JSON), "utf8") : undefined;
28
+ const problems = text === undefined ? [`app/${BRIDGE_JSON} is missing`] : bridgeManifestProblems(text);
29
+ if (problems.length) {
30
+ findings.push(...problems.map(problem => `BRIDGE_MANIFEST_INVALID ${problem}`));
31
+ return undefined;
32
+ }
33
+ const bridge = JSON.parse(text);
34
+ if (bridge.runtime !== backend)
35
+ findings.push(`BRIDGE_MANIFEST_INVALID the runtime "${bridge.runtime}" in app/${BRIDGE_JSON} differs from the backend "${backend}" in sdk-package.json`);
36
+ checkEntry(app, bridge, info, payload, findings);
37
+ const permissions = existsSync(join(app, "permissions.json")) ? readFileSync(join(app, "permissions.json")) : undefined;
38
+ if (!permissions)
39
+ findings.push("BRIDGE_PERMISSIONS app/permissions.json is missing");
40
+ else
41
+ checkPermissions(permissions, bridge, findings);
42
+ const cgi = existsSync(join(target, CGI_PAYLOAD_PATH)) ? readFileSync(join(target, CGI_PAYLOAD_PATH)) : Buffer.alloc(0);
43
+ for (const [name, bytes] of [[BRIDGE_JSON, Buffer.from(text)], ["permissions.json", permissions]]) {
44
+ if (bytes && !cgi.includes(createHash("sha256").update(bytes).digest("hex"))) {
45
+ findings.push(`BRIDGE_DIGEST_MISMATCH the sha256 bound to ${CGI_PAYLOAD_PATH} is not that of app/${name}; the runtime refuses to run it, rebuild it with synopkgland build`);
46
+ }
47
+ }
48
+ if (backend === "python" && !paths.has(`app/${PYTHON_VENDOR_DIR}/synopkgland_app/__init__.py`))
49
+ findings.push(`BRIDGE_PYTHON_SDK app/${PYTHON_VENDOR_DIR}/synopkgland_app/ is missing`);
50
+ if (backend === "node")
51
+ checkNode(app, bridge, info, findings);
52
+ return bridge;
53
+ }
54
+ /** root and delegate reach the package's executor; delegate pins the one this SPK ships. */
55
+ export function checkBridgeExecutor(bridge, executor, findings) {
56
+ const { root, delegate } = bridge.capabilities ?? {};
57
+ if ((root || delegate) && !executor)
58
+ findings.push("BRIDGE_EXECUTOR_MISSING app/bridge.json declares root or delegate, but the SPK has no executor");
59
+ if (delegate && executor && (delegate.executor_digest !== executor.executorDigest || delegate.policy_digest !== executor.policyDigest)) {
60
+ findings.push("BRIDGE_EXECUTOR_MISMATCH the delegate executor_digest/policy_digest in app/bridge.json differs from the SPK executor");
61
+ }
62
+ }
63
+ /** The entry exists; a Rust entry is an executable of the SPK's architecture. */
64
+ function checkEntry(app, bridge, info, payload, findings) {
65
+ const entry = payload.find(file => file.path === `app/${bridge.entry}`);
66
+ if (!entry) {
67
+ findings.push(`BRIDGE_ENTRY_MISSING app/${bridge.entry} is missing`);
68
+ return;
69
+ }
70
+ if (bridge.runtime !== "rust")
71
+ return;
72
+ if (entry.mode !== "0755")
73
+ findings.push(`BRIDGE_ENTRY_MISSING app/${bridge.entry} has mode ${entry.mode}; it should be 0755`);
74
+ const header = readFileSync(join(app, bridge.entry)).subarray(0, 20);
75
+ const machine = header.length === 20 && header.toString("latin1", 0, 4) === "\x7fELF" ? header.readUInt16LE(18) : undefined;
76
+ const actual = NATIVE_ARCH_FAMILIES.find(family => ELF_MACHINE[family] === machine);
77
+ const declared = familyOfArchToken((info.arch ?? "").split(" ")[0] ?? "");
78
+ if (!actual || (declared && actual !== declared))
79
+ findings.push(`BRIDGE_ENTRY_ARCH app/${bridge.entry} is not an executable for INFO arch (${declared ?? info.arch})`);
80
+ }
81
+ /** The runtime refuses to start when permissions.json sets a method the manifest does not register. */
82
+ function checkPermissions(bytes, bridge, findings) {
83
+ let methods;
84
+ try {
85
+ methods = Object.keys(JSON.parse(bytes.toString("utf8")).methods ?? {});
86
+ }
87
+ catch {
88
+ findings.push("BRIDGE_PERMISSIONS app/permissions.json is not JSON");
89
+ return;
90
+ }
91
+ const unlisted = methods.filter(method => !bridge.methods?.includes(method));
92
+ if (unlisted.length)
93
+ findings.push(`BRIDGE_PERMISSIONS app/permissions.json sets methods bridge.json does not have: ${unlisted.join(", ")}`);
94
+ }
95
+ /** The Node.js package is an install dependency, and every dependency is in node_modules. */
96
+ function checkNode(app, bridge, info, findings) {
97
+ const node = bridge.node?.package;
98
+ const deps = (info.install_dep_packages ?? "").split(":").map(dep => dep.replace(/[><=].*$/, ""));
99
+ if (!node || !deps.includes(node))
100
+ findings.push(`BRIDGE_NODE_DEPENDENCY INFO install_dep_packages must contain the node.package from bridge.json "${node ?? ""}"`);
101
+ const missing = [NODE_SDK_PACKAGE];
102
+ if (existsSync(join(app, "package.json"))) {
103
+ try {
104
+ missing.push(...Object.keys(JSON.parse(readFileSync(join(app, "package.json"), "utf8")).dependencies ?? {}));
105
+ }
106
+ catch { /* reported as missing below */ }
107
+ }
108
+ const absent = [...new Set(missing)].filter(name => !existsSync(join(app, "node_modules", name, "package.json")));
109
+ if (absent.length)
110
+ findings.push(`BRIDGE_NODE_MODULES app/node_modules is missing ${absent.join(", ")}`);
111
+ }
@@ -1,7 +1,7 @@
1
1
  import { type ExecutorInspection } from "../runtime/executor-inspect.js";
2
2
  import type { EntryMode } from "./manifest.js";
3
3
  import { type UninstallCleanupItem } from "../runtime/uninstall-cleanup.js";
4
- export type SpkKind = "native-go" | "native-rust" | "binary" | "container" | "native-layout";
4
+ export type SpkKind = "native-go" | "native-python" | "native-node" | "native-rust" | "binary" | "container" | "native-layout";
5
5
  export interface SpkInspection {
6
6
  kind?: SpkKind;
7
7
  package?: string;
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { inspectNativeLayout } from "./native-layout.js";
3
+ import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
3
4
  import { inspectExecutor, inspectExecutorAnchor } from "../runtime/executor-inspect.js";
4
5
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
5
6
  import { tmpdir } from "node:os";
@@ -13,7 +14,7 @@ import { daemonConfig, readDaemon } from "./daemon.js";
13
14
  import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
14
15
  import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
15
16
  import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
16
- import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit } from "./background.js";
17
+ import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, BRIDGE_WORKER_ARGS, backgroundUnitName, renderBackgroundUnit } from "./background.js";
17
18
  import { renderStandaloneResource } from "./resource.js";
18
19
  import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER } from "./shared-folder.js";
19
20
  import { renderInstallWizardFiles, renderNativeInstallWizard } from "../build-spk/install-wizard.js";
@@ -26,6 +27,8 @@ import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderP
26
27
  import { isPortalAlias } from "../portal/alias.js";
27
28
  import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
28
29
  import { decodePng } from "../build-spk/png.js";
30
+ /** The SPK kind of each sdk-package.json backend a CGI is built for: Go itself, or the runtime of an App in another language. */
31
+ const COMPILED_KIND = { go: "native-go", python: "native-python", node: "native-node", rust: "native-rust" };
29
32
  /** Reads an SPK without installing it and reports how it would run on DSM. */
30
33
  export async function inspectStandaloneSpk(spkPath) {
31
34
  return inspectSpk(spkPath, "native-go");
@@ -57,22 +60,22 @@ export async function inspectSpk(spkPath, expectedKind) {
57
60
  if (metadataText !== undefined) {
58
61
  try {
59
62
  const raw = JSON.parse(metadataText);
60
- if (raw.schema !== 2 || !["go", "rust", "binary"].includes(raw.backend) || !Array.isArray(raw.capabilities) || raw.capabilities.some((name) => !["files", "jobs", "identity", "rootOps", "delegate"].includes(String(name))) || new Set(raw.capabilities).size !== raw.capabilities.length || (raw.entry !== undefined && !["admin-only", "app-privilege"].includes(raw.entry)))
63
+ if (raw.schema !== 2 || !["go", "python", "node", "rust", "binary"].includes(raw.backend) || !Array.isArray(raw.capabilities) || raw.capabilities.some((name) => !["files", "jobs", "identity", "rootOps", "delegate"].includes(String(name))) || new Set(raw.capabilities).size !== raw.capabilities.length || (raw.entry !== undefined && !["admin-only", "app-privilege"].includes(raw.entry)))
61
64
  throw new Error();
62
65
  metadata = raw;
63
66
  }
64
67
  catch {
65
- findings.push("sdk-package.json 不符合 schema 2 capability inventory。");
68
+ findings.push("sdk-package.json does not match the schema 2 capability inventory.");
66
69
  }
67
70
  }
68
71
  const uninstallHook = metadata.uninstall === undefined ? undefined : readUninstallRecord(metadata.uninstall, metadata.capabilities?.includes("rootOps") === true);
69
72
  if (metadata.uninstall !== undefined && !uninstallHook)
70
- findings.push("UNINSTALL_RECORD_INVALID sdk-package.json 的 uninstall 不是 synopkgland build 寫出的 uninstall 設定。");
73
+ findings.push("UNINSTALL_RECORD_INVALID the uninstall in sdk-package.json is not the uninstall setting synopkgland build writes.");
71
74
  const binaryBackend = metadata.backend === "binary";
72
75
  // A strict standalone inspection names the language the CGI was built from.
73
- const compiled = metadata.backend === "rust" ? "native-rust" : "native-go";
76
+ const compiled = COMPILED_KIND[metadata.backend ?? "go"] ?? "native-go";
74
77
  const kind = (expectedKind === "native-go" ? compiled : expectedKind) ?? (paths.has("compose.yaml") ? "container" : binaryBackend ? "binary" : compiled);
75
- const compiledKind = kind === "native-go" || kind === "native-rust";
78
+ const compiledKind = Object.values(COMPILED_KIND).includes(kind);
76
79
  const standalone = compiledKind || kind === "binary";
77
80
  const info = readInfo(join(dir, "INFO"), findings, kind);
78
81
  await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
@@ -81,19 +84,20 @@ export async function inspectSpk(spkPath, expectedKind) {
81
84
  // A Compose App has no executor, so its SPK never carries a portal.
82
85
  const portal = standalone ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings) : undefined;
83
86
  if (portal && info.os_min_ver && OS_MIN_VER.test(info.os_min_ver) && !isDsmAtLeast(info.os_min_ver, PORTAL_MIN_DSM)) {
84
- findings.push(`INFO os_min_ver「${info.os_min_ver}」低於 portal 需要的 DSM ${PORTAL_MIN_DSM};${PORTAL_MIN_DSM_REASON}`);
87
+ findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${PORTAL_MIN_DSM} a portal needs; ${PORTAL_MIN_DSM_REASON}`);
85
88
  }
86
89
  checkConf(dir, findings, kind, background, sharedFolder, info.package, portal);
87
90
  checkScripts(dir, findings);
88
91
  checkUninstall(dir, findings);
89
92
  if (kind === "container")
90
93
  await checkContainerExposure(dir, join(dir, "package.tgz"), findings);
94
+ const bridge = compiledKind && kind !== "native-go" ? await checkBridgeApp(dir, metadata.backend, info, payload, findings) : undefined;
91
95
  const hasExecutor = (metadata.capabilities ?? []).some(name => name === "rootOps" || name === "delegate") || paths.has("executor/executor");
92
96
  if (standalone && portal && !hasExecutor)
93
- findings.push("PORTAL_REQUIRES_EXECUTOR portal 需要 executor 層級(rootOps 或 delegate):解除安裝後只有 root purge 能讓 nginx 停止提供別名");
97
+ findings.push("PORTAL_REQUIRES_EXECUTOR portal needs the executor level (rootOps or delegate): after uninstall only a root purge can stop nginx serving the alias");
94
98
  const daemon = standalone ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background) : undefined;
95
99
  if (background)
96
- checkBackground(dir, info, payload, findings, hasExecutor, daemon);
100
+ checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go");
97
101
  let executor;
98
102
  if ((metadata.capabilities ?? []).some(name => name === "rootOps" || name === "delegate") || paths.has("executor/executor")) {
99
103
  const executorDir = join(dir, "executor-inspection");
@@ -105,10 +109,10 @@ export async function inspectSpk(spkPath, expectedKind) {
105
109
  if (executor)
106
110
  findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
107
111
  if (executor?.policy.portal && info.os_min_ver && OS_MIN_VER.test(info.os_min_ver) && !isDsmAtLeast(info.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
108
- findings.push(`INFO os_min_ver「${info.os_min_ver}」低於 executor 註冊 portal 需要的 DSM ${PORTAL_EXECUTOR_MIN_DSM}`);
112
+ findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${PORTAL_EXECUTOR_MIN_DSM} the executor needs to register a portal`);
109
113
  }
110
114
  if (executor && executor.policy.purge !== uninstallHook?.uninstall.purge.root)
111
- findings.push(`UNINSTALL_ROOT_POLICY_MISMATCH executor policy 的 purge「${executor.policy.purge ?? ""}」與 uninstall.purge.root「${uninstallHook?.uninstall.purge.root ?? ""}」不同。`);
115
+ findings.push(`UNINSTALL_ROOT_POLICY_MISMATCH the executor policy purge "${executor.policy.purge ?? ""}" differs from uninstall.purge.root "${uninstallHook?.uninstall.purge.root ?? ""}".`);
112
116
  if (payload.find(p => p.path === "executor/executor")?.mode !== "0755")
113
117
  findings.push("Executor must be executable.");
114
118
  if (payload.find(p => p.path === "executor/lifecycle")?.mode !== "0755")
@@ -125,6 +129,8 @@ export async function inspectSpk(spkPath, expectedKind) {
125
129
  findings.push(`Executor WIZARD_UIFILES/${name} is missing cleanup guidance.`);
126
130
  }
127
131
  }
132
+ if (bridge)
133
+ checkBridgeExecutor(bridge, executor, findings);
128
134
  if (background && daemon && (executor?.policy.lifetime !== 'until-revoked' || !metadata.capabilities?.includes('delegate'))) {
129
135
  findings.push('daemon/background combination requires an explicitly pinned until-revoked delegate policy.');
130
136
  }
@@ -135,10 +141,10 @@ export async function inspectSpk(spkPath, expectedKind) {
135
141
  const icon = inspectPackageIcons(dir, findings);
136
142
  const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
137
143
  if (standalone && !cgiEntry) {
138
- findings.push(`payload 沒有 ${CGI_PAYLOAD_PATH}`);
144
+ findings.push(`the payload has no ${CGI_PAYLOAD_PATH}`);
139
145
  }
140
146
  else if (cgiEntry && cgiEntry.mode !== "0755") {
141
- findings.push(`${CGI_PAYLOAD_PATH} 的 mode 是 ${cgiEntry.mode},應為 0755`);
147
+ findings.push(`${CGI_PAYLOAD_PATH} has mode ${cgiEntry.mode}; it should be 0755`);
142
148
  }
143
149
  return {
144
150
  kind,
@@ -176,7 +182,7 @@ async function checkEntryLauncher(dir, pkg, entry, findings) {
176
182
  const expected = entryLauncherAccess(entry);
177
183
  const fields = ["allUsers", "grantPrivilege", "advanceGrantPrivilege"];
178
184
  if (!launcher || fields.some(field => launcher[field] !== expected[field])) {
179
- findings.push(`ENTRY_LAUNCHER_MISMATCH sdk-package.json 宣告 entry: ${entry},但 ui/config 的 launcher 不是 ${JSON.stringify(expected)};請用 synopkgland build 重新產生`);
185
+ findings.push(`ENTRY_LAUNCHER_MISMATCH sdk-package.json declares entry: ${entry}, but the ui/config launcher is not ${JSON.stringify(expected)}; regenerate it with synopkgland build`);
180
186
  }
181
187
  }
182
188
  /** Package Center shows PACKAGE_ICON at 64 and 256 px; the package source refuses a release whose icon is over 256 KiB. */
@@ -186,16 +192,16 @@ function inspectPackageIcons(dir, findings) {
186
192
  if (!bytes)
187
193
  continue;
188
194
  if (bytes.length > MAX_ICON_BYTES) {
189
- findings.push(`${name} 有 ${bytes.length} bytes,超過 package source 的 256 KiB 上限;上架後整個版本會被拒收`);
195
+ findings.push(`${name} is ${bytes.length} bytes, over the package source limit of 256 KiB; publishing rejects the whole version`);
190
196
  continue;
191
197
  }
192
198
  try {
193
199
  const image = decodePng(bytes);
194
200
  if (image.width !== size || image.height !== size)
195
- findings.push(`${name} 是 ${image.width}×${image.height},Package Center 需要 ${size}×${size}`);
201
+ findings.push(`${name} is ${image.width}x${image.height}; Package Center needs ${size}x${size}`);
196
202
  }
197
203
  catch (error) {
198
- findings.push(`${name}:${error.message}`);
204
+ findings.push(`${name}: ${error.message}`);
199
205
  }
200
206
  }
201
207
  const big = readOptionalBytes(join(dir, "PACKAGE_ICON_256.PNG"));
@@ -212,20 +218,20 @@ const OS_MIN_VER = /^7\.\d+(\.\d+)?-\d+$/;
212
218
  /** INFO os_min_ver and arch must be values DSM accepts, and arch must match the shipped CGI. */
213
219
  async function checkArch(dir, info, kind, hasCgi, findings) {
214
220
  if (info.os_min_ver && !OS_MIN_VER.test(info.os_min_ver)) {
215
- findings.push(`INFO os_min_ver「${info.os_min_ver}」不是 DSM 7 的 <版本>-<build>(例如 7.1-42661)`);
221
+ findings.push(`INFO os_min_ver "${info.os_min_ver}" is not the DSM 7 <version>-<build> (for example 7.1-42661)`);
216
222
  }
217
223
  else if (kind === "container" && info.os_min_ver && !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)) {
218
- findings.push(`INFO os_min_ver「${info.os_min_ver}」低於容器 App 需要的 DSM ${CONTAINER_MIN_DSM};docker-project/Container Manager 從 DSM 7.2.1 才有,在更早的 DSM 會安裝但無法啟動`);
224
+ findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${CONTAINER_MIN_DSM} a container App needs; docker-project/Container Manager starts at DSM 7.2.1, so on an earlier DSM it installs but cannot start`);
219
225
  }
220
226
  const tokens = (info.arch ?? "").split(" ").filter(Boolean);
221
227
  if (tokens.length === 0 || tokens.includes("noarch"))
222
228
  return;
223
229
  for (const token of tokens.filter(token => !familyOfArchToken(token))) {
224
- findings.push(`INFO arch 含未知的 platform「${token}」`);
230
+ findings.push(`INFO arch contains an unknown platform "${token}"`);
225
231
  }
226
232
  const families = new Set(tokens.map(familyOfArchToken).filter(family => family !== undefined));
227
233
  if (families.size > 1) {
228
- findings.push(`INFO arch 混用多個架構(${[...families].join("、")});一個 SPK 只含一種架構的 binary`);
234
+ findings.push(`INFO arch mixes several architectures (${[...families].join(", ")}); an SPK contains binaries for one architecture only`);
229
235
  }
230
236
  if (kind === "container" || !hasCgi || families.size !== 1)
231
237
  return;
@@ -238,16 +244,16 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
238
244
  const machine = header.length === 20 && header.toString("latin1", 0, 4) === "\x7fELF" ? header.readUInt16LE(18) : undefined;
239
245
  const actual = NATIVE_ARCH_FAMILIES.find(family => ELF_MACHINE[family] === machine);
240
246
  if (actual && actual !== declared) {
241
- findings.push(`INFO arch 宣告 ${declared},但 ${CGI_PAYLOAD_PATH} 是 ${actual} 的執行檔`);
247
+ findings.push(`INFO arch declares ${declared}, but ${CGI_PAYLOAD_PATH} is a ${actual} executable`);
242
248
  }
243
249
  }
244
250
  async function checkDaemon(dir, info, paths, payload, findings, executor, background) {
245
251
  if (![...paths].some(path => path.startsWith("daemon/")))
246
252
  return;
247
253
  if (info.precheckstartstop !== "yes")
248
- findings.push('daemon INFO 必須明確設定 precheckstartstop="yes"');
254
+ findings.push("daemon INFO must explicitly set precheckstartstop=\"yes\"");
249
255
  if (payload.find(p => p.path === "daemon/control")?.mode !== "0755")
250
- findings.push("daemon/control 缺少或非 executable");
256
+ findings.push("daemon/control is missing or not executable");
251
257
  const unpacked = join(dir, "daemon-inspection");
252
258
  // Extract only the bounded config; the payload executable is never run.
253
259
  const { mkdirSync } = await import("node:fs");
@@ -271,17 +277,17 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
271
277
  throw new Error("non-canonical daemon config");
272
278
  for (const hook of ["start-stop-status", "preupgrade", "preuninst"]) {
273
279
  if (readFileSync(join(dir, "scripts", hook), "utf8") !== renderStandaloneScript(hook, manifest))
274
- findings.push(`daemon scripts/${hook} 與固定 lifecycle 不符`);
280
+ findings.push(`daemon scripts/${hook} does not match the fixed lifecycle`);
275
281
  }
276
282
  return daemon;
277
283
  }
278
284
  catch {
279
- findings.push("daemon/config.json 或 payload 不符合固定 daemon 契約");
285
+ findings.push("daemon/config.json or the payload does not match the fixed daemon contract");
280
286
  }
281
287
  }
282
288
  function readInfo(path, findings, kind) {
283
289
  if (!existsSync(path)) {
284
- findings.push("缺少 INFO");
290
+ findings.push("INFO is missing");
285
291
  return {};
286
292
  }
287
293
  const info = {};
@@ -293,14 +299,16 @@ function readInfo(path, findings, kind) {
293
299
  }
294
300
  for (const key of ["package", "version", "arch", "os_min_ver"]) {
295
301
  if (!info[key]) {
296
- findings.push(`INFO 缺少 ${key}`);
302
+ findings.push(`INFO is missing ${key}`);
297
303
  }
298
304
  }
299
305
  if (kind !== "container" && (info.thirdparty !== "yes" || info.dsmuidir !== "ui")) {
300
- findings.push('INFO 必須有 thirdparty="yes" 與 dsmuidir="ui",DSM 才會提供 /webman/3rdparty/<package>/');
306
+ findings.push("INFO must have thirdparty=\"yes\" and dsmuidir=\"ui\" for DSM to serve /webman/3rdparty/<package>/");
301
307
  }
302
- if (info.install_dep_packages && !(kind === "container" && info.install_dep_packages.split(":").every(dep => /^(WebStation|ContainerManager)([><=].*)?$/.test(dep)))) {
303
- findings.push(`INFO 依賴其他套件:${info.install_dep_packages}`);
308
+ // A container needs Container Manager (and Web Station for a portal); a Node App the Node.js it runs on.
309
+ const allowed = kind === "container" ? /^(WebStation|ContainerManager)([><=].*)?$/ : kind === "native-node" ? /^Node\.js_v[0-9]{2}([><=].*)?$/ : undefined;
310
+ if (info.install_dep_packages && !(allowed && info.install_dep_packages.split(":").every(dep => allowed.test(dep)))) {
311
+ findings.push(`INFO depends on other packages: ${info.install_dep_packages}`);
304
312
  }
305
313
  return info;
306
314
  }
@@ -311,11 +319,11 @@ async function checkContainerExposure(dir, packageTgz, findings) {
311
319
  await tar.x({ file: packageTgz, cwd: extracted, filter: path => path.replace(/^\.\//, "") === "compose.yaml" });
312
320
  const compose = loadCompose(join(extracted, "compose.yaml"))?.compose;
313
321
  for (const exposure of compose ? listPortExposures(compose).filter(e => !e.loopbackOnly) : []) {
314
- findings.push(`CONTAINER_PORT_EXPOSED ${exposure.service}: ${exposure.entry} 綁在所有網路介面,可不經 DSM 驗證直接連線;請改綁 127.0.0.1`);
322
+ findings.push(`CONTAINER_PORT_EXPOSED ${exposure.service}: ${exposure.entry} binds to every network interface, so it is reachable without DSM verification; bind it to 127.0.0.1`);
315
323
  }
316
324
  try {
317
325
  if (JSON.parse(readFileSync(join(dir, "conf", "resource"), "utf8")).webservice) {
318
- findings.push("CONTAINER_ANONYMOUS_PORTAL conf/resource 註冊了 Web Station portal,任何人不需登入 DSM 即可開啟(integration.web.access: public)");
326
+ findings.push("CONTAINER_ANONYMOUS_PORTAL conf/resource registers a Web Station portal that anyone can open without signing in to DSM (integration.web.access: public)");
319
327
  }
320
328
  }
321
329
  catch { /* checkConf already reports an unreadable conf/resource */ }
@@ -328,29 +336,29 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal) {
328
336
  const entries = existsSync(confDir) ? readdirSync(confDir) : [];
329
337
  const nativeResource = kind !== "container" ? renderStandaloneResource({ package: pkg ?? "", capabilities: background ? { jobs: { worker: "sha256-v1" } } : {}, ...(sharedFolder ? { sharedFolder } : {}) }, portal) : undefined;
330
338
  if (nativeResource !== undefined && readOptional(join(confDir, "resource")) !== nativeResource) {
331
- findings.push(`conf/resource 必須是 ${nativeResource}(只有官方 data-share、systemd-user-unit 與 web-config,且與 install.sharedFolder、portal 宣告一致)`);
339
+ findings.push(`conf/resource must be ${nativeResource} (only the official data-share, systemd-user-unit and web-config, and consistent with the install.sharedFolder and portal declarations)`);
332
340
  }
333
341
  for (const name of entries) {
334
342
  if (name !== "privilege" && !(kind === "container" && name === "resource") && !(nativeResource !== undefined && name === "resource") && !(background && name === "systemd")) {
335
- findings.push(`conf/${name} 會向 DSM 註冊額外資源;Standalone Package 不產生它`);
343
+ findings.push(`conf/${name} registers an extra resource with DSM; a Standalone Package does not produce it`);
336
344
  }
337
345
  }
338
346
  if (kind === "container") {
339
347
  try {
340
348
  const resource = JSON.parse(readFileSync(join(confDir, "resource"), "utf8"));
341
349
  if (!resource["docker-project"])
342
- findings.push("container 缺少 docker-project resource");
350
+ findings.push("the container is missing the docker-project resource");
343
351
  for (const key of Object.keys(resource)) {
344
352
  if (!["docker-project", "webservice"].includes(key))
345
- findings.push(`未知 container resource: ${key}`);
353
+ findings.push(`Unknown container resource: ${key}`);
346
354
  }
347
355
  }
348
356
  catch {
349
- findings.push("container 缺少有效的 conf/resource");
357
+ findings.push("the container is missing a valid conf/resource");
350
358
  }
351
359
  }
352
360
  if (!entries.includes("privilege")) {
353
- findings.push("缺少 conf/privilege(DSM 7 必須以 package 帳號執行)");
361
+ findings.push("conf/privilege is missing (DSM 7 must run as the package account)");
354
362
  return;
355
363
  }
356
364
  let privilege;
@@ -358,36 +366,36 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal) {
358
366
  privilege = JSON.parse(readFileSync(join(confDir, "privilege"), "utf8"));
359
367
  }
360
368
  catch {
361
- findings.push("conf/privilege 不是 JSON");
369
+ findings.push("conf/privilege is not JSON");
362
370
  return;
363
371
  }
364
372
  const defaults = privilege.defaults;
365
373
  if (defaults?.["run-as"] !== "package") {
366
- findings.push('conf/privilege 的 defaults.run-as 必須是 "package"');
374
+ findings.push("conf/privilege defaults.run-as must be \"package\"");
367
375
  }
368
376
  for (const key of ["tool", "ctrl-script", "executable", "join-groupname"]) {
369
377
  if (key in privilege) {
370
- findings.push(`conf/privilege 要求 ${key}(root 或額外群組權限)`);
378
+ findings.push(`conf/privilege requires ${key} (root or extra group privileges)`);
371
379
  }
372
380
  }
373
381
  }
374
382
  /** The opt-in package user unit must be exactly what the generator writes. */
375
- function checkBackground(dir, info, payload, findings, executor, daemon) {
383
+ function checkBackground(dir, info, payload, findings, executor, daemon, bridge = false) {
376
384
  const pkg = info.package;
377
385
  if (!pkg)
378
386
  return;
379
387
  const read = (path) => existsSync(join(dir, path)) ? readFileSync(join(dir, path), "utf8") : undefined;
380
388
  if (payload.find(p => p.path === BACKGROUND_WORKER_PAYLOAD)?.mode !== "0755")
381
- findings.push(`${BACKGROUND_WORKER_PAYLOAD} 缺少或非 executable`);
389
+ findings.push(`${BACKGROUND_WORKER_PAYLOAD} is missing or not executable`);
382
390
  if (info.precheckstartstop !== "yes")
383
- findings.push('background INFO 必須明確設定 precheckstartstop="yes"(prestart 復原失去 socket 的套件使用者 manager)');
391
+ findings.push("background INFO must explicitly set precheckstartstop=\"yes\" (prestart recovers the package user manager that lost its socket)");
384
392
  const units = existsSync(join(dir, BACKGROUND_UNIT_DIR)) ? readdirSync(join(dir, BACKGROUND_UNIT_DIR)) : [];
385
393
  const unit = backgroundUnitName(pkg);
386
- if (units.length !== 1 || units[0] !== unit || read(`${BACKGROUND_UNIT_DIR}/${unit}`) !== renderBackgroundUnit(pkg)) {
387
- findings.push(`${BACKGROUND_UNIT_DIR} 必須只有固定的 ${unit}(套件帳號執行,無 User=、無 root)`);
394
+ if (units.length !== 1 || units[0] !== unit || read(`${BACKGROUND_UNIT_DIR}/${unit}`) !== renderBackgroundUnit(pkg, bridge ? BRIDGE_WORKER_ARGS : [])) {
395
+ findings.push(`${BACKGROUND_UNIT_DIR} must contain only the fixed ${unit} (runs as the package account, no User=, no root)`);
388
396
  }
389
397
  if (read("scripts/start-stop-status") !== renderStandaloneScript("start-stop-status", { package: pkg, ...(daemon ? { daemon } : {}), capabilities: { jobs: { worker: "sha256-v1" }, ...(executor ? { rootOps: true } : {}) } }))
390
- findings.push("background scripts/start-stop-status 與固定 synosystemctl lifecycle 不符");
398
+ findings.push("background scripts/start-stop-status does not match the fixed synosystemctl lifecycle");
391
399
  }
392
400
  /**
393
401
  * `install.sharedFolder` is recovered from the postinst marker. The install
@@ -400,18 +408,18 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal,
400
408
  const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : []).filter(name => /^(install|upgrade)_uifile(_|\.|$)/.test(name)).sort();
401
409
  const postinst = readOptional(join(dir, "scripts/postinst"));
402
410
  if (!folder && postinst?.includes(SHARED_FOLDER_MARKER))
403
- findings.push("scripts/postinst 的 install.sharedFolder 宣告無效");
411
+ findings.push("the install.sharedFolder declaration in scripts/postinst is invalid");
404
412
  if (!pkg)
405
413
  return;
406
414
  const wizards = expectedWizardFiles(pkg, folder, selected, portal);
407
- const source = selected ? "install-selected policy" : folder ? "install.sharedFolder 宣告" : portal ? "portal 宣告" : "native-go 預設安裝精靈";
415
+ const source = selected ? "the install-selected policy" : folder ? "the install.sharedFolder declaration" : portal ? "the portal declaration" : "the native-go default install wizard";
408
416
  // The default native wizard is optional; a declared shared folder or portal needs all of its files.
409
417
  for (const name of folder || selected || portal ? Object.keys(wizards) : present.filter(n => n in wizards)) {
410
418
  if (readOptional(join(wizardDir, name)) !== wizards[name])
411
- findings.push(`WIZARD_UIFILES/${name} 與${source}不符`);
419
+ findings.push(`WIZARD_UIFILES/${name} does not match ${source}`);
412
420
  }
413
421
  for (const name of present.filter(n => !(n in wizards)))
414
- findings.push(`WIZARD_UIFILES/${name} 不是${source}產生的精靈檔`);
422
+ findings.push(`WIZARD_UIFILES/${name} is not a wizard file produced by ${source}`);
415
423
  if (selected) {
416
424
  for (const hook of ['preinst', 'postinst']) {
417
425
  if (!readOptional(join(dir, 'scripts', hook))?.includes(renderDelegateInstallDraft(pkg, hook === 'preinst')))
@@ -422,7 +430,7 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal,
422
430
  return;
423
431
  for (const hook of ["postinst", "postupgrade"]) {
424
432
  if (readOptional(join(dir, "scripts", hook)) !== renderStandaloneScript(hook, { package: pkg, sharedFolder: folder, ...(selected ? { capabilities: { delegate: { scope: "install-selected" } } } : executor ? { capabilities: { rootOps: true } } : {}), ...portalManifest(portal) })) {
425
- findings.push(`scripts/${hook} 與 install.sharedFolder 宣告的固定腳本不符`);
433
+ findings.push(`scripts/${hook} does not match the fixed script declared by install.sharedFolder`);
426
434
  }
427
435
  }
428
436
  }
@@ -463,18 +471,18 @@ async function recoverPortal(dir, info, appId, paths, payload, findings) {
463
471
  return portal;
464
472
  }
465
473
  catch {
466
- findings.push("PORTAL_INVALID etc/portal.json、etc/portal.mustache 或 lifecycle 不符合固定 Application Portal 契約(應開啟套件自己的 DSM 視窗)");
474
+ findings.push("PORTAL_INVALID etc/portal.json, etc/portal.mustache or the lifecycle does not match the fixed Application Portal contract (it should open the package's own DSM window)");
467
475
  }
468
476
  }
469
477
  function checkScripts(dir, findings) {
470
478
  const scriptsDir = join(dir, "scripts");
471
479
  if (!existsSync(join(scriptsDir, "start-stop-status"))) {
472
- findings.push("缺少 scripts/start-stop-status");
480
+ findings.push("scripts/start-stop-status is missing");
473
481
  }
474
482
  for (const name of existsSync(scriptsDir) ? readdirSync(scriptsDir) : []) {
475
483
  const body = readFileSync(join(scriptsDir, name), "utf8");
476
484
  if (ROOT_SCRIPT_HINTS.some((pattern) => pattern.test(body))) {
477
- findings.push(`scripts/${name} 嘗試取得 root 權限(sudo、chown root 或 setuid)`);
485
+ findings.push(`scripts/${name} tries to gain root (sudo, chown root or setuid)`);
478
486
  }
479
487
  }
480
488
  }
@@ -515,21 +523,21 @@ function checkUninstall(dir, findings) {
515
523
  const deletes = DELETE_COMMAND.test(body);
516
524
  if (deletes) {
517
525
  if (!UPGRADE_GUARD.test(body))
518
- findings.push('UNINSTALL_NO_UPGRADE_GUARD scripts/postuninst 刪除資料但沒有 [ "$SYNOPKG_PKG_STATUS" != UPGRADE ] 防護,升級時會誤刪');
526
+ findings.push("UNINSTALL_NO_UPGRADE_GUARD scripts/postuninst deletes data without the [ \"$SYNOPKG_PKG_STATUS\" != UPGRADE ] guard, so an upgrade deletes it by mistake");
519
527
  if (!/is_package_area\(\)\s*\{/.test(body) || !/if ! is_package_area "\$real"/.test(body))
520
- findings.push("UNINSTALL_UNSAFE_DELETE scripts/postuninst 刪除的路徑沒有驗證落在 /volumeN/@appdata|@apphome|@appconf|@appstore/<package>;變數為空或指向別處時會刪錯資料");
528
+ findings.push("UNINSTALL_UNSAFE_DELETE scripts/postuninst does not verify the deleted paths are under /volumeN/@appdata|@apphome|@appconf|@appstore/<package>; an empty or redirected variable deletes the wrong data");
521
529
  for (const line of body.split("\n").filter(l => DELETE_COMMAND.test(l))) {
522
530
  for (const path of line.match(/\/volume\w*\/(?!@app(data|home|conf|store)\/)[^\s"'$;]*/g) ?? [])
523
- findings.push(`UNINSTALL_UNSAFE_DELETE scripts/postuninst 刪除 ${path},不在套件自己的 @app* 目錄內`);
531
+ findings.push(`UNINSTALL_UNSAFE_DELETE scripts/postuninst deletes ${path}, which is not inside the package's own @app* directory`);
524
532
  }
525
533
  if (!boxes.some(box => box.name === "uninstall_uifile"))
526
- findings.push("UNINSTALL_WIZARD_MISSING postuninst 會刪除資料,但 WIZARD_UIFILES/uninstall_uifile 沒有 pkgwizard_remove_additional 勾選框,使用者無法選擇");
534
+ findings.push("UNINSTALL_WIZARD_MISSING postuninst deletes data, but WIZARD_UIFILES/uninstall_uifile has no pkgwizard_remove_additional checkbox, so the user cannot choose");
527
535
  }
528
536
  for (const box of boxes) {
529
537
  if (box.defaultValue !== false)
530
- findings.push(`UNINSTALL_WIZARD_DEFAULT_CHECKED WIZARD_UIFILES/${box.name} 的 ${box.key} 預設必須不勾(保留資料)`);
538
+ findings.push(`UNINSTALL_WIZARD_DEFAULT_CHECKED ${box.key} in WIZARD_UIFILES/${box.name} must be unchecked by default (keep the data)`);
531
539
  if (!body.includes(box.key))
532
- findings.push(`UNINSTALL_WIZARD_NOT_HONOURED WIZARD_UIFILES/${box.name} 提供 ${box.key} 勾選框,但 scripts/postuninst 沒有讀它`);
540
+ findings.push(`UNINSTALL_WIZARD_NOT_HONOURED WIZARD_UIFILES/${box.name} provides the ${box.key} checkbox, but scripts/postuninst does not read it`);
533
541
  }
534
542
  }
535
543
  /** The App's delete-state box keeps the state by default, and preuninst hands its answer to the App hook. */
@@ -538,14 +546,14 @@ function checkStateBoxes(dir, boxes, findings) {
538
546
  const body = existsSync(script) ? readFileSync(script, "utf8") : "";
539
547
  for (const box of boxes) {
540
548
  if (box.defaultValue !== false)
541
- findings.push(`UNINSTALL_STATE_DEFAULT_CHECKED WIZARD_UIFILES/${box.name} 的 ${box.key} 預設必須不勾(保留 App 狀態)`);
549
+ findings.push(`UNINSTALL_STATE_DEFAULT_CHECKED ${box.key} in WIZARD_UIFILES/${box.name} must be unchecked by default (keep the App state)`);
542
550
  if (!body.includes(box.key))
543
- findings.push(`UNINSTALL_STATE_NOT_HONOURED WIZARD_UIFILES/${box.name} 提供 ${box.key} 勾選框,但 scripts/preuninst 沒有讀它`);
551
+ findings.push(`UNINSTALL_STATE_NOT_HONOURED WIZARD_UIFILES/${box.name} provides the ${box.key} checkbox, but scripts/preuninst does not read it`);
544
552
  }
545
553
  }
546
554
  export async function listPayload(packageTgz, findings) {
547
555
  if (!existsSync(packageTgz)) {
548
- findings.push("缺少 package.tgz");
556
+ findings.push("package.tgz is missing");
549
557
  return [];
550
558
  }
551
559
  const payload = [];
@@ -555,16 +563,16 @@ export async function listPayload(packageTgz, findings) {
555
563
  const path = entry.path.replace(/^\.\//, "");
556
564
  const mode = (entry.mode ?? 0) & 0o7777;
557
565
  if (entry.type === "SymbolicLink" || entry.type === "Link") {
558
- findings.push(`payload 含連結:${path}`);
566
+ findings.push(`payload contains a link: ${path}`);
559
567
  }
560
568
  if (path.startsWith("/") || path.split("/").includes("..")) {
561
- findings.push(`payload 路徑跳出套件目錄:${path}`);
569
+ findings.push(`payload path escapes the package directory: ${path}`);
562
570
  }
563
571
  if (mode & 0o6000) {
564
- findings.push(`payload 含 setuid/setgid 檔案:${path}`);
572
+ findings.push(`payload contains a setuid/setgid file: ${path}`);
565
573
  }
566
574
  if (mode & 0o002) {
567
- findings.push(`payload 含所有人可寫的檔案:${path}`);
575
+ findings.push(`payload contains a world-writable file: ${path}`);
568
576
  }
569
577
  if (entry.type === "File") {
570
578
  payload.push({ path, mode: mode.toString(8).padStart(4, "0") });
@@ -47,7 +47,7 @@ function renderStandaloneScriptBody(name, manifest) {
47
47
  }
48
48
  if (name === "preuninst")
49
49
  return renderExecutorPreuninst(manifest, workerStop, stopWorker, workerResult, request);
50
- return `#!/bin/sh\n${workerStop}${stopWorker}${request("stop")}${manifest.daemon ? renderDaemonScript(name, manifest).replace("#!/bin/sh\n", "") : workerResult}`;
50
+ return `#!/bin/sh\n${renderExecutorUpgradeStop(manifest.package)}${workerStop}${stopWorker}${UPGRADE_STOP} || exit $?\n${manifest.daemon ? renderDaemonScript(name, manifest).replace("#!/bin/sh\n", "") : workerResult}`;
51
51
  }
52
52
  if (name === "postuninst")
53
53
  return renderPackagePostuninst(manifest.package);
@@ -85,11 +85,11 @@ function renderDelegatedDaemonScript(name, manifest) {
85
85
  const worker = (action) => `/usr/syno/bin/synosystemctl ${action} ${backgroundUnitName(pkg)}`;
86
86
  // Try every owner even on failure; never leave protected ingress open merely
87
87
  // because a producer could not stop. A partial teardown remains a failure.
88
- const stop = (daemonAction, executorAction) => `result=0\nstop_background_worker || result=1\n${daemon} ${daemonAction} ${paths} || result=1\n${executor(executorAction)} || result=1\nexit "$result"\n`;
88
+ const stop = (daemonAction, executorAction, executorStop = executor(executorAction)) => `result=0\nstop_background_worker || result=1\n${daemon} ${daemonAction} ${paths} || result=1\n${executorStop} || result=1\nexit "$result"\n`;
89
89
  const workerStop = renderBackgroundWorkerStop(pkg);
90
90
  if (name !== 'start-stop-status') {
91
91
  if (name === 'preupgrade')
92
- return `#!/bin/sh\n${workerStop}${stop('stop', 'stop')}`;
92
+ return `#!/bin/sh\n${renderExecutorUpgradeStop(pkg)}${workerStop}${stop('stop', 'stop', UPGRADE_STOP)}`;
93
93
  const purge = manifest.uninstall
94
94
  ? stop(manifest.daemon?.uninstall_args ? 'uninstall' : 'stop', 'purge $synopkgland_purge_args')
95
95
  .replace(executor('purge $synopkgland_purge_args'), () => `synopkgland_uninstall_hook\n${executor('purge $synopkgland_purge_args')}`)
@@ -128,6 +128,25 @@ ${stop('stop', 'stop')} ;;
128
128
  esac
129
129
  `;
130
130
  }
131
+ const UPGRADE_STOP = "synopkgland_executor_upgrade_stop";
132
+ /**
133
+ * preupgrade runs the outgoing package's helper: `stop --upgrade` lets the
134
+ * policy's stop step prepare the upgrade, and a helper from an SDK without
135
+ * the flag gets the plain stop it knows. Any other failure fails the hook.
136
+ */
137
+ function renderExecutorUpgradeStop(pkg) {
138
+ const helper = `"\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}/executor/lifecycle"`;
139
+ return `${UPGRADE_STOP}() {
140
+ synopkgland_stop_error=$(${helper} stop --package ${pkg} --upgrade 2>&1 >/dev/null) && return 0
141
+ synopkgland_stop_status=$?
142
+ case "$synopkgland_stop_error" in
143
+ *"flag provided but not defined: -upgrade"*) ${helper} stop --package ${pkg}; return $? ;;
144
+ esac
145
+ printf '%s\\n' "$synopkgland_stop_error" >&2
146
+ return "$synopkgland_stop_status"
147
+ }
148
+ `;
149
+ }
131
150
  function renderDaemonScript(name, manifest) {
132
151
  const pkg = manifest.package;
133
152
  const control = `"/var/packages/${pkg}/target/daemon/control"`;