@synopackageland/cli 0.17.4 → 0.17.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 (49) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +27 -1
  3. package/dist/build-spk/compose-retained-data.d.ts +11 -0
  4. package/dist/build-spk/compose-retained-data.js +34 -0
  5. package/dist/build-spk/delete-app-data.d.ts +1 -1
  6. package/dist/build-spk/delete-app-data.js +1 -1
  7. package/dist/build-spk/uninstall-wizard.d.ts +2 -0
  8. package/dist/build-spk/uninstall-wizard.js +2 -1
  9. package/dist/build-spk/wizard-messages.d.ts +22 -0
  10. package/dist/build-spk/wizard-messages.js +22 -0
  11. package/dist/ctl/cli.js +10 -6
  12. package/dist/ctl/flows.js +1 -1
  13. package/dist/ctl/service.d.ts +4 -1
  14. package/dist/ctl/service.js +20 -6
  15. package/dist/ctl/transfers.d.ts +1 -1
  16. package/dist/ctl/transfers.js +5 -2
  17. package/dist/ctl/transport.d.ts +2 -1
  18. package/dist/ctl/transport.js +7 -3
  19. package/dist/runtime/compose-data.d.ts +10 -0
  20. package/dist/runtime/compose-data.js +20 -0
  21. package/dist/runtime/executor-digests.json +11 -0
  22. package/dist/runtime/executor-policy.d.ts +25 -0
  23. package/dist/runtime/executor-policy.js +10 -1
  24. package/dist/runtime/runtime-budgets.d.ts +24 -0
  25. package/dist/runtime/runtime-budgets.js +13 -0
  26. package/dist/standalone/background.d.ts +3 -3
  27. package/dist/standalone/background.js +12 -11
  28. package/dist/standalone/compose.d.ts +3 -1
  29. package/dist/standalone/compose.js +8 -1
  30. package/dist/standalone/daemon.d.ts +4 -0
  31. package/dist/standalone/daemon.js +36 -23
  32. package/dist/standalone/dsm-window.js +2 -1
  33. package/dist/standalone/identity-gateway.js +9 -1
  34. package/dist/standalone/inspect-spk.js +122 -35
  35. package/dist/standalone/lifecycle.js +16 -9
  36. package/dist/standalone/manifest.d.ts +1 -0
  37. package/dist/standalone/manifest.js +8 -5
  38. package/dist/standalone/native-gateway.d.ts +17 -0
  39. package/dist/standalone/native-gateway.js +115 -0
  40. package/dist/standalone/resource.d.ts +3 -0
  41. package/dist/standalone/resource.js +3 -1
  42. package/dist/standalone/spk.js +14 -3
  43. package/dist/standalone/uninstall-hook.js +21 -8
  44. package/dist/standalone/uninstall.d.ts +1 -1
  45. package/dist/standalone/uninstall.js +2 -2
  46. package/package.json +2 -2
  47. package/skills/reference/compose.md +31 -0
  48. package/skills/reference/create.md +1 -1
  49. package/skills/reference/lifecycle.md +25 -0
@@ -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.4",
3
+ "version": "0.17.6",
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.6",
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",
@@ -39,6 +39,12 @@ scripts run as the package account; containers are managed by Container Manager.
39
39
 
40
40
  Set `compose.startup_timeout_seconds: 600` for a slow cold start or migration.
41
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.
42
48
  Use a matching SDK that implements the field and rebuild the App; older planners
43
49
  are rejected for explicit budgets. The SDK plan drives generated daemon start
44
50
  and supplies every container with `SYNOPKGLAND_STARTUP_BUDGET_SECONDS`, overriding
@@ -386,3 +392,28 @@ Do not silently switch networking, add Docker socket access, run an App hook as
386
392
  root or delete DSM configuration to claim compatibility.
387
393
 
388
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.