@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +174 -1
  2. package/README.md +1 -1
  3. package/dist/add.js +12 -0
  4. package/dist/build-spk/dsm-version.d.ts +0 -2
  5. package/dist/build-spk/dsm-version.js +0 -6
  6. package/dist/build-spk/generator.js +4 -23
  7. package/dist/build-spk/resource-worker.js +0 -3
  8. package/dist/build-spk/uninstall-wizard.d.ts +8 -3
  9. package/dist/build-spk/uninstall-wizard.js +4 -2
  10. package/dist/build-spk/wizard-messages.d.ts +33 -0
  11. package/dist/build-spk/wizard-messages.js +33 -0
  12. package/dist/cli.js +7 -6
  13. package/dist/contract/validate.js +4 -21
  14. package/dist/error-registry.js +11 -12
  15. package/dist/errors.d.ts +2 -0
  16. package/dist/errors.js +5 -0
  17. package/dist/host/dsm-client.js +51 -30
  18. package/dist/host/types.d.ts +5 -0
  19. package/dist/images/pin.js +0 -1
  20. package/dist/info.js +14 -1
  21. package/dist/init.js +1 -2
  22. package/dist/portal/alias.d.ts +6 -4
  23. package/dist/portal/alias.js +6 -11
  24. package/dist/portal/package.d.ts +0 -3
  25. package/dist/portal/package.js +1 -7
  26. package/dist/runtime/executor-digests.json +33 -0
  27. package/dist/runtime/uninstall-cleanup.d.ts +7 -1
  28. package/dist/runtime/uninstall-cleanup.js +7 -1
  29. package/dist/standalone/dsm-window.js +8 -11
  30. package/dist/standalone/inspect-spk.js +11 -10
  31. package/dist/standalone/manifest.js +12 -3
  32. package/dist/standalone/root-window.d.ts +10 -3
  33. package/dist/standalone/root-window.js +81 -41
  34. package/dist/standalone/spk.js +3 -2
  35. package/dist/validate.js +2 -1
  36. package/package.json +3 -3
  37. package/schema/synology-app.schema.json +1 -10
  38. package/skills/SKILL.md +1 -1
  39. package/skills/reference/create.md +7 -2
  40. package/skills/reference/deploy.md +1 -1
  41. package/skills/reference/identity.md +1 -1
  42. package/skills/reference/portal.md +7 -3
package/CHANGELOG.md CHANGED
@@ -1,6 +1,179 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased (0.9.0)
3
+ ## 0.9.2 (2026-10-12)
4
+
5
+ Builds with SDK 0.9.3 and templates 0.9.2: `init` writes SDK v0.9.3 and
6
+ `@synopackageland/sdk` 0.9.3. The executor digest table records SDK 0.9.2 and
7
+ 0.9.3 (the same digests; 0.9.3 changes only translations). An App rebuilt
8
+ with SDK 0.9.2 or 0.9.3 gets the new executor; the policy digest stays the
9
+ same, so an installed App needs no new approval.
10
+
11
+ 繁中摘要:`check` 與 `build` 的錯誤訊息每個錯誤碼只出現一次;`build --json` 的 SDK
12
+ 下載失敗附上 GOPRIVATE 提示;有 executor 並宣告 `install.sharedFolder` 的 App 通過
13
+ `inspect` 的 postinst 檢查。重新安裝後的核准只需輸入一次密碼:共用資料夾讀回等 40 s,
14
+ 沒到達 executor 的選擇任務在同一次核准內再跑一次,啟動後的健康檢查等 30 s。
15
+
16
+ - `check` and `build` name each error code once. 0.9.1 printed the first code
17
+ twice, for example
18
+ `[STANDALONE_PORTAL_REQUIRES_EXECUTOR] [STANDALONE_PORTAL_REQUIRES_EXECUTOR] …`.
19
+ - A `--json` error without its own hint takes the registry suggestion of its
20
+ code. `build --json` now answers `SDK_DOWNLOAD_FAILED` with the GOPRIVATE and
21
+ git-credential step instead of the generic hint.
22
+ - `inspect` accepts an App with `capabilities.rootOps` or `delegate` that
23
+ declares `install.sharedFolder`. Its `postinst` and `postupgrade` start the
24
+ executor before the shared-folder script, and 0.9.1 reported them as not
25
+ matching.
26
+ - Re-approval after a reinstall takes one password submission for both the
27
+ executor and an install-selected share. The DSM window now reads the share
28
+ selection back for 40 s instead of 20 s, runs a selection task that missed
29
+ the restarting executor once more without a new confirmation, and waits
30
+ 30 s instead of 10 s for a started executor before withdrawing the tasks an
31
+ approval created.
32
+
33
+ ## 0.9.1 (2026-10-12)
34
+
35
+ Fixes from the 0.9.0 acceptance on DSM (t-0294, t-0290, t-0292).
36
+
37
+ 繁中摘要:`build` 重複 `--arch` 時每個架構都建置;SDK 下載失敗回報
38
+ `SDK_DOWNLOAD_FAILED`(不再包成 `STANDALONE_API_PERMISSIONS_INVALID`),`check`
39
+ 顯示每個錯誤碼自己的建議;`status` 列出 portal;Vue App 只在 SDK 的
40
+ ExecutorSettings 核准,plain UI 的 DSM 視窗面板改為有樣式、跟隨 DSM 語言並在面板內
41
+ 輸入密碼。更正 0.9.0 的核准說明:policy 不變的升級不需重新核准,只有
42
+ install-selected 的 scope 需要管理員確認一次。記錄 SDK 0.9.1 executor digests(只換
43
+ executor、policy digest 不變,免重新核准)。App 分兩層:有 executor(rootOps 或
44
+ delegate)的 App 解除安裝時自動移除應用程式權限規則,宣告 portal 時再 reload nginx,
45
+ 精靈與 cleanup 清單只把它們列為自動清除失敗時的後備;無升權 App 任何階段都不用 root,
46
+ 應用程式權限規則仍由管理員手動移除。破壞性變更:portal 只限 executor 層級,無升權
47
+ native App 以 `STANDALONE_PORTAL_REQUIRES_EXECUTOR` 拒絕,Compose App 以
48
+ `CONTRACT_PORTAL_REQUIRES_EXECUTOR` 拒絕。並附 0.9.0 安裝殘留的清理步驟。
49
+
50
+ ### Build and check
51
+
52
+ - `build --arch x86_64 --arch aarch64` builds every family named; 0.9.0 built
53
+ only the last one. Repeated values and comma lists combine.
54
+ - An SDK version Go cannot fetch is reported as `SDK_DOWNLOAD_FAILED` with the
55
+ GOPRIVATE and git-credential hint. 0.9.0 wrapped it as
56
+ `STANDALONE_API_PERMISSIONS_INVALID`.
57
+ - `check` shows the registry suggestion of each error code. It no longer
58
+ replaces every `dsm-package.yaml` error's suggestion with "fix the schema 2
59
+ fields".
60
+ - `STANDALONE_ENTRY_UNSUPPORTED` names the `main.go` of the project's
61
+ `backend.main`, not always `cmd/app/main.go`.
62
+
63
+ ### Status
64
+
65
+ - `status` lists `portal` for an App with `portal: {}`. It reads the App's
66
+ `portal.status`: `ready` with the URL when an administrator set an alias,
67
+ `needs-admin-approval` when none is set, and `blocked` before install or
68
+ when the status cannot be read.
69
+
70
+ ### DSM window approval
71
+
72
+ - A Vue App approves the executor only in the SDK's `ExecutorSettings`. The
73
+ DSM window no longer adds its own approval panel to a Vue App with
74
+ `rootOps` or a fixed delegate scope. That panel used unstyled English
75
+ buttons, and its password prompt was appended below the App, so approving
76
+ from it never reached PasswordConfirm.
77
+ - A plain-UI App keeps the window panel. It now uses the panel styles, shows
78
+ its text in the DSM language (the nine SDK locales), and asks for the
79
+ administrator password inside the panel. The password goes to
80
+ `authorize` the same way as in `ExecutorSettings`.
81
+ - Breaking: `confirmPassword` without a password fails with
82
+ `password_required` and sends nothing; the masked form it appended is gone.
83
+
84
+ ### Portal needs the executor tier (breaking)
85
+
86
+ - DSM removes a portal App's Login Portal alias at uninstall but does not
87
+ reload nginx (DSM 7.4, t-0294), so `/<alias>/` kept opening a DSM page.
88
+ Only root can reload it, so a `portal` now needs `capabilities.rootOps` or
89
+ `capabilities.delegate`. A no-privilege native App with `portal` fails
90
+ `check`/`build` with `STANDALONE_PORTAL_REQUIRES_EXECUTOR`; `add portal`
91
+ refuses it unless the same call adds `root-ops` or `delegate`; `inspect`
92
+ flags such an SPK with `PORTAL_REQUIRES_EXECUTOR`.
93
+ - A Compose App has no executor: `integration.web.portal` now always fails
94
+ with `CONTRACT_PORTAL_REQUIRES_EXECUTOR` and the schema no longer describes
95
+ its shape. `CONTRACT_PORTAL_INVALID`, `CONTRACT_PORTAL_UNSUPPORTED` and
96
+ `CONTRACT_PORTAL_MIN_DSM` are gone, and `inspect` reports a Compose SPK's
97
+ `web-config` as an unknown resource.
98
+ - Fix: add `root-ops` or `delegate`, or remove the portal and let an
99
+ administrator set up a reverse proxy in DSM.
100
+
101
+ ### Uninstall
102
+
103
+ - An App with the executor (rootOps or delegate) cleans up after DSM itself:
104
+ SDK 0.9.1's root purge deletes the App's rules in Control Panel >
105
+ Application Privileges and, for a portal App, reloads nginx once DSM has
106
+ removed the alias. The uninstall wizard says so in the nine languages, and
107
+ `status`/`inspect` list both only as fallbacks: "Only if automatic cleanup
108
+ failed after uninstall; see the system log entry synopkgland-executor."
109
+ - A no-privilege App never uses root, not even at uninstall. Its wizard keeps
110
+ saying that DSM keeps the App's rules in Control Panel > Application
111
+ Privileges, that a later install under the same name applies them again,
112
+ and that an administrator can remove them; the cleanup list keeps that
113
+ item. It never mentions a Login Portal alias, because it cannot declare one.
114
+ A test builds plain, files, jobs, identity and `entry: app-privilege` SPKs
115
+ and checks they run as the package account, ship no executor or root
116
+ tasks, and that their scripts never call synowebapi, nginx or a system
117
+ unit.
118
+
119
+ ### SDK 0.9.1
120
+
121
+ - Depends on `@synopackageland/templates` 0.9.1: `init` scaffolds Apps that
122
+ require SDK 0.9.1 (`backend/go.mod` and `@synopackageland/sdk`).
123
+ - The CLI records the SDK 0.9.1 executor digests, rebuilt with the
124
+ uninstall cleanup (x86_64 `5d75af3c…`, aarch64 `b0e1f817…`, armv7
125
+ `17db613a…`; lifecycle digests equal 0.9.0). SDK 0.9.1 only replaces the
126
+ executor: the policy format, task scripts and permissions are unchanged,
127
+ so the policy digest stays the same and no App needs a new approval. An App moved from SDK
128
+ 0.9.0 to 0.9.1 in `backend/go.mod` keeps its approval; the installed
129
+ executor re-pins the new executor by itself.
130
+
131
+ ### Cleaning up after 0.9.0 installs
132
+
133
+ An App built with SDK 0.9.0 could leave root tasks behind at uninstall
134
+ (t-0294 bugs 1 and 3). If the App is still installed, rebuild it with SDK
135
+ 0.9.1, install that build and then uninstall it: the 0.9.1 purge rebuilds the
136
+ task inventory from the approved policy and deletes the tasks. Otherwise an
137
+ administrator removes them by hand:
138
+
139
+ - Control Panel > Task Scheduler: delete `<package> executor control` and
140
+ `<package> executor selection`.
141
+ - The boot event, which DSM's web API refuses to delete over HTTP (error
142
+ 119): as root, run
143
+ `/usr/syno/bin/synowebapi --exec api=SYNO.Core.EventScheduler method=delete version=1 task_name="<package> executor boot"`.
144
+ - Control Panel > Application Privileges: remove the App's rules. Control
145
+ Panel > Login Portal: if its alias still opens, save an application alias
146
+ there to reload nginx, or restart the NAS. The 0.9.1 rebuild-and-uninstall
147
+ route above does both for you.
148
+
149
+ ### Approval after upgrading (correction to 0.9.0)
150
+
151
+ - 0.9.0 said a delegate App rebuilt with it needs one new approval. Measured
152
+ on DSM 7.4 (t-0294): a `files.moveFromStaging` App upgraded from 0.8.0 to
153
+ 0.9.0 kept the same policy digest. The installed executor re-pinned the new
154
+ executor by itself, with no new approval. Only an `install-selected` scope
155
+ needed one administrator confirmation (PasswordConfirm) of the shared
156
+ folder.
157
+ - A new approval is needed only when the executor policy changes, for
158
+ example new operations, another scope or changed root scripts.
159
+
160
+ ### Release tooling
161
+
162
+ - `node scripts/executor-digests.mjs <sdk>/release/vX.Y.Z.json` records a
163
+ released SDK's executor and lifecycle digests in
164
+ `src/runtime/executor-digests.json`. It checks the three architectures,
165
+ go1.26.9 and the CLI minor. A tagged release (`commit` set) never changes
166
+ a recorded version; a release not yet tagged (`commit: null`) replaces its
167
+ own entry. SDK 0.9.1's digests were recorded this way.
168
+ - `test/fixtures/executor-setup/` holds the `executor/setup.json` this CLI
169
+ writes for delegate, time-limited delegate with jobs, root-ops and
170
+ install-selected Apps, for the SDK executor's contract test.
171
+ `UPDATE_EXECUTOR_SETUP_FIXTURES=1 npm test` regenerates them.
172
+ - The npm gate runs `scripts/smoke-exports.mjs`, the export check of
173
+ `smoke-pack.sh`. In 0.9.0 that check still imported the removed
174
+ `authorizationTemplateDirectory`, so `gate.sh docker` always failed.
175
+
176
+ ## 0.9.0 (2026-10-11)
4
177
 
5
178
  The CLI, the SDK and the project templates are now three repositories, and
6
179
  the CLI only packages, deploys and checks. An App names its SDK version once,
package/README.md CHANGED
@@ -72,7 +72,7 @@ Add them with `init --with …` or later with `synopkgland add <capability…>`.
72
72
  | `login` | Sign users in with their DSM account, without the DSM desktop | Nobody; requires HTTPS for password sign-in |
73
73
  | `delegate` | Move files into a user's chosen folder while the user is offline | An administrator, once |
74
74
  | `root-ops` | Run fixed `root/*.sh` scripts for administrators | An administrator, once |
75
- | `portal` | Open the app from its own Application Portal alias | An administrator sets the alias; needs DSM 7.3.2-86009+ |
75
+ | `portal` | Open the app from its own Application Portal alias | An administrator sets the alias; needs `root-ops` or `delegate` and DSM 7.3.2-86009+ |
76
76
 
77
77
  Delegate is implemented; its default operation is `files.moveFromStaging`.
78
78
  Device lifecycle acceptance on real hardware is still pending for delegate and root operations.
package/dist/add.js CHANGED
@@ -8,6 +8,7 @@ import { templatesRoot } from './template-paths.js';
8
8
  import { borrowedTarget, capabilityOverlays } from './capability-overlays.js';
9
9
  import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_MIN_DSM } from './build-spk/dsm-version.js';
10
10
  import { loadStandaloneManifest } from './standalone/manifest.js';
11
+ import { PORTAL_REQUIRES_EXECUTOR } from './portal/alias.js';
11
12
  function files(root, dir = root) {
12
13
  if (!existsSync(dir))
13
14
  return [];
@@ -67,10 +68,21 @@ function addPortal(root, result) {
67
68
  writeFileSync(manifestPath, String(doc));
68
69
  return withNotes({ ...result, added: [...result.added, 'portal'] }, notes);
69
70
  }
71
+ /** A portal needs the executor tier, already declared or added in the same call; nothing is written otherwise. */
72
+ function refuseNoPrivilegePortal(root, rest) {
73
+ const loaded = loadStandaloneManifest(root);
74
+ if (!loaded.ok)
75
+ throw new Error(loaded.errors.map(e => `[${e.code}] ${e.message}`).join('\n'));
76
+ const { rootOps, delegate } = loaded.manifest.capabilities;
77
+ if (!rootOps && !delegate && !rest.some(name => name === 'root-ops' || name === 'delegate')) {
78
+ throw new Error(`[STANDALONE_PORTAL_REQUIRES_EXECUTOR] ${PORTAL_REQUIRES_EXECUTOR}`);
79
+ }
80
+ }
70
81
  export function addCapabilities(root, requested) {
71
82
  const wanted = [...new Set(requested.flatMap(value => value.split(',')).map(value => value.trim()))];
72
83
  if (wanted.includes('portal')) {
73
84
  const rest = wanted.filter(name => name !== 'portal');
85
+ refuseNoPrivilegePortal(root, rest);
74
86
  return addPortal(root, rest.length ? addCapabilities(root, rest) : { added: [], unchanged: [] });
75
87
  }
76
88
  const names = capabilityNames(requested);
@@ -19,7 +19,5 @@ export declare function portalMinDsmMessage(value: string, field: string): strin
19
19
  export declare function isDsmAtLeast(value: string, floor: string): boolean;
20
20
  /** Container Apps cannot declare an os_min_ver below {@link CONTAINER_MIN_DSM}. */
21
21
  export declare function assertContainerMinDsm(value: string): void;
22
- /** A compose portal cannot be built for an os_min_ver below {@link PORTAL_MIN_DSM}. */
23
- export declare function assertPortalMinDsm(value: string): void;
24
22
  /** `--min-dsm`: a release (`7.1`) or a full `<version>-<build>` string (`7.2-69057`). */
25
23
  export declare function resolveMinDsm(value: string): string;
@@ -44,12 +44,6 @@ export function assertContainerMinDsm(value) {
44
44
  throw new Error(`[MIN_DSM_CONTAINER_FLOOR] 容器 App 需要 DSM ${CONTAINER_MIN_DSM} 以上(docker-project/Container Manager 從 DSM 7.2.1 才有),收到「${value}」。`);
45
45
  }
46
46
  }
47
- /** A compose portal cannot be built for an os_min_ver below {@link PORTAL_MIN_DSM}. */
48
- export function assertPortalMinDsm(value) {
49
- if (!isDsmAtLeast(value, PORTAL_MIN_DSM)) {
50
- throw new Error(`[CONTRACT_PORTAL_MIN_DSM] ${portalMinDsmMessage(value, "integration.web.portal")}`);
51
- }
52
- }
53
47
  const FULL_VERSION = /^7\.\d+(\.\d+)?-\d+$/;
54
48
  /** `--min-dsm`: a release (`7.1`) or a full `<version>-<build>` string (`7.2-69057`). */
55
49
  export function resolveMinDsm(value) {
@@ -1,7 +1,7 @@
1
1
  import { metadataInfoFields } from "../package-metadata.js";
2
2
  import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { dirname, join } from "node:path";
4
+ import { join } from "node:path";
5
5
  import { createHash } from "node:crypto";
6
6
  import * as tar from "tar";
7
7
  import { parseDocument, Scalar } from "yaml";
@@ -14,13 +14,12 @@ import { validateProject } from "../validate.js";
14
14
  import { packageIcons, writeDsmWindowIcons, writePackageCenterIcons } from "./dsm-icons.js";
15
15
  import { contractIconPath } from "../contract/icon.js";
16
16
  import { infoArchValue, isContainerArchFamily } from "./dsm-arch.js";
17
- import { assertContainerMinDsm, assertPortalMinDsm, resolveMinDsm } from "./dsm-version.js";
17
+ import { assertContainerMinDsm, resolveMinDsm } from "./dsm-version.js";
18
18
  import { createReproducibleTar } from "./reproducible-archive.js";
19
19
  import { copyProjectNotices } from "./notices.js";
20
20
  import { renderDeleteAppDataBlock } from "./delete-app-data.js";
21
21
  import { renderUninstallWizardFiles } from "./uninstall-wizard.js";
22
22
  import { renderInstallWizardFiles } from "./install-wizard.js";
23
- import { contractPortal, PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep } from "../portal/package.js";
24
23
  import { renderWizardFiles, writeWizardUiFiles } from "./wizard-i18n.js";
25
24
  import { sharedFolderCombobox } from "./share-combobox.js";
26
25
  import { writeUiTexts } from "./ui-texts.js";
@@ -70,8 +69,6 @@ export async function buildSpk(options) {
70
69
  }
71
70
  if (options.minDsm !== undefined) {
72
71
  assertContainerMinDsm(String(contract.os_min_ver));
73
- if (contractPortal(contract))
74
- assertPortalMinDsm(String(contract.os_min_ver));
75
72
  }
76
73
  const parsedCompose = loadCompose(options.project.composePath);
77
74
  const route = parsedCompose?.compose
@@ -126,7 +123,7 @@ export async function buildSpk(options) {
126
123
  mkdirSync(join(staging, "scripts"), { recursive: true });
127
124
  for (const name of SCRIPT_NAMES) {
128
125
  const scriptPath = join(staging, "scripts", name);
129
- writeFileSync(scriptPath, withPortalLifecycle(name, contract, renderManagedScript(name, contract, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute, rwEligibility?.headless === true)), "utf8");
126
+ writeFileSync(scriptPath, renderManagedScript(name, contract, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute, rwEligibility?.headless === true), "utf8");
130
127
  chmodSync(scriptPath, SCRIPT_MODE);
131
128
  }
132
129
  mkdirSync(join(staging, "package"), { recursive: true });
@@ -185,14 +182,6 @@ export async function buildSpk(options) {
185
182
  writeDsmWindowIcons(uiDir, icons);
186
183
  writeUiTexts(uiDir, contractAppTitle(contract));
187
184
  }
188
- const portal = contractPortal(contract);
189
- if (portal) {
190
- for (const [path, text] of [[PORTAL_JSON_RELPATH, renderPortalJson(portal)], [PORTAL_MUSTACHE_RELPATH, renderPortalMustache()]]) {
191
- mkdirSync(dirname(join(staging, "package", path)), { recursive: true });
192
- writeFileSync(join(staging, "package", path), text, "utf8");
193
- chmodSync(join(staging, "package", path), FILE_MODE);
194
- }
195
- }
196
185
  if (gateway && gatewayBinary) {
197
186
  writeGatewayPayload(join(staging, "package"), gatewayBinary, gateway);
198
187
  }
@@ -615,11 +604,7 @@ function renderUpgradeWizard(t, contract, compose) {
615
604
  }];
616
605
  }
617
606
  function writeWizards(staging, contract, compose, kind) {
618
- const portal = contractPortal(contract);
619
- const install = renderInstallWizardFiles(String(contract.package), (t) => [
620
- ...renderInstallWizard(t, contract, compose),
621
- ...(portal ? [renderPortalWizardStep(t, portal.alias)] : []),
622
- ]);
607
+ const install = renderInstallWizardFiles(String(contract.package), (t) => renderInstallWizard(t, contract, compose));
623
608
  writeWizardUiFiles(staging, {
624
609
  ...install.static,
625
610
  ...install.dynamic,
@@ -818,10 +803,6 @@ function renderDynamicCanonicalMirror(headless = false) {
818
803
  function shellQuote(value) {
819
804
  return "'" + value.replace(/'/g, "'\\''") + "'";
820
805
  }
821
- function withPortalLifecycle(name, contract, script) {
822
- const portal = contractPortal(contract);
823
- return portal ? script.replace("#!/bin/sh\n", () => `#!/bin/sh\n${renderPortalLifecycle(name, portal)}`) : script;
824
- }
825
806
  function renderManagedScript(name, contract, isResourceWorker = false, resourceWorkerAdminport, isDynamicRoute = false, isHeadlessRoute = false) {
826
807
  if (isResourceWorker) {
827
808
  if (name === "start-stop-status") {
@@ -1,4 +1,3 @@
1
- import { contractPortal, portalResource } from "../portal/package.js";
2
1
  import { collectUnpinnedImages } from "../compose/analyze.js";
3
2
  import { cookieStripHeaders, resolvedWebLaunch, usesAuthGateway, webStationAlias, webStationServiceName, } from "../contract/web-launch.js";
4
3
  export { classifyComposeRoute, resourceWorkerEligibility, } from "../compose/analyze.js";
@@ -79,8 +78,6 @@ export function renderResourceConfig(contract, eligibility, compose) {
79
78
  ...webResource,
80
79
  };
81
80
  }
82
- if (contractPortal(contract))
83
- combined = { ...combined, ...portalResource() };
84
81
  return `${JSON.stringify(combined, null, 2)}\n`;
85
82
  }
86
83
  /**
@@ -1,7 +1,12 @@
1
1
  /** The key DSM's built-in packages use for their "also remove my data" box. */
2
2
  export declare const REMOVE_ADDITIONAL_KEY = "pkgwizard_remove_additional";
3
3
  export type UninstallWizardKind = "native" | "container";
4
- /** `uninstall_uifile` (English) plus one `uninstall_uifile_<dsm language>` per locale. */
5
- export declare function renderUninstallWizardFiles(packageName: string, kind: UninstallWizardKind, options?: {
4
+ interface UninstallWizardOptions {
5
+ /** The App runs the root executor (rootOps or delegate), which purges DSM's leftovers on uninstall. */
6
6
  executor?: boolean;
7
- }): Record<string, string>;
7
+ /** The App declares a Login Portal alias (web-config app-portal); only the executor tier can. */
8
+ portal?: boolean;
9
+ }
10
+ /** `uninstall_uifile` (English) plus one `uninstall_uifile_<dsm language>` per locale. */
11
+ export declare function renderUninstallWizardFiles(packageName: string, kind: UninstallWizardKind, options?: UninstallWizardOptions): Record<string, string>;
12
+ export {};
@@ -4,7 +4,7 @@ export const REMOVE_ADDITIONAL_KEY = "pkgwizard_remove_additional";
4
4
  function list(items) {
5
5
  return `<ul class="synopkg-ul-list">${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
6
6
  }
7
- function renderUninstallWizard(t, packageName, kind, executor) {
7
+ function renderUninstallWizard(t, packageName, kind, { executor = false, portal = false }) {
8
8
  const vars = { package: packageName };
9
9
  return [{
10
10
  step_title: t("uninstall.step"),
@@ -21,6 +21,8 @@ function renderUninstallWizard(t, packageName, kind, executor) {
21
21
  { desc: t(kind === "container" ? "uninstall.keep.container" : "uninstall.keep.native") },
22
22
  ...(kind === "native" ? [{ desc: t("uninstall.retained.native", vars) }] : []),
23
23
  ...(executor ? [{ desc: t("uninstall.executor", vars) }] : []),
24
+ { desc: t(executor ? "uninstall.appPrivilege.auto" : "uninstall.appPrivilege", vars) },
25
+ ...(executor && portal ? [{ desc: t("uninstall.portal.auto", vars) }] : []),
24
26
  { desc: t("uninstall.backup") },
25
27
  {
26
28
  type: "multiselect",
@@ -31,5 +33,5 @@ function renderUninstallWizard(t, packageName, kind, executor) {
31
33
  }
32
34
  /** `uninstall_uifile` (English) plus one `uninstall_uifile_<dsm language>` per locale. */
33
35
  export function renderUninstallWizardFiles(packageName, kind, options = {}) {
34
- return renderWizardFiles("uninstall_uifile", (t) => renderUninstallWizard(t, packageName, kind, options.executor === true));
36
+ return renderWizardFiles("uninstall_uifile", (t) => renderUninstallWizard(t, packageName, kind, options));
35
37
  }
@@ -384,6 +384,39 @@ export declare const WIZARD_MESSAGES: {
384
384
  es: string;
385
385
  it: string;
386
386
  };
387
+ "uninstall.appPrivilege": {
388
+ en: string;
389
+ "zh-TW": string;
390
+ "zh-CN": string;
391
+ ja: string;
392
+ ko: string;
393
+ de: string;
394
+ fr: string;
395
+ es: string;
396
+ it: string;
397
+ };
398
+ "uninstall.appPrivilege.auto": {
399
+ en: string;
400
+ "zh-TW": string;
401
+ "zh-CN": string;
402
+ ja: string;
403
+ ko: string;
404
+ de: string;
405
+ fr: string;
406
+ es: string;
407
+ it: string;
408
+ };
409
+ "uninstall.portal.auto": {
410
+ en: string;
411
+ "zh-TW": string;
412
+ "zh-CN": string;
413
+ ja: string;
414
+ ko: string;
415
+ de: string;
416
+ fr: string;
417
+ es: string;
418
+ it: string;
419
+ };
387
420
  "uninstall.keep.native": {
388
421
  en: string;
389
422
  "zh-TW": string;
@@ -384,6 +384,39 @@ export const WIZARD_MESSAGES = {
384
384
  "es": "La desinstalación elimina de forma asíncrona en segundo plano el runtime executor, el socket y las delegaciones. Borra las tareas «{package} executor boot» y «{package} executor control» tras verificar el nombre exacto, el propietario root y el resumen del script. El buzón purge de preuninst solicita la limpieza inmediatamente; esta se completa de forma asíncrona. El tiempo de espera agotado no bloquea la desinstalación. Como respaldo, el monitor pausa el servicio si falta el directorio del paquete o su identidad INFO y limpia solo tras 10 minutos de ausencia continua. Su reaparición reinicia el temporizador. Si el proceso no existe, la limpieza espera al próximo arranque. Las tareas modificadas o no verificables se conservan para revisión del administrador en Panel de control > Programador de tareas.",
385
385
  "it": "La disinstallazione elimina in modo asincrono in background runtime executor, socket e deleghe. Le attività “{package} executor boot” e “{package} executor control” vengono eliminate dopo aver verificato nome esatto, proprietario root e digest dello script. La mailbox purge di preuninst richiede subito la pulizia, che termina in modo asincrono. Un timeout non blocca la disinstallazione. Come riserva, il monitor sospende il servizio se mancano la directory del pacchetto o la sua identità INFO e pulisce solo dopo 10 minuti di assenza continua. La ricomparsa azzera il timer. Se il processo è assente, la pulizia attende il prossimo avvio. Le attività modificate o non verificabili restano da esaminare da un amministratore in Pannello di controllo > Utilità di pianificazione."
386
386
  },
387
+ "uninstall.appPrivilege": {
388
+ en: "DSM keeps the rules of {package} in Control Panel > Application Privileges after uninstall, and a later install under the same name applies them again. If the app will not be reinstalled, an administrator can remove its rules there.",
389
+ "zh-TW": "解除安裝後,DSM 會保留 {package} 在「控制台」>「應用程式權限」中的規則;之後以相同名稱安裝時會再次套用。若不再安裝此 App,管理員可在該處移除它的規則。",
390
+ "zh-CN": "卸载后,DSM 会保留 {package} 在“控制面板”>“应用程序权限”中的规则;之后以相同名称安装时会再次应用。如果不再安装此应用,管理员可在该处删除它的规则。",
391
+ ja: "アンインストール後も DSM は [コントロール パネル] > [アプリケーション権限] にある {package} のルールを保持し、同じ名前で再インストールすると再び適用します。再インストールしない場合は、管理者がそこでルールを削除できます。",
392
+ ko: "제거 후에도 DSM은 제어판 > 응용 프로그램 권한에 있는 {package}의 규칙을 유지하며, 같은 이름으로 다시 설치하면 다시 적용합니다. 다시 설치하지 않을 경우 관리자가 그곳에서 규칙을 삭제할 수 있습니다.",
393
+ de: "DSM behält die Regeln von {package} unter Systemsteuerung > Anwendungsberechtigungen nach der Deinstallation bei und wendet sie bei einer späteren Installation unter demselben Namen erneut an. Wird die App nicht erneut installiert, kann ein Administrator die Regeln dort entfernen.",
394
+ fr: "DSM conserve les règles de {package} dans Panneau de configuration > Privilèges d’application après la désinstallation et les applique de nouveau lors d’une installation ultérieure sous le même nom. Si l’application ne sera pas réinstallée, un administrateur peut y supprimer ses règles.",
395
+ es: "DSM conserva las reglas de {package} en Panel de control > Privilegios de aplicaciones tras la desinstalación y las aplica de nuevo en una instalación posterior con el mismo nombre. Si la aplicación no se va a reinstalar, un administrador puede eliminar allí sus reglas.",
396
+ it: "DSM mantiene le regole di {package} in Pannello di controllo > Privilegi applicazione dopo la disinstallazione e le applica di nuovo a una successiva installazione con lo stesso nome. Se l’applicazione non verrà reinstallata, un amministratore può rimuoverne le regole da lì.",
397
+ },
398
+ "uninstall.appPrivilege.auto": {
399
+ en: "Uninstall removes the rules of {package} in Control Panel > Application Privileges automatically, so a later install under the same name does not apply them again. Only if this cleanup failed, as the system log entry synopkgland-executor reports, an administrator can remove the rules there.",
400
+ "zh-TW": "解除安裝會自動移除 {package} 在「控制台」>「應用程式權限」中的規則,之後以相同名稱安裝時不會再次套用。只有在自動清除失敗時(系統日誌 synopkgland-executor 會記錄),管理員才需要在該處移除這些規則。",
401
+ "zh-CN": "卸载会自动删除 {package} 在“控制面板”>“应用程序权限”中的规则,之后以相同名称安装时不会再次应用。只有在自动清理失败时(系统日志 synopkgland-executor 会记录),管理员才需要在该处删除这些规则。",
402
+ ja: "アンインストール時に [コントロール パネル] > [アプリケーション権限] にある {package} のルールを自動的に削除するため、同じ名前で再インストールしても再び適用されません。この削除に失敗した場合 (システム ログの synopkgland-executor に記録されます) のみ、管理者がそこでルールを削除してください。",
403
+ ko: "제거 시 제어판 > 응용 프로그램 권한에 있는 {package}의 규칙을 자동으로 삭제하므로, 같은 이름으로 다시 설치해도 다시 적용되지 않습니다. 이 정리가 실패한 경우에만(시스템 로그 synopkgland-executor에 기록됨) 관리자가 그곳에서 규칙을 삭제하십시오.",
404
+ de: "Die Deinstallation entfernt die Regeln von {package} unter Systemsteuerung > Anwendungsberechtigungen automatisch, sodass eine spätere Installation unter demselben Namen sie nicht erneut anwendet. Nur wenn diese Bereinigung fehlschlägt, was der Systemprotokolleintrag synopkgland-executor meldet, muss ein Administrator die Regeln dort entfernen.",
405
+ fr: "La désinstallation supprime automatiquement les règles de {package} dans Panneau de configuration > Privilèges d’application, si bien qu’une installation ultérieure sous le même nom ne les applique pas de nouveau. Seulement si ce nettoyage échoue, ce que signale l’entrée synopkgland-executor du journal système, un administrateur doit y supprimer les règles.",
406
+ es: "La desinstalación elimina automáticamente las reglas de {package} en Panel de control > Privilegios de aplicaciones, de modo que una instalación posterior con el mismo nombre no las aplica de nuevo. Solo si esta limpieza falla, como indica la entrada synopkgland-executor del registro del sistema, un administrador debe eliminar allí las reglas.",
407
+ it: "La disinstallazione rimuove automaticamente le regole di {package} in Pannello di controllo > Privilegi applicazione, così una successiva installazione con lo stesso nome non le applica di nuovo. Solo se questa pulizia non riesce, come segnala la voce synopkgland-executor del registro di sistema, un amministratore deve rimuovere le regole da lì.",
408
+ },
409
+ "uninstall.portal.auto": {
410
+ en: "After DSM removes the Login Portal alias of {package}, uninstall reloads the web server automatically, so the alias stops at once. Only if this reload failed, as the system log entry synopkgland-executor reports, an administrator can save an application alias in Control Panel > Login Portal > Applications, which reloads the web server, or restart the NAS.",
411
+ "zh-TW": "DSM 移除 {package} 的登入入口別名後,解除安裝會自動重新載入網頁伺服器,讓該別名立即失效。只有在重新載入失敗時(系統日誌 synopkgland-executor 會記錄),管理員才需要在「控制台」>「登入入口」>「應用程式」儲存任一應用程式別名(會重新載入網頁伺服器),或重新啟動 NAS。",
412
+ "zh-CN": "DSM 删除 {package} 的登录门户别名后,卸载会自动重新加载网页服务器,使该别名立即失效。只有在重新加载失败时(系统日志 synopkgland-executor 会记录),管理员才需要在“控制面板”>“登录门户”>“应用程序”保存任一应用程序别名(会重新加载网页服务器),或重新启动 NAS。",
413
+ ja: "DSM が {package} のログイン ポータルのエイリアスを削除した後、アンインストール時に Web サーバーを自動的に再読み込みするため、エイリアスはすぐに無効になります。再読み込みに失敗した場合 (システム ログの synopkgland-executor に記録されます) のみ、管理者が [コントロール パネル] > [ログイン ポータル] > [アプリケーション] でいずれかのアプリケーションのエイリアスを保存する (Web サーバーが再読み込みされます) か、NAS を再起動してください。",
414
+ ko: "DSM이 {package}의 로그인 포털 별칭을 삭제한 후 제거 과정에서 웹 서버를 자동으로 다시 로드하므로 별칭이 즉시 중지됩니다. 다시 로드가 실패한 경우에만(시스템 로그 synopkgland-executor에 기록됨) 관리자가 제어판 > 로그인 포털 > 응용 프로그램에서 응용 프로그램 별칭을 저장하거나(웹 서버가 다시 로드됨) NAS를 다시 시작하십시오.",
415
+ de: "Nachdem DSM den Anmeldeportal-Alias von {package} entfernt hat, lädt die Deinstallation den Webserver automatisch neu, sodass der Alias sofort endet. Nur wenn dieses Neuladen fehlschlägt, was der Systemprotokolleintrag synopkgland-executor meldet, muss ein Administrator unter Systemsteuerung > Anmeldeportal > Anwendungen einen Anwendungsalias speichern, wodurch der Webserver neu geladen wird, oder das NAS neu starten.",
416
+ fr: "Après que DSM a supprimé l’alias du Portail de connexion de {package}, la désinstallation recharge automatiquement le serveur web, si bien que l’alias cesse immédiatement. Seulement si ce rechargement échoue, ce que signale l’entrée synopkgland-executor du journal système, un administrateur doit enregistrer un alias d’application dans Panneau de configuration > Portail de connexion > Applications, ce qui recharge le serveur web, ou redémarrer le NAS.",
417
+ es: "Después de que DSM elimine el alias del Portal de inicio de sesión de {package}, la desinstalación recarga automáticamente el servidor web, de modo que el alias deja de funcionar al instante. Solo si esta recarga falla, como indica la entrada synopkgland-executor del registro del sistema, un administrador debe guardar un alias de aplicación en Panel de control > Portal de inicio de sesión > Aplicaciones, lo que recarga el servidor web, o reiniciar el NAS.",
418
+ it: "Dopo che DSM ha rimosso l’alias del Portale di accesso di {package}, la disinstallazione ricarica automaticamente il server web, così l’alias smette subito di funzionare. Solo se questo ricaricamento non riesce, come segnala la voce synopkgland-executor del registro di sistema, un amministratore deve salvare un alias di applicazione in Pannello di controllo > Portale di accesso > Applicazioni, che ricarica il server web, oppure riavviare il NAS.",
419
+ },
387
420
  "uninstall.keep.native": {
388
421
  en: "Never deleted: Shared Folders, the package account, and other apps' data.",
389
422
  "zh-TW": "不會被刪除:共用資料夾(Shared Folder)、套件帳號,以及其他 App 的資料。",
package/dist/cli.js CHANGED
@@ -5,7 +5,8 @@ import { basename, resolve, dirname, join } from "node:path";
5
5
  import { findProjectRoot } from "./discovery.js";
6
6
  import { loadContract } from "./contract/load.js";
7
7
  import { contractIconPath } from "./contract/icon.js";
8
- import { formatErrors } from "./errors.js";
8
+ import { codedMessage, formatErrors } from "./errors.js";
9
+ import { getErrorCode } from "./error-registry.js";
9
10
  import { formatInspectReport, inspectProject } from "./inspect.js";
10
11
  import { formatPinReport, pinProjectImages } from "./images/pin.js";
11
12
  import { initFromTemplate, isTemplateName, listTemplates } from "./init.js";
@@ -33,7 +34,7 @@ import { checkJobs, commitOutputs, forEachArch } from "./standalone/arch-jobs.js
33
34
  import { checkReproducible } from "./reproducible.js";
34
35
  import { inspectAnySpk } from "./spk/inspect.js";
35
36
  function failure(code, message, hint = "修正設定後重試;使用 --help 查看指令用法。") {
36
- return Object.assign(new Error(`[${code}] ${message}`), { code, hint });
37
+ return Object.assign(new Error(codedMessage(code, message)), { code, hint });
37
38
  }
38
39
  function requireProject(cwd, project) {
39
40
  const found = findProjectRoot(project ? resolve(cwd, project) : cwd);
@@ -460,7 +461,7 @@ export async function runCli(argv) {
460
461
  .option("--json", "輸出單一 JSON 物件")
461
462
  .option("-o, --output <path>", "輸出 SPK,搭配單一 --arch")
462
463
  .option("--out-dir <dir>", "輸出目錄,原生專案預設 dist/")
463
- .option("--arch <families>", `架構,可用逗號分隔:${NATIVE_ARCH_FAMILIES.join(",")}(armv7 用於 native-go 與 binary,不用於 Container Manager App);native layout 另支援 armv8`)
464
+ .option("--arch <families>", `架構,可用逗號分隔或重複指定:${NATIVE_ARCH_FAMILIES.join(",")}(armv7 用於 native-go 與 binary,不用於 Container Manager App);native layout 另支援 armv8`, (value, previous) => [...(previous ?? []), value])
464
465
  .option("--min-dsm <version>", "最低 DSM 版本,省略時使用 manifest 值")
465
466
  .option("--jobs <n>", "同時建置的架構數,預設 1(依序)", Number)
466
467
  .option("--verify-reproducible [when]", "從乾淨狀態再建置一次並比對;when:changed(預設,依賴指紋未變時略過)或 always")
@@ -476,12 +477,12 @@ export async function runCli(argv) {
476
477
  options.arch === undefined
477
478
  ? undefined
478
479
  : standalone && isNativeLayout(standalone)
479
- ? String(options.arch).split(",").map((arch) => {
480
+ ? options.arch.join(",").split(",").map((arch) => {
480
481
  if (!Object.hasOwn(LAYOUT_ARCHES, arch))
481
482
  throw new Error(`Native layout arch must be one of ${Object.keys(LAYOUT_ARCHES).join(", ")}`);
482
483
  return arch;
483
484
  })
484
- : parseArchList(String(options.arch));
485
+ : parseArchList(options.arch.join(","));
485
486
  }
486
487
  catch (error) {
487
488
  throw failure("CLI_ARCH_INVALID", error instanceof Error ? error.message : String(error));
@@ -531,7 +532,7 @@ export async function runCli(argv) {
531
532
  ok: false,
532
533
  code,
533
534
  message,
534
- hint: detail.hint ?? "修正設定後重試;使用 --help 查看指令用法。",
535
+ hint: detail.hint ?? getErrorCode(code)?.suggestion ?? "修正設定後重試;使用 --help 查看指令用法。",
535
536
  ...(detail.report ? { data: detail.report } : {}),
536
537
  }));
537
538
  }
@@ -4,13 +4,11 @@ import { contractSpan, makeError } from "../errors.js";
4
4
  import { collectInstallParameters, getValuePosition, stringViolations, } from "./load.js";
5
5
  import { validateContractSchema } from "./schema-validate.js";
6
6
  import { DSM_GROUP_NAME, isWebAccessMode, isWebLaunchMode, readWebIntegration } from "./web-launch.js";
7
- import { contractPortalSupported, readPortalDeclaration } from "../portal/alias.js";
8
7
  import { validateContractIcon } from "./icon.js";
9
8
  import { validateContractI18n } from "./i18n.js";
10
9
  import { loadCompose } from "../compose/analyze.js";
11
10
  import { collectPosixAllSharesParameters, isPosixAllSharesType, validatePosixAllSharesSources, } from "./posix-all-shares.js";
12
11
  import { getErrorCode } from "../error-registry.js";
13
- import { isDsmAtLeast, PORTAL_MIN_DSM, portalMinDsmMessage } from "../build-spk/dsm-version.js";
14
12
  import { FORMAL_PACKAGE_IDENTITY_MAX_LENGTH, PACKAGE_IDENTITY_MAX_LENGTH, } from "../deploy/identity-shared.js";
15
13
  /** DSM 7.4 Package Center rejects a longer package identity outright. */
16
14
  const DSM_PACKAGE_IDENTITY_MAX = 32;
@@ -133,26 +131,11 @@ function validateWebPortal(parsed, contractFile) {
133
131
  return [];
134
132
  }
135
133
  const pos = getValuePosition(doc, ["integration", "web", "portal"]) ?? { line: 1, column: 1 };
136
- const at = contractSpan(contractFile, pos.line, pos.column, "integration.web.portal");
137
- if (!contractPortalSupported(contract)) {
138
- return [
139
- makeError("CONTRACT_PORTAL_UNSUPPORTED", at, "Application Portal 只支援以 DSM 視窗開啟、經 DSM 登入閘道的網頁 App。", "請使用 adminport、launch: dsm-window 與非 public 的 access,或移除 integration.web.portal。public App 已有同名的 Web Station 網址。"),
140
- ];
141
- }
142
- if (!readPortalDeclaration(portal, String(contract.package ?? ""))) {
143
- return [
144
- makeError("CONTRACT_PORTAL_INVALID", at, "integration.web.portal 必須是 {} 或 { alias },alias 為 1–64 個小寫英數字或 -,且不是 DSM 保留路徑。", "請省略 alias 以使用套件名稱,或改成例如 alias: my-app。"),
145
- ];
146
- }
147
- const osMinVer = contract.os_min_ver;
148
- if (typeof osMinVer === "string" && FULL_OS_MIN_VER.test(osMinVer) && !isDsmAtLeast(osMinVer, PORTAL_MIN_DSM)) {
149
- return [
150
- makeError("CONTRACT_PORTAL_MIN_DSM", at, portalMinDsmMessage(osMinVer, "integration.web.portal"), `請把 os_min_ver 改成 "${PORTAL_MIN_DSM}" 或更新,或移除 integration.web.portal。`),
151
- ];
152
- }
153
- return [];
134
+ // A Compose App has no root executor, and only its purge can stop nginx serving the alias after uninstall.
135
+ return [
136
+ makeError("CONTRACT_PORTAL_REQUIRES_EXECUTOR", contractSpan(contractFile, pos.line, pos.column, "integration.web.portal"), "Compose App 沒有 executor,不能宣告 Application Portal:解除安裝後 DSM 不會 reload nginx,別名要 root 才能清除。", "請移除 integration.web.portal;需要登入入口別名時,由管理員在 DSM 手動設定反向代理。"),
137
+ ];
154
138
  }
155
- const FULL_OS_MIN_VER = /^7\.\d+(\.\d+)?-\d+$/;
156
139
  function validateDeveloperString(value, contractFile, path, pos) {
157
140
  const errors = [];
158
141
  const violations = stringViolations(value);