@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
package/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.17.6 (2026-10-20)
4
+
5
+ - Inspect sharedFolder, Portal and install-selected Apps by reconstructing fixed lifecycle scripts from their complete inputs, including native HTTPS ingress and App hooks.
6
+
7
+ ## 0.17.5 (2026-10-20)
8
+
9
+ - Run native HTTPS preparation inside the SDK lifecycle steps before App hooks, keeping generated hook wrappers inspectable.
10
+
11
+ - Preserve older SDK gateway/controller JSON and supported timeout limits so their direct-connection fallback remains buildable; reject expanded budgets that require SDK 0.17.5.
12
+
13
+ - Keep Compose named volumes without adding executor/root permissions; warn before uninstall and report their exact undeleted names in nine native locales when delete-data is selected. Clear package-owned data/config/home contents only on explicit uninstall, preserving data on upgrades, stops and keep.
14
+
15
+ - Default supported gateway Apps to a DSM-managed HTTPS origin using native package resources and a private authenticated gateway socket. Preserve direct connections for older SDK/DSM targets with an explanation before opening them.
16
+ - Prepare private connection credentials as the package account after DSM creates package state, and reject modified gateway proxy settings during SPK inspection.
17
+ - Apply `ctl --timeout` to discovery, JSON requests, polling and transfer inactivity without replaying uncertain writes; use a 62-second default transport allowance.
18
+ - Reuse `daemon.timeouts` for native, Compose and background lifecycle work, including declarations that need no daemon executable. Allow lifecycle and App purge budgets up to 3600 seconds while retaining the Compose startup cap.
19
+ - Generate matching gateway, supervisor and approved executor budgets, preserve them through SPK inspection, and report pending uninstall cleanup within bounded TERM/reap reserve.
20
+
21
+ - Pin templates 0.17.8 and SDK/App 0.17.5, including UI 0.12.4 full-page layout. Updated executor and lifecycle payloads require administrator reapproval after upgrading rebuilt Apps.
4
22
 
5
23
  ## 0.17.4 (2026-10-19)
6
24
 
package/README.md CHANGED
@@ -315,7 +315,11 @@ App is absent from discovery results; no business operation runs.
315
315
  `ctl host list/show` can also succeed while reporting `authenticated:false` and
316
316
  exit 0. Check discovery `apps`/`diagnostics` and session fields as well as the exit
317
317
  code when deciding whether an App is ready.
318
- After a network or job timeout, query the App's state before retrying a write.
318
+ `ctl --timeout <seconds>` applies to discovery, JSON calls, polling and transfer
319
+ inactivity. The transport default is 62 seconds; a supplied 1–3600-second budget
320
+ is shared across the operation. Jobs retain their described wait default when no
321
+ override is supplied. After a network or job timeout, query the App's state before
322
+ retrying a write.
319
323
 
320
324
  Read `synopkgland skill show ctl` for the AI workflow, batch discovery approval,
321
325
  cache refresh, host removal/revocation and explicit `ctl.commands` authoring.
@@ -328,3 +332,25 @@ share a request; results keep input order and retain per-method errors. A batch
328
332
  uses the same token recovery, request deadline and cancellation as `ctx.call`,
329
333
  and is never replayed automatically. Older SDKs and binary backends retain the
330
334
  single-call client. Rebuild the window with the current CLI/SDK to adopt it.
335
+
336
+ ### DSM-managed gateway HTTPS
337
+
338
+ With gateway SDK 0.17.5 or later and a package target of DSM 7.2-64570 or later,
339
+ the build uses DSM's native web-config worker for a separate HTTPS App origin.
340
+ No Web Station dependency or executor capability is added. nginx inherits DSM's
341
+ system default certificate, which must be trusted by the browser and cover the
342
+ App hostname. The package never reads DSM's private key.
343
+
344
+ The declared HTTPS port is served by nginx. If only an HTTP port was declared,
345
+ that port becomes the HTTPS entry; use the generated gateway launcher. A second
346
+ HTTP port redirects to HTTPS. nginx connects to a private, mutually authenticated
347
+ gateway socket and preserves the session, client IP, CSRF and WebSocket checks.
348
+ The package-account post-install/upgrade hooks prepare private connection
349
+ credentials with DSM's OpenSSL after DSM creates package state and before
350
+ package start activates nginx.
351
+
352
+ Older SDK/DSM targets retain the direct listener and explain its certificate or
353
+ unencrypted-HTTP limitations before opening it. Native resource/startup failures
354
+ fail closed; they do not silently expose a direct listener. The native runtime
355
+ requires the corresponding SDK release; this CLI change does not publish that
356
+ release or change existing manifest version pins.
@@ -0,0 +1,11 @@
1
+ import { type ComposeAppData } from "../runtime/compose-data.js";
2
+ import { type Locale } from "../standalone/i18n.js";
3
+ export declare const COMPOSE_DATA_RETAINED = "COMPOSE_DATA_RETAINED";
4
+ /** Same segment spacing as the SDK image-pull diagnosis; support code goes last. */
5
+ export declare function composeRetainedMessage(data: ComposeAppData, locale: Locale): string;
6
+ /**
7
+ * Embedded in the native hook: postuninst need not find an already removed
8
+ * payload/helper. Names describe the plan, not a privileged Docker readback.
9
+ * Reporting is informational and must never prevent package removal.
10
+ */
11
+ export declare function renderComposeRetainedData(data?: ComposeAppData): string;
@@ -0,0 +1,34 @@
1
+ import { COMPOSE_DELETE_CHOICE } from "../runtime/compose-data.js";
2
+ import { DSM_LANGS, LOCALES } from "../standalone/i18n.js";
3
+ import { translator } from "./wizard-i18n.js";
4
+ export const COMPOSE_DATA_RETAINED = "COMPOSE_DATA_RETAINED";
5
+ /** Same segment spacing as the SDK image-pull diagnosis; support code goes last. */
6
+ export function composeRetainedMessage(data, locale) {
7
+ const t = translator(locale);
8
+ const separator = ["zh-TW", "zh-CN", "ja"].includes(locale) ? "" : " ";
9
+ return t("compose.retained.message", { volumes: data.volumes.map(v => v.name).join(", ") }) +
10
+ separator + t("compose.diagnosis.error_code", { code: COMPOSE_DATA_RETAINED });
11
+ }
12
+ const quote = (text) => `'${text.replaceAll("'", "'\\''")}'`;
13
+ /**
14
+ * Embedded in the native hook: postuninst need not find an already removed
15
+ * payload/helper. Names describe the plan, not a privileged Docker readback.
16
+ * Reporting is informational and must never prevent package removal.
17
+ */
18
+ export function renderComposeRetainedData(data) {
19
+ if (!data?.volumes.length)
20
+ return "";
21
+ const cases = LOCALES.map(locale => ` ${locale}|${DSM_LANGS[locale]}) compose_data_message=${quote(composeRetainedMessage(data, locale))} ;;`).join("\n");
22
+ return `${COMPOSE_DELETE_CHOICE}if [ "$compose_remove_data" -eq 1 ]; then
23
+ case "\${SYNOPKG_DSM_LANGUAGE:-enu}" in
24
+ ${cases}
25
+ *) compose_data_message=${quote(composeRetainedMessage(data, "en"))} ;;
26
+ esac
27
+ if [ -n "\${SYNOPKG_TEMP_LOGFILE:-}" ] && [ ! -L "$SYNOPKG_TEMP_LOGFILE" ]; then
28
+ if [ ! -e "$SYNOPKG_TEMP_LOGFILE" ] || [ -f "$SYNOPKG_TEMP_LOGFILE" ]; then
29
+ (printf '%s\\n' "$compose_data_message" >> "$SYNOPKG_TEMP_LOGFILE") 2>/dev/null || :
30
+ fi
31
+ fi
32
+ fi
33
+ `;
34
+ }
@@ -8,7 +8,7 @@
8
8
  * pre-0.5 key `delete_app_data` and DSM's `wizard_` mirror of either key are
9
9
  * still honoured for scripted callers.
10
10
  *
11
- * Deletion never runs on an upgrade: DSM replays postuninst for the old
11
+ * Deletion runs only on exact UNINSTALL, never on an upgrade or stop: DSM replays postuninst for the old
12
12
  * version, and the new version must find its data.
13
13
  *
14
14
  * Every root is resolved first and must land on `/volumeN/@<area>/<package>`
@@ -4,7 +4,7 @@ function shellQuote(value) {
4
4
  export function renderDeleteAppDataBlock(packageName, roots, options = {}) {
5
5
  const calls = roots.map((root) => ` delete_root "$${root.variable}" ${root.area}`).join("\n");
6
6
  return `remove_additional=0
7
- if [ "\${SYNOPKG_PKG_STATUS:-}" != UPGRADE ]; then
7
+ if [ "\${SYNOPKG_PKG_STATUS:-}" = UNINSTALL ]; then
8
8
  for flag in "\${pkgwizard_remove_additional:-}" "\${wizard_pkgwizard_remove_additional:-}" "\${delete_app_data:-}" "\${wizard_delete_app_data:-}"; do
9
9
  case "$flag" in
10
10
  true|True|TRUE|yes|1) remove_additional=1 ;;
@@ -1,3 +1,4 @@
1
+ import type { ComposeAppData } from "../runtime/compose-data.js";
1
2
  import type { Locale } from "../standalone/i18n.js";
2
3
  /** The key DSM's built-in packages use for their "also remove my data" box. */
3
4
  export declare const REMOVE_ADDITIONAL_KEY = "pkgwizard_remove_additional";
@@ -5,6 +6,7 @@ export declare const REMOVE_ADDITIONAL_KEY = "pkgwizard_remove_additional";
5
6
  export declare const DELETE_STATE_KEY = "pkgwizard_delete_state";
6
7
  export type UninstallWizardKind = "native" | "container";
7
8
  interface UninstallWizardOptions {
9
+ composeData?: ComposeAppData;
8
10
  /** The App runs the root executor (rootOps or delegate), which purges DSM's leftovers on uninstall. */
9
11
  executor?: boolean;
10
12
  /** The App declares a Login Portal alias (web-config app-portal); DSM reloads nginx through INFO. */
@@ -8,7 +8,7 @@ function list(items) {
8
8
  return `<ul class="synopkg-ul-list">${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
9
9
  }
10
10
  const html = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
11
- function renderUninstallWizard(t, locale, packageName, kind, { executor = false, portal = false, portalEntry = false, state, rootPurge = false }) {
11
+ function renderUninstallWizard(t, locale, packageName, kind, { executor = false, portal = false, portalEntry = false, state, rootPurge = false, composeData }) {
12
12
  const vars = { package: packageName };
13
13
  return [{
14
14
  step_title: t("uninstall.step"),
@@ -33,6 +33,7 @@ function renderUninstallWizard(t, locale, packageName, kind, { executor = false,
33
33
  ] : []),
34
34
  ...(executor && rootPurge ? [{ desc: t("uninstall.purge.root", vars) }] : []),
35
35
  ...(state ? [{ desc: t("uninstall.state.heading") }, { desc: list(state.items(locale).map(html)) }] : []),
36
+ ...(composeData?.volumes.length ? [{ desc: t("uninstall.composeRetained", { volumes: composeData.volumes.map(v => v.name).join(", ") }) }] : []),
36
37
  { desc: t("uninstall.backup") },
37
38
  {
38
39
  type: "multiselect",
@@ -1,4 +1,26 @@
1
1
  export declare const WIZARD_MESSAGES: {
2
+ "uninstall.composeRetained": {
3
+ en: string;
4
+ "zh-TW": string;
5
+ "zh-CN": string;
6
+ ja: string;
7
+ ko: string;
8
+ de: string;
9
+ fr: string;
10
+ es: string;
11
+ it: string;
12
+ };
13
+ "compose.retained.message": {
14
+ en: string;
15
+ "zh-TW": string;
16
+ "zh-CN": string;
17
+ ja: string;
18
+ ko: string;
19
+ de: string;
20
+ fr: string;
21
+ es: string;
22
+ it: string;
23
+ };
2
24
  "compose.diagnosis.registry_check": {
3
25
  en: string;
4
26
  "zh-TW": string;
@@ -1,4 +1,26 @@
1
1
  export const WIZARD_MESSAGES = {
2
+ "uninstall.composeRetained": {
3
+ "en": "Container data volumes are kept, even when you select delete-data: {volumes}. Their data will NOT be deleted.",
4
+ "zh-TW": "即使勾選刪除資料,容器資料磁碟區會保留:{volumes}。其中的資料不會刪除。",
5
+ "zh-CN": "即使勾选删除数据,容器数据卷也会保留:{volumes}。其中的数据不会删除。",
6
+ "ja": "データ削除を選択しても、コンテナのデータボリュームは保持されます:{volumes}。その中のデータは削除されません。",
7
+ "ko": "데이터 삭제를 선택해도 컨테이너 데이터 볼륨은 보존됩니다: {volumes}. 볼륨의 데이터는 삭제되지 않습니다.",
8
+ "de": "Container-Datenvolumes bleiben erhalten, auch wenn Sie die Datenlöschung auswählen: {volumes}. Die darin gespeicherten Daten werden NICHT gelöscht.",
9
+ "fr": "Les volumes de données des conteneurs sont conservés, même si vous choisissez de supprimer les données : {volumes}. Leurs données ne seront PAS supprimées.",
10
+ "es": "Los volúmenes de datos de los contenedores se conservan aunque seleccione eliminar datos: {volumes}. Sus datos NO se eliminarán.",
11
+ "it": "I volumi dati dei container vengono conservati anche se si seleziona l’eliminazione dei dati: {volumes}. I dati al loro interno NON verranno eliminati."
12
+ },
13
+ "compose.retained.message": {
14
+ "en": "Container data volumes were NOT deleted: {volumes}. Their data is kept.",
15
+ "zh-TW": "容器資料磁碟區並未刪除:{volumes}。其中的資料已保留。",
16
+ "zh-CN": "容器数据卷并未删除:{volumes}。其中的数据已保留。",
17
+ "ja": "コンテナのデータボリュームは削除されていません:{volumes}。その中のデータは保持されます。",
18
+ "ko": "컨테이너 데이터 볼륨이 삭제되지 않았습니다: {volumes}. 볼륨의 데이터는 보존됩니다.",
19
+ "de": "Container-Datenvolumes wurden NICHT gelöscht: {volumes}. Die darin gespeicherten Daten bleiben erhalten.",
20
+ "fr": "Les volumes de données des conteneurs n’ont PAS été supprimés : {volumes}. Leurs données sont conservées.",
21
+ "es": "Los volúmenes de datos de los contenedores NO se eliminaron: {volumes}. Sus datos se conservan.",
22
+ "it": "I volumi dati dei container NON sono stati eliminati: {volumes}. I dati al loro interno vengono conservati."
23
+ },
2
24
  "compose.diagnosis.registry_check": {
3
25
  "en": "Registry check",
4
26
  "zh-TW": "登錄站檢查",
package/dist/ctl/cli.js CHANGED
@@ -6,7 +6,7 @@ import { CtlError, errorData, ctlError } from './errors.js';
6
6
  import { CtlStore } from './store.js';
7
7
  import { CtlClient, boundedMap } from './service.js';
8
8
  import { selectHosts, selectApp, selectOperation, flagName, RESERVED_FLAGS } from './input.js';
9
- import { canonicalOrigin, createRequest } from './transport.js';
9
+ import { canonicalOrigin, CTL_TIMEOUT_MS, createRequest } from './transport.js';
10
10
  import { loginDsm, logoutDsm } from './dsm-auth.js';
11
11
  import { publicOperation } from './redaction.js';
12
12
  const USAGE = 'synopkgland ctl host add|list|show|login|logout|remove|revoke; ctl list [--host a,b|--all-hosts] [--app pkg] [--refresh]; ctl <alias|package> <command...> [typed flags|--input file|-] [--dry-run] [--yes]';
@@ -70,7 +70,11 @@ export async function runCtl(argv, io = {}) {
70
70
  emit({ ok: true, data: { usage: USAGE } });
71
71
  return;
72
72
  }
73
- const client = (host) => new CtlClient(store, host, io.requestFor?.(host));
73
+ const timeout = take(args, '--timeout', true);
74
+ if (timeout && (!/^\d+$/.test(timeout) || Number(timeout) < 1 || Number(timeout) > 3600))
75
+ throw new CtlError('CTL_ARGUMENT_INVALID', '--timeout must be 1–3600 seconds.', 'Use --timeout 60.');
76
+ const timeoutMs = timeout ? Number(timeout) * 1000 : CTL_TIMEOUT_MS;
77
+ const client = (host) => new CtlClient(store, host, io.requestFor?.(host), timeoutMs);
74
78
  const discoverOptions = { refresh, app: appFilter, approve, confirm: tty ? async (packages) => /^y(es)?$/i.test(await ask('Allow DSM session to fixed description endpoints for ' + packages.map(p => p.id + '@' + p.version).join(', ') + '? [y/N] ')) : undefined };
75
79
  if (args[0] === 'host') {
76
80
  const action = args[1], name = args[2];
@@ -100,7 +104,7 @@ export async function runCtl(argv, io = {}) {
100
104
  const host = old ?? { name, origin: canonicalOrigin(url, tunnel), username: usernameOption ?? await ask('DSM account: '), approved: {}, ...(tunnel ? { tunnel: true } : {}), ...(ca ? { ca } : {}) };
101
105
  const previous = old ? await store.credentials.get(store.key(host)) : null;
102
106
  const secret = passwordStdin ? readBounded(0, 4096).replace(/[\r\n]+$/, '') : await ask('User password: ', true);
103
- const session = await loginDsm(host.origin, host.username, secret, { remember: !noRemember, deviceId: previous?.deviceId, otp: () => ask('DSM OTP: ', true), request: io.requestFor?.(host), ca: host.ca ? readFileSync(host.ca, 'utf8') : undefined });
107
+ const session = await loginDsm(host.origin, host.username, secret, { remember: !noRemember, deviceId: previous?.deviceId, otp: () => ask('DSM OTP: ', true), request: io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined), ca: host.ca ? readFileSync(host.ca, 'utf8') : undefined });
104
108
  try {
105
109
  if (old)
106
110
  await store.credentials.set(store.key(host), session);
@@ -108,7 +112,7 @@ export async function runCtl(argv, io = {}) {
108
112
  await store.add(host, session);
109
113
  }
110
114
  catch (e) {
111
- await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin)).catch(() => { });
115
+ await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs)).catch(() => { });
112
116
  throw e;
113
117
  }
114
118
  host.lastUsed = new Date().toISOString();
@@ -135,7 +139,7 @@ export async function runCtl(argv, io = {}) {
135
139
  let logoutError;
136
140
  if (session?.cookie) {
137
141
  try {
138
- await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, 10000, host.ca ? readFileSync(host.ca, 'utf8') : undefined));
142
+ await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined));
139
143
  loggedOut = true;
140
144
  }
141
145
  catch (error) {
@@ -184,7 +188,7 @@ export async function runCtl(argv, io = {}) {
184
188
  return { host: name, ok: true, data: path.length ? { ...view, commands: [view.commands.find(c => c.id === selectOperation(app, path).id)] } : view };
185
189
  }
186
190
  const tail = args.slice(1);
187
- const noWait = !!take(tail, '--no-wait'), wait = !!take(tail, '--wait'), timeout = take(tail, '--timeout', true);
191
+ const noWait = !!take(tail, '--no-wait'), wait = !!take(tail, '--wait');
188
192
  if (noWait && wait)
189
193
  throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --wait or --no-wait.', 'run: synopkgland ctl list --json');
190
194
  if (timeout && (!/^\d+$/.test(timeout) || Number(timeout) < 1 || Number(timeout) > 3600))
package/dist/ctl/flows.js CHANGED
@@ -32,13 +32,13 @@ export async function runFlow(flow, params, invoke, options) {
32
32
  }
33
33
  throw new CtlError('CTL_FLOW_LIMIT', 'Pagination page limit reached; result is incomplete.', 'Narrow the query.', 5);
34
34
  }
35
+ const until = performance.now() + Math.min(options.timeoutMs ?? flow.timeoutMs, 3600000);
35
36
  const accepted = await invoke(flow.start, params);
36
37
  if (options.wait === false)
37
38
  return { accepted: true, job: accepted };
38
39
  const id = field(accepted, flow.jobId);
39
40
  if (!['string', 'number'].includes(typeof id))
40
41
  throw new CtlError('CTL_FLOW_RESPONSE', 'Start response has no job ID.', 'Check command metadata; start is not retried.', 5);
41
- const until = performance.now() + Math.min(options.timeoutMs ?? flow.timeoutMs, 3600000);
42
42
  while (performance.now() < until) {
43
43
  const poll = await invoke(flow.poll, { [flow.idParam]: id });
44
44
  const state = field(poll, flow.state);
@@ -15,7 +15,9 @@ export declare class CtlClient {
15
15
  store: CtlStore;
16
16
  host: Host;
17
17
  request: JsonRequest;
18
- constructor(store: CtlStore, host: Host, request?: JsonRequest);
18
+ constructor(store: CtlStore, host: Host, request?: JsonRequest, timeoutMs?: number);
19
+ private until?;
20
+ private remaining;
19
21
  valid(): Promise<boolean>;
20
22
  inventory(session: DsmSession): Promise<{
21
23
  id: string;
@@ -34,5 +36,6 @@ export declare class CtlClient {
34
36
  wait?: boolean;
35
37
  timeoutMs?: number;
36
38
  }): Promise<any>;
39
+ private executeWithin;
37
40
  private appCookieSession;
38
41
  }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { CtlError, errorData, choose } from './errors.js';
3
- import { APP_COOKIE, createRequest } from './transport.js';
3
+ import { APP_COOKIE, CTL_TIMEOUT_MS, createRequest } from './transport.js';
4
4
  import { DESCRIPTION_METHOD, validateDescriptor, ajv } from './descriptor.js';
5
5
  import { runFlow } from './flows.js';
6
6
  import { SensitiveOutput } from './redaction.js';
@@ -14,11 +14,13 @@ export class CtlClient {
14
14
  store;
15
15
  host;
16
16
  request;
17
- constructor(store, host, request) {
17
+ constructor(store, host, request, timeoutMs = CTL_TIMEOUT_MS) {
18
18
  this.store = store;
19
19
  this.host = host;
20
- this.request = request ?? createRequest(host.origin, 10000, host.ca ? readFileSync(host.ca, 'utf8') : undefined);
20
+ this.request = request ?? createRequest(host.origin, () => this.remaining(timeoutMs), host.ca ? readFileSync(host.ca, 'utf8') : undefined);
21
21
  }
22
+ until;
23
+ remaining(fallback) { return this.until === undefined ? fallback : Math.max(0, this.until - performance.now()); }
22
24
  async valid() { try {
23
25
  await this.inventory(await this.store.session(this.host));
24
26
  return true;
@@ -102,11 +104,23 @@ export class CtlClient {
102
104
  return { apps: results.flatMap(r => r.description ? [r.description] : []), diagnostics: results.flatMap(r => r.error ? [r.error] : []), cacheHits };
103
105
  }
104
106
  async execute(name, path, args, options = {}) {
107
+ try {
108
+ return await this.executeWithin(name, path, args, options);
109
+ }
110
+ finally {
111
+ this.until = undefined;
112
+ }
113
+ }
114
+ async executeWithin(name, path, args, options = {}) {
115
+ const started = performance.now();
116
+ this.until = started + (options.timeoutMs ?? CTL_TIMEOUT_MS);
105
117
  const listing = await this.discover(options);
106
118
  const app = selectApp(listing.apps, name);
107
119
  const { id, operation } = selectOperation(app, path);
108
120
  const params = operationInput(operation, args, options.stdin, `run: synopkgland ctl ${name} ${operation.cli.join(' ')} --host ${this.host.name} --help`);
109
121
  const output = new SensitiveOutput(params, operation.paramsSchema);
122
+ if (options.timeoutMs === undefined && operation.flow.type === 'job')
123
+ this.until = started + operation.flow.timeoutMs;
110
124
  try {
111
125
  const plan = requestPlan(app, id, params);
112
126
  if (operation.flow.type === 'upload')
@@ -135,9 +149,9 @@ export class CtlClient {
135
149
  return response.data;
136
150
  };
137
151
  const ca = this.host.ca ? readFileSync(this.host.ca, 'utf8') : undefined;
138
- const result = await runFlow(operation.flow, params, invoke, { wait: options.wait, timeoutMs: options.timeoutMs,
139
- ndjson: (method, input) => transferRequest(appSession, app.payload.transport.path, method, input, { ca, timeoutMs: options.timeoutMs }),
140
- upload: (method, input, flow) => transferRequest(appSession, app.payload.transport.path, method, {}, { ca, timeoutMs: options.timeoutMs, file: { path: String(input[flow.fileParam]), parent: String(input[flow.parentParam]), name: String(input[flow.nameParam]) } }) });
152
+ const result = await runFlow(operation.flow, params, invoke, { wait: options.wait, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS),
153
+ ndjson: (method, input) => transferRequest(appSession, app.payload.transport.path, method, input, { ca, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS) }),
154
+ upload: (method, input, flow) => transferRequest(appSession, app.payload.transport.path, method, {}, { ca, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS), file: { path: String(input[flow.fileParam]), parent: String(input[flow.parentParam]), name: String(input[flow.nameParam]) } }) });
141
155
  if (operation.resultSchema && !ajv.validate(operation.resultSchema, result))
142
156
  throw new CtlError('CTL_RESPONSE_INVALID', 'Result does not match command schema.', 'Check the App version.', 5);
143
157
  return output.clean(result, operation.resultSchema);
@@ -1,4 +1,4 @@
1
- import type { DsmSession } from './transport.js';
1
+ import { type DsmSession } from './transport.js';
2
2
  export declare function localFile(file: unknown): {
3
3
  file: string;
4
4
  size: number;
@@ -2,6 +2,7 @@ import { createReadStream, statSync } from 'node:fs';
2
2
  import http from 'node:http';
3
3
  import https from 'node:https';
4
4
  import { CtlError } from './errors.js';
5
+ import { CTL_TIMEOUT_MS } from './transport.js';
5
6
  export function localFile(file) { if (typeof file !== 'string' || !file)
6
7
  throw new CtlError('CTL_INPUT_FILE', 'A local filename is required.', 'Use --file <path>.'); try {
7
8
  const stat = statSync(file);
@@ -15,6 +16,8 @@ catch {
15
16
  export async function transferRequest(session, path, method, params, options = {}) {
16
17
  if (!/^\/webman\/3rdparty\/[A-Za-z][A-Za-z0-9_.+-]*\/cgi\/api\.cgi$/.test(path) || !/^([a-z][a-z0-9_]*\.)+[a-z][a-z0-9_]*$/.test(method))
17
18
  throw new CtlError('CTL_PATH_INVALID', 'Invalid transfer binding.', 'Refresh the API description.', 4);
19
+ if (options.timeoutMs !== undefined && options.timeoutMs <= 0)
20
+ throw new CtlError('CTL_NETWORK', 'Transfer budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
18
21
  const url = new URL(path, session.origin);
19
22
  const file = options.file ? localFile(options.file.path) : undefined;
20
23
  if (options.file) {
@@ -23,7 +26,7 @@ export async function transferRequest(session, path, method, params, options = {
23
26
  const raw = file ? undefined : Buffer.from(JSON.stringify({ method, params }));
24
27
  return new Promise((resolve, reject) => {
25
28
  const headers = { 'content-type': file ? 'application/octet-stream' : 'application/json', cookie: session.cookie, 'X-SYNO-TOKEN': session.token, origin: session.origin, 'content-length': String(file?.size ?? raw.length) };
26
- const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: 30000, ...(options.ca ? { ca: options.ca } : {}) }, res => {
29
+ const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: options.timeoutMs ?? CTL_TIMEOUT_MS, ...(options.ca ? { ca: options.ca } : {}) }, res => {
27
30
  const expected = file ? 'application/json' : 'application/x-ndjson';
28
31
  if (res.statusCode !== 200 || !res.headers['content-type']?.includes(expected)) {
29
32
  res.resume();
@@ -58,7 +61,7 @@ export async function transferRequest(session, path, method, params, options = {
58
61
  reject(new CtlError('CTL_TRANSFER_FAILED', 'Invalid or failed transfer response.', 'Inspect the App result; do not retry writes blindly.', 5));
59
62
  } });
60
63
  });
61
- const wallTimer = setTimeout(() => req.destroy(), options.timeoutMs ?? 30000);
64
+ const wallTimer = setTimeout(() => req.destroy(), options.timeoutMs ?? CTL_TIMEOUT_MS);
62
65
  req.once('close', () => clearTimeout(wallTimer));
63
66
  req.on('timeout', () => req.destroy());
64
67
  req.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer interrupted; completion may be uncertain.', 'Check App state before retrying.', 6)));
@@ -1,3 +1,4 @@
1
+ export declare const CTL_TIMEOUT_MS = 62000;
1
2
  export declare const APP_COOKIE: unique symbol;
2
3
  export interface DsmSession {
3
4
  origin: string;
@@ -8,4 +9,4 @@ export interface DsmSession {
8
9
  }
9
10
  export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
10
11
  export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
11
- export declare function createRequest(origin: string, timeoutMs?: number, ca?: string): JsonRequest;
12
+ export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string): JsonRequest;
@@ -2,6 +2,7 @@ import { strictJson } from './json.js';
2
2
  import http from 'node:http';
3
3
  import https from 'node:https';
4
4
  import { CtlError } from './errors.js';
5
+ export const CTL_TIMEOUT_MS = 62000;
5
6
  export const APP_COOKIE = Symbol("ctlAppCookie");
6
7
  export function canonicalOrigin(input, tunnel = false) {
7
8
  let url;
@@ -15,10 +16,13 @@ export function canonicalOrigin(input, tunnel = false) {
15
16
  throw new CtlError('CTL_URL_INVALID', 'Use HTTPS, or explicit --ssh-tunnel for a loopback HTTP tunnel.', 'run: synopkgland ctl host add <name> --url https://<nas>:5001');
16
17
  return url.origin;
17
18
  }
18
- export function createRequest(origin, timeoutMs = 10000, ca) {
19
+ export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca) {
19
20
  return async (path, body, session) => {
20
21
  if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
21
22
  throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
23
+ const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs;
24
+ if (remaining <= 0)
25
+ throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
22
26
  const url = new URL(path, origin);
23
27
  if (url.origin !== origin || (session && session.origin !== origin))
24
28
  throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins.', 'run: synopkgland ctl host list', 4);
@@ -32,7 +36,7 @@ export function createRequest(origin, timeoutMs = 10000, ca) {
32
36
  headers.cookie = session.cookie;
33
37
  headers['X-SYNO-TOKEN'] = session.token;
34
38
  }
35
- const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: timeoutMs, ...(ca ? { ca } : {}) }, res => {
39
+ const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: remaining, ...(ca ? { ca } : {}) }, res => {
36
40
  if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
37
41
  res.resume();
38
42
  reject(new CtlError('CTL_HTTP_ERROR', `HTTP ${res.statusCode}; redirects are never followed.`, 'Check the NAS URL and connection.', 6));
@@ -69,7 +73,7 @@ export function createRequest(origin, timeoutMs = 10000, ca) {
69
73
  }
70
74
  });
71
75
  });
72
- const wallTimer = setTimeout(() => req.destroy(), timeoutMs);
76
+ const wallTimer = setTimeout(() => req.destroy(), remaining);
73
77
  req.once('close', () => clearTimeout(wallTimer));
74
78
  req.on('timeout', () => req.destroy());
75
79
  req.on('error', () => reject(new CtlError('CTL_NETWORK', 'NAS request failed or timed out.', 'Check the NAS connection and trusted CA; requests are not replayed.', 6)));
@@ -0,0 +1,10 @@
1
+ /** Only the trusted SDK planner supplies names for retained-data reporting. */
2
+ export interface ComposeAppData {
3
+ project: string;
4
+ volumes: Array<{
5
+ key: string;
6
+ name: string;
7
+ }>;
8
+ }
9
+ export declare function validComposeAppData(value: unknown, pkg: string): value is ComposeAppData;
10
+ export declare const COMPOSE_DELETE_CHOICE = "compose_remove_data=0\nif [ \"${SYNOPKG_PKG_STATUS:-}\" = UNINSTALL ]; then\n for flag in \"${pkgwizard_remove_additional:-}\" \"${wizard_pkgwizard_remove_additional:-}\" \"${delete_app_data:-}\" \"${wizard_delete_app_data:-}\"; do\n case \"$flag\" in true|True|TRUE|yes|1) compose_remove_data=1 ;; esac\n done\nfi\n";
@@ -0,0 +1,20 @@
1
+ export function validComposeAppData(value, pkg) {
2
+ if (!value || typeof value !== "object" || Array.isArray(value))
3
+ return false;
4
+ const inventory = value;
5
+ return Object.keys(inventory).every(key => ["project", "volumes"].includes(key)) &&
6
+ typeof inventory.project === "string" && /^[a-z][a-z0-9-]{0,96}$/.test(inventory.project) &&
7
+ (inventory.project === pkg.toLowerCase() || inventory.project.endsWith("-" + pkg.toLowerCase())) &&
8
+ Array.isArray(inventory.volumes) && inventory.volumes.length <= 256 &&
9
+ new Set(inventory.volumes.map(volume => volume?.key)).size === inventory.volumes.length &&
10
+ inventory.volumes.every(volume => volume && Object.keys(volume).length === 2 &&
11
+ typeof volume.key === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(volume.key) &&
12
+ volume.name === inventory.project + "_" + volume.key);
13
+ }
14
+ export const COMPOSE_DELETE_CHOICE = `compose_remove_data=0
15
+ if [ "\${SYNOPKG_PKG_STATUS:-}" = UNINSTALL ]; then
16
+ for flag in "\${pkgwizard_remove_additional:-}" "\${wizard_pkgwizard_remove_additional:-}" "\${delete_app_data:-}" "\${wizard_delete_app_data:-}"; do
17
+ case "$flag" in true|True|TRUE|yes|1) compose_remove_data=1 ;; esac
18
+ done
19
+ fi
20
+ `;
@@ -42,5 +42,16 @@
42
42
  "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
43
43
  },
44
44
  "goToolchain": "go1.26.9"
45
+ },
46
+ "0.17.5": {
47
+ "x86_64": "af37ecd6bb5ab3e954b5f5bb3db2499b3cf86f0502afdcea18e8dc0614a46e5d",
48
+ "aarch64": "87436df3f591e0126d88866922b032968d0dbd01a109de53ab751ef779257baf",
49
+ "armv7": "de354d6e582ad1fe3124c3f30eb7f3ce8af456b0fcfad8f23c10421d3a3f3d42",
50
+ "lifecycle": {
51
+ "x86_64": "f3b368c86677741b48b059699a86c935157ea797634a6116131678e8bbaf33ed",
52
+ "aarch64": "b6ccd1365844e6eee9db4ce5833b655f4f1e138bf34317cdbd0e2342d3342a9e",
53
+ "armv7": "57b6350c2812783cb536384f8b363c26d894af6589b5f19464d71d8d0a8035d5"
54
+ },
55
+ "goToolchain": "go1.26.9"
45
56
  }
46
57
  }
@@ -18,6 +18,12 @@ export interface DelegateCapability {
18
18
  export declare function validDelegate(value: unknown): value is DelegateCapability;
19
19
  export declare const sha256: (data: string | Buffer) => string;
20
20
  export interface ExecutorPolicy {
21
+ timeouts?: {
22
+ start: number;
23
+ stop: number;
24
+ purge: number;
25
+ operation: number;
26
+ };
21
27
  webapi?: WebAPIProfiles;
22
28
  v: 1;
23
29
  app: string;
@@ -56,9 +62,28 @@ export declare function executorPolicy(m: {
56
62
  portal?: PortalDeclaration;
57
63
  os_min_ver?: string;
58
64
  displayname?: string;
65
+ daemon?: {
66
+ timeouts: {
67
+ start: number;
68
+ stop: number;
69
+ status: number;
70
+ uninstall: number;
71
+ };
72
+ };
73
+ lifecycleTimeouts?: {
74
+ start: number;
75
+ stop: number;
76
+ status: number;
77
+ uninstall: number;
78
+ };
79
+ backend?: {
80
+ kind: string;
81
+ timeoutSeconds?: number;
82
+ };
59
83
  uninstall?: {
60
84
  purge: {
61
85
  root?: string;
86
+ timeout?: number;
62
87
  };
63
88
  };
64
89
  }, arch?: NativeArchFamily): ExecutorPolicy;
@@ -1,3 +1,4 @@
1
+ import { operationBudgetSeconds } from "./runtime-budgets.js";
1
2
  import { validWebAPIProfiles, sortedWebAPIProfiles } from './webapi-profile.js';
2
3
  import { createHash } from 'node:crypto';
3
4
  import { MOVE_FROM_STAGING, DELEGATE_EXTENSION_OPERATIONS } from './delegate-contract.js';
@@ -57,6 +58,12 @@ export function executorPolicy(m, arch) {
57
58
  operations: [...new Set(operations)].sort(), ...(portal ? { portal } : {}), ...(purge ? { purge } : {}), ...(settings?.stop ? { stop: settings.stop } : {}),
58
59
  ...(settings?.start ? { start: settings.start } : {}),
59
60
  ...(m.capabilities.webapi ? { webapi: sortedWebAPIProfiles(m.capabilities.webapi) } : {}),
61
+ ...((m.lifecycleTimeouts || m.daemon || m.backend?.timeoutSeconds || (m.uninstall?.purge.root && m.uninstall.purge.timeout)) ? { timeouts: {
62
+ start: m.lifecycleTimeouts?.start ?? m.daemon?.timeouts.start ?? 60,
63
+ stop: m.lifecycleTimeouts?.stop ?? m.daemon?.timeouts.stop ?? 60,
64
+ purge: m.lifecycleTimeouts?.uninstall ?? m.daemon?.timeouts.uninstall ?? Math.min(3600, Math.max(300, m.uninstall?.purge.timeout ?? 0)),
65
+ operation: operationBudgetSeconds(m),
66
+ } } : {}),
60
67
  root, ...(settings?.state ? { root_state: true } : {}) };
61
68
  }
62
69
  /** A script without settings keeps the bare digest; a binary pins the bytes built for this architecture. */
@@ -86,7 +93,7 @@ export function validExecutorPolicy(value, pkg) {
86
93
  if (!value || typeof value !== 'object' || Array.isArray(value))
87
94
  return false;
88
95
  const p = value;
89
- if (Object.keys(p).some(key => !['v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'purge', 'stop', 'start', 'root', 'root_state', 'webapi'].includes(key))
96
+ if (Object.keys(p).some(key => !['timeouts', 'v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'purge', 'stop', 'start', 'root', 'root_state', 'webapi'].includes(key))
90
97
  || p.v !== 1 || p.package !== pkg || p.app !== `SYNO.SDS.${pkg}.Instance`
91
98
  || 'entry' in p && p.entry !== 'admin-only' && p.entry !== 'app-privilege'
92
99
  || !Array.isArray(p.operations) || !p.operations.includes('status') || new Set(p.operations).size !== p.operations.length
@@ -97,6 +104,8 @@ export function validExecutorPolicy(value, pkg) {
97
104
  || 'root_state' in p && p.root_state !== true
98
105
  || Object.entries(p.root).some(([name, entry]) => !ROOT_PROGRAM_NAME.test(name) || rootEntryProblem(entry) !== undefined))
99
106
  return false;
107
+ if (p.timeouts !== undefined && (!p.timeouts || typeof p.timeouts !== 'object' || Array.isArray(p.timeouts) || Object.keys(p.timeouts).sort().join(',') !== 'operation,purge,start,stop' || Object.entries(p.timeouts).some(([key, n]) => !Number.isInteger(n) || n < (key === 'operation' ? 1 : 2) || n > 3600)))
108
+ return false;
100
109
  const rootOps = rootOperations(p.root);
101
110
  if (![...ROOT_JOB_OPERATIONS, 'root.run'].every(op => p.operations.includes(op) === rootOps.includes(op)))
102
111
  return false;
@@ -0,0 +1,24 @@
1
+ /** Work budgets use existing author settings; credential lifetimes stay separate. */
2
+ interface OperationBudgetSource {
3
+ backend?: {
4
+ kind: string;
5
+ timeoutSeconds?: number;
6
+ };
7
+ daemon?: {
8
+ timeouts?: {
9
+ status: number;
10
+ };
11
+ };
12
+ lifecycleTimeouts?: {
13
+ status: number;
14
+ };
15
+ }
16
+ /**
17
+ * A CGI's declared request budget owns operation work. Binary and Compose Apps
18
+ * have no compiled CGI deadline to configure, so their existing status allowance
19
+ * can enlarge the shared operation budget above its 60-second default.
20
+ */
21
+ export declare function operationBudgetSeconds(source: OperationBudgetSource): number;
22
+ /** First released SDK that decodes operation/lifecycle budget config fields. */
23
+ export declare function runtimeBudgetSdkSupported(sdk: string): boolean;
24
+ export {};