@synopackageland/cli 0.7.2 → 0.8.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 (47) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +1 -1
  3. package/dist/build-spk/gateway.d.ts +2 -2
  4. package/dist/build-spk/gateway.js +2 -2
  5. package/dist/build-spk/generator.d.ts +1 -0
  6. package/dist/build-spk/generator.js +2 -2
  7. package/dist/build-spk/ui-texts.d.ts +3 -0
  8. package/dist/build-spk/ui-texts.js +7 -3
  9. package/dist/cli.js +107 -43
  10. package/dist/contract/web-launch.js +1 -4
  11. package/dist/error-registry.js +6 -1
  12. package/dist/reproducible-config.d.ts +8 -0
  13. package/dist/reproducible-config.js +32 -0
  14. package/dist/reproducible.d.ts +38 -0
  15. package/dist/reproducible.js +121 -0
  16. package/dist/runtime/executor-build.d.ts +1 -1
  17. package/dist/runtime/executor-build.js +3 -3
  18. package/dist/spk/check-info.d.ts +5 -0
  19. package/dist/spk/check-info.js +123 -0
  20. package/dist/spk/check-metadata.d.ts +7 -0
  21. package/dist/spk/check-metadata.js +113 -0
  22. package/dist/spk/check-payload.d.ts +2 -0
  23. package/dist/spk/check-payload.js +156 -0
  24. package/dist/spk/dsm-spec.d.ts +42 -0
  25. package/dist/spk/dsm-spec.js +86 -0
  26. package/dist/spk/findings.d.ts +13 -0
  27. package/dist/spk/findings.js +8 -0
  28. package/dist/spk/inspect.d.ts +18 -0
  29. package/dist/spk/inspect.js +62 -0
  30. package/dist/spk/rules.d.ts +265 -0
  31. package/dist/spk/rules.js +53 -0
  32. package/dist/spk/validate-spk.d.ts +13 -0
  33. package/dist/spk/validate-spk.js +107 -0
  34. package/dist/standalone/arch-jobs.d.ts +11 -0
  35. package/dist/standalone/arch-jobs.js +54 -0
  36. package/dist/standalone/build.d.ts +11 -2
  37. package/dist/standalone/build.js +74 -31
  38. package/dist/standalone/daemon.d.ts +1 -1
  39. package/dist/standalone/daemon.js +2 -2
  40. package/dist/standalone/dsm-window.js +4 -5
  41. package/dist/standalone/inspect-spk.d.ts +1 -0
  42. package/dist/standalone/inspect-spk.js +1 -1
  43. package/dist/standalone/manifest.js +10 -1
  44. package/dist/standalone/native-layout.d.ts +3 -0
  45. package/dist/standalone/native-layout.js +94 -33
  46. package/package.json +1 -1
  47. package/schema/synology-app.schema.json +9 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,165 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 (2026-10-11)
4
+
5
+ Faster builds, one SPK checker for any package, and a guide for scripts and
6
+ interpreted languages. `validate` is merged into `inspect`.
7
+
8
+ 繁中摘要:toolkit 改用使用者層級共用快取;新增 `build --jobs` 多架構並行與
9
+ `build --verify-reproducible` 可重現檢查(可用依賴指紋略過);`inspect` 對任何
10
+ SPK 檢查 DSM 7 格式,synopkgland 建置的 SPK 另查建置合約;native layout 的
11
+ aarch64 INFO arch 改寫機型清單、拒絕無執行權限的 script、toolkit 可用 GNU tar
12
+ 或 bsdtar 解開;新增腳本/直譯語言打包指南。無圖示的 native layout SPK 現在會
13
+ 因 `SPK_ICON_MISSING` 無法通過 inspect。
14
+
15
+ ### Shared toolkit cache
16
+
17
+ - Native layout toolkit archives now default to one per-user cache shared by
18
+ every project and worktree: `$XDG_CACHE_HOME/synopkgland` or
19
+ `~/.cache/synopkgland` on Linux, `~/Library/Caches/synopkgland` on macOS.
20
+ `SYNOPKGLAND_CACHE_DIR` still overrides it. The project's
21
+ `.synopkgland-cache` folder is no longer used by default; delete it or point
22
+ `SYNOPKGLAND_CACHE_DIR` at it. The private-folder and `sha256` checks are
23
+ unchanged, and concurrent builds may share the cache.
24
+
25
+ ### Parallel architecture builds
26
+
27
+ - `synopkgland build --jobs <n>` builds up to `n` architectures at the same
28
+ time for native-go, binary, native layout and Compose gateway projects. The
29
+ default stays `1`. Output files and names are byte-identical to a sequential
30
+ build; with `n` above 1 each output line is prefixed with its architecture.
31
+ - SPK files now reach the output folder only after every architecture
32
+ succeeds, also with `--jobs 1`. A failed architecture is named in the error,
33
+ for example `[aarch64]`, and no SPK from that build is written. Before, the
34
+ architectures built before the failure stayed in `dist/`.
35
+ - Native layout compile hooks get `SYNOPKGLAND_WORK_DIR`, an empty scratch
36
+ folder per architecture. `--jobs` above 1 requires `layout.payload` to
37
+ contain `{arch}`. Architectures that pin the same toolkit archive download it
38
+ once.
39
+ - New error code `CLI_JOBS_INVALID`.
40
+
41
+ ### Reproducible build check
42
+
43
+ - `synopkgland build --verify-reproducible [changed|always]` keeps this
44
+ build's SPKs as the first round, builds once more into a temporary folder
45
+ with an empty `GOCACHE` after removing the manifest's `reproducible.clean`
46
+ paths, and fails with `BUILD_NOT_REPRODUCIBLE` listing the payload and
47
+ metadata files that differ. Works for every project type.
48
+ - New optional manifest block `reproducible: {inputs, clean}` in
49
+ `dsm-package.yaml` (native-go, binary, native layout) and
50
+ `synology-app.yaml`. The SHA-256 of the `inputs` files, the CLI version and
51
+ native layout toolkit pins form a dependency fingerprint. A passing check
52
+ writes it to `reproducible-fingerprint.json`; `changed` (the default) skips
53
+ the second build while it is unchanged. New error code
54
+ `REPRODUCIBLE_INVALID`.
55
+
56
+ ### `inspect` checks any SPK
57
+
58
+ - `synopkgland inspect <spk>` now checks any SPK against the DSM 7 package
59
+ format, whether `synopkgland` built it or not, without installing it: the
60
+ outer archive, `INFO` fields, formats and `checksum`, path and link safety in
61
+ `package.tgz`, `ui/config` JSON and its `"texts"` declaration, ELF files
62
+ against `arch` (none in `noarch`), lifecycle scripts and their execute bit,
63
+ `conf/privilege` and `conf/resource`, icons, `LICENSE`, wizards and
64
+ `syno_signature.asc`. Every finding has a stable `SPK_*` code listed in the
65
+ errors reference.
66
+ - Only an SPK that `synopkgland` built (top-level `sdk-package.json` or
67
+ `native-layout.json`, or `synology-app.yaml` in the payload) is also checked
68
+ against the build contract. Other SPKs are no longer rejected by it.
69
+ - The output has a DSM format section and, for `synopkgland` SPKs, a
70
+ `synopkgland` contract section. An error in either exits 1 with
71
+ `SPK_INSPECTION_FAILED`; warnings alone exit 0. `--json` keeps every earlier
72
+ field and adds `synopkgland` (boolean) and `dsm: {ok, errors, warnings}`.
73
+ - There is no `validate` command; the DSM rules it had during 0.8.0
74
+ development are part of `inspect`. The project check is `check`.
75
+
76
+ ### Native layout fixes
77
+
78
+ - `arches: [aarch64]` now writes the same `INFO` `arch` as native-go and
79
+ binary (`armada37xx rtd1296 rtd1619 rtd1619b`) instead of `aarch64`, which
80
+ the Developer Guide arch table does not list.
81
+ - `build` refuses a lifecycle or replace script in `layout.metadata` without
82
+ the execute bit with `NATIVE_LAYOUT_SCRIPT_NOT_EXECUTABLE`; run `chmod +x`.
83
+ Before, the SPK was built and DSM could not run the script.
84
+ - `native.toolkits` archives extract with GNU tar and with bsdtar (the macOS
85
+ default). Other tar programs fail with `NATIVE_TOOLKIT_TAR_UNSUPPORTED`.
86
+ Members with absolute or `..` paths are refused.
87
+
88
+ ### Scripts and interpreted languages guide
89
+
90
+ - New guide: package a shell, Node.js, Python, Bun or Deno program by
91
+ compiling it to one executable, bundling the interpreter through
92
+ `native.toolkits`, or running it in a Compose container, with measured sizes,
93
+ architectures and glibc needs, and a hand-packed `noarch` shell SPK. The CLI
94
+ cannot declare `install_dep_packages` for Package Center's Node.js or Python
95
+ packages; the guide says so.
96
+
97
+ ### 繁體中文摘要
98
+
99
+ - `synopkgland build --jobs <n>` 可同時建置多個架構(native-go、binary、native layout、
100
+ Compose gateway),預設仍為 1;產出與依序建置逐位元組相同。所有架構成功後才寫入
101
+ SPK,失敗時錯誤標明架構且不寫入任何 SPK。native layout hook 新增
102
+ `SYNOPKGLAND_WORK_DIR`;`--jobs` 大於 1 時 `layout.payload` 必須含 `{arch}`。
103
+ - `synopkgland build --verify-reproducible` 以這次建置為第一輪、從乾淨狀態再建一次比對,
104
+ 不同時列出 payload 檔案並以 `BUILD_NOT_REPRODUCIBLE` 失敗。manifest 新增
105
+ `reproducible: {inputs, clean}`;依賴指紋未變時略過(記錄在
106
+ `reproducible-fingerprint.json`)。
107
+ - native layout 的 toolkit 快取預設改為每位使用者一份、所有專案與 worktree 共用
108
+ (Linux `$XDG_CACHE_HOME/synopkgland` 或 `~/.cache/synopkgland`,macOS
109
+ `~/Library/Caches/synopkgland`);`SYNOPKGLAND_CACHE_DIR` 仍可覆寫。專案內的
110
+ `.synopkgland-cache` 不再預設使用。
111
+ - `synopkgland inspect <spk>` 改為不安裝、依 DSM 7 套件格式檢查任何 SPK(不限本 CLI 建置),
112
+ 涵蓋外層封存、`INFO` 欄位與 `checksum`、`package.tgz` 路徑與連結安全、`ui/config` 的
113
+ `"texts"`、ELF 與 `arch`(`noarch` 不得有 ELF)、生命週期指令碼與執行權限、`conf/privilege`
114
+ 與 `conf/resource`、圖示、`LICENSE`、精靈與簽章。每個問題有固定 `SPK_*` 代碼,列在錯誤碼參考。
115
+ - 只有 `synopkgland` 建置的 SPK(最上層有 `sdk-package.json` 或 `native-layout.json`,或 payload
116
+ 有 `synology-app.yaml`)另查建置合約;其他 SPK 不再因合約被拒。任一區段有錯誤時以
117
+ `SPK_INSPECTION_FAILED` 結束碼 1 失敗,只有警告時結束碼 0。`--json` 保留原有欄位,新增
118
+ `synopkgland` 與 `dsm: {ok, errors, warnings}`。
119
+ - 沒有 `validate` 指令;0.8.0 開發期間的 DSM 規則已併入 `inspect`。專案檢查請用 `check`。
120
+ - native layout 以 `arches: [aarch64]` 建置時,INFO `arch` 改為與 native-go/binary 相同的
121
+ `armada37xx rtd1296 rtd1619 rtd1619b`。`layout.metadata` 的指令碼沒有執行權限時 `build` 以
122
+ `NATIVE_LAYOUT_SCRIPT_NOT_EXECUTABLE` 拒絕,請 `chmod +x`。`native.toolkits` 可用 GNU tar 與
123
+ bsdtar 解壓,其他 tar 以 `NATIVE_TOOLKIT_TAR_UNSUPPORTED` 失敗。
124
+ - 新增「封裝指令碼與直譯式語言」指南:編譯成單一執行檔、經 `native.toolkits` 附帶直譯器、或以
125
+ Compose 容器執行,附實測大小、架構與 glibc 需求,以及手動打包的 `noarch` shell SPK。CLI 無法宣告
126
+ `install_dep_packages` 依賴套件中心的 Node.js/Python 套件,指南已明確說明。
127
+
128
+ ## 0.7.3 (2026-10-11)
129
+
130
+ Rebuild every package built with 0.7.2 that has `i18n.info` translations. In
131
+ 0.7.2 its DSM main-menu entry, desktop shortcut and window title showed the raw
132
+ key `synopkgland:app_title` in every language.
133
+
134
+ ### Translated DSM title fix
135
+
136
+ - `ui/config` entries that use the `synopkgland:app_title` and `app_desc`
137
+ strings now declare `"texts": "texts"`: the main-menu `app` entry, its window
138
+ `lib` entry and the `.url` entry of a `synology-app.yaml` app. DSM loads
139
+ `ui/texts/<DSM language>/strings` only for entries that declare `texts`, so
140
+ 0.7.2 titles never resolved (seen on DSM 7.4 and 7.1.1). Without `i18n.info`
141
+ `ui/config` is unchanged.
142
+
143
+ ### Correction to 0.7.2
144
+
145
+ - 0.7.2 said `synology-app.yaml` `i18n.info` localizes the DSM window title.
146
+ It localizes the DSM main-menu and desktop label only. `launch: dsm-window`
147
+ adds a main-menu and desktop entry that DSM always opens in a new browser
148
+ tab, as it does for every URL entry; the app has no DSM window and no window
149
+ title. This is not caused by DSM's CSRF protection setting. The container app
150
+ guide and the `synology-app.yaml` reference now say so.
151
+
152
+ ### 繁體中文摘要
153
+
154
+ - 修正 0.7.2 的在地化標題:有 `i18n.info` 翻譯時,`ui/config` 中使用 `synopkgland:` 字串的
155
+ `app`、`lib` 與 `.url` 項目都加上 `"texts": "texts"`。0.7.2 少了這個欄位,DSM 不會載入
156
+ `ui/texts/<語言>/strings`,主選單、桌面捷徑與視窗標題在各語言都顯示 `synopkgland:app_title`。
157
+ - 用 0.7.2 建置且含 `i18n.info` 的套件,請以 0.7.3 重新建置並遞增 build 後重新發佈;沒有
158
+ `i18n.info` 的套件不受影響。
159
+ - 更正 0.7.2:`synology-app.yaml` 的 `i18n.info` 只翻譯 DSM 主選單與桌面標籤,沒有視窗標題。
160
+ `launch: dsm-window` 的項目一律由 DSM 在新的瀏覽器分頁開啟(DSM 對所有網址項目都如此),
161
+ 與 CSRF 防護設定無關;容器 App 指南與 `synology-app.yaml` 參考已更正。
162
+
3
163
  ## 0.7.2 (2026-10-11)
4
164
 
5
165
  Rebuild packages to pick up the new default icon. Packages without `icon` now
package/README.md CHANGED
@@ -64,7 +64,7 @@ Device lifecycle acceptance on real hardware is still pending for delegate and r
64
64
  | `add` | Add capabilities or `portal` to a project |
65
65
  | `check` | Validate the manifest, capability combinations and Compose file |
66
66
  | `build` | Build the SPK for one or more architectures |
67
- | `inspect` | Inspect an SPK without installing it |
67
+ | `inspect` | Check any SPK against the DSM 7 package format without installing it; SPKs built by synopkgland also get the build contract |
68
68
  | `deploy` | Build and install on a NAS |
69
69
  | `status` | Show package state, Open URL and capability readiness |
70
70
  | `logs` | Show package status diagnostics |
@@ -21,11 +21,11 @@ export declare function buildGatewayBinary(input: {
21
21
  config: GatewayConfig;
22
22
  work: string;
23
23
  run?: CommandRunner;
24
- }): {
24
+ }): Promise<{
25
25
  binary: string;
26
26
  } | {
27
27
  error: GatewayErrorCode;
28
28
  detail: string;
29
- };
29
+ }>;
30
30
  /** Puts the CGI (mode 0755) and its readable configuration into the package payload. */
31
31
  export declare function writeGatewayPayload(packageDir: string, binary: string, config: GatewayConfig): void;
@@ -24,10 +24,10 @@ export function gatewayConfig(contract, adminport) {
24
24
  };
25
25
  }
26
26
  /** Compiles the fixed gateway module for `arch`; the result is a static ELF or a coded failure. */
27
- export function buildGatewayBinary(input) {
27
+ export async function buildGatewayBinary(input) {
28
28
  const out = join(input.work, `gateway-${input.arch}.cgi`);
29
29
  const ldflags = ["-s", "-w", "-buildid=", `-X main.packageID=${input.packageId}`, `-X 'main.configJSON=${JSON.stringify(input.config)}'`].join(" ");
30
- const result = (input.run ?? runCommand)("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", out, "."], {
30
+ const result = await (input.run ?? runCommand)("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", out, "."], {
31
31
  cwd: join(templatesRoot(), "_gateway"),
32
32
  env: { ...process.env, ...pinnedGoBuildEnv(input.arch) },
33
33
  });
@@ -20,6 +20,7 @@ export interface BuildSpkResult {
20
20
  errors: string[];
21
21
  }
22
22
  export declare function buildSpk(options: BuildSpkOptions): Promise<BuildSpkResult>;
23
+ export declare function walkFiles(root: string, prefix?: string): string[];
23
24
  export declare function digestSpkPayload(spkPath: string): Promise<string>;
24
25
  export declare function extractSpk(spkPath: string, targetDir: string): Promise<void>;
25
26
  export declare function readInfoPackage(spkDir: string): string | null;
@@ -99,7 +99,7 @@ export async function buildSpk(options) {
99
99
  try {
100
100
  let gatewayBinary;
101
101
  if (gateway) {
102
- const built = buildGatewayBinary({
102
+ const built = await buildGatewayBinary({
103
103
  packageId: String(contract.package),
104
104
  arch: options.archFamily,
105
105
  config: gateway,
@@ -862,7 +862,7 @@ async function createDeterministicSpk(stagingDir, outputPath) {
862
862
  }
863
863
  await createReproducibleTar(stagingDir, outputPath);
864
864
  }
865
- function walkFiles(root, prefix = "") {
865
+ export function walkFiles(root, prefix = "") {
866
866
  const files = [];
867
867
  for (const entry of readdirSync(root).sort()) {
868
868
  const relative = prefix ? `${prefix}/${entry}` : entry;
@@ -9,10 +9,13 @@ export interface AppTitle {
9
9
  * `ui/config` title fields. With translations they are `section:key`
10
10
  * references DSM resolves from `ui/texts/<lang>/strings` for the main menu
11
11
  * and the window title; without, the literal English text as before.
12
+ * DSM loads those strings only for entries that declare `texts`. Spread the
13
+ * result into an entry; absent fields stay out of the JSON.
12
14
  */
13
15
  export declare function uiTitleFields(app: AppTitle): {
14
16
  title: string;
15
17
  desc: string;
18
+ texts?: string;
16
19
  preloadTexts?: string[];
17
20
  };
18
21
  /** `ui/texts/<dsm lang>/strings` for English and every translated language; none without translations. */
@@ -4,6 +4,8 @@ import { DSM_LANGS, LOCALES } from "../standalone/i18n.js";
4
4
  const SECTION = "synopkgland";
5
5
  const TITLE = `${SECTION}:app_title`;
6
6
  const DESC = `${SECTION}:app_desc`;
7
+ /** `ui/` subdirectory with the strings; a `ui/config` entry's `texts` names it relative to `ui/`. */
8
+ const TEXTS_DIR = "texts";
7
9
  function translated(app) {
8
10
  return Object.values(app.info ?? {}).some((text) => text?.displayname || text?.description);
9
11
  }
@@ -11,10 +13,12 @@ function translated(app) {
11
13
  * `ui/config` title fields. With translations they are `section:key`
12
14
  * references DSM resolves from `ui/texts/<lang>/strings` for the main menu
13
15
  * and the window title; without, the literal English text as before.
16
+ * DSM loads those strings only for entries that declare `texts`. Spread the
17
+ * result into an entry; absent fields stay out of the JSON.
14
18
  */
15
19
  export function uiTitleFields(app) {
16
20
  return translated(app)
17
- ? { title: TITLE, desc: DESC, preloadTexts: [TITLE, DESC] }
21
+ ? { title: TITLE, desc: DESC, texts: TEXTS_DIR, preloadTexts: [TITLE, DESC] }
18
22
  : { title: app.displayname, desc: app.description };
19
23
  }
20
24
  /** `ui/texts/<dsm lang>/strings` for English and every translated language; none without translations. */
@@ -34,8 +38,8 @@ export function renderUiTexts(app) {
34
38
  }
35
39
  export function writeUiTexts(uiRoot, app) {
36
40
  for (const [lang, strings] of renderUiTexts(app)) {
37
- mkdirSync(join(uiRoot, "texts", lang), { recursive: true });
38
- const path = join(uiRoot, "texts", lang, "strings");
41
+ mkdirSync(join(uiRoot, TEXTS_DIR, lang), { recursive: true });
42
+ const path = join(uiRoot, TEXTS_DIR, lang, "strings");
39
43
  writeFileSync(path, strings, "utf8");
40
44
  chmodSync(path, 0o644);
41
45
  }
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Command, CommanderError } from "commander";
2
- import { existsSync, readFileSync, renameSync } from "node:fs";
3
- import { resolve, dirname, join } from "node:path";
2
+ import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { basename, resolve, dirname, join } from "node:path";
4
5
  import { findProjectRoot } from "./discovery.js";
5
6
  import { loadContract } from "./contract/load.js";
6
7
  import { contractIconPath } from "./contract/icon.js";
@@ -27,8 +28,10 @@ import { formatSkillList, readSkill, resolveSkillsRoot } from "./skill.js";
27
28
  import { resolveInstallParameterExtraValues } from "./deploy/param-values.js";
28
29
  import { findStandaloneRoot, loadStandaloneManifest, } from "./standalone/manifest.js";
29
30
  import { buildNativeLayout, isNativeLayout, loadNativeLayout, LAYOUT_ARCHES } from "./standalone/native-layout.js";
30
- import { buildStandalone } from "./standalone/build.js";
31
- import { inspectSpk } from "./standalone/inspect-spk.js";
31
+ import { buildStandalone, runnerFor } from "./standalone/build.js";
32
+ import { checkJobs, commitOutputs, forEachArch } from "./standalone/arch-jobs.js";
33
+ import { checkReproducible } from "./reproducible.js";
34
+ import { inspectAnySpk } from "./spk/inspect.js";
32
35
  function failure(code, message, hint = "修正設定後重試;使用 --help 查看指令用法。") {
33
36
  return Object.assign(new Error(`[${code}] ${message}`), { code, hint });
34
37
  }
@@ -61,13 +64,38 @@ function formatIconReport(report, manifestFile) {
61
64
  ...(report.solid ? { warning: "warning:圖示只有單一顏色,Package Center 與 DSM 主選單會像一個色塊;建議換成有圖形的圖示。" } : {}),
62
65
  };
63
66
  }
64
- function formatSpkInspection(spk, report) {
67
+ const LABEL = { error: "錯誤", warning: "警告" };
68
+ /** One line per finding: severity, stable code, where, and what to fix. */
69
+ function formatFindings(findings) {
70
+ return findings.map((f) => `${LABEL[f.severity]} ${f.code}${f.path ? ` ${f.path}` : ""}: ${f.message}`);
71
+ }
72
+ /** The DSM format section, and the build contract section for an SPK synopkgland built. */
73
+ function formatSpkChecks(report) {
74
+ const { errors, warnings } = report.dsm;
75
+ return [
76
+ "DSM 格式:",
77
+ ...formatFindings([...errors, ...warnings]).map((line) => ` ${line}`),
78
+ ` ${report.dsm.ok ? "符合" : "不符合"} DSM 7 套件格式:${errors.length} 個錯誤、${warnings.length} 個警告。`,
79
+ ...(report.synopkgland
80
+ ? [
81
+ "synopkgland 合約:",
82
+ report.findings.length
83
+ ? " 問題:"
84
+ : report.executor
85
+ ? " 檢查:沒有異常。管理員核准後會建立 root executor 排程(boot 與 control task),以 root 執行此 SPK 固定的 executor 與 policy。"
86
+ : " 檢查:沒有 root 權限需求或異常。",
87
+ ...report.findings.map((f) => ` - ${f}`),
88
+ ]
89
+ : []),
90
+ ];
91
+ }
92
+ function formatSpkReport(spk, report) {
65
93
  return [
66
94
  `SPK:${spk}`,
67
95
  `package:${report.package ?? "?"} version:${report.version ?? "?"}`,
68
- `arch:${report.arch ?? "?"} runtime:${report.kind ?? "?"}`,
96
+ report.synopkgland ? `arch:${report.arch ?? "?"} runtime:${report.kind ?? "?"}` : `arch:${report.arch ?? "?"} 建置:不是 synopkgland(只檢查 DSM 格式)`,
69
97
  `簽署:${report.signed ? "有 Synology 簽章" : "未簽署(Package Center 需允許任何發行者)"}`,
70
- `CGI:${report.cgi ?? "無"}`,
98
+ ...(report.synopkgland ? [`CGI:${report.cgi ?? "無"}`] : []),
71
99
  ...(report.icon ? [`圖示:${report.icon.source === "default" ? "SDK 預設圖示" : report.icon.source === "custom" ? `自訂(PACKAGE_ICON_256 sha256 ${report.icon.sha256.slice(0, 12)}…)` : "無 PACKAGE_ICON"}`] : []),
72
100
  ...(report.entry === "admin-only" ? ["入口:entry: admin-only,只有 DSM 管理員能開啟與呼叫任何 route(含 SDK routes)"] : []),
73
101
  ...(report.entry === "app-privilege" ? ["入口:entry: app-privilege,由 DSM 控制台「應用程式權限」決定誰能使用"] : []),
@@ -76,12 +104,7 @@ function formatSpkInspection(spk, report) {
76
104
  "payload:",
77
105
  ...(report.uninstallCleanup ?? []).map(item => `Uninstall cleanup (if needed):${item.target} — ${item.condition} ${item.action}`),
78
106
  ...report.payload.map((e) => ` ${e.mode} ${e.path}`),
79
- report.findings.length
80
- ? "問題:"
81
- : report.executor
82
- ? "檢查:沒有異常。管理員核准後會建立 root executor 排程(boot 與 control task),以 root 執行此 SPK 固定的 executor 與 policy。"
83
- : "檢查:沒有 root 權限需求或異常。",
84
- ...report.findings.map((f) => ` - ${f}`),
107
+ ...formatSpkChecks(report),
85
108
  ].join("\n");
86
109
  }
87
110
  async function standaloneBuild(root, arches, options) {
@@ -93,8 +116,8 @@ async function standaloneBuild(root, arches, options) {
93
116
  ? resolve(options.outDir)
94
117
  : undefined;
95
118
  const result = isNativeLayout(root)
96
- ? await buildNativeLayout({ root, arches, outDir, minDsm: options.minDsm })
97
- : await buildStandalone({ root, arches: arches, outDir, minDsm: options.minDsm });
119
+ ? await buildNativeLayout({ root, arches, outDir, minDsm: options.minDsm, jobs: options.jobs })
120
+ : await buildStandalone({ root, arches: arches, outDir, minDsm: options.minDsm, jobs: options.jobs });
98
121
  return result.outputs.map((output) => {
99
122
  if (!options.output)
100
123
  return output;
@@ -103,35 +126,55 @@ async function standaloneBuild(root, arches, options) {
103
126
  return target;
104
127
  });
105
128
  }
129
+ /** What the reproducible check needs to know about the project being built. */
130
+ function reproducibleTarget(standalone, project, arches) {
131
+ if (!standalone)
132
+ return { root: project.root, manifestPath: project.contractPath, arches: arches ?? [] };
133
+ const manifestPath = join(standalone, "dsm-package.yaml");
134
+ if (!isNativeLayout(standalone)) {
135
+ const loaded = loadStandaloneManifest(standalone);
136
+ return { root: standalone, manifestPath, arches: arches ?? (loaded.ok ? loaded.manifest.arches : []) };
137
+ }
138
+ const loaded = loadNativeLayout(standalone);
139
+ if (!loaded.ok)
140
+ return { root: standalone, manifestPath, arches: arches ?? [] };
141
+ // Toolkit pins decide what the native hooks compile against.
142
+ const toolkits = createHash("sha256").update(JSON.stringify(loaded.manifest.native.toolkits)).digest("hex");
143
+ return { root: standalone, manifestPath, arches: arches ?? loaded.manifest.arches, extraInputs: { "native.toolkits": toolkits } };
144
+ }
145
+ function reproducibleLines(report) {
146
+ if (report.skipped)
147
+ return [`可重現檢查:依賴指紋 ${report.fingerprint.slice(0, 12)} 與上次通過時相同,略過。`];
148
+ return report.results.map((result) => result.identical
149
+ ? `可重現檢查:${basename(result.output)} 相同(sha256 ${result.sha256[0].slice(0, 12)})`
150
+ : `可重現檢查:${basename(result.output)} 不同(sha256 ${result.sha256[0].slice(0, 12)} ≠ ${result.sha256[1].slice(0, 12)})\n` +
151
+ [...result.payload.map((file) => ` payload ${file}`), ...result.metadata.map((file) => ` metadata ${file}`)].join("\n"));
152
+ }
106
153
  const ARCH_REQUIRED = [
107
154
  "NATIVE_ARCH_FAMILY_REQUIRED",
108
155
  "GATEWAY_ARCH_FAMILY_REQUIRED",
109
156
  ];
110
157
  async function containerBuild(project, arches, options) {
158
+ const jobs = checkJobs(options.jobs);
111
159
  const unsupported = arches?.find((arch) => !isContainerArchFamily(arch));
112
160
  if (unsupported)
113
161
  throw failure("CONTAINER_ARCH_UNSUPPORTED", `Container Manager App 只支援 ${CONTAINER_ARCH_FAMILIES.join("、")};DSM 不為 ${unsupported} 提供 Container Manager。`);
114
162
  const output = options.output ? resolve(options.output) : undefined;
115
- const build = (archFamily, outputPath = output) => buildSpk({ project, archFamily, outputPath, minDsm: options.minDsm });
116
- let results = [];
117
- if (arches?.length === 1)
118
- results = [await build(arches[0])];
119
- else {
120
- const first = await build();
121
- if (first.ok || !first.errors.every((code) => ARCH_REQUIRED.includes(code)))
122
- results = [first];
123
- else {
124
- if (output)
125
- throw failure("CLI_OUTPUT_ARCH_REQUIRED", "-o 只能搭配 --arch 建置單一架構。");
126
- const packageId = String(loadContract(project.contractPath).contract.package);
127
- for (const arch of arches ?? CONTAINER_ARCH_FAMILIES)
128
- results.push(await build(arch, join(options.outDir ? resolve(options.outDir) : project.root, `${packageId}-${arch}.spk`)));
129
- }
130
- }
131
- for (const result of results)
132
- if (!result.ok)
163
+ const build = async (archFamily, outputPath = output) => {
164
+ const result = await buildSpk({ project, archFamily, outputPath, minDsm: options.minDsm, gatewayRun: archFamily && runnerFor(undefined, jobs, archFamily) });
165
+ if (!result.ok && !(archFamily === undefined && result.errors.every((code) => ARCH_REQUIRED.includes(code))))
133
166
  throw failure(result.errors[0] ?? "BUILD_FAILED", `建置失敗:${result.errors.join(", ")}`);
134
- return results.map((result) => result.outputPath);
167
+ return result;
168
+ };
169
+ if (arches?.length === 1)
170
+ return [(await build(arches[0])).outputPath];
171
+ const first = await build();
172
+ if (first.ok)
173
+ return [first.outputPath];
174
+ if (output)
175
+ throw failure("CLI_OUTPUT_ARCH_REQUIRED", "-o 只能搭配 --arch 建置單一架構。");
176
+ const packageId = String(loadContract(project.contractPath).contract.package);
177
+ return commitOutputs(options.outDir ? resolve(options.outDir) : project.root, (stage) => forEachArch(arches ?? CONTAINER_ARCH_FAMILIES, jobs, async (arch) => (await build(arch, join(stage, `${packageId}-${arch}.spk`))).outputPath));
135
178
  }
136
179
  export async function runCli(argv) {
137
180
  const json = argv.includes("--json");
@@ -194,14 +237,16 @@ export async function runCli(argv) {
194
237
  });
195
238
  program
196
239
  .command("inspect <spk>")
197
- .description("不安裝,檢查 SPK 身分、檔案與 root 權限需求")
240
+ .description("不安裝,依 DSM 7 套件格式檢查任何 SPK;synopkgland 建置的 SPK 另查建置合約與 root 權限需求")
198
241
  .option("--json", "輸出單一 JSON 物件")
199
242
  .action(async (input) => {
200
243
  const spk = resolve(input);
201
- const report = await inspectSpk(spk);
202
- if (report.findings.length)
203
- throw Object.assign(failure("SPK_INSPECTION_FAILED", report.findings.join("\n"), "修正產物並重新 build;勿安裝檢查未通過的 SPK。"), { report });
204
- emit("inspect", { spk, ...report }, formatSpkInspection(spk, report));
244
+ if (existsSync(spk) && statSync(spk).isDirectory())
245
+ throw failure("SPK_INSPECTION_FAILED", `${spk} 是目錄;inspect 檢查 .spk 檔。`, "檢查專案設定請用 synopkgland check。");
246
+ const report = await inspectAnySpk(spk);
247
+ if (!report.dsm.ok || report.findings.length)
248
+ throw Object.assign(failure("SPK_INSPECTION_FAILED", formatSpkChecks(report).join("\n"), "依錯誤碼修正 SPK 並重新打包,錯誤碼說明見 reference/errors;勿安裝檢查未通過的 SPK。"), { report: { spk, ...report } });
249
+ emit("inspect", { spk, ...report }, formatSpkReport(spk, report));
205
250
  });
206
251
  const images = program.command("images").description("Container image 工具");
207
252
  images
@@ -415,9 +460,14 @@ export async function runCli(argv) {
415
460
  .option("--out-dir <dir>", "輸出目錄,原生專案預設 dist/")
416
461
  .option("--arch <families>", `架構,可用逗號分隔:${NATIVE_ARCH_FAMILIES.join(",")}(armv7 用於 native-go 與 binary,不用於 Container Manager App);native layout 另支援 armv8`)
417
462
  .option("--min-dsm <version>", "最低 DSM 版本,省略時使用 manifest 值")
463
+ .option("--jobs <n>", "同時建置的架構數,預設 1(依序)", Number)
464
+ .option("--verify-reproducible [when]", "從乾淨狀態再建置一次並比對;when:changed(預設,依賴指紋未變時略過)或 always")
418
465
  .action(async (options, command) => {
419
466
  const root = rootOf(command);
420
467
  const standalone = standaloneRoot(root);
468
+ const verify = options.verifyReproducible === true ? "changed" : options.verifyReproducible;
469
+ if (verify !== undefined && verify !== "changed" && verify !== "always")
470
+ throw failure("CLI_OPTION_INVALID", `--verify-reproducible 只接受 changed 或 always,收到 ${verify}。`);
421
471
  let arches;
422
472
  try {
423
473
  arches =
@@ -434,10 +484,24 @@ export async function runCli(argv) {
434
484
  catch (error) {
435
485
  throw failure("CLI_ARCH_INVALID", error instanceof Error ? error.message : String(error));
436
486
  }
437
- const outputs = standalone
438
- ? await standaloneBuild(standalone, arches, options)
439
- : await containerBuild(requireProject(root), arches, options);
440
- emit("build", { outputs }, outputs.map((output) => `已產生 ${output}`).join("\n"));
487
+ const project = standalone ? undefined : requireProject(root);
488
+ const build = (overrides = {}) => standalone
489
+ ? standaloneBuild(standalone, arches, { ...options, ...overrides })
490
+ : containerBuild(project, arches, { ...options, ...overrides });
491
+ const outputs = await build();
492
+ const reproducible = verify === undefined ? undefined : await checkReproducible({
493
+ ...reproducibleTarget(standalone, project, arches),
494
+ outputs,
495
+ mode: verify,
496
+ // The second build writes only to its own folder; a single Compose SPK keeps its name there.
497
+ rebuild: (outDir) => standalone || outputs.length > 1
498
+ ? build({ output: undefined, outDir })
499
+ : build({ output: join(outDir, basename(outputs[0])), outDir: undefined }),
500
+ });
501
+ const lines = [...outputs.map((output) => `已產生 ${output}`), ...(reproducible ? reproducibleLines(reproducible) : [])];
502
+ if (reproducible && !reproducible.identical)
503
+ throw Object.assign(failure("BUILD_NOT_REPRODUCIBLE", lines.join("\n"), "找出 payload 中每次建置都不同的檔案(時間戳、隨機路徑、未鎖定的依賴),修正後重試。"), { report: { outputs, reproducible } });
504
+ emit("build", { outputs, ...(reproducible ? { reproducible } : {}) }, lines.join("\n"));
441
505
  });
442
506
  if (json)
443
507
  console.log = (...args) => console.error(...args);
@@ -141,15 +141,12 @@ export function renderWebStationResource(contract) {
141
141
  }
142
142
  export function renderDsmWindowConfig(contract) {
143
143
  const packageId = String(contract.package);
144
- const { title, desc, preloadTexts } = uiTitleFields(contractAppTitle(contract));
145
144
  return `${JSON.stringify({
146
145
  ".url": {
147
146
  [packageId]: {
148
147
  type: "url",
149
148
  allUsers: true,
150
- title,
151
- desc,
152
- ...(preloadTexts ? { preloadTexts } : {}),
149
+ ...uiTitleFields(contractAppTitle(contract)),
153
150
  icon: "images/app_{0}.png",
154
151
  url: trustedAppEntryPath(contract),
155
152
  },
@@ -659,11 +659,16 @@ ERROR_REGISTRY["CLI_OPTION_INVALID"] = { "code": "CLI_OPTION_INVALID", "descript
659
659
  ERROR_REGISTRY["CLI_OUTPUT_ARCH_REQUIRED"] = { "code": "CLI_OUTPUT_ARCH_REQUIRED", "description": "輸出單一檔案需要指定架構。", "suggestion": "-o 搭配單一 --arch,或使用 --out-dir。" };
660
660
  ERROR_REGISTRY["HOST_REQUIRED"] = { "code": "HOST_REQUIRED", "description": "沒有指定 NAS。", "suggestion": "使用 --host <profile>;deploy 本地檢查可用 --dry-run。" };
661
661
  ERROR_REGISTRY["NAS_CREDENTIALS_MISSING"] = { "code": "NAS_CREDENTIALS_MISSING", "description": "找不到 NAS 憑證。", "suggestion": "設定 synoagentcli profile 或 DSM_URL/DSM_PASSWORD。" };
662
- ERROR_REGISTRY["SPK_INSPECTION_FAILED"] = { "code": "SPK_INSPECTION_FAILED", "description": "SPK 產物檢查失敗。", "suggestion": "修正 findings 並重新 build。" };
662
+ ERROR_REGISTRY["NATIVE_LAYOUT_SCRIPT_NOT_EXECUTABLE"] = { "code": "NATIVE_LAYOUT_SCRIPT_NOT_EXECUTABLE", "description": "native layout metadata 的 scripts/ 生命週期腳本沒有執行權限(inspect 的 SPK_SCRIPT_NOT_EXECUTABLE)。", "suggestion": "在 layout metadata 目錄對該腳本執行 chmod +x 後重新 build。" };
663
+ ERROR_REGISTRY["NATIVE_TOOLKIT_TAR_UNSUPPORTED"] = { "code": "NATIVE_TOOLKIT_TAR_UNSUPPORTED", "description": "解開 native.toolkits 需要 GNU tar 或 bsdtar,但 PATH 上的 tar 兩者都不是。", "suggestion": "讓 PATH 上的 tar 指向 GNU tar 或 bsdtar(macOS 內建 bsdtar、多數 Linux 是 GNU tar)後重新 build。" };
664
+ ERROR_REGISTRY["SPK_INSPECTION_FAILED"] = { "code": "SPK_INSPECTION_FAILED", "description": "SPK 不符合 DSM 7 套件格式(dsm.errors),或 synopkgland 建置的 SPK 不符合建置合約(findings)。", "suggestion": "依 SPK_* 規則碼與 findings 修正後重新打包;warnings 不影響結果。" };
663
665
  ERROR_REGISTRY["IMAGE_PIN_REQUIRED"] = { "code": "IMAGE_PIN_REQUIRED", "description": "映像 digest 尚未固定。", "suggestion": "執行 images pin。" };
664
666
  ERROR_REGISTRY["INIT_TEMPLATE_REQUIRED"] = { "code": "INIT_TEMPLATE_REQUIRED", "description": "未指定模板。", "suggestion": "使用 -t native-go、binary 或 compose。" };
665
667
  ERROR_REGISTRY["INIT_TEMPLATE_UNKNOWN"] = { "code": "INIT_TEMPLATE_UNKNOWN", "description": "未知模板。", "suggestion": "使用 -t native-go、binary 或 compose。" };
666
668
  ERROR_REGISTRY["CLI_ARCH_INVALID"] = { "code": "CLI_ARCH_INVALID", "description": "架構選項不合法。", "suggestion": "使用 x86_64、aarch64 或 armv7(armv7 用於 native-go 與 binary,不用於 Container Manager App)。" };
669
+ ERROR_REGISTRY["CLI_JOBS_INVALID"] = { "code": "CLI_JOBS_INVALID", "description": "--jobs 不是正整數。", "suggestion": "使用 --jobs 1(預設)或更大的整數。" };
670
+ ERROR_REGISTRY["BUILD_NOT_REPRODUCIBLE"] = { "code": "BUILD_NOT_REPRODUCIBLE", "description": "build --verify-reproducible 再建置一次的 SPK 與第一次不同。", "suggestion": "查看列出的 payload 與 metadata 檔案,移除每次建置都會變的內容(時間戳、隨機或絕對路徑、未鎖定的依賴)。" };
671
+ ERROR_REGISTRY["REPRODUCIBLE_INVALID"] = { "code": "REPRODUCIBLE_INVALID", "description": "reproducible 設定格式不對,或列出的 input 不是專案內的檔案。", "suggestion": "使用 reproducible: {inputs: [檔案...], clean: [路徑...]},路徑相對於專案;{arch} 只能用在 clean。" };
667
672
  ERROR_REGISTRY["NATIVE_ARCH_UNDETERMINED"] = { "code": "NATIVE_ARCH_UNDETERMINED", "description": "無法判定 NAS 架構。", "suggestion": "指定 --arch x86_64、aarch64 或 armv7。" };
668
673
  ERROR_REGISTRY["DEVELOPMENT_INSTANCE_UNSUPPORTED"] = { "code": "DEVELOPMENT_INSTANCE_UNSUPPORTED", "description": "原生專案不支援 --dev。", "suggestion": "init 時指定不同 package ID。" };
669
674
  ERROR_REGISTRY["INIT_OPTIONS_UNSUPPORTED"] = { "code": "INIT_OPTIONS_UNSUPPORTED", "description": "選項不適用於這個模板。", "suggestion": "--from 僅 compose;--ui 僅 native-go。" };
@@ -0,0 +1,8 @@
1
+ export interface ReproducibleConfig {
2
+ inputs: string[];
3
+ clean: string[];
4
+ }
5
+ export declare function invalid(message: string): never;
6
+ /** `reproducible: {inputs?: [file...], clean?: [path...]}`; `clean` paths may use `{arch}`. */
7
+ export declare function validateReproducible(value: unknown): ReproducibleConfig;
8
+ export declare function readReproducible(manifestPath: string): ReproducibleConfig;
@@ -0,0 +1,32 @@
1
+ /** The `reproducible` manifest block, shared by dsm-package.yaml and synology-app.yaml. */
2
+ import { readFileSync } from "node:fs";
3
+ import { isAbsolute } from "node:path";
4
+ import { parse } from "yaml";
5
+ export function invalid(message) {
6
+ throw Object.assign(new Error(`[REPRODUCIBLE_INVALID] ${message}`), { code: "REPRODUCIBLE_INVALID" });
7
+ }
8
+ function projectPaths(value, field, arch) {
9
+ if (value === undefined)
10
+ return [];
11
+ if (!Array.isArray(value) || value.length > 64)
12
+ invalid(`reproducible.${field} must be a list of at most 64 project paths.`);
13
+ return value.map((path) => {
14
+ if (typeof path !== "string" || !path || isAbsolute(path) || /[\\\0]/.test(path) || path.split("/").some((part) => ["", ".", ".."].includes(part))
15
+ || (!arch && path.includes("{arch}")) || /[{}]/.test(path.replaceAll("{arch}", ""))) {
16
+ invalid(`reproducible.${field}: ${JSON.stringify(path)} must be a path inside the project${arch ? "" : " without {arch}"}.`);
17
+ }
18
+ return path;
19
+ });
20
+ }
21
+ /** `reproducible: {inputs?: [file...], clean?: [path...]}`; `clean` paths may use `{arch}`. */
22
+ export function validateReproducible(value) {
23
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => key !== "inputs" && key !== "clean")) {
24
+ invalid("reproducible takes only the inputs and clean lists.");
25
+ }
26
+ const raw = value;
27
+ return { inputs: projectPaths(raw.inputs, "inputs", false), clean: projectPaths(raw.clean, "clean", true) };
28
+ }
29
+ export function readReproducible(manifestPath) {
30
+ const raw = parse(readFileSync(manifestPath, "utf8"));
31
+ return raw?.reproducible === undefined ? { inputs: [], clean: [] } : validateReproducible(raw.reproducible);
32
+ }
@@ -0,0 +1,38 @@
1
+ import { type ReproducibleConfig } from "./reproducible-config.js";
2
+ export { readReproducible, validateReproducible, type ReproducibleConfig } from "./reproducible-config.js";
3
+ export interface ReproducibleResult {
4
+ output: string;
5
+ identical: boolean;
6
+ sha256: [string, string];
7
+ payload: string[];
8
+ metadata: string[];
9
+ }
10
+ export interface ReproducibleReport {
11
+ skipped: boolean;
12
+ identical: boolean;
13
+ fingerprint?: string;
14
+ changed?: string[];
15
+ results: ReproducibleResult[];
16
+ }
17
+ export declare const FINGERPRINT_RECORD = "reproducible-fingerprint.json";
18
+ /** SHA-256 of each declared input plus the CLI version and `extra` pins, and one digest over all of them. */
19
+ export declare function dependencyFingerprint(root: string, config: ReproducibleConfig, extra?: Record<string, string>): {
20
+ fingerprint: string;
21
+ inputs: Record<string, string>;
22
+ };
23
+ /**
24
+ * Builds the project again with `rebuild` into a temporary folder, after removing
25
+ * the declared `clean` paths and with an empty GOCACHE, and compares each SPK with
26
+ * `outputs`, the SPKs of this build. With declared inputs, mode `changed` skips
27
+ * while the fingerprint and architectures match the last passing record, and a
28
+ * passing check updates that record.
29
+ */
30
+ export declare function checkReproducible(options: {
31
+ root: string;
32
+ manifestPath: string;
33
+ outputs: string[];
34
+ arches: string[];
35
+ mode: "changed" | "always";
36
+ extraInputs?: Record<string, string>;
37
+ rebuild: (outDir: string) => Promise<string[]>;
38
+ }): Promise<ReproducibleReport>;