@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
@@ -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
+ `;
@@ -31,5 +31,27 @@
31
31
  "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
32
32
  },
33
33
  "goToolchain": "go1.26.9"
34
+ },
35
+ "0.17.4": {
36
+ "x86_64": "68f372684997258adc61312f2c353e1a286a6b5aaa556f716b4e6ba6f33893d0",
37
+ "aarch64": "f6950301e201a4ebc14577a4554fa1b671f90d0f354d1e8809f4d1dafb57c723",
38
+ "armv7": "e77c9b6ae22ecfa85d9834af3350192e445987265a7ca6c02ca9b01d88a1b4f7",
39
+ "lifecycle": {
40
+ "x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
41
+ "aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
42
+ "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
43
+ },
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"
34
56
  }
35
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 {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A CGI's declared request budget owns operation work. Binary and Compose Apps
3
+ * have no compiled CGI deadline to configure, so their existing status allowance
4
+ * can enlarge the shared operation budget above its 60-second default.
5
+ */
6
+ export function operationBudgetSeconds(source) {
7
+ return source.backend?.timeoutSeconds ?? Math.max(60, source.lifecycleTimeouts?.status ?? source.daemon?.timeouts?.status ?? 5);
8
+ }
9
+ /** First released SDK that decodes operation/lifecycle budget config fields. */
10
+ export function runtimeBudgetSdkSupported(sdk) {
11
+ const [major, minor, patch] = sdk.split('.').map(Number);
12
+ return major > 0 || minor > 17 || minor === 17 && patch >= 5;
13
+ }
@@ -18,7 +18,7 @@ export declare function readBackground(value: unknown, fail: (error: StandaloneE
18
18
  export declare function renderBackgroundResource(): string;
19
19
  /** The worker's arguments: none for a Go App's own worker, `--worker` for the bridge runtime (spec/bridge/v1). */
20
20
  export declare const BRIDGE_WORKER_ARGS: readonly ["--worker"];
21
- export declare function renderBackgroundUnit(pkg: string, args?: readonly string[]): string;
21
+ export declare function renderBackgroundUnit(pkg: string, args?: readonly string[], stopSeconds?: number): string;
22
22
  /** Shell function that stops the worker in lifecycle hooks; call it as `stop_background_worker`. */
23
23
  export declare function renderBackgroundWorkerStop(pkg: string): string;
24
24
  /**
@@ -32,8 +32,8 @@ export declare function renderBackgroundWorkerStop(pkg: string): string;
32
32
  * exits. Stop only such a manager, as the package user; the acquire that
33
33
  * follows then starts a fresh one.
34
34
  */
35
- export declare function renderUserManagerRecovery(): string;
35
+ export declare function renderUserManagerRecovery(startSeconds?: number): string;
36
36
  /** Shell function that starts the worker and names the failed step; call it as `start_background_worker`. */
37
37
  export declare function renderBackgroundWorkerStart(pkg: string): string;
38
38
  /** Package Center's start/stop/status map straight onto the user unit. */
39
- export declare function renderBackgroundControl(pkg: string): string;
39
+ export declare function renderBackgroundControl(pkg: string, startSeconds?: number): string;
@@ -24,7 +24,7 @@ export function renderBackgroundResource() {
24
24
  }
25
25
  /** The worker's arguments: none for a Go App's own worker, `--worker` for the bridge runtime (spec/bridge/v1). */
26
26
  export const BRIDGE_WORKER_ARGS = ["--worker"];
27
- export function renderBackgroundUnit(pkg, args = []) {
27
+ export function renderBackgroundUnit(pkg, args = [], stopSeconds = 30) {
28
28
  return `[Unit]
29
29
  Description=${pkg} background worker
30
30
 
@@ -33,7 +33,7 @@ Type=simple
33
33
  ExecStart=${[`/var/packages/${pkg}/target/${BACKGROUND_WORKER_PAYLOAD}`, ...args].join(" ")}
34
34
  UMask=0077
35
35
  KillMode=control-group
36
- TimeoutStopSec=10
36
+ TimeoutStopSec=${stopSeconds}
37
37
  Restart=no
38
38
  `;
39
39
  }
@@ -66,13 +66,15 @@ export function renderBackgroundWorkerStop(pkg) {
66
66
  * exits. Stop only such a manager, as the package user; the acquire that
67
67
  * follows then starts a fresh one.
68
68
  */
69
- export function renderUserManagerRecovery() {
69
+ export function renderUserManagerRecovery(startSeconds = 30) {
70
70
  return `recover_user_manager() {
71
71
  uid="$(id -u)" || return 1
72
72
  [ "$uid" != 0 ] || return 0
73
- i=0
74
- while [ "$(/usr/bin/loginctl show-user "$uid" -p State 2>/dev/null)" = State=closing ] && [ "$i" -lt 100 ]; do
75
- sleep 0.1; i=$((i + 1))
73
+ manager_now() { awk '{print int($1)}' /proc/uptime; }
74
+ manager_until=$(( $(manager_now) + ${startSeconds} ))
75
+ while [ "$(/usr/bin/loginctl show-user "$uid" -p State 2>/dev/null)" = State=closing ]; do
76
+ [ "$(manager_now)" -lt "$manager_until" ] || { echo "package user manager still closing" >&2; return 1; }
77
+ sleep 0.1
76
78
  done
77
79
  [ -S "/run/user/$uid/systemd/private" ] && return 0
78
80
  for exe in /proc/[0-9]*/exe; do
@@ -81,12 +83,11 @@ export function renderUserManagerRecovery() {
81
83
  case " $(tr '\\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)" in *" --user "*) ;; *) continue ;; esac
82
84
  echo "package user manager $pid has no socket in /run/user/$uid; stopping it so DSM starts a fresh one" >&2
83
85
  kill -TERM "$pid" || return 1
84
- i=0
85
86
  # DSM's bash logs a failed kill to syslog even with 2>/dev/null, so the
86
87
  # exit is looked up in /proc.
87
88
  while [ -d "/proc/$pid" ]; do
88
- [ "$i" -lt 100 ] || { echo "package user manager $pid did not exit" >&2; return 1; }
89
- sleep 0.1; i=$((i + 1))
89
+ [ "$(manager_now)" -lt "$manager_until" ] || { echo "package user manager $pid did not exit" >&2; return 1; }
90
+ sleep 0.1
90
91
  done
91
92
  done
92
93
  }
@@ -105,10 +106,10 @@ export function renderBackgroundWorkerStart(pkg) {
105
106
  `;
106
107
  }
107
108
  /** Package Center's start/stop/status map straight onto the user unit. */
108
- export function renderBackgroundControl(pkg) {
109
+ export function renderBackgroundControl(pkg, startSeconds = 30) {
109
110
  const unit = backgroundUnitName(pkg);
110
111
  return `#!/bin/sh
111
- ${renderUserManagerRecovery()}${renderBackgroundWorkerStart(pkg)}case "$1" in
112
+ ${renderUserManagerRecovery(startSeconds)}${renderBackgroundWorkerStart(pkg)}case "$1" in
112
113
  prestart) recover_user_manager ;;
113
114
  prestop) exit 0 ;;
114
115
  start) start_background_worker ;;
@@ -1,5 +1,15 @@
1
1
  /** SDK planner errors accepted at this versioned tool boundary. */
2
2
  export const COMPOSE_ERRORS = {
3
+ COMPOSE_STARTUP_INVALID: {
4
+ code: "COMPOSE_STARTUP_INVALID",
5
+ description: "The container startup budget is invalid.",
6
+ suggestion: "Set compose.startup_timeout_seconds to an integer from 2 to 900 seconds; the default is 120. Read the same SYNOPKGLAND_STARTUP_BUDGET_SECONDS in the App bootstrap adapter.",
7
+ },
8
+ COMPOSE_STARTUP_TIMEOUT: {
9
+ code: "COMPOSE_STARTUP_TIMEOUT",
10
+ description: "Container readiness exhausted its declared startup budget.",
11
+ suggestion: "Inspect the safe phase code in daemon/daemon.log and upstream migration/bootstrap health. Declare a sufficient bounded startup_timeout_seconds and rebuild; keep ingress closed until ready.",
12
+ },
3
13
  COMPOSE_STORAGE_INIT_INVALID: {
4
14
  code: "COMPOSE_STORAGE_INIT_INVALID",
5
15
  description: "The container storage initialization declaration is invalid.",
@@ -1,7 +1,10 @@
1
+ import { type ComposeAppData } from "../runtime/compose-data.js";
1
2
  import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
2
3
  import { type Daemon } from "./daemon.js";
3
4
  import type { StandaloneError, StandaloneManifest } from "./manifest.js";
4
5
  export interface ComposePlan {
6
+ app_data?: ComposeAppData;
7
+ startup_timeout_seconds?: number;
5
8
  ingress?: {
6
9
  address: string;
7
10
  subnet: string;
@@ -68,7 +71,7 @@ export interface ComposeApp {
68
71
  document: Record<string, any>;
69
72
  plan: ComposePlan;
70
73
  }
71
- export declare const COMPOSE_FIELDS: readonly ["resource_prefix", "files", "env_files", "profiles", "web", "data", "shares", "network", "services", "helpers", "sdk"];
74
+ export declare const COMPOSE_FIELDS: readonly ["startup_timeout_seconds", "resource_prefix", "files", "env_files", "profiles", "web", "data", "shares", "network", "services", "helpers", "sdk"];
72
75
  export declare function readComposeApp(value: unknown, root: string, arches: NativeArchFamily[], fail: (e: StandaloneError) => void, context?: {
73
76
  package?: string;
74
77
  sdk?: string;
@@ -78,7 +81,7 @@ export declare function readComposeApp(value: unknown, root: string, arches: Nat
78
81
  architecture?: NativeArchFamily;
79
82
  imageProofs?: unknown;
80
83
  }): ComposeApp | undefined;
81
- export declare function composeDaemon(m: Pick<StandaloneManifest, "compose">): Daemon;
84
+ export declare function composeDaemon(m: Pick<StandaloneManifest, "compose" | "lifecycleTimeouts">): Daemon;
82
85
  export declare function composeNetwork(m: Pick<StandaloneManifest, "compose" | "identityGateway" | "spkVersion">): {
83
86
  public_ports: number[];
84
87
  readiness?: {
@@ -1,3 +1,5 @@
1
+ import { validComposeAppData } from "../runtime/compose-data.js";
2
+ import { composeDiagnosisMessages, renderComposeDiagnosis } from "../build-spk/compose-diagnosis.js";
1
3
  import { COMPOSE_ERRORS } from "./compose-errors.js";
2
4
  import { spawnSync } from "node:child_process";
3
5
  import { createHash } from "node:crypto";
@@ -10,6 +12,7 @@ import { goChildEnv } from "../runtime/go-env.js";
10
12
  import { sdkSource } from "../runtime/sdk-source.js";
11
13
  import { readDaemon } from "./daemon.js";
12
14
  export const COMPOSE_FIELDS = [
15
+ "startup_timeout_seconds",
13
16
  "resource_prefix",
14
17
  "files",
15
18
  "env_files",
@@ -69,6 +72,10 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
69
72
  if (value.sdk !== undefined &&
70
73
  (typeof value.sdk !== "string" || !/^\d+\.\d+\.\d+$/.test(value.sdk)))
71
74
  return invalid("compose.sdk must name the matching SDK release.", "SDK_COMPOSE_UNSUPPORTED");
75
+ if (value.startup_timeout_seconds !== undefined &&
76
+ (!Number.isInteger(value.startup_timeout_seconds) ||
77
+ value.startup_timeout_seconds < 2 || value.startup_timeout_seconds > 900))
78
+ return invalid("compose.startup_timeout_seconds must be an integer from 2 to 900 seconds (default 120).", "COMPOSE_STARTUP_INVALID");
72
79
  const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ?? templateSdkVersion());
73
80
  let targets = {};
74
81
  if (value.helpers !== undefined) {
@@ -107,7 +114,16 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
107
114
  }
108
115
  if (result.status !== 0 || plan.error)
109
116
  throw new Error(plan.error ?? "[SDK_COMPOSE_FAILED] SDK planner failed.");
117
+ if (value.startup_timeout_seconds !== undefined &&
118
+ plan.startup_timeout_seconds !== value.startup_timeout_seconds)
119
+ throw new Error("[SDK_COMPOSE_UNSUPPORTED] This SDK does not implement the declared startup budget; use the matching SDK/CLI release.");
110
120
  const document = parse(plan.effective);
121
+ const volumes = Object.entries(document?.volumes ?? {});
122
+ if ((!validComposeAppData(plan.app_data, context.package ?? "ComposeApp")
123
+ || JSON.stringify(plan.app_data) !== JSON.stringify(plan.lock?.app_data)
124
+ || plan.app_data.project !== plan.project_name || plan.app_data.volumes.length !== volumes.length
125
+ || volumes.some(([key, value]) => !plan.app_data.volumes.some(v => v.key === key && v.name === value.name))))
126
+ throw new Error("[SDK_COMPOSE_UNSUPPORTED] This SDK does not provide the complete Compose App data contract; update the SDK and rebuild.");
111
127
  const web = value.web;
112
128
  return {
113
129
  declaration: { ...value },
@@ -141,7 +157,7 @@ export function composeDaemon(m) {
141
157
  args: ["run"],
142
158
  targets: m.compose.targets,
143
159
  env: {},
144
- timeouts: { start: 120, stop: 30, status: 5, uninstall: 60 },
160
+ timeouts: { stop: 30, status: 5, uninstall: 60, ...m.lifecycleTimeouts, start: m.compose.plan.startup_timeout_seconds ?? m.lifecycleTimeouts?.start ?? 120 },
145
161
  };
146
162
  }
147
163
  export function composeNetwork(m) {
@@ -187,6 +203,7 @@ export function writeComposePayload(c, root, target, gateway) {
187
203
  "compose.lock.json": JSON.stringify(c.plan.lock, null, 2) + "\n",
188
204
  "request.json": JSON.stringify({ declaration: c.declaration, sdk: c.sdk, gateway }, null, 2) + "\n",
189
205
  "images.json": JSON.stringify(c.plan.image_proofs ?? {}, null, 2) + "\n",
206
+ "diagnosis-messages.json": JSON.stringify(composeDiagnosisMessages(), null, 2) + "\n",
190
207
  }))
191
208
  writeFileSync(join(target, name), content, { mode: 0o600 });
192
209
  }
@@ -195,7 +212,7 @@ export function renderComposeInstall(pkg) {
195
212
  return `#!/bin/sh
196
213
  set -eu
197
214
  "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" preflight || exit $?
198
- exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose" --package '${pkg}'
215
+ ${renderComposeDiagnosis("\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose", "preinst")}exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose" --package '${pkg}'
199
216
  `;
200
217
  }
201
218
  /** The same SDK entry point supplies the package-account install materializer. */
@@ -30,3 +30,7 @@ export declare function buildDaemonControl(manifest: StandaloneManifest, arch: N
30
30
  export declare function copyDaemonPayload(manifest: StandaloneManifest, arch: NativeArchFamily, target: string, controller: string): void;
31
31
  /** Copies a bounded, link-free package-account payload tree. */
32
32
  export declare function copyPayloadTree(root: string, source: string, target: string): void;
33
+ /** Existing lifecycle namespace; a timeout-only declaration runs no daemon. */
34
+ export declare function readLifecycleTimeouts(value: unknown, fail: (e: StandaloneError) => void): Daemon['timeouts'] | undefined;
35
+ /** Legacy SDKs keep their supported config and timeout bounds. */
36
+ export declare function runtimeBudgetSource(dir: string, manifest?: StandaloneManifest): string;