@synopackageland/cli 0.9.4 → 0.9.6

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 (73) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +1 -1
  3. package/assets/licenses/MUSL-COPYRIGHT +193 -0
  4. package/assets/licenses/RUST-COPYRIGHT-library.html +5556 -0
  5. package/dist/add.js +29 -29
  6. package/dist/build-spk/dsm-version.d.ts +14 -4
  7. package/dist/build-spk/dsm-version.js +17 -5
  8. package/dist/build-spk/notices.d.ts +8 -1
  9. package/dist/build-spk/notices.js +13 -4
  10. package/dist/build-spk/uninstall-wizard.d.ts +11 -0
  11. package/dist/build-spk/uninstall-wizard.js +16 -3
  12. package/dist/build-spk/wizard-i18n.d.ts +1 -1
  13. package/dist/build-spk/wizard-i18n.js +1 -1
  14. package/dist/build-spk/wizard-messages.d.ts +44 -0
  15. package/dist/build-spk/wizard-messages.js +44 -0
  16. package/dist/capability-overlays.d.ts +10 -5
  17. package/dist/capability-overlays.js +32 -10
  18. package/dist/cli.js +5 -5
  19. package/dist/deploy/plan.d.ts +1 -1
  20. package/dist/deploy/plan.js +1 -1
  21. package/dist/error-registry.js +46 -4
  22. package/dist/info.js +4 -3
  23. package/dist/init.js +53 -28
  24. package/dist/portal/alias.d.ts +8 -0
  25. package/dist/portal/alias.js +16 -0
  26. package/dist/runtime/executor-build.d.ts +1 -1
  27. package/dist/runtime/executor-build.js +2 -1
  28. package/dist/runtime/executor-digests.json +22 -0
  29. package/dist/runtime/executor-policy.d.ts +15 -0
  30. package/dist/runtime/executor-policy.js +32 -6
  31. package/dist/runtime/sdk-source.d.ts +14 -2
  32. package/dist/runtime/sdk-source.js +39 -4
  33. package/dist/runtime/uninstall-cleanup.d.ts +5 -1
  34. package/dist/runtime/uninstall-cleanup.js +11 -1
  35. package/dist/standalone/api-permissions.d.ts +4 -2
  36. package/dist/standalone/api-permissions.js +6 -5
  37. package/dist/standalone/binary.d.ts +3 -2
  38. package/dist/standalone/binary.js +17 -5
  39. package/dist/standalone/build.js +16 -12
  40. package/dist/standalone/daemon.d.ts +6 -0
  41. package/dist/standalone/daemon.js +8 -4
  42. package/dist/standalone/dsm-window.js +16 -3
  43. package/dist/standalone/inspect-spk.d.ts +1 -1
  44. package/dist/standalone/inspect-spk.js +46 -13
  45. package/dist/standalone/lifecycle.js +38 -4
  46. package/dist/standalone/manifest.d.ts +12 -0
  47. package/dist/standalone/manifest.js +62 -20
  48. package/dist/standalone/native-layout.d.ts +4 -7
  49. package/dist/standalone/native-layout.js +41 -102
  50. package/dist/standalone/native-root-window.d.ts +2 -1
  51. package/dist/standalone/native-root-window.js +15 -5
  52. package/dist/standalone/portal-window.d.ts +4 -2
  53. package/dist/standalone/portal-window.js +54 -12
  54. package/dist/standalone/portal.d.ts +4 -1
  55. package/dist/standalone/portal.js +8 -2
  56. package/dist/standalone/root-ops.d.ts +2 -0
  57. package/dist/standalone/root-ops.js +2 -0
  58. package/dist/standalone/rust-build.d.ts +35 -0
  59. package/dist/standalone/rust-build.js +127 -0
  60. package/dist/standalone/sdk-check.d.ts +5 -3
  61. package/dist/standalone/sdk-check.js +21 -8
  62. package/dist/standalone/spk.js +15 -3
  63. package/dist/standalone/toolkit.d.ts +10 -0
  64. package/dist/standalone/toolkit.js +101 -0
  65. package/dist/standalone/uninstall-hook.d.ts +10 -0
  66. package/dist/standalone/uninstall-hook.js +71 -0
  67. package/dist/standalone/uninstall.d.ts +53 -0
  68. package/dist/standalone/uninstall.js +136 -0
  69. package/dist/types.d.ts +1 -1
  70. package/dist/yaml-edit.d.ts +6 -0
  71. package/dist/yaml-edit.js +31 -0
  72. package/package.json +2 -2
  73. package/skills/reference/portal.md +8 -4
@@ -0,0 +1,71 @@
1
+ /** The syslog tag of a failed or timed-out App uninstall hook. */
2
+ export const UNINSTALL_SYSLOG_TAG = "synopkgland-uninstall";
3
+ const TRUE_VALUES = "true|True|TRUE|yes|1";
4
+ /** A fixed token in single quotes; only the package's own directories expand. */
5
+ function word(value) {
6
+ return `'${value.replaceAll("'", "'\\''")}'`
7
+ .replaceAll("${SYNOPKG_PKGDEST}", `'"$hook_dest"'`)
8
+ .replaceAll("${SYNOPKG_PKGVAR}", `'"$hook_var"'`);
9
+ }
10
+ function wizardFlag(variable, keys) {
11
+ const values = keys.flatMap(key => [`"\${${key}:-}"`, `"\${wizard_${key}:-}"`]).join(" ");
12
+ return ` for flag in ${values}; do\n case "$flag" in\n ${TRUE_VALUES}) ${variable}=1 ;;\n esac\n done\n`;
13
+ }
14
+ /**
15
+ * `synopkgland_uninstall_hook`: runs `uninstall.purge` as the package account
16
+ * with a fixed environment and the wizard's two answers, within its timeout.
17
+ * A failure is logged and never blocks the uninstall. It sets
18
+ * `synopkgland_purge_args` for the executor purge request.
19
+ */
20
+ export function renderUninstallHook(manifest) {
21
+ const purge = manifest.uninstall?.purge;
22
+ if (!purge)
23
+ return "";
24
+ const pkg = manifest.package;
25
+ const env = [
26
+ "PATH=/usr/bin:/bin", 'HOME="$hook_var"', 'TMPDIR="$hook_var/tmp"',
27
+ ...Object.entries(purge.env).map(([key, value]) => `${key}=${word(value)}`),
28
+ 'SYNOPKGLAND_DELETE_STATE="$delete_state"', 'SYNOPKGLAND_REMOVE_DATA="$remove_data"',
29
+ ].join(" ");
30
+ const argv = [`"$hook_dest"/${word(purge.exec)}`, ...purge.args.map(word)].join(" ");
31
+ const failed = `${pkg} uninstall hook failed: $hook_failure`;
32
+ const logged = `${failed}; output in $hook_var/uninstall-hook.log`;
33
+ return `synopkgland_purge_args=
34
+ synopkgland_uninstall_hook() {
35
+ delete_state=0
36
+ remove_data=0
37
+ ${manifest.uninstall?.state ? wizardFlag("delete_state", ["pkgwizard_delete_state"]) : ""}${wizardFlag("remove_data", ["pkgwizard_remove_additional", "delete_app_data"])} [ "$delete_state" -eq 0 ] || synopkgland_purge_args=--delete-state
38
+ hook_dest="\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}"
39
+ hook_var="\${SYNOPKG_PKGVAR:-/var/packages/${pkg}/var}"
40
+ mkdir -p "$hook_var/tmp" 2>/dev/null || true
41
+ # Its own process group, so a timeout stops the hook's children too; its
42
+ # output goes to a file, so a lingering child cannot hold DSM's pipe open.
43
+ set -m
44
+ (cd "$hook_var" 2>/dev/null || cd /; exec /usr/bin/env -i ${env} ${argv}) </dev/null >"$hook_var/uninstall-hook.log" 2>&1 &
45
+ hook_pid=$!
46
+ set +m
47
+ hook_waited=0
48
+ while kill -0 "$hook_pid" 2>/dev/null && [ "$hook_waited" -lt ${purge.timeout} ]; do
49
+ sleep 1
50
+ hook_waited=$((hook_waited + 1))
51
+ done
52
+ hook_failure=
53
+ if kill -0 "$hook_pid" 2>/dev/null; then
54
+ kill -TERM -"$hook_pid" 2>/dev/null || kill -TERM "$hook_pid" 2>/dev/null
55
+ sleep 1
56
+ kill -KILL -"$hook_pid" 2>/dev/null || kill -KILL "$hook_pid" 2>/dev/null
57
+ wait "$hook_pid" 2>/dev/null
58
+ hook_failure="timeout after ${purge.timeout} s"
59
+ else
60
+ wait "$hook_pid"
61
+ hook_status=$?
62
+ [ "$hook_status" -eq 0 ] || hook_failure="exit $hook_status"
63
+ fi
64
+ if [ -n "$hook_failure" ]; then
65
+ echo "${logged}" >&2
66
+ logger -t ${UNINSTALL_SYSLOG_TAG} "${logged}" 2>/dev/null || true
67
+ fi
68
+ return 0
69
+ }
70
+ `;
71
+ }
@@ -0,0 +1,53 @@
1
+ import { type Catalog, type Catalogs, type Locale } from "./i18n.js";
2
+ import type { StandaloneError } from "./manifest.js";
3
+ import { type RootOp } from "./root-ops.js";
4
+ /**
5
+ * `uninstall`: the App's own hook on a real uninstall, run as the package
6
+ * account before the executor purge, and the state it deletes when the
7
+ * administrator ticks the uninstall wizard's "delete state" box.
8
+ */
9
+ export interface Uninstall {
10
+ purge: UninstallPurge;
11
+ state?: {
12
+ items: Array<{
13
+ key: string;
14
+ }>;
15
+ };
16
+ }
17
+ export interface UninstallPurge {
18
+ /** A package-relative executable, outside executor/ and root/. */
19
+ exec: string;
20
+ args: string[];
21
+ env: Record<string, string>;
22
+ /** Seconds, 2..120. */
23
+ timeout: number;
24
+ /** A root/<name>.sh the executor runs at uninstall, only while its approval is live. */
25
+ root?: string;
26
+ }
27
+ /** The two flags the generated preuninst hands the hook. */
28
+ export declare const UNINSTALL_FLAGS: readonly ["SYNOPKGLAND_DELETE_STATE", "SYNOPKGLAND_REMOVE_DATA"];
29
+ export interface UninstallContext {
30
+ rootOps: boolean;
31
+ ops?: RootOp[];
32
+ /** The App's English catalog; each state item needs `uninstall.state.<key>`. */
33
+ en?: Catalog;
34
+ }
35
+ export declare function stateTextKey(key: string): string;
36
+ export declare function readUninstall(value: unknown, context: UninstallContext, fail: (error: StandaloneError) => void): Uninstall | undefined;
37
+ /** The hook in the staged package: a regular executable that does not try to become root. */
38
+ export declare function uninstallExecProblem(packageDir: string, purge: UninstallPurge): StandaloneError | undefined;
39
+ /** Each state item's text in this locale's catalog, English when the App does not ship it. */
40
+ export declare function stateItemTexts(uninstall: Uninstall, catalogs: Catalogs | undefined, locale: Locale): string[];
41
+ /**
42
+ * sdk-package.json's `uninstall`: the hook and the state item texts per
43
+ * locale, from which inspect renders the same preuninst and wizard again.
44
+ */
45
+ export interface UninstallRecord extends Uninstall {
46
+ texts?: Catalogs;
47
+ }
48
+ export declare function uninstallRecord(uninstall: Uninstall, catalogs: Catalogs | undefined): UninstallRecord;
49
+ /** The record read back and checked as the manifest's `uninstall` is; undefined when it does not hold. */
50
+ export declare function readUninstallRecord(value: unknown, rootOps: boolean): {
51
+ uninstall: Uninstall;
52
+ catalogs?: Catalogs;
53
+ } | undefined;
@@ -0,0 +1,136 @@
1
+ import { lstatSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { LOCALES } from "./i18n.js";
4
+ import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
5
+ import { envKey, relativeExec, token } from "./daemon.js";
6
+ /** The two flags the generated preuninst hands the hook. */
7
+ export const UNINSTALL_FLAGS = ["SYNOPKGLAND_DELETE_STATE", "SYNOPKGLAND_REMOVE_DATA"];
8
+ const FIELDS = new Set(["purge", "state"]);
9
+ const PURGE_FIELDS = new Set(["exec", "args", "env", "timeout", "root"]);
10
+ const STATE_KEY = /^[a-z][A-Za-z0-9_-]{0,31}$/;
11
+ const record = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
12
+ export function stateTextKey(key) {
13
+ return `uninstall.state.${key}`;
14
+ }
15
+ export function readUninstall(value, context, fail) {
16
+ if (value === undefined)
17
+ return undefined;
18
+ const invalid = () => {
19
+ fail({ code: "UNINSTALL_INVALID", message: "uninstall 只接受 purge {exec, args, env, timeout, root} 與 state {items: [{key}]};exec 須是套件內固定執行檔(不可在 executor/ 或 root/),timeout 為 2–120 秒整數。" });
20
+ return undefined;
21
+ };
22
+ if (!record(value) || Object.keys(value).some(k => !FIELDS.has(k)))
23
+ return invalid();
24
+ if (value.purge === undefined) {
25
+ if (value.state === undefined)
26
+ return invalid();
27
+ fail({ code: "UNINSTALL_STATE_REQUIRES_PURGE", message: "uninstall.state 需要 uninstall.purge:只有 App 自己知道狀態存在哪裡。" });
28
+ return undefined;
29
+ }
30
+ const purge = readPurge(value.purge, context, fail);
31
+ if (purge === "invalid")
32
+ return invalid();
33
+ const state = value.state === undefined ? undefined : readState(value.state);
34
+ if (state === "invalid")
35
+ return invalid();
36
+ const missing = (state?.items ?? []).map(item => stateTextKey(item.key)).filter(key => context.en?.[key] === undefined);
37
+ if (missing.length) {
38
+ fail({ code: "UNINSTALL_STATE_TEXT_MISSING", message: `uninstall.state 的項目需要 i18n en.json 文字:${missing.join(", ")}。` });
39
+ return undefined;
40
+ }
41
+ return purge && { purge, ...(state ? { state } : {}) };
42
+ }
43
+ function readPurge(value, context, fail) {
44
+ if (!record(value) || Object.keys(value).some(k => !PURGE_FIELDS.has(k)))
45
+ return "invalid";
46
+ const top = typeof value.exec === "string" ? value.exec.split("/")[0] : "";
47
+ if (!relativeExec(value.exec) || top === "executor" || top === "root")
48
+ return "invalid";
49
+ const args = value.args ?? [];
50
+ if (!Array.isArray(args) || args.length > 64 || !args.every(token))
51
+ return "invalid";
52
+ const env = value.env ?? {};
53
+ if (!record(env) || Object.keys(env).length > 64
54
+ || Object.entries(env).some(([k, v]) => !envKey(k) || k.startsWith("SYNOPKGLAND_") || !token(v)))
55
+ return "invalid";
56
+ const timeout = value.timeout ?? 60;
57
+ if (!Number.isInteger(timeout) || timeout < 2 || timeout > 120)
58
+ return "invalid";
59
+ const root = value.root;
60
+ if (root !== undefined) {
61
+ if (typeof root !== "string")
62
+ return "invalid";
63
+ if (!context.rootOps) {
64
+ fail({ code: "UNINSTALL_ROOT_REQUIRES_ROOT_OPS", message: "uninstall.purge.root 需要 capabilities.rootOps: true。" });
65
+ return undefined;
66
+ }
67
+ if (context.ops && !context.ops.some(op => op.name === root)) {
68
+ fail({ code: "UNINSTALL_ROOT_OP_MISSING", message: `uninstall.purge.root「${root}」需要 root/${root}.sh。` });
69
+ return undefined;
70
+ }
71
+ }
72
+ return {
73
+ exec: value.exec,
74
+ args: args,
75
+ env: Object.fromEntries(Object.keys(env).sort().map(key => [key, env[key]])),
76
+ timeout: timeout,
77
+ ...(root === undefined ? {} : { root }),
78
+ };
79
+ }
80
+ function readState(value) {
81
+ if (!record(value) || Object.keys(value).some(k => k !== "items") || !Array.isArray(value.items))
82
+ return "invalid";
83
+ const items = value.items;
84
+ if (items.length === 0 || items.length > 16)
85
+ return "invalid";
86
+ const keys = items.map(item => record(item) && Object.keys(item).length === 1 && typeof item.key === "string" && STATE_KEY.test(item.key) ? item.key : undefined);
87
+ if (keys.some(key => key === undefined) || new Set(keys).size !== keys.length)
88
+ return "invalid";
89
+ return { items: keys.map(key => ({ key: key })) };
90
+ }
91
+ /** The hook in the staged package: a regular executable that does not try to become root. */
92
+ export function uninstallExecProblem(packageDir, purge) {
93
+ const path = join(packageDir, purge.exec);
94
+ let stat;
95
+ try {
96
+ stat = lstatSync(path);
97
+ }
98
+ catch {
99
+ stat = undefined;
100
+ }
101
+ if (!stat?.isFile() || (stat.mode & 0o111) === 0) {
102
+ return { code: "UNINSTALL_EXEC_MISSING", message: `uninstall.purge.exec「${purge.exec}」不是套件內可執行的一般檔案。` };
103
+ }
104
+ const text = readFileSync(path).toString("latin1");
105
+ if (text.startsWith("#!") && ROOT_SCRIPT_HINTS.some(hint => hint.test(text))) {
106
+ return { code: "UNINSTALL_EXEC_UNSAFE", message: `uninstall.purge.exec「${purge.exec}」嘗試取得 root(sudo、chown root 或 setuid);root 只能經 uninstall.purge.root。` };
107
+ }
108
+ return undefined;
109
+ }
110
+ /** Each state item's text in this locale's catalog, English when the App does not ship it. */
111
+ export function stateItemTexts(uninstall, catalogs, locale) {
112
+ return (uninstall.state?.items ?? []).map(({ key }) => catalogs?.[locale]?.[stateTextKey(key)] ?? catalogs?.en[stateTextKey(key)] ?? key);
113
+ }
114
+ export function uninstallRecord(uninstall, catalogs) {
115
+ if (!uninstall.state)
116
+ return uninstall;
117
+ const keys = uninstall.state.items.map(item => stateTextKey(item.key));
118
+ const texts = Object.fromEntries(LOCALES.flatMap(locale => {
119
+ const catalog = catalogs?.[locale];
120
+ const found = Object.fromEntries(keys.filter(key => catalog?.[key] !== undefined).map(key => [key, catalog[key]]));
121
+ return Object.keys(found).length ? [[locale, found]] : [];
122
+ }));
123
+ return { ...uninstall, texts: texts };
124
+ }
125
+ /** The record read back and checked as the manifest's `uninstall` is; undefined when it does not hold. */
126
+ export function readUninstallRecord(value, rootOps) {
127
+ if (!record(value))
128
+ return undefined;
129
+ const { texts, ...rest } = value;
130
+ if (texts !== undefined && (!record(texts) || Object.entries(texts).some(([locale, catalog]) => !LOCALES.includes(locale) || !record(catalog) || Object.values(catalog).some(text => typeof text !== "string"))))
131
+ return undefined;
132
+ let failed = false;
133
+ const catalogs = texts;
134
+ const uninstall = readUninstall(rest, { rootOps, en: catalogs?.en }, () => { failed = true; });
135
+ return uninstall && !failed ? { uninstall, ...(catalogs ? { catalogs } : {}) } : undefined;
136
+ }
package/dist/types.d.ts CHANGED
@@ -56,4 +56,4 @@ export interface ComposeDocument {
56
56
  secrets?: Record<string, unknown>;
57
57
  [key: string]: unknown;
58
58
  }
59
- export type TemplateName = "compose" | "binary" | "native-go";
59
+ export type TemplateName = "compose" | "binary" | "native-go" | "native-rust";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Sets top-level keys of a YAML mapping in its source text: each named key's
3
+ * entry is rewritten, a missing one is appended, and every other line,
4
+ * comments and formatting included, stays as written.
5
+ */
6
+ export declare function setTopLevel(text: string, values: Record<string, unknown>): string;
@@ -0,0 +1,31 @@
1
+ import { isMap, isScalar, parseDocument, stringify } from "yaml";
2
+ /**
3
+ * Sets top-level keys of a YAML mapping in its source text: each named key's
4
+ * entry is rewritten, a missing one is appended, and every other line,
5
+ * comments and formatting included, stays as written.
6
+ */
7
+ export function setTopLevel(text, values) {
8
+ const doc = parseDocument(text);
9
+ if (!isMap(doc.contents))
10
+ throw new Error("YAML document is not a mapping.");
11
+ const edits = [];
12
+ let appended = "";
13
+ for (const [key, value] of Object.entries(values)) {
14
+ const rendered = stringify({ [key]: value });
15
+ const pair = doc.contents.items.find(item => isScalar(item.key) && item.key.value === key);
16
+ const start = isScalar(pair?.key) ? pair.key.range?.[0] : undefined;
17
+ const end = pair?.value?.range?.[1];
18
+ if (start === undefined || end === undefined) {
19
+ appended += rendered;
20
+ continue;
21
+ }
22
+ // A block value ends after its last line's newline; a flow or plain value before it.
23
+ edits.push({ start, end, text: text[end - 1] === "\n" ? rendered : rendered.replace(/\n$/, "") });
24
+ }
25
+ let out = text;
26
+ for (const edit of edits.sort((a, b) => b.start - a.start))
27
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
28
+ if (!appended)
29
+ return out;
30
+ return `${out}${out === "" || out.endsWith("\n") ? "" : "\n"}${appended}`;
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
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.9.4",
27
+ "@synopackageland/templates": "0.9.6",
28
28
  "ajv": "^8.17.1",
29
29
  "commander": "^13.1.0",
30
30
  "tar": "^7.4.3",
@@ -2,20 +2,24 @@
2
2
 
3
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.
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: the SDK does not register a portal for it.
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`.
6
6
 
7
- Build: 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
+ 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
9
  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
10
 
11
11
  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.
12
12
 
13
- DSM floor: os_min_ver must be 7.3.2-86009 or later. DSM 7.1.1-42962 refuses an unsigned SPK whose conf/resource carries web-config app-portal (Package Center error 320, "non-synology-signed package should not use internal worker"); 7.3.2-86009 and 7.4 install it; 7.2 and 7.3.0/7.3.1 are untested (unknown). validate/build fail with `STANDALONE_PORTAL_MIN_DSM`, `--min-dsm` cannot go below it, `add portal` raises a lower os_min_ver, and inspect flags a lower INFO os_min_ver. Fix: raise os_min_ver or remove the portal.
13
+ 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 CGI is built with `-X main.portal=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).
14
+
15
+ 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.
14
16
 
15
17
  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.
16
18
 
17
19
  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.
18
20
 
19
- Never: write `AppPortal.json`, `@appconf/<package>` or `/usr/syno/etc/www/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.
21
+ 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.
20
22
 
21
23
  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.
24
+
25
+ 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`).