@synopackageland/cli 0.10.1 → 0.10.3

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 (68) hide show
  1. package/CHANGELOG.md +219 -1669
  2. package/README.md +21 -2
  3. package/assets/login-style/mask.png +0 -0
  4. package/assets/login-style/wallpaper-thumbnail.jpg +0 -0
  5. package/assets/login-style/wallpaper.jpg +0 -0
  6. package/dist/build-spk/generator.js +10 -1
  7. package/dist/build-spk/login-style.d.ts +55 -0
  8. package/dist/build-spk/login-style.js +115 -0
  9. package/dist/cli.js +22 -1
  10. package/dist/error-registry.js +37 -0
  11. package/dist/host/dsm-client.js +6 -9
  12. package/dist/init.js +9 -5
  13. package/dist/runtime/delegate-contract.d.ts +2 -92
  14. package/dist/runtime/delegate-contract.js +2 -24
  15. package/dist/runtime/executor-build.js +10 -0
  16. package/dist/runtime/executor-digests.json +22 -0
  17. package/dist/runtime/executor-inspect.js +3 -0
  18. package/dist/runtime/executor-policy-check.d.ts +14 -0
  19. package/dist/runtime/executor-policy-check.js +70 -0
  20. package/dist/runtime/executor-policy.d.ts +2 -0
  21. package/dist/runtime/executor-policy.js +5 -1
  22. package/dist/runtime/root-programs.d.ts +2 -0
  23. package/dist/runtime/sdk-source.d.ts +4 -0
  24. package/dist/runtime/sdk-source.js +7 -0
  25. package/dist/runtime/sdk-timeouts.d.ts +23 -0
  26. package/dist/runtime/sdk-timeouts.js +44 -0
  27. package/dist/skill.js +1 -0
  28. package/dist/standalone/app-scripts.d.ts +26 -0
  29. package/dist/standalone/app-scripts.js +141 -0
  30. package/dist/standalone/bridge-app.d.ts +2 -0
  31. package/dist/standalone/bridge-app.js +9 -1
  32. package/dist/standalone/bridge-backend.d.ts +1 -1
  33. package/dist/standalone/build.js +5 -2
  34. package/dist/standalone/dsm-window.d.ts +14 -3
  35. package/dist/standalone/dsm-window.js +34 -12
  36. package/dist/standalone/host-adapter.d.ts +1 -1
  37. package/dist/standalone/host-adapter.js +1 -1
  38. package/dist/standalone/host-file-navigation.d.ts +6 -2
  39. package/dist/standalone/host-file-navigation.js +8 -2
  40. package/dist/standalone/inspect-spk.d.ts +9 -0
  41. package/dist/standalone/inspect-spk.js +60 -6
  42. package/dist/standalone/lifecycle.js +3 -1
  43. package/dist/standalone/manifest.d.ts +6 -0
  44. package/dist/standalone/manifest.js +51 -3
  45. package/dist/standalone/portal-window.d.ts +6 -6
  46. package/dist/standalone/portal-window.js +12 -32
  47. package/dist/standalone/request-timeout.d.ts +6 -3
  48. package/dist/standalone/request-timeout.js +10 -4
  49. package/dist/standalone/root-ops.d.ts +3 -2
  50. package/dist/standalone/root-ops.js +9 -3
  51. package/dist/standalone/root-window.d.ts +2 -2
  52. package/dist/standalone/root-window.js +173 -49
  53. package/dist/standalone/sdk-check.js +4 -1
  54. package/dist/standalone/spk.d.ts +3 -0
  55. package/dist/standalone/spk.js +10 -3
  56. package/dist/validate.js +6 -1
  57. package/package.json +2 -3
  58. package/skills/SKILL.md +3 -1
  59. package/skills/reference/create.md +5 -1
  60. package/skills/reference/identity.md +1 -1
  61. package/skills/reference/lifecycle.md +110 -0
  62. package/skills/reference/portal.md +12 -0
  63. package/dist/authorization-sdk.d.ts +0 -6
  64. package/dist/authorization-sdk.js +0 -6
  65. package/dist/standalone/native-authorization-window.d.ts +0 -2
  66. package/dist/standalone/native-authorization-window.js +0 -36
  67. package/dist/standalone/native-root-window.d.ts +0 -29
  68. package/dist/standalone/native-root-window.js +0 -307
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { CLI_VERSION } from "../cli-version.js";
4
- import { appSdkVersion, SDK_DEPENDENCY, SDK_PACKAGE } from "../runtime/sdk-source.js";
4
+ import { appSdkVersion, SDK_DEPENDENCY, SDK_FLOOR, SDK_PACKAGE, sdkBelowFloor } from "../runtime/sdk-source.js";
5
5
  const minorLine = (version) => version.split(".").slice(0, 2).join(".");
6
6
  /** The file that names each backend kind's SDK version. */
7
7
  const SDK_VERSION_FILE = { go: "backend/go.mod", rust: "Cargo.lock", python: "backend.sdk", node: "backend.sdk" };
@@ -29,6 +29,9 @@ export function sdkProblems(backend, frontendDir, needsSdk, cliVersion = CLI_VER
29
29
  if (minorLine(version) !== minorLine(cliVersion)) {
30
30
  problems.push({ code: "SDK_UNSUPPORTED", message: `${file} requires SDK ${version}; CLI ${cliVersion} supports only SDK ${minorLine(cliVersion)}.x. Use the matching CLI, or raise the SDK to ${minorLine(cliVersion)}.x.` });
31
31
  }
32
+ else if (sdkBelowFloor(version)) {
33
+ problems.push({ code: "SDK_UNSUPPORTED", message: `${file} requires SDK ${version}; CLI ${cliVersion} supports SDK ${SDK_FLOOR} or later. Raise the SDK to ${SDK_FLOOR} or later.` });
34
+ }
32
35
  const frontend = frontendDir === undefined ? undefined : frontendSdkVersion(frontendDir);
33
36
  if (frontend && frontend.version !== version) {
34
37
  problems.push({ code: "SDK_VERSION_MISMATCH", message: `frontend/package.json has ${SDK_PACKAGE} ${frontend.spec} (${frontend.version ?? "version unreadable"}), while ${file} requires ${version}; the two must match.` });
@@ -1,6 +1,7 @@
1
1
  import { type NativeArchFamily } from "../build-spk/dsm-arch.js";
2
2
  import type { StandaloneManifest } from "./manifest.js";
3
3
  import { type ExecutorArtifact } from "../runtime/executor-build.js";
4
+ import type { PortalCatalogs } from "./portal-window.js";
4
5
  export declare const LIFECYCLE_SCRIPTS: readonly ["preinst", "postinst", "preuninst", "postuninst", "preupgrade", "postupgrade", "start-stop-status"];
5
6
  export declare class StandaloneBuildError extends Error {
6
7
  readonly code: string;
@@ -18,6 +19,8 @@ export interface AssembleOptions {
18
19
  backgroundWorker?: string;
19
20
  /** A Python, Node or Rust App's finished directory, installed as target/app. */
20
21
  app?: string;
22
+ /** The SDK's portal panel text, required exactly when the manifest declares a portal. */
23
+ portalText?: PortalCatalogs;
21
24
  }
22
25
  /**
23
26
  * Packs an unsigned Standalone Package SPK. DSM runs installation/lifecycle
@@ -12,10 +12,12 @@ import { writeWizardUiFiles } from "../build-spk/wizard-i18n.js";
12
12
  import { renderDelegateScopeSteps } from '../build-spk/delegate-scope.js';
13
13
  import { createReproducibleTar } from "../build-spk/reproducible-archive.js";
14
14
  import { renderStandaloneScript } from "./lifecycle.js";
15
+ import { APP_SCRIPTS_DIR, APP_SCRIPTS_SPK_DIR } from "./app-scripts.js";
15
16
  import { codedMessage } from "../errors.js";
16
17
  import { DSM_LANGS, LOCALES } from "./i18n.js";
17
18
  import { appTitle, CGI_PAYLOAD_PATH, renderUiConfig, renderWindowScript, windowScriptName } from "./dsm-window.js";
18
19
  import { writeUiTexts } from "../build-spk/ui-texts.js";
20
+ import { loginStyleAssets } from "../build-spk/login-style.js";
19
21
  import { copyStandaloneNotices } from "../build-spk/notices.js";
20
22
  import { copyAppTree } from "./bridge-app.js";
21
23
  import { isBridgeBackend } from "./bridge-backend.js";
@@ -65,8 +67,11 @@ export async function assembleStandaloneSpk(options) {
65
67
  const appDir = join(uiRoot, "app");
66
68
  copyUiTree(manifest.ui.dir, appDir);
67
69
  const assetVersion = digestTree(appDir).slice(0, 12);
68
- writeFile(join(uiRoot, "config"), renderUiConfig(manifest, icons));
69
- writeFile(join(uiRoot, windowScriptName(manifest)), renderWindowScript(manifest, assetVersion, icons));
70
+ const login = manifest.portal ? loginStyleAssets(manifest.loginStyle) : undefined;
71
+ writeFile(join(uiRoot, "config"), renderUiConfig(manifest, icons, login?.fields));
72
+ for (const [path, bytes] of login?.files ?? [])
73
+ writeFile(join(uiRoot, path), bytes);
74
+ writeFile(join(uiRoot, windowScriptName(manifest)), renderWindowScript(manifest, assetVersion, icons, options.portalText));
70
75
  writeFile(join(uiRoot, hostScriptName(manifest.build)), renderHostAdapterScript(manifest.package));
71
76
  writeDsmWindowIcons(uiRoot, icons);
72
77
  writeUiTexts(uiRoot, appTitle(manifest));
@@ -141,6 +146,8 @@ export async function assembleStandaloneSpk(options) {
141
146
  for (const name of LIFECYCLE_SCRIPTS) {
142
147
  writeFile(join(staging, "scripts", name), renderStandaloneScript(name, manifest), EXEC_MODE);
143
148
  }
149
+ for (const name of manifest.appScripts ?? [])
150
+ copyExecutable(join(manifest.root, APP_SCRIPTS_DIR, name), join(staging, APP_SCRIPTS_SPK_DIR, name));
144
151
  const folder = manifest.sharedFolder;
145
152
  const steps = manifest.capabilities.delegate?.scope === 'install-selected'
146
153
  ? (t) => [...renderDelegateScopeSteps(t), ...(folder ? sharedFolderInstallSteps(manifest.package, folder)(t) : [])]
@@ -171,7 +178,7 @@ function copyExecutable(source, target) {
171
178
  }
172
179
  function writeFile(path, content, mode = FILE_MODE) {
173
180
  mkdirSync(dirname(path), { recursive: true });
174
- writeFileSync(path, content, "utf8");
181
+ writeFileSync(path, content);
175
182
  chmodSync(path, mode);
176
183
  }
177
184
  function quote(value) {
package/dist/validate.js CHANGED
@@ -9,6 +9,8 @@ import { classifyComposeRoute, collectComposeInterpolations, collectVolumeSource
9
9
  import { usesAuthGateway } from "./contract/web-launch.js";
10
10
  import { isNativeLayout, loadNativeLayout } from "./standalone/native-layout.js";
11
11
  import { loadStandaloneManifest } from "./standalone/manifest.js";
12
+ import { APP_SCRIPTS_DIR, readAppScripts } from "./standalone/app-scripts.js";
13
+ import { manifestPolicyProblem } from "./runtime/executor-policy-check.js";
12
14
  import { COMPOSE_FILENAME, CONTRACT_FILENAME, DSM_PACKAGE_FILENAME, } from "./discovery.js";
13
15
  import { declaredPrivilegeFlags } from "./compose/privilege.js";
14
16
  export function validateProject(project, options = {}) {
@@ -29,7 +31,9 @@ export function validateProject(project, options = {}) {
29
31
  }
30
32
  if (hasStandalone) {
31
33
  const loaded = isNativeLayout(project.root) ? loadNativeLayout(project.root) : loadStandaloneManifest(project.root);
32
- return loaded.ok ? { ok: true, errors: [] } : { ok: false, errors: loaded.errors.map(error => ({
34
+ const policy = loaded.ok && "capabilities" in loaded.manifest ? manifestPolicyProblem(loaded.manifest) : undefined;
35
+ const problems = loaded.ok ? (policy ? [policy] : []) : loaded.errors;
36
+ return !problems.length ? { ok: true, errors: [] } : { ok: false, errors: problems.map(error => ({
33
37
  ...error, span: { file: DSM_PACKAGE_FILENAME, line: 1, column: 1 },
34
38
  suggestion: getErrorCode(error.code)?.suggestion ?? "Fix the schema 2 fields and capability declarations in dsm-package.yaml.",
35
39
  })) };
@@ -40,6 +44,7 @@ export function validateProject(project, options = {}) {
40
44
  if (capabilities !== undefined) {
41
45
  errors.push(makeError("CAPABILITY_UNSUPPORTED", contractSpan(contractFile, 1, 1, "capabilities"), "A compose project does not support capabilities.", "Remove capabilities, or use a dsm-package.yaml native-go project."));
42
46
  }
47
+ readAppScripts(project.root, e => errors.push({ ...e, span: { file: APP_SCRIPTS_DIR, line: 1, column: 1 }, suggestion: getErrorCode(e.code).suggestion }));
43
48
  if (profile === "listing") {
44
49
  for (const { name, type, code } of findListingUnsupportedInstallTypes(parsed.contract)) {
45
50
  const pos = getValuePosition(parsed.doc, ["install", name, "type"]) ??
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "prepublishOnly": "npm run build"
25
25
  },
26
26
  "dependencies": {
27
- "@synopackageland/templates": "0.10.1",
27
+ "@synopackageland/templates": "0.10.3",
28
28
  "ajv": "^8.17.1",
29
29
  "commander": "^13.1.0",
30
30
  "tar": "^7.4.3",
@@ -49,7 +49,6 @@
49
49
  "url": "https://github.com/synopackageland/syno-pkg-land-cli/issues"
50
50
  },
51
51
  "exports": {
52
- "./authorization-sdk": "./dist/authorization-sdk.js",
53
52
  "./dist/*": "./dist/*",
54
53
  "./package.json": "./package.json",
55
54
  "./native-layout": "./dist/standalone/native-layout.js"
package/skills/SKILL.md CHANGED
@@ -13,7 +13,9 @@ Never install, uninstall, publish or modify NAS data without task authorization.
13
13
 
14
14
  Native projects use schema 2 capabilities and `--with files,jobs,login,root-ops,delegate`; use `add` for existing projects. Prefer `check --json`, `build --json`, `inspect <spk> --json`, `deploy --dry-run --json`, and `status --host <profile> --json` for structured results. Read the conflict policy in reference/create.md before adding overlays.
15
15
 
16
- Read reference/portal.md before declaring or changing a DSM Application Portal alias (`add portal`); DSM owns the saved setting, and only an App with rootOps or delegate may declare one. Read reference/files.md and reference/identity.md for capability boundaries. RootOps and delegate share one-time protected executor boot/control setup with digest approval. Existing native products can explicitly use the separate authorization-sdk build-time adapter; do not mix its tasks with generated executor capabilities. App sessions alone do not grant NAS file authority.
16
+ Read reference/portal.md before declaring or changing a DSM Application Portal alias (`add portal`); DSM owns the saved setting, and only an App with rootOps or delegate may declare one. Read reference/files.md and reference/identity.md for capability boundaries. RootOps and delegate share one-time protected executor boot/control setup with digest approval. Existing native products can explicitly use the separate native runtimes in @synopackageland/sdk/native; do not mix its tasks with generated executor capabilities. App sessions alone do not grant NAS file authority.
17
+
18
+ Read [reference/lifecycle.md](reference/lifecycle.md) before editing `dsm-scripts/` (preinst, postinst, preupgrade, postupgrade, preuninst, postuninst, start-stop-status): the SDK runs them inside its generated scripts as the package account; never add root work or repeat SDK steps there.
17
19
 
18
20
  Read [reference/ui-vue.md](reference/ui-vue.md) when editing Vue components in the native-go Vue template; use the installed SDK contracts, keep DSM embedding boundaries, and verify the actual app.
19
21
 
@@ -32,7 +32,7 @@ native-go 與 binary 使用 `dsm-package.yaml` 的 `schema: 2`,backend 寫 `ki
32
32
  既有 native-go 專案用 `synopkgland add files jobs login`;已存在能力回報 no-op。overlay 檔案已有不同內容或 stub 被改動時,add 拒絕並列出衝突;不修改業務 routes 或 App.vue。
33
33
  check、inspect、build、deploy、status 支援 `--json`,stdout 只有單一 JSON 物件,錯誤包含 code、message、hint;進度在 stderr。
34
34
 
35
- rootOps 與 delegate 已共用管理員 PasswordConfirm 核准的 protected executor、boot/control tasks;精確 task readback 與 digest health 一致才 ready;policy(operations、scope、root 腳本)變更須重核准,policy 不變的 executor 更新會自動 repin,install-selected 的 scope 需管理員確認一次。Vue App 只在 SDK 的 ExecutorSettings 核准;plain UI 由 DSM 視窗面板核准,兩者都在面板內輸入密碼並走 PasswordConfirm。native 產品另可明確 opt-in authorization-sdk adapter,不與 generated executor tasks 混用。SDK off stub 與 routes_test.go 的預設 policy scaffold 只有內容仍等於 bundled base 時才會替換;App 自訂版本拒絕。
35
+ rootOps 與 delegate 已共用管理員 PasswordConfirm 核准的 protected executor、boot/control tasks;精確 task readback 與 digest health 一致才 ready;policy(operations、scope、root 腳本)變更須重核准,policy 不變的 executor 更新會自動 repin,install-selected 的 scope 需管理員確認一次。Vue App 只在 SDK 的 ExecutorSettings 核准;plain UI 由 DSM 視窗面板核准,兩者都在面板內輸入密碼並走 PasswordConfirm。native 產品另可明確 opt-in `@synopackageland/sdk/native` 的 runtime,不與 generated executor tasks 混用。SDK off stub 與 routes_test.go 的預設 policy scaffold 只有內容仍等於 bundled base 時才會替換;App 自訂版本拒絕。
36
36
 
37
37
  ## Python、Node、Rust App
38
38
 
@@ -100,6 +100,7 @@ width、minWidth 是 320–3840,height、minHeight 是 240–2160,最小值
100
100
  ```yaml
101
101
  root:
102
102
  state: true # root_state:/volumeN/@synopkgland/<package>,只有 root 可讀,升級保留
103
+ start: migrate # 每次安裝或升級後第一次啟動時由 executor 執行一次(install,或 upgrade <前一版>)
103
104
  stop: prepare # 套件停止與升級時由 executor 執行(stop 或 prepare-upgrade)
104
105
  programs:
105
106
  scan:
@@ -108,8 +109,11 @@ root:
108
109
  run: {timeout_s: 60} # root.run:1–120 秒,max_output ≤ 64 MiB
109
110
  job: {timeout_s: 3600, max_concurrent: 1} # root.start 與 root.jobs.*
110
111
  prepare: {} # 沒有 run、job:只作為 stop 步驟
112
+ migrate: {} # 沒有 run、job:只作為 start 步驟
111
113
  ```
112
114
 
115
+ start、stop 步驟與 `uninstall.purge.root` 都以 root、無 session 執行,只在管理員核准有效時跑;這些程式不能宣告 run 或 job(session 要用時以另一個名稱設定,可指向同一份內容)。start 步驟在新版本第一次啟動、executor 開 socket 並回讀核准任務之後執行,最多 60 秒;執行期間 executor 只回應 `status`(`state: "starting"`),其他請求回可重試的 `EXECUTOR_STARTING`,DSM 視窗的核准會等它結束,失敗只寫進 executor log,不擋啟動;同一版本不重跑,程式要能重複執行。結果由 executor `status` 的 `start_step` 讀取(`pending`、`ok`、`failed`,含 exit 與 output)。DSM 的 start 只寫入要求就返回,所以 `dsm-scripts/postinst`、`postupgrade`、`start` 看不到它的結果;root 的清理或遷移放 `root.start`,非特權的收尾放 `dsm-scripts/`。宣告 `root.start` 會改變 policy digest,需要管理員重新核准;沒有宣告的 App policy 不變。需要 SDK 0.10.3 以上。
116
+
113
117
  schema 是 executor 的封閉子集(每個 object 都要 `additionalProperties: false`,key `[a-z0-9_]`,pattern 為 RE2)。這些設定需要支援它們的 SDK executor(`EXECUTOR_ROOT_PROGRAMS_UNSUPPORTED`)。preupgrade 呼叫 `executor/lifecycle stop --upgrade`;舊 SDK 的 helper 不認得此旗標時改用一般 stop。
114
118
 
115
119
  ## Permanent destination integration
@@ -75,6 +75,6 @@ recovered admission of that ref returns `canceled` and never copies; a producer
75
75
  cancel is also written to the mirror first, so `MoveFromStaging` and the worker
76
76
  sweep forward the cancel instead of admitting it.
77
77
 
78
- RootOps/delegate share PasswordConfirm-approved digest-pinned setup with exact task readback and health. Pins changes require reapproval. The separate native authorization-sdk adapter must not mix its tasks with generated executor tasks.
78
+ RootOps/delegate share PasswordConfirm-approved digest-pinned setup with exact task readback and health. Pins changes require reapproval. The separate native runtimes (@synopackageland/sdk/native) must not mix its tasks with generated executor tasks.
79
79
 
80
80
  Compose uses its DSM gateway plus upstream authentication. A fixed service UID/GID identifies the service, not the browser user. Do not infer general SSO or per-user file authority from a service mount.
@@ -0,0 +1,110 @@
1
+ # DSM lifecycle scripts
2
+
3
+ DSM runs a package's lifecycle scripts at install, upgrade, uninstall, start and
4
+ stop. `synopkgland build` generates all of them, because the SDK's own steps
5
+ (executor, daemon, background worker, portal, Shared Folder, data deletion)
6
+ live there. An App adds its own work with a file of the same name in the
7
+ project's `dsm-scripts/` folder (`scripts/` stays free for your own
8
+ tools). Every template ships these files; each one does
9
+ nothing (`exit 0`) until you edit it.
10
+
11
+ ```
12
+ dsm-scripts/preinst dsm-scripts/postinst dsm-scripts/preupgrade
13
+ dsm-scripts/postupgrade dsm-scripts/preuninst dsm-scripts/postuninst
14
+ dsm-scripts/start-stop-status
15
+ ```
16
+
17
+ There is no setting in `dsm-package.yaml` or `synology-app.yaml`: a file with
18
+ a DSM script name is used, a missing file changes nothing. `check` and `build`
19
+ refuse any other name in `dsm-scripts/` (`APP_SCRIPT_UNKNOWN`), a file that is not
20
+ executable (`APP_SCRIPT_NOT_EXECUTABLE`), a file without `#!`
21
+ (`APP_SCRIPT_NO_SHEBANG`) and a symlink or directory (`APP_SCRIPT_INVALID`).
22
+ prereplace and postreplace are not supported: DSM runs them only for
23
+ `install_replace_packages`, which the SDK never declares. Dotfiles are ignored.
24
+
25
+ ## How the SDK runs your script
26
+
27
+ The SPK carries your file as `scripts/app/<name>`. The generated
28
+ `scripts/<name>` runs the SDK steps in a subshell and calls your script with
29
+ the same arguments and environment. You cannot replace or skip an SDK step.
30
+ `inspect` lists the App scripts and flags an edited wrapper.
31
+
32
+ | Script | Order | Your exit code |
33
+ | --- | --- | --- |
34
+ | preinst, postinst, postupgrade, postuninst | SDK, then App | An SDK failure stops before your script; yours becomes the script's exit code |
35
+ | preupgrade, preuninst | App, then SDK | Non-zero stops before the SDK steps and DSM aborts the upgrade or uninstall |
36
+ | start-stop-status `start`, `prestart`, `status` | SDK, then App | `start`: a failure makes the SDK stop again, then DSM reports the start failed. `status`: asked only while the SDK reports running |
37
+ | start-stop-status `stop`, `prestop` | App, then SDK | The SDK stop always runs; the script fails if either failed |
38
+ | start-stop-status `restart` | `stop`, then `start` | As above |
39
+
40
+ Why `stop` and a failed `start` always stop the SDK: DSM then treats the
41
+ package as stopped, so the SDK must not leave its ingress, daemon or worker
42
+ running.
43
+
44
+ ## What DSM does (DSM 7.4, Package Developer Guide)
45
+
46
+ - **Install:** preinst, files copied, postinst, then start-stop-status
47
+ `prestart` (only with INFO `precheckstartstop`) and `start` if the user
48
+ chose to start.
49
+ - **Upgrade:** old `stop`; the NEW package's preupgrade, run against the old
50
+ install; the OLD package's preuninst; the old files are deleted; the OLD
51
+ package's postuninst; the new preinst, files, postinst, postupgrade; then
52
+ `start` if it was running. So an upgrade also runs the old preuninst and
53
+ postuninst, with `SYNOPKG_PKG_STATUS=UPGRADE`.
54
+ - **Uninstall:** `stop`, preuninst, files deleted, postuninst. postuninst
55
+ cannot read `$SYNOPKG_PKGDEST` any more; `scripts/app` still exists.
56
+ - **Start / stop:** `prestart`, `start` / `prestop`, `stop`. At boot DSM runs
57
+ `start` and at shutdown `stop`, with `SYNOPKG_PKG_STATUS` empty.
58
+
59
+ `SYNOPKG_PKG_STATUS` is `INSTALL`, `UPGRADE`, `UNINSTALL`, `START`, `STOP` or
60
+ empty (boot and shutdown). Guard uninstall-only work with
61
+ `[ "$SYNOPKG_PKG_STATUS" = UNINSTALL ]`.
62
+
63
+ Exit codes have DSM's meaning: non-zero preinst, preupgrade or preuninst
64
+ aborts the operation; non-zero postinst or postupgrade leaves the package
65
+ failed; the guide gives postuninst no meaning. `status` returns 0 running,
66
+ 1 dead with a pid file, 2 dead with a lock file, 3 not running, 4 unknown,
67
+ 150 broken.
68
+
69
+ To show the user a message, write plain text to `$SYNOPKG_TEMP_LOGFILE`
70
+ (for example `echo "Port 8080 is in use." > "$SYNOPKG_TEMP_LOGFILE"`) and exit
71
+ non-zero; DSM shows it after the action. Branch on `$SYNOPKG_DSM_LANGUAGE`
72
+ (`enu`, `cht`, ...) to localize. Do not rely on it being shown on success.
73
+
74
+ ## Environment
75
+
76
+ | Variable | Meaning |
77
+ | --- | --- |
78
+ | `SYNOPKG_PKGNAME`, `SYNOPKG_PKGVER`, `SYNOPKG_OLD_PKGVER` | Package id, version, previous version (upgrade only) |
79
+ | `SYNOPKG_PKG_STATUS` | See above |
80
+ | `SYNOPKG_PKGDEST` | Installed files (`/var/packages/<id>/target`) |
81
+ | `SYNOPKG_PKGVAR`, `SYNOPKG_PKGHOME` | Package data and home; they survive an upgrade |
82
+ | `SYNOPKG_TEMP_LOGFILE` | Message file for the user |
83
+ | `SYNOPKG_TEMP_UPGRADE_FOLDER` | Scratch folder from preupgrade to postupgrade |
84
+ | `SYNOPKG_DSM_LANGUAGE`, `SYNOPKG_DSM_VERSION_MAJOR/MINOR/BUILD`, `SYNOPKG_DSM_ARCH` | DSM facts |
85
+ | `SYNOPKG_USERNAME` | Who started the action; empty when DSM did |
86
+
87
+ Each wizard item `key` arrives as an environment variable of the same name
88
+ (`true`, `false` or text): install wizard in preinst and postinst, upgrade
89
+ wizard in every upgrade script, uninstall wizard in preuninst and postuninst.
90
+ A silent upgrade has no wizard values; use a default.
91
+
92
+ ## Rules
93
+
94
+ - Scripts run as the package account (`conf/privilege` `run-as: package`),
95
+ never as root. Root work belongs in `root/*.sh` through the SDK executor
96
+ (`add root-ops`). Root cleanup or migration after an install or upgrade is
97
+ `root.start` in dsm-package.yaml: the executor runs it once per version,
98
+ after an administrator's approval. Package start only requests the
99
+ executor, so `postinst`, `postupgrade` and `start` cannot wait for that
100
+ step; keep the unprivileged follow-up here, and read the step's result from
101
+ the executor `status` (`start_step`).
102
+ - Do not repeat what the SDK does: starting or stopping the daemon, worker or
103
+ executor, the portal, the Shared Folder, deleting app data (the uninstall
104
+ wizard), or `uninstall.purge`.
105
+ - Keep scripts short. Neither DSM nor the SDK documents a timeout, so a script
106
+ that hangs blocks the install or Package Center.
107
+ - Keep pre-scripts to checks; DSM aborts after them, and nothing undoes a
108
+ change they made.
109
+ - An upgrade runs the old package's preuninst and postuninst; check
110
+ `SYNOPKG_PKG_STATUS` before deleting anything.
@@ -6,6 +6,18 @@ Declare: native-go/binary with rootOps or delegate, `portal: {}` or `portal: {al
6
6
 
7
7
  Build (os_min_ver 7.2-64570+): the SPK carries `etc/portal.json` (every field DSM's app-portal worker needs) and `etc/portal.mustache`, `conf/resource` registers them, and the install wizard adds a prefilled alias step. preinst validates the answer; postinst stores it as the package's own draft and default alias. Nothing is active until an administrator applies it. `inspect <spk> --json` reports `portal: {appId, alias}` and refuses any other portal content.
8
8
 
9
+ Login style: every portal build gives the launcher in `ui/config` a DSM `loginStyle`, which Control Panel > Login Portal > Applications draws as the entry's thumbnail and the alias login page uses for its wallpaper and button colour. Without it the thumbnail is blank. The CLI default is a neutral wallpaper, its thumbnail, a login-box mask and colour `#2F6BD8`; the launcher's `loginIcon` is the App icon, so the login page shows the icon beside the NAS name. Override any part in dsm-package.yaml:
10
+
11
+ ```yaml
12
+ login_style:
13
+ color: "#0F766E" # login button, #RGB or #RRGGBB
14
+ wallpaper: branding/login-wallpaper.jpg # JPEG, at least 1920x1080, at most 2 MiB
15
+ wallpaper_thumbnail: branding/login-thumbnail.jpg # JPEG 320x200; required with wallpaper
16
+ title: branding/login-title.png # PNG 680x124 (shown at 340x62) in place of icon and NAS name
17
+ ```
18
+
19
+ DSM uses a package wallpaper only when both wallpaper and its thumbnail are set, so they go together. check refuses `login_style` without `portal` and a malformed one (`STANDALONE_LOGIN_STYLE_INVALID`), and a missing image or one of the wrong format or size (`STANDALONE_LOGIN_STYLE_IMAGE_INVALID`); it prints the style in effect. inspect prints it (`loginStyle: {color, wallpaper: default|custom, title: app-icon|custom|none}` in `--json`) and flags a portal SPK without one (`LOGIN_STYLE_MISSING`) or naming a file it does not carry (`LOGIN_STYLE_FILE_MISSING`). An administrator's own wallpaper in Login Portal still wins.
20
+
9
21
  Activate: DSM owns the saved setting. Administrators use the app's portal panel (`ctx.portal.apply(alias)` / `disable()`, DSM `SYNO.Core.AppPortal set` with their own session; DSM refuses non-administrators with 105) or Control Panel > Login Portal > Applications (`ctx.portal.openControlPanel()`). DSM errors map to `portal_alias_invalid`, `portal_alias_reserved`, `portal_alias_in_use`, `admin_required`, `portal_unavailable`.
10
22
 
11
23
  Read: ordinary users call the Go backend's `portal.status` (`authenticated`), which reads `/var/packages/<package>/etc/AppPortal.json` read-only, bounded (64 KiB, no symlinks, one JSON object, matching app id). Anything else is `unavailable` and the UI shows "ask an administrator". A binary CGI must implement the same `{state, alias}` answer itself or ordinary users never see the link.
@@ -1,6 +0,0 @@
1
- /** Explicit build-time adapter for native products with their own pinned root tasks.
2
- * Generated Go capabilities continue to use the protected executor.
3
- * No task/grant is created by importing this module.
4
- */
5
- export { ROOT_RUNTIME, type RootTask } from './standalone/native-root-window.js';
6
- export { AUTHORIZATION_RUNTIME } from './standalone/native-authorization-window.js';
@@ -1,6 +0,0 @@
1
- /** Explicit build-time adapter for native products with their own pinned root tasks.
2
- * Generated Go capabilities continue to use the protected executor.
3
- * No task/grant is created by importing this module.
4
- */
5
- export { ROOT_RUNTIME } from './standalone/native-root-window.js';
6
- export { AUTHORIZATION_RUNTIME } from './standalone/native-authorization-window.js';
@@ -1,2 +0,0 @@
1
- /** Shared host flow. CGI plans are never accepted as completed authorization. */
2
- export declare const AUTHORIZATION_RUNTIME = "function createAuthorizationAccess(options) {\n var busy = null;\n function fail(code) { var error = new Error(code); error.code = code; return error; }\n function status() { return options.call(\"authorization.status\").then(function (d) {\n if (!d || d.command !== \"authorization\" || d.schema_version !== 1 || [\"ready\",\"authorization_required\",\"not_admin\",\"session_expired\",\"unavailable\"].indexOf(d.state) < 0) throw fail(\"authorization_bad_response\");\n // upgrade_reapproval is a reason-only tombstone: it can only explain a pending request.\n if (d.reason === \"upgrade_reapproval\" && d.state !== \"authorization_required\") throw fail(\"authorization_bad_response\");\n return d;\n }); }\n function plan(method) { return options.call(method).then(function (p) {\n if (!p || p.command !== \"authorization\" || p.schema_version !== 1 || p.phase !== \"plan\" || !Array.isArray(p.operations) || p.operations.length === 0 || p.operations.length > options.tasks.length) throw fail(\"authorization_bad_response\");\n var seen = {};\n p.operations.forEach(function (op) { if (seen[op] || !options.tasks.some(function (t) { return t.op === op; })) throw fail(\"authorization_bad_response\"); seen[op] = true; });\n return p;\n }); }\n // The backend's grant decides: a task still waiting at the window's limit, or\n // one signalled after it wrote the grant, has still authorized the package.\n function settled(e) {\n if (!e || (e.code !== \"root_timeout\" && e.code !== \"root_task_failed\")) throw e;\n return status().then(function (d) { if (d.state !== \"ready\") throw e; });\n }\n function exclusive(fn) { if (busy) return busy; busy = Promise.resolve().then(fn).then(function (s) { busy = null; return s; },function (e) { busy = null; throw e; });return busy; }\n function authorize(ops,run) { return ops.reduce(function (previous,op) { return previous.then(function () {\n return options.root.authorize(op).then(function (state) { if (state !== \"ready\") throw fail(\"root_cancelled\"); return run ? options.root.run(op).catch(settled) : null; });\n }); },Promise.resolve()); }\n return {canManage:function () {return !!options.user && options.user().admin === true;},status:status,request:function () { return exclusive(function () {\n return plan(\"authorization.request\").then(function (p) { return authorize(p.operations,p.run === true); }).then(status,function (e) { if (e.code === \"root_cancelled\") return status();throw e; });\n }); },revoke:function () { return exclusive(function () {\n return plan(\"authorization.revoke\").then(function (p) {\n if (p.withdraw === true && typeof options.withdraw !== \"function\") throw fail(\"authorization_bad_response\");\n var disabled = p.withdraw === true ? options.withdraw() : p.disable === true ? authorize([\"helper_disable\"],true) : Promise.resolve();\n return disabled.then(function () { return p.purge === true ? authorize([\"helper_purge\"],true) : null; }).then(function () { return p.operations.reduce(function (previous,op) {return previous.then(function () {return options.root.remove(op);});},Promise.resolve()); });\n }).then(status,function (e) { if (e.code === \"root_cancelled\") return status();throw e; });\n }); }};\n}";
@@ -1,36 +0,0 @@
1
- /** Shared host flow. CGI plans are never accepted as completed authorization. */
2
- export const AUTHORIZATION_RUNTIME = `function createAuthorizationAccess(options) {
3
- var busy = null;
4
- function fail(code) { var error = new Error(code); error.code = code; return error; }
5
- function status() { return options.call("authorization.status").then(function (d) {
6
- if (!d || d.command !== "authorization" || d.schema_version !== 1 || ["ready","authorization_required","not_admin","session_expired","unavailable"].indexOf(d.state) < 0) throw fail("authorization_bad_response");
7
- // upgrade_reapproval is a reason-only tombstone: it can only explain a pending request.
8
- if (d.reason === "upgrade_reapproval" && d.state !== "authorization_required") throw fail("authorization_bad_response");
9
- return d;
10
- }); }
11
- function plan(method) { return options.call(method).then(function (p) {
12
- if (!p || p.command !== "authorization" || p.schema_version !== 1 || p.phase !== "plan" || !Array.isArray(p.operations) || p.operations.length === 0 || p.operations.length > options.tasks.length) throw fail("authorization_bad_response");
13
- var seen = {};
14
- p.operations.forEach(function (op) { if (seen[op] || !options.tasks.some(function (t) { return t.op === op; })) throw fail("authorization_bad_response"); seen[op] = true; });
15
- return p;
16
- }); }
17
- // The backend's grant decides: a task still waiting at the window's limit, or
18
- // one signalled after it wrote the grant, has still authorized the package.
19
- function settled(e) {
20
- if (!e || (e.code !== "root_timeout" && e.code !== "root_task_failed")) throw e;
21
- return status().then(function (d) { if (d.state !== "ready") throw e; });
22
- }
23
- function exclusive(fn) { if (busy) return busy; busy = Promise.resolve().then(fn).then(function (s) { busy = null; return s; },function (e) { busy = null; throw e; });return busy; }
24
- function authorize(ops,run) { return ops.reduce(function (previous,op) { return previous.then(function () {
25
- return options.root.authorize(op).then(function (state) { if (state !== "ready") throw fail("root_cancelled"); return run ? options.root.run(op).catch(settled) : null; });
26
- }); },Promise.resolve()); }
27
- return {canManage:function () {return !!options.user && options.user().admin === true;},status:status,request:function () { return exclusive(function () {
28
- return plan("authorization.request").then(function (p) { return authorize(p.operations,p.run === true); }).then(status,function (e) { if (e.code === "root_cancelled") return status();throw e; });
29
- }); },revoke:function () { return exclusive(function () {
30
- return plan("authorization.revoke").then(function (p) {
31
- if (p.withdraw === true && typeof options.withdraw !== "function") throw fail("authorization_bad_response");
32
- var disabled = p.withdraw === true ? options.withdraw() : p.disable === true ? authorize(["helper_disable"],true) : Promise.resolve();
33
- return disabled.then(function () { return p.purge === true ? authorize(["helper_purge"],true) : null; }).then(function () { return p.operations.reduce(function (previous,op) {return previous.then(function () {return options.root.remove(op);});},Promise.resolve()); });
34
- }).then(status,function (e) { if (e.code === "root_cancelled") return status();throw e; });
35
- }); }};
36
- }`;
@@ -1,29 +0,0 @@
1
- /** Build-time adapter for explicitly approved native product tasks. */
2
- /** What the window embeds per root operation: the task it may create, and how to recognize it. */
3
- export interface RootTask {
4
- op: string;
5
- name: string;
6
- header: string;
7
- script: string;
8
- /** Whether the package backend serves `root.output` for this op; helper ops report through the helper instead. */
9
- output: boolean;
10
- }
11
- /**
12
- * `createRootAccess(options)` → `{ state, authorize, run, remove }`, plain
13
- * ES5 for DSM's desktop. Every DSM call goes through `options.send` (the
14
- * AppWindow's sendWebAPI) as the signed-in administrator:
15
- *
16
- * - authorize: DSM's own password confirmation, then one disabled root task
17
- * with exactly the embedded script. The one-use token goes into that single
18
- * create call and is never kept.
19
- * - run: re-reads the task and runs it only if it is still exactly ours (the
20
- * first run after an authorize uses the task that authorize just read back), then
21
- * waits for its history entry and asks the backend for the output. A queued
22
- * or running task keeps the wait going, up to RUN_LIMIT_MS (loaded NAS took
23
- * 4.5 minutes), and `options.progress({op, state, elapsedMs})` hears each poll. Repair
24
- * additionally requires fresh PasswordConfirm and another exact task check.
25
- * - an older version of our task is "outdated" and never runs; authorizing
26
- * again replaces it. A same-named task that is not ours is a "conflict" and
27
- * is never run, replaced or deleted.
28
- */
29
- export declare const ROOT_RUNTIME: string;