@synopackageland/cli 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.1 (unreleased)
4
+
5
+ - Remove environment-specific release notes and source comments from distributed content, including generated DSM window scripts.
6
+ - Prepare npm releases from a clean package directory without local archive references.
7
+ - 清理發佈內容中的環境識別敘述;以乾淨套件目錄準備 npm 發佈。
8
+
9
+ ## 0.17.0 (2026-10-19)
10
+
11
+ - Pin SDK/App 0.17.0 and templates 0.17.1, with UI 0.12.1 in Vue scaffolds; package the executor fixes for directory users and request batches.
12
+ - Fetch SynoToken early in standalone and Portal windows so the first API request can start sooner.
13
+ - Allow Portal without an executor from DSM 7.2-64570. Request DSM nginx reload through INFO during install, upgrade and uninstall; DSM 7.1 still needs an executor for registration.
14
+
15
+ - Expose ctx.batch in generated SDK windows, sharing the existing request guards and token recovery. Keep single-call retries unchanged; never replay a batch after partial execution.
16
+
3
17
  ## 0.16.0 (2026-10-19)
4
18
 
5
19
  - Pin SDK/templates 0.16.0; generated Shared Folder flows use the shared SDK readback and settings components.
package/README.md CHANGED
@@ -95,7 +95,7 @@ Add them with `init --with …` or later with `synopkgland add <capability…>`.
95
95
  | `login` | Sign users in with their DSM account, without the DSM desktop | Nobody; requires HTTPS for password sign-in |
96
96
  | `delegate` | Move files into a user's chosen folder while the user is offline | An administrator, once |
97
97
  | `root-ops` | Run fixed `root/*.sh` scripts for administrators | An administrator, once |
98
- | `portal` | Open the app from its own Application Portal alias, with a login page style (`login_style` changes the colour, wallpaper and title) | An administrator sets the alias; needs `root-ops` or `delegate` and DSM 7.1-42661+ (below 7.2-64570 the executor registers the entry) |
98
+ | `portal` | Open the app from its own Application Portal alias, with a login page style (`login_style` changes the colour, wallpaper and title) | An administrator applies the alias; DSM 7.2-64570+ needs no executor (INFO asks DSM to reload nginx on uninstall). DSM 7.1-42661+ needs an executor to register the entry |
99
99
 
100
100
  Delegate is implemented; its default operation is `files.moveFromStaging`.
101
101
  Device lifecycle acceptance on real hardware is still pending for delegate and root operations.
@@ -311,3 +311,10 @@ Read `synopkgland skill show ctl` for the AI workflow, batch discovery approval,
311
311
  cache refresh, host removal/revocation and explicit `ctl.commands` authoring.
312
312
  API descriptions are private SPK files named `synopkgland-api.json`, served
313
313
  only by the authenticated fixed `synopkgland.describe` CGI operation.
314
+
315
+ Generated SDK windows expose `ctx.batch([{method, params}, ...])` when the
316
+ selected SDK supplies its batch factory. One to eight independent JSON reads
317
+ share a request; results keep input order and retain per-method errors. A batch
318
+ uses the same token recovery, request deadline and cancellation as `ctx.call`,
319
+ and is never replayed automatically. Older SDKs and binary backends retain the
320
+ single-call client. Rebuild the window with the current CLI/SDK to adopt it.
@@ -22,7 +22,7 @@ Application Portal. Headless Apps omit compose.web and have no launcher.
22
22
  Compose ctl is unsupported.
23
23
 
24
24
  Use supported upstream config files, environment, argv or reverse proxy for
25
- login/settings. For integrated upstream Apps, select supported trusted-header authentication or disable upstream login behind the DSM admin gate; or use the existing SDK 0.16.0 token adapter: OpenList uses mode: token, access: dsm-admins and entry: admin-only. Its unmodified admin token CLI supplies a package-private 0600 credential; the gateway injects Authorization server-side after DSM admission, without delivering it to the browser. All DSM admins share one upstream admin; token reset requires package restart; WebDAV keeps its own credentials. Verify each App adapter and DSM admission separately.
25
+ login/settings. For integrated upstream Apps, select supported trusted-header authentication or disable upstream login behind the DSM admin gate; or use the existing SDK 0.17.0 token adapter: OpenList uses mode: token, access: dsm-admins and entry: admin-only. Its unmodified admin token CLI supplies a package-private 0600 credential; the gateway injects Authorization server-side after DSM admission, without delivering it to the browser. All DSM admins share one upstream admin; token reset requires package restart; WebDAV keeps its own credentials. Verify each App adapter and DSM admission separately.
26
26
  Containers use the package account and a dedicated bridge; they do not inherit
27
27
  an interactive user's NAS ACL. Do not use root/chown during any App lifecycle
28
28
  phase. Preserve licenses and dependency/source provenance. Read the CLI skill
package/dist/add.js CHANGED
@@ -8,7 +8,7 @@ import { initFromTemplate, nativeCapabilitySettings } from './init.js';
8
8
  import { templatesRoot } from './template-paths.js';
9
9
  import { borrowedFiles, borrowedTarget, capabilityOverlays } from './capability-overlays.js';
10
10
  import { API_PERMISSIONS_FILE } from './standalone/api-permissions.js';
11
- import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM } from './build-spk/dsm-version.js';
11
+ import { DEFAULT_MIN_DSM, isDsmAtLeast, portalByExecutor, PORTAL_EXECUTOR_MIN_DSM } from './build-spk/dsm-version.js';
12
12
  import { loadStandaloneManifest } from './standalone/manifest.js';
13
13
  import { PORTAL_REQUIRES_EXECUTOR } from './portal/alias.js';
14
14
  function files(root, dir = root) {
@@ -66,13 +66,13 @@ function addPortal(root, result) {
66
66
  writeFileSync(manifestPath, setTopLevel(readFileSync(manifestPath, 'utf8'), { ...values, portal: {} }));
67
67
  return withNotes({ ...result, added: [...result.added, 'portal'] }, notes);
68
68
  }
69
- /** A portal needs the executor tier, already declared or added in the same call; nothing is written otherwise. */
69
+ /** Before DSM 7.2 a portal needs an executor, already declared or added in the same call. */
70
70
  function refuseNoPrivilegePortal(root, rest) {
71
71
  const loaded = loadStandaloneManifest(root);
72
72
  if (!loaded.ok)
73
73
  throw new Error(loaded.errors.map(e => `[${e.code}] ${e.message}`).join('\n'));
74
74
  const { rootOps, delegate, webapi } = loaded.manifest.capabilities;
75
- if (!rootOps && !delegate && !webapi && !rest.some(name => name === 'root-ops' || name === 'delegate')) {
75
+ if (portalByExecutor(loaded.manifest.os_min_ver) && !rootOps && !delegate && !webapi && !rest.some(name => name === 'root-ops' || name === 'delegate')) {
76
76
  throw new Error(`[STANDALONE_PORTAL_REQUIRES_EXECUTOR] ${PORTAL_REQUIRES_EXECUTOR}`);
77
77
  }
78
78
  }
@@ -7,7 +7,7 @@ export type UninstallWizardKind = "native" | "container";
7
7
  interface UninstallWizardOptions {
8
8
  /** The App runs the root executor (rootOps or delegate), which purges DSM's leftovers on uninstall. */
9
9
  executor?: boolean;
10
- /** The App declares a Login Portal alias (web-config app-portal); only the executor tier can. */
10
+ /** The App declares a Login Portal alias (web-config app-portal); DSM reloads nginx through INFO. */
11
11
  portal?: boolean;
12
12
  /** Below DSM 7.2 the executor registered the App's Application Portal entry. */
13
13
  portalEntry?: boolean;
@@ -26,7 +26,7 @@ function renderUninstallWizard(t, locale, packageName, kind, { executor = false,
26
26
  ...(kind === "native" ? [{ desc: t("uninstall.retained.native", vars) }] : []),
27
27
  ...(executor ? [{ desc: t("uninstall.executor", vars) }] : []),
28
28
  { desc: t(executor ? "uninstall.appPrivilege.auto" : "uninstall.appPrivilege", vars) },
29
- ...(executor && portal ? [{ desc: t("uninstall.portal.auto", vars) }] : []),
29
+ ...(portal ? [{ desc: t("uninstall.portal.auto", vars) }] : []),
30
30
  ...(executor && portalEntry ? [
31
31
  { desc: t("uninstall.portalEntry", vars) },
32
32
  { desc: portalEntryCleanupCommands(packageName).map((line) => `<code>${html(line)}</code>`).join("<br>") },
@@ -407,15 +407,15 @@ export const WIZARD_MESSAGES = {
407
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
408
  },
409
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.",
410
+ en: "DSM removes the Login Portal alias of {package} and reloads the web server automatically. The reload can finish after uninstall. If the alias still opens a DSM page, an administrator can save an application alias in Control Panel > Login Portal > Applications to reload the web server.",
411
+ "zh-TW": "DSM 會移除 {package} 的登入入口別名並自動重新載入網頁伺服器;重新載入可能在解除安裝結束後才完成。如果別名仍開啟 DSM 頁面,管理員可在「控制台」>「登入入口」>「應用程式」儲存任一應用程式別名,以重新載入網頁伺服器。",
412
+ "zh-CN": "DSM 会删除 {package} 的登录门户别名并自动重新加载网页服务器;重新加载可能在卸载结束后才完成。如果别名仍打开 DSM 页面,管理员可在“控制面板”>“登录门户”>“应用程序”保存任一应用程序别名,以重新加载网页服务器。",
413
+ ja: "DSM は {package} のログイン ポータルのエイリアスを削除し、Web サーバーを自動的に再読み込みします。再読み込みはアンインストール完了後に終了する場合があります。エイリアスで DSM ページが開く場合、管理者は [コントロール パネル] > [ログイン ポータル] > [アプリケーション] でアプリケーションのエイリアスを保存して再読み込みできます。",
414
+ ko: "DSM은 {package}의 로그인 포털 별칭을 삭제하고 웹 서버를 자동으로 다시 로드합니다. 다시 로드는 제거 완료 후에 끝날 수 있습니다. 별칭이 여전히 DSM 페이지를 열면 관리자는 제어판 > 로그인 포털 > 응용 프로그램에서 응용 프로그램 별칭을 저장하여 웹 서버를 다시 로드할 수 있습니다.",
415
+ de: "DSM entfernt den Anmeldeportal-Alias von {package} und lädt den Webserver automatisch neu. Das Neuladen kann nach der Deinstallation enden. Öffnet der Alias weiterhin eine DSM-Seite, kann ein Administrator unter Systemsteuerung > Anmeldeportal > Anwendungen einen Anwendungsalias speichern, um den Webserver neu zu laden.",
416
+ fr: "DSM supprime l’alias du Portail de connexion de {package} et recharge automatiquement le serveur web. Le rechargement peut se terminer après la désinstallation. Si l’alias ouvre encore une page DSM, un administrateur peut enregistrer un alias d’application dans Panneau de configuration > Portail de connexion > Applications pour recharger le serveur web.",
417
+ es: "DSM elimina el alias del Portal de inicio de sesión de {package} y recarga automáticamente el servidor web. La recarga puede terminar después de la desinstalación. Si el alias aún abre una página DSM, un administrador puede guardar un alias de aplicación en Panel de control > Portal de inicio de sesión > Aplicaciones para recargar el servidor web.",
418
+ it: "DSM rimuove l’alias del Portale di accesso di {package} e ricarica automaticamente il server web. Il ricaricamento può terminare dopo la disinstallazione. Se l’alias apre ancora una pagina DSM, un amministratore può salvare un alias di applicazione in Pannello di controllo > Portale di accesso > Applicazioni per ricaricare il server web.",
419
419
  },
420
420
  "uninstall.portalEntry": {
421
421
  en: "Uninstall asks the root executor to remove the Application Portal entry of {package}. If the executor is not running or was never approved, uninstall does not remove the entry; an administrator then runs these commands as root:",
@@ -131,9 +131,9 @@ function validateWebPortal(parsed, contractFile) {
131
131
  return [];
132
132
  }
133
133
  const pos = getValuePosition(doc, ["integration", "web", "portal"]) ?? { line: 1, column: 1 };
134
- // A Compose App has no root executor, and only its purge can stop nginx serving the alias after uninstall.
134
+ // The legacy Compose contract does not generate Application Portal resources.
135
135
  return [
136
- makeError("CONTRACT_PORTAL_REQUIRES_EXECUTOR", contractSpan(contractFile, pos.line, pos.column, "integration.web.portal"), "A Compose App has no executor and cannot declare an Application Portal: after uninstall DSM does not reload nginx, and only root can remove the alias.", "Remove integration.web.portal; for a sign-in alias, ask an administrator to configure a reverse proxy in DSM."),
136
+ makeError("CONTRACT_PORTAL_REQUIRES_EXECUTOR", contractSpan(contractFile, pos.line, pos.column, "integration.web.portal"), "The legacy Compose contract does not support integration.web.portal. Use a schema-2 App with a top-level portal declaration and DSM 7.2-64570 or later.", "Migrate to schema 2 and top-level portal, or remove integration.web.portal."),
137
137
  ];
138
138
  }
139
139
  function validateDeveloperString(value, contractFile, path, pos) {
@@ -75,8 +75,8 @@ export const ERROR_REGISTRY = {
75
75
  },
76
76
  CONTRACT_PORTAL_REQUIRES_EXECUTOR: {
77
77
  code: "CONTRACT_PORTAL_REQUIRES_EXECUTOR",
78
- description: "The Compose App declares integration.web.portal, but a Compose App has no executor; removing the sign-in alias after uninstall needs root so nginx stops serving it.",
79
- suggestion: "Remove integration.web.portal; for a sign-in alias, ask an administrator to configure a reverse proxy in DSM instead.",
78
+ description: "The legacy Compose contract declares unsupported integration.web.portal.",
79
+ suggestion: "Use schema 2 with a top-level portal declaration and DSM 7.2-64570+, or remove integration.web.portal.",
80
80
  },
81
81
  CONTRACT_ACCESS_INVALID: {
82
82
  code: "CONTRACT_ACCESS_INVALID",
@@ -530,8 +530,8 @@ export const ERROR_REGISTRY = {
530
530
  },
531
531
  STANDALONE_PORTAL_REQUIRES_EXECUTOR: {
532
532
  code: "STANDALONE_PORTAL_REQUIRES_EXECUTOR",
533
- description: "An App without an executor (capabilities.rootOps or delegate) declares portal.",
534
- suggestion: "Removing the sign-in alias after uninstall needs root so nginx stops serving it, but an App without escalation uses no root at any stage. Add capabilities.rootOps or delegate (executor level), or remove portal and let an administrator configure a reverse proxy in DSM.",
533
+ description: "An App targeting DSM below 7.2-64570 declares portal without an executor.",
534
+ suggestion: "Raise os_min_ver to 7.2-64570 or later to use DSM web-config and INFO nginx reload without root. On DSM 7.1, add an executor capability to register the entry, or remove portal.",
535
535
  },
536
536
  STANDALONE_LOGIN_STYLE_INVALID: {
537
537
  code: "STANDALONE_LOGIN_STYLE_INVALID",
@@ -10,10 +10,10 @@
10
10
  * extra_values directly, and share permission can change after install.
11
11
  *
12
12
  * The rows are SYNO.Core.Share.Permission v1 output verbatim. Read the tests
13
- * before changing the predicate: on the test NAS, `fixtureadmin` has is_writable=false on
14
- * its own row for `pkgland-p0b` and still writes there, because the
13
+ * before changing the predicate: an administrator can have is_writable=false on
14
+ * its own row for a share and still have write access, because the
15
15
  * `administrators` group row carries the grant. A user-row-only check rejects
16
- * the only configuration with live proof behind it.
16
+ * valid group-based access.
17
17
  */
18
18
  export interface SharePermissionRow {
19
19
  name: string;
@@ -10,10 +10,10 @@
10
10
  * extra_values directly, and share permission can change after install.
11
11
  *
12
12
  * The rows are SYNO.Core.Share.Permission v1 output verbatim. Read the tests
13
- * before changing the predicate: on the test NAS, `fixtureadmin` has is_writable=false on
14
- * its own row for `pkgland-p0b` and still writes there, because the
13
+ * before changing the predicate: an administrator can have is_writable=false on
14
+ * its own row for a share and still have write access, because the
15
15
  * `administrators` group row carries the grant. A user-row-only check rejects
16
- * the only configuration with live proof behind it.
16
+ * valid group-based access.
17
17
  */
18
18
  function relevantRows(input) {
19
19
  const groups = new Set(input.userGroups);
@@ -58,7 +58,7 @@ Commit the scaffold, then run bash synology/upgrade.sh <upstream-tag> <build-num
58
58
 
59
59
  ${official ? 'Default: use the upstream official image. upgrade.sh updates its release tag and resolves an OCI index/manifest digest through synopkgland images pin, writing only synology/compose.image.yaml. Container Manager downloads that pinned official image at install. Configure upstream-recipe.json repository/service and imageTag: tag (exact upstream tag) or version (strip v only when upstream image naming requires it). Review multi-architecture support and upstream source/license correspondence. The SELECT_UPSTREAM_TAG placeholder must be replaced by a successful upgrade before check/build.' : 'Native App: implement build-source.sh as the native payload hook; it deliberately fails until implemented. Stage official release binaries pinned by SHA-256 (the OpenList route), or compile the merged source with a source build hook. Verify the release digest before staging architecture-specific daemon.targets; keep the SDK management shell separate.'}
60
60
 
61
- Use DSM administrator identity without an additional App password, an OIDC/SSO server or upstream source changes. Prefer supported trusted-header/proxy auth (FreshRSS), otherwise disable upstream login/enable guest behind the administrator-only gateway after a bounded first bootstrap (Uptime Kuma disableAuth). The third shipped pattern is the existing SDK 0.16.0 token adapter: mode: token, access: dsm-admins, entry: admin-only. OpenList obtains its native API token with the unmodified admin token CLI while stopped, stores it package-private 0600 under a 0700 directory, and the gateway injects Authorization server-side after DSM admission; packaging never delivers it to the browser. All DSM admins share one upstream admin; token reset requires package restart; WebDAV keeps its own native credentials. The upstream admin token/settings APIs remain unchanged. Verify actual DSM admission/revocation separately. Use supported config, environment, argv or proxy adapters. Every App lifecycle phase runs as the package UID. For upstream fixed-UID data directories, currently provide a synology/ startup adapter or writable path without root/chown; replace it with the framework ownership mechanism when available. Preserve licenses and the full source/component inventory.
61
+ Use DSM administrator identity without an additional App password, an OIDC/SSO server or upstream source changes. Prefer supported trusted-header/proxy auth (FreshRSS), otherwise disable upstream login/enable guest behind the administrator-only gateway after a bounded first bootstrap (Uptime Kuma disableAuth). The third shipped pattern is the existing SDK 0.17.0 token adapter: mode: token, access: dsm-admins, entry: admin-only. OpenList obtains its native API token with the unmodified admin token CLI while stopped, stores it package-private 0600 under a 0700 directory, and the gateway injects Authorization server-side after DSM admission; packaging never delivers it to the browser. All DSM admins share one upstream admin; token reset requires package restart; WebDAV keeps its own native credentials. The upstream admin token/settings APIs remain unchanged. Verify actual DSM admission/revocation separately. Use supported config, environment, argv or proxy adapters. Every App lifecycle phase runs as the package UID. For upstream fixed-UID data directories, currently provide a synology/ startup adapter or writable path without root/chown; replace it with the framework ownership mechanism when available. Preserve licenses and the full source/component inventory.
62
62
  `;
63
63
  writeFileSync(join(packaging, 'AGENTS.md'), readFileSync(join(packaging, 'AGENTS.md'), 'utf8') + guidance);
64
64
  }
@@ -11,12 +11,8 @@ export declare const PORTAL_ALIAS_MAX = 64;
11
11
  * of Synology Apps, as observed on DSM 7.4-90069. DSM stays the authority.
12
12
  */
13
13
  export declare const RESERVED_PORTAL_ALIASES: readonly string[];
14
- /**
15
- * A portal needs the executor tier: DSM removes the alias files on uninstall
16
- * but leaves nginx serving the alias, and only the root executor purge can
17
- * reload it. A no-privilege App never runs anything as root.
18
- */
19
- export declare const PORTAL_REQUIRES_EXECUTOR = "portal needs the executor level (capabilities.rootOps or delegate): after uninstall DSM does not reload nginx, and a root executor must remove the sign-in alias. An App without an executor should remove portal and let an administrator configure a reverse proxy in DSM.";
14
+ /** DSM below 7.2 refuses unsigned web-config; only an executor can register the entry there. */
15
+ export declare const PORTAL_REQUIRES_EXECUTOR = "portal before DSM 7.2-64570 needs an executor (capabilities.rootOps, delegate or webapi) to register its entry: DSM 7.1 rejects the unsigned web-config worker. Raise os_min_ver to 7.2-64570 or later, add an executor capability, or remove portal. DSM reloads nginx through INFO; a no-privilege App never runs root code.";
20
16
  export interface PortalDeclaration {
21
17
  alias: string;
22
18
  }
@@ -15,12 +15,8 @@ export const RESERVED_PORTAL_ALIASES = [
15
15
  "dsfile", "indexdb", "mobile", "oauth", "scripts", "sharing", "ssdp", "synosdsjslib", "synohdpack", "webapi", "webman",
16
16
  "calendar", "chatplus", "contacts", "drive", "file", "photo", "sso", "synologymeet",
17
17
  ];
18
- /**
19
- * A portal needs the executor tier: DSM removes the alias files on uninstall
20
- * but leaves nginx serving the alias, and only the root executor purge can
21
- * reload it. A no-privilege App never runs anything as root.
22
- */
23
- export const PORTAL_REQUIRES_EXECUTOR = "portal needs the executor level (capabilities.rootOps or delegate): after uninstall DSM does not reload nginx, and a root executor must remove the sign-in alias. An App without an executor should remove portal and let an administrator configure a reverse proxy in DSM.";
18
+ /** DSM below 7.2 refuses unsigned web-config; only an executor can register the entry there. */
19
+ export const PORTAL_REQUIRES_EXECUTOR = "portal before DSM 7.2-64570 needs an executor (capabilities.rootOps, delegate or webapi) to register its entry: DSM 7.1 rejects the unsigned web-config worker. Raise os_min_ver to 7.2-64570 or later, add an executor capability, or remove portal. DSM reloads nginx through INFO; a no-privilege App never runs root code.";
24
20
  export function isPortalAlias(value) {
25
21
  return typeof value === "string" && PORTAL_ALIAS_PATTERN.test(value) && !RESERVED_PORTAL_ALIASES.includes(value);
26
22
  }
@@ -0,0 +1,4 @@
1
+ /** DSM reloads these services after install, upgrade and uninstall; hooks stay unprivileged. */
2
+ export declare function portalInfoFields(fields: Array<[string, string]>, portal: boolean): Array<[string, string]>;
3
+ /** Check a resource-worker Portal. The legacy app.d registration still needs an executor. */
4
+ export declare function inspectPortalInfo(info: Record<string, string>, hasExecutor: boolean): string[];
@@ -0,0 +1,32 @@
1
+ import { portalByExecutor } from "../build-spk/dsm-version.js";
2
+ const RELOAD_SERVICES = "instuninst_restart_services";
3
+ const NGINX = "nginx.service";
4
+ /** DSM reloads these services after install, upgrade and uninstall; hooks stay unprivileged. */
5
+ export function portalInfoFields(fields, portal) {
6
+ if (!portal)
7
+ return fields;
8
+ const services = [...new Set([...fields.filter(([key]) => key === RELOAD_SERVICES)
9
+ .flatMap(([, value]) => value.split(/\s+/).filter(Boolean)), NGINX])].join(" ");
10
+ const result = [];
11
+ let written = false;
12
+ for (const [key, value] of fields) {
13
+ if (key !== RELOAD_SERVICES)
14
+ result.push([key, value]);
15
+ else if (!written) {
16
+ result.push([key, services]);
17
+ written = true;
18
+ }
19
+ }
20
+ if (!written)
21
+ result.push([RELOAD_SERVICES, services]);
22
+ return result;
23
+ }
24
+ /** Check a resource-worker Portal. The legacy app.d registration still needs an executor. */
25
+ export function inspectPortalInfo(info, hasExecutor) {
26
+ const findings = [];
27
+ if (!(info[RELOAD_SERVICES] ?? "").split(/\s+/).includes(NGINX))
28
+ findings.push(`PORTAL_RELOAD_MISSING INFO ${RELOAD_SERVICES} must include ${NGINX} so DSM reloads nginx after uninstall`);
29
+ if (info.os_min_ver && portalByExecutor(info.os_min_ver) && !hasExecutor)
30
+ findings.push("PORTAL_REQUIRES_EXECUTOR before DSM 7.2-64570 the portal entry must be registered by an executor; raise os_min_ver or declare an executor capability");
31
+ return findings;
32
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
- "0.16.0": {
3
- "x86_64": "4f47fb43f6e60dbad064afb0bbec96dd009fa046670e2c4b590b128a855cb404",
4
- "aarch64": "c903b09ad657a838f0c62b6688e65e9094572b22b969770fca92fcc3640a5364",
5
- "armv7": "8178d90de7ea720530bb9496e50ed118086b0e57382d84675aadc68f482d219f",
2
+ "0.17.0": {
3
+ "x86_64": "68f372684997258adc61312f2c353e1a286a6b5aaa556f716b4e6ba6f33893d0",
4
+ "aarch64": "f6950301e201a4ebc14577a4554fa1b671f90d0f354d1e8809f4d1dafb57c723",
5
+ "armv7": "e77c9b6ae22ecfa85d9834af3350192e445987265a7ca6c02ca9b01d88a1b4f7",
6
6
  "lifecycle": {
7
7
  "x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
8
8
  "aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
@@ -0,0 +1,2 @@
1
+ /** The SDK's plain browser factory, embedded without imposing a TS build on Go Apps. */
2
+ export declare function appSdkBatchRuntime(backend: object, env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,23 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { appSdkVersion, sdkBelowFloor, sdkSource } from './sdk-source.js';
4
+ /** The SDK's plain browser factory, embedded without imposing a TS build on Go Apps. */
5
+ export function appSdkBatchRuntime(backend, env = process.env) {
6
+ if (backend.kind === 'binary')
7
+ return '';
8
+ const version = appSdkVersion(backend);
9
+ if (!version || sdkBelowFloor(version))
10
+ return '';
11
+ const sdk = sdkSource(version, env);
12
+ let source;
13
+ try {
14
+ source = readFileSync(join(sdk.goDir, 'ts/src/batch.js'), 'utf8');
15
+ }
16
+ catch (error) {
17
+ // Older supported SDKs retain their desktop-only carrier behavior.
18
+ if (error.code === 'ENOENT')
19
+ return '';
20
+ throw error;
21
+ }
22
+ return source.replace(/^export function createBatch\(/m, 'function createBatch(');
23
+ }
@@ -16,7 +16,7 @@ export function uninstallCleanup(pkg, { executor = false, portal = false, portal
16
16
  executor
17
17
  ? { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: AUTOMATIC_FAILED, action: 'The root executor removes these rules automatically through the Application Privileges API on uninstall. Administrator: remove surviving rules in Control Panel > Application Privileges; DSM would apply them again to a later install under the same name.' }
18
18
  : { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: 'After uninstall, if this package will not be reinstalled.', action: 'DSM keeps these rules in /etc/synoappprivilege.db and applies them again to a later install under the same name. Administrator: remove them in Control Panel > Application Privileges.' },
19
- ...(executor && portal ? [{ target: `${pkg} Login Portal alias`, condition: AUTOMATIC_FAILED, action: 'DSM removes the alias configuration without reloading its web server; the root executor then reloads the web server automatically. Administrator: if the alias still opens a DSM page, save an application alias in Control Panel > Login Portal > Applications to reload it, or restart the NAS.' }] : []),
19
+ ...(portal ? [{ target: `${pkg} Login Portal alias`, condition: 'Only if automatic cleanup failed and the alias still opens a DSM page after the asynchronous reload.', action: 'DSM removes the alias configuration and reloads the web server automatically through INFO instuninst_restart_services. Administrator: if the alias still opens a DSM page, save an application alias in Control Panel > Login Portal > Applications to reload it, or restart the NAS.' }] : []),
20
20
  ...(executor && portalEntry ? [{ target: `${pkg} Application Portal entry`, condition: 'After uninstall, if the executor was not running or was never approved, so it could not remove the entry.', action: `Administrator, as root: ${portalEntryCleanupCommands(pkg).join('; ')}` }] : []),
21
21
  { target: `${pkg} package account/group`, condition: 'After uninstall, only if this package will not be reinstalled and the identity is unused.', action: 'Administrator: review DSM internal account/group and associated ACLs before cleanup. Package scripts do not delete system identities.' },
22
22
  ];
@@ -1,3 +1,4 @@
1
+ import { PORTAL_REQUIRES_EXECUTOR } from "../portal/alias.js";
1
2
  import { buildApiDescription } from "./api-description.js";
2
3
  import { goChildEnv } from "../runtime/go-env.js";
3
4
  import { spawn } from "node:child_process";
@@ -113,6 +114,10 @@ export async function buildStandalone(options) {
113
114
  !isDsmAtLeast(manifest.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
114
115
  throw new StandaloneBuildError("STANDALONE_PORTAL_MIN_DSM", `[STANDALONE_PORTAL_MIN_DSM] ${portalMinDsmMessage(manifest.os_min_ver, "portal")}`);
115
116
  }
117
+ if (manifest.portal && portalByExecutor(manifest.os_min_ver) &&
118
+ !manifest.capabilities.rootOps && !manifest.capabilities.delegate && !manifest.capabilities.webapi) {
119
+ throw new StandaloneBuildError("STANDALONE_PORTAL_REQUIRES_EXECUTOR", PORTAL_REQUIRES_EXECUTOR);
120
+ }
116
121
  if (manifest.compose && !isDsmAtLeast(manifest.os_min_ver, "7.2.1-69057"))
117
122
  throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose identity_gateway requires DSM >= 7.2.1.");
118
123
  const composeProblem = manifest.compose && dsmComposeProblem(manifest.compose);
@@ -12,6 +12,7 @@ import { packageIcons, windowIconFile, windowIconTemplate, } from "../build-spk/
12
12
  import { DEFAULT_WINDOW, FULL_PAGE_MIN_WIDTH } from "./window-size.js";
13
13
  import { requestTimeoutSeconds } from "./request-timeout.js";
14
14
  import { appSdkTimeouts } from "../runtime/sdk-timeouts.js";
15
+ import { appSdkBatchRuntime } from "../runtime/sdk-batch.js";
15
16
  import { appSdkSessionRuntime } from "../runtime/sdk-session.js";
16
17
  /** Same path the generated wrapper POSTs to, and the payload location of the CGI. */
17
18
  export const CGI_PAYLOAD_PATH = "ui/cgi/api.cgi";
@@ -171,6 +172,7 @@ export function renderWindowScript(manifest, assetVersion, icons = packageIcons(
171
172
  const requestTimeout = requestTimeoutSeconds(manifest.backend) * 1000;
172
173
  const clientMargin = appSdkTimeouts(manifest.backend).browserMarginMs;
173
174
  const sessionRuntime = appSdkSessionRuntime(manifest.backend);
175
+ const batchRuntime = appSdkBatchRuntime(manifest.backend);
174
176
  if (manifest.portal && !portalText)
175
177
  throw new Error("A portal App's window needs the SDK's portal panel text.");
176
178
  return `/* Generated by synopkgland build. Edit your UI in the project's ui folder. */
@@ -186,13 +188,17 @@ ${sessionRuntime}
186
188
  fetch: function (url, options) { return fetch(url, options); }
187
189
  });
188
190
  })();
191
+ // DSM loads this script when the App launches. A page it gave no SynoToken
192
+ // (Portal, standalone) asks for one now, while app.js downloads, so the
193
+ // first call does not wait a round trip for it after the App mounts.
194
+ if (${ns}.session && !${ns}.session.token()) ${ns}.session.ready();
189
195
  `
190
196
  : ""}
191
197
 
192
198
  // The SDK's clientTimeout: past the backend's request deadline
193
199
  // (ctx.requestTimeout, from backend.timeout_seconds), so its request_timeout
194
200
  // reply arrives first. options.signal aborts the fetch, for a page the user leaves.
195
- ${ns}.callOnce = function (method, params, options, requestTimeout) {
201
+ ${ns}.callOnce = function (method, params, options, requestTimeout, envelope) {
196
202
  var signal = options && options.signal;
197
203
  var canceled = { code: "request_canceled", message: "The request was canceled" };
198
204
  if (signal && signal.aborted) {
@@ -224,7 +230,7 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
224
230
  method: "POST",
225
231
  credentials: "same-origin",
226
232
  headers: headers,
227
- body: JSON.stringify({ method: method, params: params || {} }),
233
+ body: JSON.stringify(envelope || { method: method, params: params || {} }),
228
234
  signal: controller.signal
229
235
  }).then(function (response) {
230
236
  return response.json().catch(function () {
@@ -254,9 +260,21 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
254
260
  });
255
261
  };
256
262
 
263
+ ${batchRuntime ? `
264
+ // A batch is one request and is never replayed after a partial result.
265
+ ${ns}.batch = (function () {
266
+ ${batchRuntime}
267
+ return function (calls, options, requestTimeout) {
268
+ return createBatch(function (envelope, callOptions) {
269
+ return ${ns}.callOnce(null, null, callOptions, requestTimeout, envelope);
270
+ })(calls, options);
271
+ };
272
+ })();
273
+ ` : ""}
274
+
257
275
  // APP_STOPPED, APP_STARTING and EXECUTOR_STARTING mean the executor op was not
258
276
  // sent. A stopped package waits for the user to start it in Package Center, so
259
- // APP_STOPPED rejects at once. A start ends by itself: a call repeats
277
+ // APP_STOPPED rejects immediately. Starting states retry with bounded backoff:
260
278
  // half a second, then doubling, for 4 s of waiting in all, so a short start
261
279
  // shows the page nothing and the SDK's starting copy follows a long one within
262
280
  // its own 4 s recheck; executor.control, which the window's approval starts the
@@ -402,6 +420,7 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
402
420
  try { if (typeof _S === "function") lang = _S("lang"); } catch (error) {}
403
421
  var ctx = { requestTimeout: ${requestTimeout} };
404
422
  var call = function (method, params, options) { return ${ns}.call(method, params, options, ctx.requestTimeout); };
423
+ var batch = ${batchRuntime ? `function (calls, options) { return ${ns}.batch(calls, options, ctx.requestTimeout); }` : "undefined"};
405
424
  ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
406
425
  if (me.mountToken !== mountToken) return;
407
426
  me.mountPending = false;
@@ -415,6 +434,7 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
415
434
  me.mounted = app.mount(root, Ext.apply(ctx, {
416
435
  displayName: ${ns}.createI18n(lang, ${JSON.stringify(displayNameCatalogs(manifest))}).t("app_title"),
417
436
  call: call,
437
+ batch: batch,
418
438
  files: {endpoint: ${JSON.stringify(cgiUrl(pkg))}, token: function () { return SYNO.SDS.Session && SYNO.SDS.Session.SynoToken; }},
419
439
  reopen: me.reopenEvents.subscribe,
420
440
  launchParams: me.launchParams,
@@ -495,6 +515,7 @@ function renderPortalAccess(ns, manifest) {
495
515
  isAdmin: function () { return ${ns}.sessionUser().isAdmin; },
496
516
  send: ${ns}.webApi(me),
497
517
  call: call,
518
+ batch: batch,
498
519
  launch: function (app, params) { SYNO.SDS.AppLaunch(app, params); },
499
520
  location: window.location,
500
521
  locale: ${ns}.createI18n(lang, ${JSON.stringify(Object.fromEntries(Object.keys(DSM_LANGS).map((locale) => [locale, {}])))}).locale
@@ -1,3 +1,4 @@
1
+ import { inspectPortalInfo } from "../portal/info.js";
1
2
  import { isDeepStrictEqual } from "node:util";
2
3
  import { inspectApiDescription } from "./api-description-inspect.js";
3
4
  import { dsmLauncherType } from "../contract/app-launch.js";
@@ -152,7 +153,7 @@ export async function inspectSpk(spkPath, expectedKind) {
152
153
  const sharedFolder = standalone
153
154
  ? recoverSharedFolder(readOptional(join(dir, "scripts/postinst")))
154
155
  : undefined;
155
- // A Compose App has no executor, so its SPK never carries a portal.
156
+ // Resource-worker portals use DSM-owned INFO reload, with no executor requirement.
156
157
  const portal = standalone
157
158
  ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings)
158
159
  : undefined;
@@ -187,8 +188,8 @@ export async function inspectSpk(spkPath, expectedKind) {
187
188
  ? await checkBridgeApp(dir, metadata.backend, info, payload, findings)
188
189
  : undefined;
189
190
  const hasExecutor = (metadata.capabilities ?? []).some((name) => name === "rootOps" || name === "delegate" || name === "webapi") || paths.has("executor/executor");
190
- if (standalone && portal && !hasExecutor)
191
- findings.push("PORTAL_REQUIRES_EXECUTOR portal needs the executor level (rootOps or delegate): after uninstall only a root purge can stop nginx serving the alias");
191
+ if (standalone && portal)
192
+ findings.push(...inspectPortalInfo(info, hasExecutor));
192
193
  const daemon = standalone
193
194
  ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined)
194
195
  : undefined;
@@ -205,6 +206,8 @@ export async function inspectSpk(spkPath, expectedKind) {
205
206
  executor = inspected.executor;
206
207
  if (executor)
207
208
  findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
209
+ if (executor?.policy.portal && !portal)
210
+ findings.push(...inspectPortalInfo(info, true));
208
211
  if (executor?.policy.portal &&
209
212
  info.os_min_ver &&
210
213
  OS_MIN_VER.test(info.os_min_ver) &&
@@ -374,7 +374,7 @@ function readManifest(raw, c) {
374
374
  ? undefined
375
375
  : (readPortalDeclaration(raw.portal, packageId) ??
376
376
  c.fail("STANDALONE_PORTAL_INVALID", "portal must be {} or { alias: <1-64 lowercase alphanumerics or -, not a DSM reserved path> }."));
377
- if (portal &&
377
+ if (portal && osMinVer && portalByExecutor(osMinVer) &&
378
378
  !capabilities.rootOps &&
379
379
  !capabilities.delegate &&
380
380
  !capabilities.webapi) {
@@ -1,3 +1,4 @@
1
+ import { portalInfoFields } from "../portal/info.js";
1
2
  import { validateMetadata, metadataInfoFields } from "../package-metadata.js";
2
3
  /** Existing native products: the CLI owns archives/INFO, the project owns its layout. */
3
4
  import { createHash } from 'node:crypto';
@@ -329,7 +330,7 @@ export async function buildNativeLayout(options) {
329
330
  await createReproducibleTar(target, join(stage, 'package.tgz'), { gzip: true });
330
331
  rmSync(target, { recursive: true });
331
332
  const fields = { package: m.package, version, arch: layoutInfoArch(arch), os_min_ver: m.os_min_ver, displayname: m.displayname, description: m.description, maintainer: m.maintainer, dsmuidir: 'ui', dsmappname: `SYNO.SDS.${m.package}.Instance`, startable: 'yes', thirdparty: 'yes', checksum: createHash('md5').update(readFileSync(join(stage, 'package.tgz'))).digest('hex') };
332
- const info = [...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)];
333
+ const info = portalInfoFields([...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)], Boolean(m.portal));
333
334
  if (new Set(info.map(([k]) => k)).size !== info.length)
334
335
  fail('INFO anchor collides with an INFO field');
335
336
  writeFileSync(join(stage, 'INFO'), info.map(([k, v]) => `${k}="${v.replace(/([\\"$`])/g, '\\$1')}"\n`).join(''));
@@ -370,8 +371,11 @@ export function inspectNativeLayout(dir, payload, info) {
370
371
  fail('package checksum mismatch');
371
372
  checkInfoAnchors(readFileSync(join(dir, 'INFO'), 'utf8'), inv.anchors === undefined ? {} : anchors(inv.anchors), actual);
372
373
  const declared = inv.portal === undefined ? undefined : portal(inv.portal, info.package);
373
- if (declared)
374
+ if (declared) {
374
375
  checkPortal(declared, info.package, info.displayname ?? '', info.os_min_ver);
376
+ if (!(info.instuninst_restart_services ?? '').split(/\s+/).includes('nginx.service'))
377
+ fail('Portal INFO instuninst_restart_services must include nginx.service');
378
+ }
375
379
  checkInfoPortal(readFileSync(join(dir, 'INFO'), 'utf8'), declared);
376
380
  return [];
377
381
  }
@@ -34,3 +34,5 @@ export interface AssembleOptions {
34
34
  * authorization remains separate and requires explicit administrator approval.
35
35
  */
36
36
  export declare function assembleStandaloneSpk(options: AssembleOptions): Promise<void>;
37
+ /** Manifest validation already refused quotes, backslashes and newlines. */
38
+ export declare function renderInfo(manifest: StandaloneManifest, arch: NativeArchFamily, checksum: string, executorDigest?: string): string;
@@ -1,3 +1,4 @@
1
+ import { portalInfoFields } from "../portal/info.js";
1
2
  import { sdkSource } from "../runtime/sdk-source.js";
2
3
  import { resolvedSdkLaunch } from "../contract/app-launch.js";
3
4
  import { metadataInfoFields } from "../package-metadata.js";
@@ -299,7 +300,7 @@ function quote(value) {
299
300
  return `"${value.replace(/([\\"$`])/g, "\\$1")}"`;
300
301
  }
301
302
  /** Manifest validation already refused quotes, backslashes and newlines. */
302
- function renderInfo(manifest, arch, checksum, executorDigest) {
303
+ export function renderInfo(manifest, arch, checksum, executorDigest) {
303
304
  const fields = [
304
305
  ["package", manifest.package],
305
306
  ["version", manifest.spkVersion],
@@ -340,7 +341,7 @@ function renderInfo(manifest, arch, checksum, executorDigest) {
340
341
  if (text?.description)
341
342
  fields.push([`description_${DSM_LANGS[locale]}`, text.description]);
342
343
  }
343
- return fields.map(([key, value]) => `${key}=${quote(value)}\n`).join("");
344
+ return portalInfoFields(fields, Boolean(manifest.portal)).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
344
345
  }
345
346
  function copyUiTree(source, target) {
346
347
  for (const entry of ["app.js", "app.css"]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "0.16.0",
30
+ "@synopackageland/templates": "0.17.2",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
package/skills/SKILL.md CHANGED
@@ -7,7 +7,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
7
7
 
8
8
  Use this guide to initialize, build and inspect own applications or upstream OSS as independent packages. 依已實作能力區分 DSM session 檔案、私有 jobs 與 App 登入;delegate 已實作並隨 CLI 提供固定操作的離線委託(預設 `files.moveFromStaging`);共用 delegate/rootOps executor 的完整裝置生命週期驗收仍待完成,不得宣稱已通過;支援邊界依 delegate guide/support matrix,禁止發明新的身分平台。
9
9
 
10
- For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.16.0 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
10
+ For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.0 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
11
11
 
12
12
  Start with `synopkgland init -t native-go`, `synopkgland init -t binary`, or `synopkgland init -t compose`. Read reference/create.md or reference/wrap.md, then reference/deploy.md. Native CGI session checks belong to the package. For wrapping upstream OSS with DSM accounts, read [reference/identity-gateway.md](reference/identity-gateway.md) first. For all Compose work, read [reference/compose.md](reference/compose.md): schema-2 upstream files, generated override, SDK planner, fixed package identity and current DSM blockers. Containers do not inherit a signed-in user's File Station ACL. Existing Shared Folder mounts are blocked until official protected ACL/project control is verified.
13
13
 
@@ -15,7 +15,7 @@ Never install, uninstall, publish or modify NAS data without task authorization.
15
15
 
16
16
  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.
17
17
 
18
- 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.
18
+ Read reference/portal.md before declaring or changing a DSM Application Portal alias (`add portal`); DSM owns the saved setting; DSM 7.2-64570+ supports schema-2 Portal without an executor, while DSM 7.1 requires executor registration. 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.
19
19
 
20
20
  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.
21
21
 
@@ -23,7 +23,7 @@ compose:
23
23
  network: {egress: isolated}
24
24
  # Private package binds are rejected by DSM build; keep compatible upstream named volumes.
25
25
  identity_gateway:
26
- sdk: 0.16.0
26
+ sdk: 0.17.0
27
27
  mode: none
28
28
  access: dsm-users
29
29
  listen: {http: 28000}
@@ -151,7 +151,7 @@ declared availability readiness, LAN isolation/egress, protected grant lifecycle
151
151
  ACLs, two-user visibility and clean install/upgrade/reboot/stop/uninstall before
152
152
  calling the package supported on a NAS. No NAS is accessed by the build gate.
153
153
 
154
- The unified Compose contract uses SDK/templates 0.16.0 with CLI 0.16.0. Use the published, matching versions together; the legacy packaging route and bridge publish mode are removed.
154
+ The unified Compose contract uses SDK 0.17.0 and templates 0.17.1 with CLI 0.17.0. Use the published, matching versions together; the legacy packaging route and bridge publish mode are removed.
155
155
 
156
156
  Primary override files, and each included override set, must share a directory. Cross-directory override bases are rejected before loader I/O. Included implicit .env files are confined, inventoried and preserved; symlinks are rejected.
157
157
 
@@ -213,7 +213,7 @@ resources and does not move or delete retained data.
213
213
  Bridge web ingress uses a dedicated `sdk_ingress` internal bridge. The web service
214
214
  receives a fixed IPv4 address, and the package-account gateway connects directly
215
215
  to its declared container port. Internal bridges were reachable by package-account
216
- hooks in feasibility probes on an SA3200D and another test NAS. No HAProxy helper, published
216
+ hooks in feasibility probes on DSM. No HAProxy helper, published
217
217
  container port, Docker socket or host root is required. Explicit internet egress
218
218
  still adds the existing outbound bridge; other App services retain their internal networks.
219
219
 
@@ -40,8 +40,8 @@ rootOps 與 delegate 已共用管理員 PasswordConfirm 核准的 protected exec
40
40
  backend 寫 `runtime: python|node|rust`(`runtime: go` 等同 `kind: go`)。CGI 是 SDK 的 Go runtime(`bridge/cmd/runtime`),它驗證 DSM session、entry 與 `permissions.json` 後,以套件帳號(不是 root)每個請求啟動一次 App。App 放在 ui 以外的目錄,打包成 `target/app/`,build 寫入 `bridge.json`(spec/bridge/v1)並把它與 `permissions.json` 的 sha256 編進 runtime。
41
41
 
42
42
  ```yaml
43
- backend: {runtime: python, dir: app, entry: main.py, sdk: 0.16.0}
44
- backend: {runtime: node, dir: app, entry: main.mjs, sdk: 0.16.0, node: {package: Node.js_v20}}
43
+ backend: {runtime: python, dir: app, entry: main.py, sdk: 0.17.0}
44
+ backend: {runtime: node, dir: app, entry: main.mjs, sdk: 0.17.0, node: {package: Node.js_v20}}
45
45
  backend: {runtime: rust, dir: app, entry: hello, methods: [hello.greet]}
46
46
  ```
47
47
 
@@ -91,8 +91,8 @@ width、minWidth 是 320–3840,height、minHeight 是 240–2160,最小值
91
91
 
92
92
  ## 兩種層級
93
93
 
94
- - 無升權 App(沒有 rootOps、delegate;含所有 Compose App):安裝、執行、解除安裝都只用套件帳號,任何階段都不用 root。不能宣告 portal(native `STANDALONE_PORTAL_REQUIRES_EXECUTOR`、Compose `CONTRACT_PORTAL_REQUIRES_EXECUTOR`);需要登入入口別名時由管理員在 DSM 手動設定反向代理。解除安裝後 DSM 保留的「控制台」>「應用程式權限」規則由管理員手動移除,精靈與 cleanup 清單會說明。
95
- - executor App(rootOps 或 delegate):解除安裝時 root purge 自動刪除 `SYNO.SDS.<package>.Instance` 的應用程式權限規則,宣告 portal 時在 DSM 移除別名後 reload nginx。精靈說明自動清除;cleanup 清單只在自動清除失敗時(系統日誌 `synopkgland-executor`)才需要管理員處理。
94
+ - 無升權 App(沒有 rootOps、delegate;含所有 Compose App):安裝、執行、解除安裝都只用套件帳號,任何階段都不用 root。schema-2 App 的 os_min_ver 為 7.2-64570+ 時可以宣告 portal(含 unified Compose),由管理員明確套用別名,App 不會自動啟用或開啟入口;DSM 透過 INFO `instuninst_restart_services="nginx.service"` 處理解除安裝後的 nginx reload。DSM 7.1 仍需 executor 註冊入口,無升權 App 應提高最低 DSM 或省略 portal。解除安裝後 DSM 保留的「控制台」>「應用程式權限」規則由管理員手動移除,精靈與 cleanup 清單會說明。
95
+ - executor App(rootOps 或 delegate):解除安裝時 root purge 自動刪除 `SYNO.SDS.<package>.Instance` 的應用程式權限規則,Portal 的 nginx reload 由 DSM 依 INFO 執行,與 executor 核准分開。精靈說明自動清除;cleanup 清單只在自動清除失敗時(系統日誌 `synopkgland-executor`)才需要管理員處理。
96
96
 
97
97
  ## root 程式
98
98
 
@@ -1,10 +1,12 @@
1
1
  # DSM Application Portal
2
2
 
3
- Tier: only an App with the executor (`capabilities.rootOps` or `capabilities.delegate`) may declare a portal, because after uninstall only root can make nginx stop serving the alias. A no-privilege native App fails check/build with `STANDALONE_PORTAL_REQUIRES_EXECUTOR`, `add portal` refuses it unless the same call adds `root-ops` or `delegate`, and inspect flags such an SPK with `PORTAL_REQUIRES_EXECUTOR`. A Compose App has no executor: `integration.web.portal` always fails with `CONTRACT_PORTAL_REQUIRES_EXECUTOR`. Without the executor, an administrator can set up a reverse proxy in DSM instead.
3
+ Tier: a schema-2 App targeting DSM 7.2-64570+ may declare a Portal without an executor, including a unified Compose App. The CLI adds `instuninst_restart_services="nginx.service"` to INFO (merging service names without duplicates), so DSM reloads nginx after install, upgrade and uninstall. App hooks still run as the package account; a no-privilege App never runs root code. Declaring a Portal does not activate an alias or automatically open it: an administrator must apply the alias through DSM.
4
4
 
5
- Declare: native-go/binary with rootOps or delegate, `portal: {}` or `portal: {alias: my-app}` in dsm-package.yaml (`synopkgland add root-ops portal`, or `add portal` once the executor is declared). The alias defaults to the package name in lowercase. Aliases are 1–64 lowercase letters, digits or `-`, not starting or ending with `-`, and not a DSM path or Synology App alias (`webman`, `file`, …). The native-layout `backend: {kind: native}` owns its conf/scripts and its own root helper: `portal: {}` or `{alias}` there needs os_min_ver 7.1-42661+ and writes only `synopkgland_portal_alias="<alias>"` into INFO, which Package Center writes as root, so the product's root helper can trust it; the helper registers the app.d entry (application = INFO displayname) and owns its uninstall cleanup and wizard. inspect checks that INFO line against `native-layout.json`.
5
+ On DSM 7.1 the unsigned web-config worker is unavailable, so entry registration still requires an executor (`rootOps`, `delegate` or `webapi`). `check`/`build` and `add portal` refuse a no-executor App targeting below 7.2 with `STANDALONE_PORTAL_REQUIRES_EXECUTOR`; raise `os_min_ver` to 7.2-64570+, add an executor capability, or omit Portal. `add portal` preserves a supported DSM target; it does not silently upgrade a no-privilege App's target or add root capabilities. The legacy `integration.web.portal` contract is unsupported; use the schema-2 top-level `portal` declaration.
6
6
 
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.
7
+ Declare: `portal: {}` or `portal: {alias: my-app}` in dsm-package.yaml (`synopkgland add portal` with os_min_ver 7.2-64570+, or `synopkgland add root-ops portal` for the DSM 7.1 executor route). The alias defaults to the package name in lowercase. Aliases are 1–64 lowercase letters, digits or `-`, not starting or ending with `-`, and not a DSM path or Synology App alias (`webman`, `file`, …). The native-layout `backend: {kind: native}` owns its conf/scripts and its own root helper: `portal: {}` or `{alias}` there needs os_min_ver 7.1-42661+ and writes `synopkgland_portal_alias="<alias>"` and the nginx reload declaration into INFO, which Package Center writes as root, so the product's root helper can trust it; the helper registers the app.d entry (application = INFO displayname) and owns its uninstall cleanup and wizard. inspect checks that INFO line against `native-layout.json`.
8
+
9
+ 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 or a missing nginx reload declaration (`PORTAL_RELOAD_MISSING`).
8
10
 
9
11
  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
12
 
@@ -24,15 +26,17 @@ Read: ordinary users call the Go backend's `portal.status` (`authenticated`), wh
24
26
 
25
27
  Executor mode (7.1-42661 ≤ os_min_ver < 7.2-64570): no web-config resource, no `etc/portal.*`, no alias wizard step or alias lifecycle. The executor policy carries `portal: {application: <displayname>, alias}` and the operations `portal.register` and `portal.unregister`; the executor writes `/usr/syno/etc/www/app.d/<package>.{json,mustache}`. The package name must match `^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$` (≤ 64) and displayname be 1–64 characters without control characters, or build fails with `STANDALONE_PORTAL_ENTRY_INVALID`. The native Go CGI is built with `-X main.portal=executor`; the Python/Node/Rust bridge CGI uses `-X main.portalMode=executor`: `portal.status` adds `entry` (`registered`, `unregistered`, `cleanup-required`; an unreadable executor is `unavailable`), and `portal.register` / `portal.unregister` take the administrator's DSM session and return the fresh status. `ctx.portal.register()` / `unregister()` call them; the plain panel shows text and "Enable entry" while unregistered, the alias form and "Remove entry" once registered, text and "Remove entry" when cleanup is required, and no entry buttons for non-administrators. Executor refusals keep their code (`PORTAL_FAILED`). Needs templates with the executor portal routes (after 0.9.4) and an SDK with the `portal` entry API (after 0.9.5).
26
28
 
27
- DSM floor: os_min_ver must be 7.1-42661 or later; below 7.2-64570 the executor registers the entry (above). DSM 7.1 marks the web-config worker Synology-only, so it refuses an unsigned SPK whose conf/resource carries web-config (Package Center error 320, "non-synology-signed package should not use internal worker"; seen on 7.1.1-42962). synopkg from DSM 7.2 on drops that mark, the only change to its worker table; the 7.2 floor comes from that source and is not yet verified on a 7.2 NAS. 7.3.2-86009 and 7.4 install it. Below 7.1 validate/build fail with `STANDALONE_PORTAL_MIN_DSM`, `--min-dsm` cannot go below 7.1, `add portal` raises a lower os_min_ver to 7.1-42661, and inspect flags an INFO os_min_ver below the floor of the SPK's mode. Fix: raise os_min_ver or remove the portal.
29
+ DSM floor: os_min_ver must be 7.1-42661 or later; below 7.2-64570 the executor registers the entry (above). DSM 7.1 marks the web-config worker Synology-only, so it refuses an unsigned SPK whose conf/resource carries web-config (Package Center error 320, "non-synology-signed package should not use internal worker"; seen on 7.1.1-42962). synopkg from DSM 7.2 on drops that mark, the only change to its worker table; the 7.2 floor comes from that source and is not yet verified on a 7.2 NAS. 7.3.2-86009 and 7.4 install it. Below 7.1 validate/build fail with `STANDALONE_PORTAL_MIN_DSM`, `--min-dsm` cannot go below 7.1, with an executor, `add portal` raises a lower os_min_ver to 7.1-42661; a no-executor target below 7.2-64570 is refused, and inspect flags an INFO os_min_ver below the floor of the SPK's mode. Fix: raise os_min_ver or remove the portal.
28
30
 
29
31
  Capability: beyond that floor, no version gate. The panel needs SYNO.API.Info to list `SYNO.Core.AppPortal` (v2) and the app's registration to exist; otherwise administrators get only the Control Panel shortcut.
30
32
 
31
33
  UI: plain JS calls `ctx.portal && ctx.portal.mountPanel(element)` and disposes it on unmount; Vue renders `portal/PortalSettings.vue`. `ctx.portal.messages` carries the SDK text in all nine languages.
32
34
 
33
- Never: write `AppPortal.json`, `@appconf/<package>` or `/usr/syno/etc/www/app.d` from the App (only the executor's approved `portal.*` operations write app.d); add a backend route that changes the alias; relay QuickConnect responses or build QuickConnect links; reboot or change other packages' portal entries while testing. DSM keeps the alias across upgrades and removes the entry on uninstall, but it does not reload nginx, and it keeps the App's Application Privileges rules.
35
+ Never: write `AppPortal.json`, `@appconf/<package>` or `/usr/syno/etc/www/app.d` from the App (only the executor's approved `portal.*` operations write app.d); add a backend route that changes the alias; relay QuickConnect responses or build QuickConnect links; reboot or change other packages' portal entries while testing. DSM keeps the alias across upgrades and removes the entry on uninstall. The INFO declaration requests nginx reload; DSM keeps the App's Application Privileges rules unless an executor removes those separately.
36
+
37
+ Uninstall: DSM removes the resource-worker Portal entry and reloads nginx through INFO. Reload is asynchronous and can finish after uninstall; the App needs no executor approval for this cleanup. If the alias still serves a DSM page after reload, an administrator can save an application alias in Control Panel > Login Portal > Applications to request another reload. Application Privileges are separate: an executor removes the App's rules when available; without one, an administrator removes retained rules there if the App will not be reinstalled. The wizard and `status`/`inspect` cleanup list describe these separately.
34
38
 
35
- Uninstall: the root executor's purge deletes the rules of `SYNO.SDS.<package>.Instance` through the Application Privileges API and, once DSM has removed the alias files, reloads nginx (`synosystemctl reload nginx`), so the alias stops at once. Failures are only logged to the system log entry `synopkgland-executor`; then an administrator saves an alias in Control Panel > Login Portal > Applications (which reloads nginx) or restarts the NAS, and removes the rules in Control Panel > Application Privileges. The uninstall wizard and the `status`/`inspect` cleanup list say this in that order: automatic first, manual only as the fallback.
39
+ Version evidence: Synology's [DSM 7 WordPress example](https://help.synology.com/developer-guide/examples/compile_web_package.html) declares `os_min_ver="7.0-40337"` with this INFO key; the [web-config guide](https://help.synology.com/developer-guide/resource_acquisition/web_config.html) requires it because the worker does not reload nginx itself. The key therefore adds no DSM 7.0/7.1 version floor; the remaining 7.2 floor for no-executor Portal is the unsigned worker restriction, not INFO support. Documentation confidence: high. End-to-end uninstall was verified on DSM 7.3.2 in a minimal Portal fixture; the 7.1/7.4/7.4.1 hardware matrix remains deferred. DSM 7.0 Portal registration remains unsupported.
36
40
 
37
41
  Executor mode uninstall: the purge reads the retained policy and unregisters the entry. If the executor is not running or was never approved, uninstall does not remove the entry; the wizard and cleanup list say so and give the root commands (`rm -f /usr/syno/etc/www/app.d/<package>.json /usr/syno/etc/www/app.d/<package>.mustache /usr/syno/etc/packages/<package>/AppPortal.json`, `synow3tool --disable-nginx-confd/--unreg-nginx-confd` for `dsm www alias .alias`, `--disable-nginx-sites/--unreg-nginx-sites` for `server .server` on `<c>.syno-app-portal.<package>.conf`, then `nginx -t && synow3tool --nginx=reload`).
38
42
 
@@ -61,6 +61,18 @@ Reuse the embedded theme's typography, spacing and public controls. Preserve
61
61
  loading, empty, error, disabled and permission states. Backend policies and DSM
62
62
  checks decide authority; component visibility cannot grant access.
63
63
 
64
+ ## Start fast
65
+
66
+ Every api.cgi call pays DSM's session check (about 60–240 ms on a NAS), so
67
+ the first screen waits for the startup chain, not the bundle:
68
+
69
+ - Run the entry gate first, then start the reads that do not depend on each
70
+ other together (`Promise.all`); do not chain them.
71
+ - Render `AppShell` as soon as the gate passes, with `:loading` while those
72
+ reads run: the sidebar and title show and the page body says it is loading.
73
+ - Start polling on its schedule; do not repeat at mount a read startup just
74
+ made. Delay background sampling that the first screen does not need.
75
+
64
76
  ## Verify the actual Vue app
65
77
 
66
78
  From `frontend`, run:
@@ -104,7 +104,7 @@ upstream mechanisms in order:
104
104
  If manual App login is still necessary, label bootstrap incomplete. Verify
105
105
  subsequent DSM entry, restart and upgrade all retain password-free access,
106
106
  while anonymous/nonadmin requests and direct upstream ports remain blocked.
107
- 3. **Existing SDK token adapter**: SDK **0.16.0** supports `mode: token`, with
107
+ 3. **Existing SDK token adapter**: SDK **0.17.0** supports `mode: token`, with
108
108
  `access: dsm-admins` and `entry: admin-only`. OpenList uses its unmodified
109
109
  upstream `admin token` CLI while the server is stopped to obtain the native
110
110
  administrator API token. The package stores it in a package-private **0600**
@@ -142,7 +142,7 @@ is undecided. Never copy credentials into generated projects.
142
142
 
143
143
  | Prototype | Reusable lesson and remaining boundary |
144
144
  | --- | --- |
145
- | OpenList | Official release binary pinned by SHA-256, `/ping` and SDK 0.16.0 server-side token adapter; shared upstream admin, restart after token reset, native WebDAV credentials. Share selection does not grant package ACL/register Local storage. |
145
+ | OpenList | Official release binary pinned by SHA-256, `/ping` and SDK 0.17.0 server-side token adapter; shared upstream admin, restart after token reset, native WebDAV credentials. Share selection does not grant package ACL/register Local storage. |
146
146
  | FreshRSS | Official image, package-UID Apache/config adapter in `synology/`, explicit health; supported proxy authentication with fixed admin mapping and header clearing. |
147
147
  | Uptime Kuma | Official image, writable-path adapter, nonredirecting health and `disableAuth` after first bootstrap; WebSocket expiry/revocation need separate tests. |
148
148