@synopackageland/cli 0.17.3 → 0.17.5

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 (52) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +37 -1
  3. package/dist/build-spk/compose-diagnosis.d.ts +4 -0
  4. package/dist/build-spk/compose-diagnosis.js +12 -0
  5. package/dist/build-spk/compose-retained-data.d.ts +11 -0
  6. package/dist/build-spk/compose-retained-data.js +34 -0
  7. package/dist/build-spk/delete-app-data.d.ts +1 -1
  8. package/dist/build-spk/delete-app-data.js +1 -1
  9. package/dist/build-spk/uninstall-wizard.d.ts +2 -0
  10. package/dist/build-spk/uninstall-wizard.js +2 -1
  11. package/dist/build-spk/wizard-messages.d.ts +154 -0
  12. package/dist/build-spk/wizard-messages.js +154 -0
  13. package/dist/ctl/cli.js +10 -6
  14. package/dist/ctl/flows.js +1 -1
  15. package/dist/ctl/service.d.ts +4 -1
  16. package/dist/ctl/service.js +20 -6
  17. package/dist/ctl/transfers.d.ts +1 -1
  18. package/dist/ctl/transfers.js +5 -2
  19. package/dist/ctl/transport.d.ts +2 -1
  20. package/dist/ctl/transport.js +7 -3
  21. package/dist/runtime/compose-data.d.ts +10 -0
  22. package/dist/runtime/compose-data.js +20 -0
  23. package/dist/runtime/executor-digests.json +22 -0
  24. package/dist/runtime/executor-policy.d.ts +25 -0
  25. package/dist/runtime/executor-policy.js +10 -1
  26. package/dist/runtime/runtime-budgets.d.ts +24 -0
  27. package/dist/runtime/runtime-budgets.js +13 -0
  28. package/dist/standalone/background.d.ts +3 -3
  29. package/dist/standalone/background.js +12 -11
  30. package/dist/standalone/compose-errors.js +10 -0
  31. package/dist/standalone/compose.d.ts +5 -2
  32. package/dist/standalone/compose.js +19 -2
  33. package/dist/standalone/daemon.d.ts +4 -0
  34. package/dist/standalone/daemon.js +36 -23
  35. package/dist/standalone/dsm-window.js +2 -1
  36. package/dist/standalone/identity-gateway.js +9 -1
  37. package/dist/standalone/inspect-spk.js +87 -15
  38. package/dist/standalone/lifecycle.js +24 -10
  39. package/dist/standalone/manifest.d.ts +1 -0
  40. package/dist/standalone/manifest.js +8 -5
  41. package/dist/standalone/native-gateway.d.ts +17 -0
  42. package/dist/standalone/native-gateway.js +115 -0
  43. package/dist/standalone/resource.d.ts +3 -0
  44. package/dist/standalone/resource.js +3 -1
  45. package/dist/standalone/spk.js +14 -3
  46. package/dist/standalone/uninstall-hook.js +21 -8
  47. package/dist/standalone/uninstall.d.ts +1 -1
  48. package/dist/standalone/uninstall.js +2 -2
  49. package/package.json +2 -2
  50. package/skills/reference/compose.md +53 -0
  51. package/skills/reference/create.md +1 -1
  52. package/skills/reference/lifecycle.md +25 -0
@@ -0,0 +1,115 @@
1
+ import { isDsmAtLeast, PORTAL_MIN_DSM } from '../build-spk/dsm-version.js';
2
+ export const NATIVE_GATEWAY_CONFIG = 'sso/native-nginx.conf';
3
+ // Earlier SDK releases do not understand native_ingress. Keep their JSON ABI
4
+ // and put an explanation in front of their existing direct-port launcher.
5
+ export function nativeGatewaySdk(sdk) {
6
+ const [major, minor, patch] = sdk.split('.').map(Number);
7
+ return major > 0 || minor > 17 || minor === 17 && patch >= 5;
8
+ }
9
+ export function nativeGatewaySupported(m) {
10
+ return Boolean(m.identityGateway && nativeGatewaySdk(m.identityGateway.sdk)
11
+ && m.os_min_ver && isDsmAtLeast(m.os_min_ver, PORTAL_MIN_DSM));
12
+ }
13
+ export function gatewayEntryPath(m) {
14
+ return `/webman/3rdparty/${m.package}/sso/open.${m.identityGateway && !nativeGatewaySdk(m.identityGateway.sdk) ? 'html' : 'cgi'}`;
15
+ }
16
+ export function legacyGatewayNotice() {
17
+ return '<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; frame-ancestors \'none\'"><title>Direct App connection</title><p>DSM-managed HTTPS is unavailable for this App. The direct connection may show a certificate warning or use unencrypted HTTP.</p><a href="open.cgi">Continue to the direct connection</a></html>\n';
18
+ }
19
+ export function nativeGatewayResource(m) {
20
+ if (!nativeGatewaySupported(m))
21
+ return undefined;
22
+ const g = m.identityGateway, https = g.listen.https ?? g.listen.http;
23
+ const ports = [https, ...(g.listen.http && g.listen.http !== https ? [g.listen.http] : [])];
24
+ return { 'nginx-static-config': { enable: [{ type: 'server', relpath: NATIVE_GATEWAY_CONFIG,
25
+ ports: ports.map(port => ({ port, protocol: 'tcp', schema: port === https ? 'https' : 'http' })) }] } };
26
+ }
27
+ export function renderNativeGatewayConfig(m) {
28
+ if (!nativeGatewaySupported(m))
29
+ return '';
30
+ const g = m.identityGateway, port = g.listen.https ?? g.listen.http;
31
+ const state = `/var/packages/${m.package}/var/sso`;
32
+ // Public ssl_certificate is deliberately inherited from DSM's http context.
33
+ // Only nginx's master reads the package's private upstream key. No DSM key
34
+ // is read or copied by the package, and no secret appears in this config.
35
+ return `server {
36
+ listen ${port} ssl;
37
+ listen [::]:${port} ssl;
38
+ server_name _;
39
+ client_max_body_size 0;
40
+ location / {
41
+ proxy_pass https://unix:/tmp/synopkgland-gateway-${m.package}.sock:;
42
+ proxy_http_version 1.1;
43
+ proxy_ssl_verify on;
44
+ proxy_ssl_verify_depth 1;
45
+ proxy_ssl_server_name on;
46
+ proxy_ssl_name gateway.invalid;
47
+ proxy_ssl_trusted_certificate ${state}/native-cert.pem;
48
+ proxy_ssl_certificate ${state}/native-cert.pem;
49
+ proxy_ssl_certificate_key ${state}/native-key.pem;
50
+ proxy_set_header Host $http_host;
51
+ proxy_set_header X-Synopkgland-Client-IP $remote_addr;
52
+ proxy_set_header Upgrade $http_upgrade;
53
+ proxy_set_header Connection $http_connection;
54
+ proxy_request_buffering off;
55
+ proxy_buffering off;
56
+ proxy_read_timeout 3600s;
57
+ }
58
+ }
59
+ ${g.listen.http && g.listen.http !== port ? `server {
60
+ listen ${g.listen.http};
61
+ listen [::]:${g.listen.http};
62
+ server_name _;
63
+ return 308 https://$host:${port}$request_uri;
64
+ }
65
+ ` : ''}`;
66
+ }
67
+ /** Runs after DSM creates pkgvar, before package start activates the nginx resource. */
68
+ export function renderNativeGatewayPrepare(m) {
69
+ if (!nativeGatewaySupported(m))
70
+ return '';
71
+ return `synopkgland_native_keys() (
72
+ set -eu
73
+ umask 077
74
+ v="\${SYNOPKG_PKGVAR:-/var/packages/${m.package}/var}/sso"
75
+ if [ -L "$v" ]; then echo 'Native HTTPS state must not be a symlink.' >&2; exit 1; fi
76
+ mkdir -p "$v"
77
+ if [ "$(stat -c %a "$v")" != 700 ]; then echo 'Native HTTPS state must have mode 0700.' >&2; exit 1; fi
78
+ c="$v/native-cert.pem"; k="$v/native-key.pem"
79
+ if [ -e "$c" ] || [ -L "$c" ] || [ -e "$k" ] || [ -L "$k" ]; then
80
+ for f in "$c" "$k"; do
81
+ if [ -L "$f" ] || [ ! -f "$f" ] || [ "$(stat -c %a "$f")" != 600 ]; then
82
+ echo 'Native HTTPS requires a complete private keypair with mode 0600.' >&2; exit 1
83
+ fi
84
+ done
85
+ /usr/bin/openssl x509 -in "$c" -noout -checkend 0 >/dev/null || exit 1
86
+ cp=$(/usr/bin/openssl x509 -in "$c" -pubkey -noout | /usr/bin/openssl sha256)
87
+ kp=$(/usr/bin/openssl pkey -in "$k" -pubout | /usr/bin/openssl sha256)
88
+ [ "$cp" = "$kp" ] || { echo 'Native HTTPS keypair does not match.' >&2; exit 1; }
89
+ exit 0
90
+ fi
91
+ t=$(mktemp -d "$v/.native-tls.XXXXXX")
92
+ trap 'rm -rf "$t"' EXIT HUP INT TERM
93
+ cat > "$t/openssl.cnf" <<'NATIVE_TLS_CONFIG'
94
+ [req]
95
+ distinguished_name=subject
96
+ x509_extensions=extensions
97
+ prompt=no
98
+ [subject]
99
+ CN=gateway.invalid
100
+ [extensions]
101
+ basicConstraints=critical,CA:TRUE
102
+ keyUsage=critical,digitalSignature,keyEncipherment,keyCertSign
103
+ extendedKeyUsage=serverAuth,clientAuth
104
+ subjectAltName=DNS:gateway.invalid
105
+ NATIVE_TLS_CONFIG
106
+ /usr/bin/openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -config "$t/openssl.cnf" -keyout "$t/key.pem" -out "$t/cert.pem" >/dev/null 2>&1 || {
107
+ echo 'Could not prepare the private native HTTPS connection.' >&2; exit 1;
108
+ }
109
+ chmod 600 "$t/key.pem" "$t/cert.pem"
110
+ mv "$t/key.pem" "$k"
111
+ mv "$t/cert.pem" "$c"
112
+ )
113
+ synopkgland_native_keys || exit $?
114
+ `;
115
+ }
@@ -2,6 +2,7 @@ import type { ComposeApp } from "./compose.js";
2
2
  import type { Capabilities } from "./manifest.js";
3
3
  import { type SharedFolder } from "./shared-folder.js";
4
4
  import { type PortalPackage } from "../portal/package.js";
5
+ import { type NativeGatewayPlan } from './native-gateway.js';
5
6
  /**
6
7
  * `conf/resource` for a native package: only DSM's official `data-share`,
7
8
  * `systemd-user-unit` and `web-config` workers, in a fixed key order.
@@ -9,6 +10,8 @@ import { type PortalPackage } from "../portal/package.js";
9
10
  */
10
11
  export declare function renderStandaloneResource(m: {
11
12
  package: string;
13
+ os_min_ver?: string;
14
+ identityGateway?: NativeGatewayPlan;
12
15
  capabilities: Capabilities;
13
16
  compose?: ComposeApp;
14
17
  sharedFolder?: SharedFolder;
@@ -1,6 +1,7 @@
1
1
  import { renderDockerProjectResource } from "../build-spk/resource-worker.js";
2
2
  import { sharedFolderResource } from "./shared-folder.js";
3
3
  import { portalResource } from "../portal/package.js";
4
+ import { nativeGatewayResource } from './native-gateway.js';
4
5
  /**
5
6
  * `conf/resource` for a native package: only DSM's official `data-share`,
6
7
  * `systemd-user-unit` and `web-config` workers, in a fixed key order.
@@ -8,13 +9,14 @@ import { portalResource } from "../portal/package.js";
8
9
  */
9
10
  export function renderStandaloneResource(m, portal) {
10
11
  const dataShare = m.sharedFolder && sharedFolderResource(m.package, m.sharedFolder);
12
+ const webConfig = { ...(portal ? portalResource()['web-config'] : {}), ...nativeGatewayResource(m) };
11
13
  const resource = {
12
14
  ...(m.compose && !m.compose.plan.mounts_blocked
13
15
  ? renderDockerProjectResource(m.package, "compose", false, m.compose.plan.project_name)
14
16
  : {}),
15
17
  ...(dataShare ? { "data-share": dataShare } : {}),
16
18
  ...(m.capabilities.jobs ? { "systemd-user-unit": {} } : {}),
17
- ...(portal ? portalResource() : {}),
19
+ ...(Object.keys(webConfig).length ? { 'web-config': webConfig } : {}),
18
20
  };
19
21
  return Object.keys(resource).length > 0
20
22
  ? JSON.stringify(resource)
@@ -1,3 +1,4 @@
1
+ import { nativeGatewaySupported, nativeGatewaySdk, renderNativeGatewayConfig, NATIVE_GATEWAY_CONFIG, legacyGatewayNotice, gatewayEntryPath } from './native-gateway.js';
1
2
  import { portalInfoFields } from "../portal/info.js";
2
3
  import { sdkSource } from "../runtime/sdk-source.js";
3
4
  import { resolvedSdkLaunch } from "../contract/app-launch.js";
@@ -116,6 +117,10 @@ export async function assembleStandaloneSpk(options) {
116
117
  chmodSync(target, EXEC_MODE);
117
118
  }
118
119
  writeFile(join(staging, "package", GATEWAY_CONFIG_PATH), identityGatewayConfig(manifest));
120
+ if (nativeGatewaySupported(manifest))
121
+ writeFile(join(staging, 'package', NATIVE_GATEWAY_CONFIG), renderNativeGatewayConfig(manifest));
122
+ if (!nativeGatewaySdk(manifest.identityGateway.sdk))
123
+ writeFile(join(staging, 'package/ui/sso/open.html'), legacyGatewayNotice());
119
124
  }
120
125
  if (manifest.capabilities.jobs) {
121
126
  if (!options.backgroundWorker)
@@ -208,8 +213,11 @@ export async function assembleStandaloneSpk(options) {
208
213
  .digest("hex");
209
214
  writeFile(join(staging, "sdk-package.json"), JSON.stringify({
210
215
  schema: 2,
216
+ ...(manifest.lifecycleTimeouts ? { lifecycleTimeouts: manifest.lifecycleTimeouts } : {}),
217
+ ...("timeoutSeconds" in manifest.backend && manifest.backend.timeoutSeconds ? { operationSeconds: manifest.backend.timeoutSeconds } : {}),
211
218
  ...(options.apiDescription ? { ctl: { hash: options.apiDescription.sha256, registry: options.apiRegistry } } : {}),
212
219
  backend: manifest.backend.kind,
220
+ ...(nativeGatewaySupported(manifest) ? { native_gateway: { sdk: manifest.identityGateway.sdk, listen: manifest.identityGateway.listen } } : {}),
213
221
  launch: resolvedSdkLaunch(manifest),
214
222
  capabilities: Object.keys(manifest.capabilities).sort(),
215
223
  ...(manifest.compose
@@ -236,7 +244,7 @@ export async function assembleStandaloneSpk(options) {
236
244
  writeFile(join(staging, "conf", "resource"), resource);
237
245
  if (manifest.capabilities.jobs) {
238
246
  mkdirSync(join(staging, BACKGROUND_UNIT_DIR), { recursive: true });
239
- writeFile(join(staging, BACKGROUND_UNIT_DIR, backgroundUnitName(manifest.package)), renderBackgroundUnit(manifest.package, isBridgeBackend(manifest.backend) ? BRIDGE_WORKER_ARGS : []));
247
+ writeFile(join(staging, BACKGROUND_UNIT_DIR, backgroundUnitName(manifest.package)), renderBackgroundUnit(manifest.package, isBridgeBackend(manifest.backend) ? BRIDGE_WORKER_ARGS : [], manifest.daemon?.timeouts.stop ?? manifest.lifecycleTimeouts?.stop ?? 30));
240
248
  }
241
249
  mkdirSync(join(staging, "scripts"));
242
250
  for (const name of LIFECYCLE_SCRIPTS) {
@@ -265,6 +273,7 @@ export async function assembleStandaloneSpk(options) {
265
273
  ? renderSharedFolderUpgradeWizardFiles(manifest.package, folder)
266
274
  : {}),
267
275
  ...renderUninstallWizardFiles(manifest.package, "native", {
276
+ composeData: manifest.compose?.plan.app_data,
268
277
  executor: Boolean(options.executor),
269
278
  portal: Boolean(portal),
270
279
  portalEntry: Boolean(manifest.portal && portalByExecutor(manifest.os_min_ver)),
@@ -324,7 +333,7 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
324
333
  if (manifest.identityGateway)
325
334
  fields.push([
326
335
  "adminurl",
327
- `/webman/3rdparty/${manifest.package}/sso/open.cgi`,
336
+ gatewayEntryPath(manifest),
328
337
  ]);
329
338
  }
330
339
  const backend = manifest.backend;
@@ -341,7 +350,9 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
341
350
  if (text?.description)
342
351
  fields.push([`description_${DSM_LANGS[locale]}`, text.description]);
343
352
  }
344
- return portalInfoFields(fields, Boolean(manifest.portal)).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
353
+ if (nativeGatewaySupported(manifest))
354
+ fields.push(['startstop_restart_services', 'nginx.service']);
355
+ return portalInfoFields(fields, Boolean(manifest.portal) || nativeGatewaySupported(manifest)).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
345
356
  }
346
357
  function copyUiTree(source, target) {
347
358
  for (const entry of ["app.js", "app.css"]) {
@@ -22,6 +22,7 @@ export function renderUninstallHook(manifest) {
22
22
  if (!purge)
23
23
  return "";
24
24
  const pkg = manifest.package;
25
+ const reserve = Math.min(5, Math.max(1, Math.floor(purge.timeout / 4)));
25
26
  const env = [
26
27
  "PATH=/usr/bin:/bin", 'HOME="$hook_var"', 'TMPDIR="$hook_var/tmp"',
27
28
  ...Object.entries(purge.env).map(([key, value]) => `${key}=${word(value)}`),
@@ -47,7 +48,11 @@ ${manifest.uninstall?.state ? wizardFlag("delete_state", ["pkgwizard_delete_stat
47
48
  # DSM's bash logs a failed kill to syslog even with 2>/dev/null, so a
48
49
  # finished hook is looked up in /proc wherever there is one.
49
50
  hook_running() {
50
- if [ -d /proc/self ]; then [ -d "/proc/$hook_pid" ]; else kill -0 "$hook_pid" 2>/dev/null; fi
51
+ if [ -d /proc/self ]; then
52
+ [ -r "/proc/$hook_pid/stat" ] || return 1
53
+ case "$(sed 's/.*) //' "/proc/$hook_pid/stat" 2>/dev/null)" in Z*|X*|'') return 1 ;; esac
54
+ return 0
55
+ else kill -0 "$hook_pid" 2>/dev/null; fi
51
56
  }
52
57
  # A process in /proc whose group is the hook's ("pid (comm) state ppid pgrp ...").
53
58
  hook_group_running() {
@@ -62,19 +67,27 @@ ${manifest.uninstall?.state ? wizardFlag("delete_state", ["pkgwizard_delete_stat
62
67
  kill -"$1" "$hook_pid"
63
68
  fi
64
69
  }
65
- hook_waited=0
66
- while hook_running && [ "$hook_waited" -lt ${purge.timeout} ]; do
67
- sleep 1
68
- hook_waited=$((hook_waited + 1))
70
+ hook_now() { if [ -r /proc/uptime ]; then awk '{print int($1)}' /proc/uptime; else date +%s; fi; }
71
+ hook_until=$(( $(hook_now) + ${purge.timeout} ))
72
+ hook_work_until=$((hook_until - ${reserve}))
73
+ while hook_running && [ "$(hook_now)" -lt "$hook_work_until" ]; do
74
+ sleep 0.1
69
75
  done
70
76
  hook_failure=
71
77
  if hook_running; then
72
78
  hook_signal TERM
73
- sleep 1
79
+ hook_grace_until=$((hook_until - 1))
80
+ while hook_group_running && [ "$(hook_now)" -lt "$hook_grace_until" ]; do sleep 0.1; done
74
81
  hook_signal KILL
75
- wait "$hook_pid" 2>/dev/null
76
- hook_failure="timeout after ${purge.timeout} s"
82
+ while hook_running && [ "$(hook_now)" -lt "$hook_until" ]; do sleep 0.1; done
83
+ if hook_running || hook_group_running; then
84
+ echo cleanup-pending >"$hook_var/uninstall-hook.pending"
85
+ else
86
+ wait "$hook_pid" 2>/dev/null
87
+ fi
88
+ hook_failure="timeout after ${purge.timeout} s; cleanup may be pending"
77
89
  else
90
+ rm -f "$hook_var/uninstall-hook.pending"
78
91
  wait "$hook_pid"
79
92
  hook_status=$?
80
93
  [ "$hook_status" -eq 0 ] || hook_failure="exit $hook_status"
@@ -19,7 +19,7 @@ export interface UninstallPurge {
19
19
  exec: string;
20
20
  args: string[];
21
21
  env: Record<string, string>;
22
- /** Seconds, 2..120. */
22
+ /** Seconds, 2..3600. */
23
23
  timeout: number;
24
24
  /** A root/<name>.sh the executor runs at uninstall, only while its approval is live. */
25
25
  root?: string;
@@ -16,7 +16,7 @@ export function readUninstall(value, context, fail) {
16
16
  if (value === undefined)
17
17
  return undefined;
18
18
  const invalid = () => {
19
- fail({ code: "UNINSTALL_INVALID", message: "uninstall accepts only purge {exec, args, env, timeout, root} and state {items: [{key}]}; exec must be a fixed executable inside the package (not in executor/ or root/), and timeout is an integer from 2 to 120 seconds." });
19
+ fail({ code: "UNINSTALL_INVALID", message: "uninstall accepts only purge {exec, args, env, timeout, root} and state {items: [{key}]}; exec must be a fixed executable inside the package (not in executor/ or root/), and timeout is an integer from 2 to 3600 seconds." });
20
20
  return undefined;
21
21
  };
22
22
  if (!record(value) || Object.keys(value).some(k => !FIELDS.has(k)))
@@ -54,7 +54,7 @@ function readPurge(value, context, fail) {
54
54
  || Object.entries(env).some(([k, v]) => !envKey(k) || k.startsWith("SYNOPKGLAND_") || !token(v)))
55
55
  return "invalid";
56
56
  const timeout = value.timeout ?? 60;
57
- if (!Number.isInteger(timeout) || timeout < 2 || timeout > 120)
57
+ if (!Number.isInteger(timeout) || timeout < 2 || timeout > 3600)
58
58
  return "invalid";
59
59
  const root = value.root;
60
60
  if (root !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.17.3",
3
+ "version": "0.17.5",
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.17.5",
30
+ "@synopackageland/templates": "0.17.8",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
@@ -35,6 +35,34 @@ and `identity_gateway` for headless projects. Optional `compose.helpers` names
35
35
  architecture-specific native payloads for token/account adapters. All lifecycle
36
36
  scripts run as the package account; containers are managed by Container Manager.
37
37
 
38
+ ## Startup budget
39
+
40
+ Set `compose.startup_timeout_seconds: 600` for a slow cold start or migration.
41
+ Omitting it uses 120 seconds; only integers from 2 through 900 are accepted.
42
+ Lock waiting, status, readiness, stability and handoff share that deadline; there
43
+ is no extra fixed handoff allowance. The cap is a policy bound, not a measured
44
+ DSM lifecycle limit. `daemon: {timeouts: {stop: 120, status: 15, uninstall: 300}}`
45
+ configures the other lifecycle phases without declaring a daemon payload.
46
+ A transient health failure closes new ingress while the running App gets one
47
+ operation-budget recovery window; authority or generation failures stop at once.
48
+ Use a matching SDK that implements the field and rebuild the App; older planners
49
+ are rejected for explicit budgets. The SDK plan drives generated daemon start
50
+ and supplies every container with `SYNOPKGLAND_STARTUP_BUDGET_SECONDS`, overriding
51
+ upstream and service-policy values for that SDK-owned key.
52
+
53
+ Custom bootstrap adapters must read that environment value once and use one
54
+ deadline across all bootstrap phases; an existing hardcoded adapter timer does
55
+ not change automatically. Keep the health endpoint unready until setup/migration
56
+ and any serving restart finish. Do not log credentials, raw API errors or bodies.
57
+
58
+ The gateway starts after readiness, so ingress remains closed while waiting.
59
+ The supervisor reserves five extra seconds for handoff/probe completion/process
60
+ stability. Timeout returns `COMPOSE_STARTUP_TIMEOUT` plus a safe last `phase` code
61
+ in the start result and `daemon/daemon.log`. Stop/uninstall limits are independent.
62
+ The budget begins after the native Container Manager worker returns; it does not
63
+ extend image pulls, native worker acquisition or Package Center deadlines. Verify
64
+ the installed DSM unit and full fresh-install lifecycle on the target NAS.
65
+
38
66
  ## Planning and artifacts
39
67
 
40
68
  The pinned compose-go reference loader merges ordered files, env files and
@@ -364,3 +392,28 @@ Do not silently switch networking, add Docker socket access, run an App hook as
364
392
  root or delete DSM configuration to claim compatibility.
365
393
 
366
394
  Compose web health is required before check/build; init has no guessed endpoint or status. Select both `--health-path` and `--health-status`, or edit `compose.web.health`. Readiness does not follow redirects. Static check is not an HTTP or arbitrary-UID/storage preflight. For official-image upstream packaging and DSM login, read [upstream-fork.md](upstream-fork.md).
395
+
396
+
397
+ ## Named volumes and uninstall
398
+
399
+ The SDK planner and lock inventory all current project-owned named volumes as
400
+ App data, with exact names. Compose alone never selects an executor or root
401
+ permissions. The package account does not delete named volumes or access Docker.
402
+
403
+ Before confirmation, the uninstall wizard states that container data volumes
404
+ are kept even when delete-data is selected, and lists the current exact names.
405
+ Only exact `SYNOPKG_PKG_STATUS=UNINSTALL` with an affirmative
406
+ `pkgwizard_remove_additional` (or supported mirror/legacy alias) clears the
407
+ package data/config/home contents the package account can clear. Upgrade, stop,
408
+ unknown status and keep preserve data. Unsafe paths and remnants are reported.
409
+
410
+ On delete-data uninstall with a nonempty volume inventory, generated postuninst
411
+ appends nine-language native text through `SYNOPKG_TEMP_LOGFILE`, preserving
412
+ existing text. It says plainly the listed volumes were NOT deleted and ends
413
+ with `COMPOSE_DATA_RETAINED`. The list is the current planner/lock inventory,
414
+ not a runtime existence check; volumes retired by older versions may also remain.
415
+ No full-data-deletion success is claimed and no unverified deletion path is given.
416
+ Administrator cleanup procedures, executor-tier Apps and storage redesign are
417
+ out of scope. Final DSM native UI acceptance remains required.
418
+
419
+ See the SDK's [Compose App data contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/main/docs/compose-app-data.md).
@@ -113,7 +113,7 @@ root:
113
113
  migrate: {} # 沒有 run、job:只作為 start 步驟
114
114
  ```
115
115
 
116
- start、stop 步驟與 `uninstall.purge.root` 都以 root、無 session 執行,只在管理員核准有效時跑;這些程式不能宣告 run 或 job(session 要用時以另一個名稱設定,可指向同一份內容)。start 步驟在新版本第一次啟動、executor 開 socket 並回讀核准任務之後執行,最多 60 秒;執行期間 executor 只回應 `status`(`state: "starting"`),其他請求回可重試的 `EXECUTOR_STARTING`,DSM 視窗的核准會等它結束,狀態讀取則立即回 `executor_starting`、由 SDK 面板自行重試,失敗只寫進 executor log,不擋啟動;同一版本不重跑,程式要能重複執行。結果由 executor `status` 的 `start_step` 讀取(`pending`、`ok`、`failed`,含 exit 與 output)。DSM 的 start 只寫入要求就返回,所以 `dsm-scripts/postinst`、`postupgrade`、`start` 看不到它的結果;root 的清理或遷移放 `root.start`,非特權的收尾放 `dsm-scripts/`。宣告 `root.start` 會改變 policy digest,需要管理員重新核准;沒有宣告的 App policy 不變。需要 SDK 0.10.3 以上。
116
+ start、stop 步驟與 `uninstall.purge.root` 都以 root、無 session 執行,只在管理員核准有效時跑;這些程式不能宣告 run 或 job(session 要用時以另一個名稱設定,可指向同一份內容)。start 步驟在新版本第一次啟動、executor 開 socket 並回讀核准任務之後執行,預設最多 60 秒;若宣告 lifecycle budget,則從核准的 start 剩餘預算扣除收尾餘裕;執行期間 executor 只回應 `status`(`state: "starting"`),其他請求回可重試的 `EXECUTOR_STARTING`,DSM 視窗的核准會等它結束,狀態讀取則立即回 `executor_starting`、由 SDK 面板自行重試,失敗只寫進 executor log,不擋啟動;同一版本不重跑,程式要能重複執行。結果由 executor `status` 的 `start_step` 讀取(`pending`、`ok`、`failed`,含 exit 與 output)。DSM 的 start 只寫入要求就返回,所以 `dsm-scripts/postinst`、`postupgrade`、`start` 看不到它的結果;root 的清理或遷移放 `root.start`,非特權的收尾放 `dsm-scripts/`。宣告 `root.start` 會改變 policy digest,需要管理員重新核准;沒有宣告的 App policy 不變。需要 SDK 0.10.3 以上。
117
117
 
118
118
  schema 是 executor 的封閉子集(每個 object 都要 `additionalProperties: false`,key `[a-z0-9_]`,pattern 為 RE2)。這些設定需要支援它們的 SDK executor(`EXECUTOR_ROOT_PROGRAMS_UNSUPPORTED`)。preupgrade 呼叫 `executor/lifecycle stop --upgrade`;舊 SDK 的 helper 不認得此旗標時改用一般 stop。
119
119
 
@@ -108,3 +108,28 @@ A silent upgrade has no wizard values; use a default.
108
108
  change they made.
109
109
  - An upgrade runs the old package's preuninst and postuninst; check
110
110
  `SYNOPKG_PKG_STATUS` before deleting anything.
111
+
112
+ ## Work budgets
113
+
114
+ Use existing `daemon.timeouts` for start/stop/status/uninstall (defaults
115
+ 30/30/5/60 seconds). Start/stop/uninstall accept 2–3600 seconds and status accepts
116
+ 1–3600. A `daemon` containing only `timeouts` declares budgets without an
117
+ executable, including background and Compose Apps; Compose start still uses
118
+ `compose.startup_timeout_seconds` (120, 2–900). Background user-manager recovery
119
+ uses the start allowance and its generated package user unit uses the stop
120
+ allowance with `KillMode=control-group`.
121
+
122
+ `backend.timeout_seconds` owns request/operation work. Binary and Compose Apps
123
+ without a CGI request setting can enlarge the shared operation allowance through
124
+ `daemon.timeouts.status` above its 60-second default. Generated gateway and
125
+ approved executor configurations inherit it; nested calls use remaining time and
126
+ leave response reserve. Budget changes in the protected policy require renewed
127
+ approval and do not change token/session/grant lifetimes.
128
+
129
+ `uninstall.purge.timeout` accepts 2–3600 seconds and includes termination reserve.
130
+ A timeout is logged and may leave `uninstall-hook.pending`; the uninstall may
131
+ continue. The executor helper's JSON state distinguishes `accepted`,
132
+ `cleanup-complete` and `cleanup-pending`. An exit of zero acknowledges the retained
133
+ request and does not prove cleanup finished. Unconfirmed cleanup retains protected
134
+ state for a later retry. DSM's outer install/uninstall deadline is not established
135
+ by these settings.