@synopackageland/cli 0.41.9 → 0.41.10
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.
- package/CHANGELOG.md +16 -0
- package/README.md +34 -0
- package/dist/compose/registry-mirrors.d.ts +3 -0
- package/dist/compose/registry-mirrors.js +38 -0
- package/dist/e2e/dsm.js +14 -2
- package/dist/error-registry.js +5 -0
- package/dist/host/catalog-install.d.ts +11 -3
- package/dist/host/catalog-install.js +168 -80
- package/dist/host/dsm-compound.js +10 -3
- package/dist/images/pin.js +6 -16
- package/dist/nas/auth.js +1 -1
- package/dist/nas/connection.js +1 -1
- package/dist/nas/http.d.ts +1 -1
- package/dist/nas/http.js +2 -2
- package/dist/nas/store.js +1 -1
- package/dist/nas/timeouts.d.ts +7 -0
- package/dist/nas/timeouts.js +24 -0
- package/dist/nas/tls.js +2 -1
- package/dist/nas/transport.d.ts +2 -2
- package/dist/nas/transport.js +11 -6
- package/dist/standalone/compose.js +2 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.41.10 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
- Give DSM reads/login three minutes and SPK upload, install/upgrade and package
|
|
6
|
+
start/stop requests ten minutes by default, configurable through `DSM_*`
|
|
7
|
+
timeout variables.
|
|
8
|
+
- After an install/upgrade response timeout, keep polling the requested version
|
|
9
|
+
and running state within a shared 30-minute install budget, with progress on
|
|
10
|
+
stderr. Preserve DSM failures and distinguish an exhausted budget from a
|
|
11
|
+
failed installation; never replay the install request.
|
|
12
|
+
- Apply the same tolerant polling and progress to stop/uninstall, with a shared
|
|
13
|
+
configurable 30-minute budget and no write replay after response timeouts.
|
|
14
|
+
- Load the optional AI provider only when `magpieAgent()` is called, so DSM
|
|
15
|
+
smoke suites import without `@ai-sdk/openai` or `ai` installed.
|
|
16
|
+
- Share Compose registry-mirror configuration with `images pin`; verify returned
|
|
17
|
+
manifest bytes against the registry digest before writing the pin.
|
|
18
|
+
|
|
3
19
|
## 0.41.9 (2026-10-30)
|
|
4
20
|
|
|
5
21
|
- Accept self-signed DSM and redirected gateway certificates by default in the
|
package/README.md
CHANGED
|
@@ -446,6 +446,15 @@ installation; official protected ACL/project control and bridge readback remain
|
|
|
446
446
|
blocked. No SSO Server or wrapper container is added. Linux tests do not prove
|
|
447
447
|
DSM readiness; inspect lists explicit acceptance blockers.
|
|
448
448
|
|
|
449
|
+
`images pin` and the Compose planner share the build-host mirror configuration:
|
|
450
|
+
`SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS='{"docker.io":"https://mirror.example.com"}'`.
|
|
451
|
+
Each mirror must be an HTTPS origin. Image names retain their original registry;
|
|
452
|
+
manifest bytes are hashed and checked against the registry's digest before pinning.
|
|
453
|
+
|
|
454
|
+
`appOpenSmoke` and `dsmTarget` need only the optional `e2e` and `@e2e-dev/web`
|
|
455
|
+
peers. The optional `@ai-sdk/openai` provider is loaded when `magpieAgent()` is
|
|
456
|
+
called; install it and `ai` only for AI agent runs.
|
|
457
|
+
|
|
449
458
|
## Control installed Apps
|
|
450
459
|
|
|
451
460
|
Use `synopkgland host add nas --url https://nas.example:5001 --username alice`
|
|
@@ -468,6 +477,31 @@ certificate checks. Strict failures use `HOST_TLS_UNTRUSTED` or `HOST_CERT_CHANG
|
|
|
468
477
|
are sent only over a connection whose certificate was inspected. Public package
|
|
469
478
|
feeds and SPK downloads retain normal HTTPS verification.
|
|
470
479
|
|
|
480
|
+
DSM API requests tolerate slow NAS storage and startup. These environment
|
|
481
|
+
variables also work in `.env`; each accepts integer seconds from 1 to 86400:
|
|
482
|
+
|
|
483
|
+
| Variable | Default | Budget |
|
|
484
|
+
| --- | ---: | --- |
|
|
485
|
+
| `DSM_REQUEST_TIMEOUT_SECONDS` | 180 | One DSM read, login or other API request |
|
|
486
|
+
| `DSM_OPERATION_TIMEOUT_SECONDS` | 600 | One SPK upload, install/upgrade compound, or package start/stop request |
|
|
487
|
+
| `DSM_INSTALL_TIMEOUT_SECONDS` | 1800 | Per-package state check, upload, install/upgrade and startup together, in `install` and `deploy` |
|
|
488
|
+
| `DSM_UNINSTALL_TIMEOUT_SECONDS` | 1800 | State check, stop and uninstall together |
|
|
489
|
+
| `DSM_POLL_INTERVAL_SECONDS` | 5 | Package-state polling and progress interval |
|
|
490
|
+
|
|
491
|
+
For example, set `DSM_INSTALL_TIMEOUT_SECONDS=3600` for an hour per package.
|
|
492
|
+
An install/upgrade response timeout does not cancel DSM's work: the CLI keeps
|
|
493
|
+
checking for the requested version **and running state**, without replaying the
|
|
494
|
+
write. Progress continues on stderr, including with `--json`. A DSM failure
|
|
495
|
+
reports `DSM_INSTALL_FAILED` and its reported details; an exhausted overall
|
|
496
|
+
budget reports `DSM_INSTALL_TIMEOUT` and warns that DSM may still be working.
|
|
497
|
+
Uninstall also keeps checking after stop/uninstall response or status timeouts,
|
|
498
|
+
without replaying either write. Its exhausted budget reports `DSM_UNINSTALL_TIMEOUT`.
|
|
499
|
+
Check Package Center before retrying. A timed-out same-version reinstall also
|
|
500
|
+
needs an observed state transition before the old running instance can count
|
|
501
|
+
as completion. Upload timeouts are reported separately because no install
|
|
502
|
+
request has been sent yet. Explicit `ctl --timeout` budgets still take priority
|
|
503
|
+
for `ctl` calls; App command/job/transfer deadlines retain their existing defaults.
|
|
504
|
+
|
|
471
505
|
`ctl` preserves these process exit codes, including with `--json`:
|
|
472
506
|
|
|
473
507
|
| Exit code | Meaning and examples |
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Build-host registry policy shared by image pinning and SDK Compose planning. */
|
|
2
|
+
export declare function composeRegistryMirrors(env?: NodeJS.ProcessEnv): Record<string, string> | undefined;
|
|
3
|
+
export declare function registryBaseUrl(registry: string, mirrors?: Record<string, string> | undefined): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Build-host registry policy shared by image pinning and SDK Compose planning. */
|
|
2
|
+
export function composeRegistryMirrors(env = process.env) {
|
|
3
|
+
const raw = env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS;
|
|
4
|
+
if (raw === undefined)
|
|
5
|
+
return undefined;
|
|
6
|
+
let value;
|
|
7
|
+
try {
|
|
8
|
+
value = JSON.parse(raw);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error('SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS must be a JSON object of registry HTTPS origins');
|
|
12
|
+
}
|
|
13
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
14
|
+
throw new Error('SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS must be a JSON object of registry HTTPS origins');
|
|
15
|
+
const mirrors = {};
|
|
16
|
+
for (const [registry, origin] of Object.entries(value)) {
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(typeof origin === 'string' ? origin : '');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error('Registry mirror must be an HTTPS origin');
|
|
23
|
+
}
|
|
24
|
+
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.search || url.hash || url.pathname !== '/')
|
|
25
|
+
throw new Error('Registry mirror must be an HTTPS origin');
|
|
26
|
+
mirrors[registry] = url.origin;
|
|
27
|
+
}
|
|
28
|
+
return mirrors;
|
|
29
|
+
}
|
|
30
|
+
export function registryBaseUrl(registry, mirrors = composeRegistryMirrors()) {
|
|
31
|
+
const domain = registry === 'index.docker.io' ? 'docker.io' : registry;
|
|
32
|
+
if (mirrors && Object.hasOwn(mirrors, domain))
|
|
33
|
+
return mirrors[domain];
|
|
34
|
+
if (domain === 'docker.io')
|
|
35
|
+
return 'https://registry-1.docker.io';
|
|
36
|
+
const host = registry.startsWith('[') ? registry.slice(0, registry.indexOf(']') + 1) : registry.split(':')[0];
|
|
37
|
+
return `${['localhost', '127.0.0.1', '::1', '[::1]'].includes(host) ? 'http' : 'https'}://${registry}`;
|
|
38
|
+
}
|
package/dist/e2e/dsm.js
CHANGED
|
@@ -19,7 +19,7 @@ import { connect, createServer } from "node:net";
|
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { dirname, join, resolve } from "node:path";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
|
-
import {
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
23
|
import { readSmokeEntry } from "./app-open.js";
|
|
24
24
|
export { deriveSmokeEntry } from "./app-open.js";
|
|
25
25
|
import { surfaceOf, test, web } from "@e2e-dev/web";
|
|
@@ -222,9 +222,21 @@ export function llmSettings(env = process.env) {
|
|
|
222
222
|
}
|
|
223
223
|
/** The `agents.default` entry of e2e.config.ts: model plus the effort sent with every request. */
|
|
224
224
|
export function magpieAgent(env = process.env) {
|
|
225
|
+
// Keep the synchronous config API; load the optional ESM provider only when
|
|
226
|
+
// an agent is requested (Node 22.12+ supports require(esm)). Smoke tests do
|
|
227
|
+
// not need the AI SDK or any model provider installed.
|
|
228
|
+
let provider;
|
|
229
|
+
try {
|
|
230
|
+
provider = createRequire(import.meta.url)('@ai-sdk/openai');
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (!['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND'].includes(error.code ?? ''))
|
|
234
|
+
throw error;
|
|
235
|
+
throw new Error('magpieAgent requires optional @ai-sdk/openai and ai; install them to run AI agents.', { cause: error });
|
|
236
|
+
}
|
|
225
237
|
const settings = llmSettings(env);
|
|
226
238
|
return {
|
|
227
|
-
model: createOpenAI({ baseURL: settings.baseURL, apiKey: settings.apiKey }).chat(settings.model),
|
|
239
|
+
model: provider.createOpenAI({ baseURL: settings.baseURL, apiKey: settings.apiKey }).chat(settings.model),
|
|
228
240
|
providerOptions: { openai: { reasoningEffort: settings.reasoningEffort } },
|
|
229
241
|
};
|
|
230
242
|
}
|
package/dist/error-registry.js
CHANGED
|
@@ -5,6 +5,11 @@ export const ERROR_REGISTRY = {
|
|
|
5
5
|
HOST_TLS_UNTRUSTED: { "code": "HOST_TLS_UNTRUSTED", "description": "Strict TLS: the NAS certificate failed CA, hostname or validity verification", "suggestion": "Check the CA file, NAS hostname and certificate validity, or omit strict TLS" },
|
|
6
6
|
HOST_DNS_FAILED: { "code": "HOST_DNS_FAILED", "description": "The NAS hostname could not be resolved", "suggestion": "Check the registered hostname and DNS" },
|
|
7
7
|
HOST_CONNECTION_REFUSED: { "code": "HOST_CONNECTION_REFUSED", "description": "The NAS refused the connection", "suggestion": "Check the DSM port and NAS availability" },
|
|
8
|
+
DSM_UNINSTALL_TIMEOUT: { code: 'DSM_UNINSTALL_TIMEOUT', description: 'The overall stop/uninstall budget was exhausted; DSM may still be working', suggestion: 'Check Package Center before retrying or increase DSM_UNINSTALL_TIMEOUT_SECONDS' },
|
|
9
|
+
DSM_UNINSTALL_FAILED: { code: 'DSM_UNINSTALL_FAILED', description: 'DSM reported a package stop or uninstall failure', suggestion: 'Check the reported DSM error and Package Center log' },
|
|
10
|
+
DSM_TIMEOUT_INVALID: { code: 'DSM_TIMEOUT_INVALID', description: 'A DSM timeout budget is invalid', suggestion: 'Use whole seconds from 1 to 86400 for DSM timeout variables' },
|
|
11
|
+
DSM_INSTALL_TIMEOUT: { code: 'DSM_INSTALL_TIMEOUT', description: 'The overall install/start budget was exhausted; DSM may still be working', suggestion: 'Check Package Center before retrying or increase DSM_INSTALL_TIMEOUT_SECONDS' },
|
|
12
|
+
DSM_INSTALL_FAILED: { code: 'DSM_INSTALL_FAILED', description: 'DSM reported a package installation or startup failure', suggestion: 'Check the reported DSM error and Package Center log' },
|
|
8
13
|
HOST_TIMEOUT: { "code": "HOST_TIMEOUT", "description": "The NAS request timed out", "suggestion": "Check connection and firewall; inspect state before retrying writes" },
|
|
9
14
|
CTL_HOST_REQUIRED: { "code": "CTL_HOST_REQUIRED", "description": "Select a registered NAS host", "suggestion": "Run synopkgland host add, select --host, or set host default" },
|
|
10
15
|
CTL_NOT_AUTHENTICATED: { "code": "CTL_NOT_AUTHENTICATED", "description": "The DSM session is absent or expired", "suggestion": "Run synopkgland host login <name>" },
|
|
@@ -32,19 +32,24 @@ export interface UploadedCatalogPackage {
|
|
|
32
32
|
}
|
|
33
33
|
export declare function packageStateFromRecord(value: unknown): InstalledPackageState;
|
|
34
34
|
export declare function packageStateFromListPayload(payload: unknown, packageId: string): InstalledPackageState | undefined;
|
|
35
|
-
export declare function fetchInstalledPackageState(dsmOrigin: string, cookieHeader: string, packageId: string): Promise<InstalledPackageState | undefined>;
|
|
36
|
-
export
|
|
35
|
+
export declare function fetchInstalledPackageState(dsmOrigin: string, cookieHeader: string, packageId: string, timeoutMs?: number): Promise<InstalledPackageState | undefined>;
|
|
36
|
+
export interface InstallWaitOptions {
|
|
37
37
|
packageId: string;
|
|
38
38
|
expectedVersion?: string;
|
|
39
39
|
deadlineMs?: number;
|
|
40
40
|
pollMs?: number;
|
|
41
|
-
|
|
41
|
+
requireRunning?: boolean;
|
|
42
|
+
requireTransition?: boolean;
|
|
43
|
+
onProgress?: (message: string) => void;
|
|
44
|
+
}
|
|
45
|
+
export declare function waitForInstalledVersion(readState: (remainingMs: number) => InstalledPackageState | undefined | Promise<InstalledPackageState | undefined>, options: InstallWaitOptions): Promise<void>;
|
|
42
46
|
export interface UninstallPackageOptions {
|
|
43
47
|
dsmOrigin: string;
|
|
44
48
|
cookieHeader: string;
|
|
45
49
|
packageId: string;
|
|
46
50
|
deadlineMs?: number;
|
|
47
51
|
pollMs?: number;
|
|
52
|
+
onProgress?: (message: string) => void;
|
|
48
53
|
}
|
|
49
54
|
export declare function uninstallPackageFromDsm(options: UninstallPackageOptions): Promise<void>;
|
|
50
55
|
export declare function catalogInstallationRequests(entry: Pick<CatalogPackageEntry, "package" | "version" | "size" | "beta" | "link" | "md5" | "qinst" | "deppkgs" | "breakpkgs" | "replacepkgs">, installed: boolean, sid: string, volumePath?: string): {
|
|
@@ -64,6 +69,9 @@ export interface LocalSpkInstallOptions {
|
|
|
64
69
|
version: string;
|
|
65
70
|
profileName?: string;
|
|
66
71
|
extraValues?: Record<string, string>;
|
|
72
|
+
deadlineMs?: number;
|
|
73
|
+
pollMs?: number;
|
|
74
|
+
onProgress?: (message: string) => void;
|
|
67
75
|
}
|
|
68
76
|
export declare function installPackageFromLocalSpk(options: LocalSpkInstallOptions): Promise<void>;
|
|
69
77
|
export {};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { CtlError } from '../ctl/errors.js';
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { terminalSafe } from '../terminal-text.js';
|
|
3
4
|
import { dsmFetch } from "./dsm-http.js";
|
|
4
5
|
import { assertDsmCompoundResponse } from "./dsm-compound.js";
|
|
6
|
+
import { dsmRequestTimeoutMs, dsmOperationTimeoutMs, dsmInstallTimeoutMs, dsmUninstallTimeoutMs, dsmPollIntervalMs, isHostTimeout } from '../nas/timeouts.js';
|
|
5
7
|
function normalizeDeppkgs(deppkgs) {
|
|
6
8
|
if (typeof deppkgs === "string") {
|
|
7
9
|
const match = /^([^><=]+)(>=|>|<=|<|==)(.+)$/.exec(deppkgs.trim());
|
|
@@ -25,13 +27,13 @@ async function dsmWebApi(dsmOrigin, cookieHeader, params, options = {}) {
|
|
|
25
27
|
const query = new URLSearchParams(params);
|
|
26
28
|
const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?${query.toString()}`, {
|
|
27
29
|
headers: { cookie: cookieHeader },
|
|
28
|
-
});
|
|
30
|
+
}, options.timeoutMs);
|
|
29
31
|
const body = (await response.json());
|
|
30
32
|
const errorCode = body.error?.code;
|
|
31
33
|
if (!body.success &&
|
|
32
34
|
!options.allowFailure &&
|
|
33
35
|
!(errorCode && options.allowErrorCodes?.includes(errorCode))) {
|
|
34
|
-
throw new
|
|
36
|
+
throw new CtlError('DSM_API_FAILED', `DSM ${params.api}/${params.method} failed: ${Number(body.error?.code) || "invalid response"}${typeof body.error?.description === "string" ? ` ${body.error.description}` : ""}`, 'Check DSM permissions and Package Center');
|
|
35
37
|
}
|
|
36
38
|
return body;
|
|
37
39
|
}
|
|
@@ -110,7 +112,7 @@ export function packageStateFromListPayload(payload, packageId) {
|
|
|
110
112
|
}
|
|
111
113
|
return packageStateFromRecord(match);
|
|
112
114
|
}
|
|
113
|
-
export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packageId) {
|
|
115
|
+
export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packageId, timeoutMs) {
|
|
114
116
|
try {
|
|
115
117
|
const body = await dsmWebApi(dsmOrigin, cookieHeader, {
|
|
116
118
|
api: "SYNO.Core.Package",
|
|
@@ -118,7 +120,7 @@ export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packag
|
|
|
118
120
|
method: "list",
|
|
119
121
|
additional: JSON.stringify(["status", "installed_info"]),
|
|
120
122
|
ignore_hidden: "false",
|
|
121
|
-
});
|
|
123
|
+
}, { timeoutMs });
|
|
122
124
|
return packageStateFromListPayload(body, packageId);
|
|
123
125
|
}
|
|
124
126
|
catch (error) {
|
|
@@ -133,89 +135,126 @@ function installStatusIsBroken(status) {
|
|
|
133
135
|
}
|
|
134
136
|
return /(broken|error|fail|150)/i.test(status);
|
|
135
137
|
}
|
|
138
|
+
function installBudgetExpired(options) {
|
|
139
|
+
const target = options.expectedVersion ? ` to reach version ${options.expectedVersion}` : ' to install from catalog';
|
|
140
|
+
return new CtlError('DSM_INSTALL_TIMEOUT', `Timed out waiting for ${options.packageId}${target}: overall install budget exhausted; DSM may still be installing/starting.`, 'Check Package Center state before retrying; the install was not replayed');
|
|
141
|
+
}
|
|
136
142
|
export async function waitForInstalledVersion(readState, options) {
|
|
137
|
-
const deadline = Date.now() + (options.deadlineMs ??
|
|
138
|
-
const pollMs = options.pollMs ??
|
|
143
|
+
const deadline = Date.now() + (options.deadlineMs ?? dsmInstallTimeoutMs());
|
|
144
|
+
const pollMs = options.pollMs ?? dsmPollIntervalMs();
|
|
145
|
+
let transitioned = !options.requireTransition;
|
|
139
146
|
while (Date.now() < deadline) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
147
|
+
let state;
|
|
148
|
+
try {
|
|
149
|
+
state = await readState(Math.max(1, deadline - Date.now()));
|
|
143
150
|
}
|
|
144
|
-
|
|
145
|
-
if (!
|
|
146
|
-
|
|
147
|
-
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (!isHostTimeout(error))
|
|
153
|
+
throw error;
|
|
154
|
+
options.onProgress?.(`${options.packageId}: status request timed out; still installing/starting…`);
|
|
148
155
|
}
|
|
149
|
-
|
|
156
|
+
if (state && (installStatusIsBroken(state.status) ||
|
|
157
|
+
state.error && (state.error.code !== undefined ? state.error.code !== 0 : Boolean(state.error.description)))) {
|
|
158
|
+
throw new CtlError('DSM_INSTALL_FAILED', `DSM reports package ${options.packageId} install did not complete (Package Center status ${state.status ?? 'unknown'}): ${state.error?.code ?? ''} ${state.error?.description ?? state.statusDescription ?? ''}`.trim(), 'Check the DSM Package Center install/start log');
|
|
159
|
+
}
|
|
160
|
+
const running = /^(run|running)$/i.test(state?.status ?? '');
|
|
161
|
+
if (state && (!state.installed || (state.status !== undefined && !running) ||
|
|
162
|
+
(state.version !== undefined && state.version !== options.expectedVersion)))
|
|
163
|
+
transitioned = true;
|
|
164
|
+
if (Date.now() >= deadline)
|
|
165
|
+
break;
|
|
166
|
+
if (state?.installed && !/installing|upgrading|starting/i.test(state.status ?? '') &&
|
|
167
|
+
(!options.expectedVersion || state.version === options.expectedVersion) &&
|
|
168
|
+
(!options.requireRunning || running) && transitioned)
|
|
169
|
+
return;
|
|
170
|
+
options.onProgress?.(`${options.packageId}: installing/starting… (DSM status ${state?.status ?? 'unavailable'})`);
|
|
171
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
|
|
150
172
|
}
|
|
151
|
-
throw
|
|
152
|
-
? `Timed out waiting for ${options.packageId} to reach version ${options.expectedVersion}`
|
|
153
|
-
: `Timed out waiting for ${options.packageId} to install from catalog`);
|
|
173
|
+
throw installBudgetExpired(options);
|
|
154
174
|
}
|
|
155
175
|
function packageStatusIsStopped(state) {
|
|
156
176
|
const status = state?.status?.trim().toLowerCase();
|
|
157
177
|
return state?.installed === true && (status === "stop" || status === "stopped" || status === "manually-stopped");
|
|
158
178
|
}
|
|
159
|
-
async function waitForPackageStopped(readState, options) {
|
|
160
|
-
const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
|
|
161
|
-
const pollMs = options.pollMs ?? 1000;
|
|
162
|
-
while (Date.now() < deadline) {
|
|
163
|
-
const state = await readState();
|
|
164
|
-
if (packageStatusIsStopped(state)) {
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
168
|
-
}
|
|
169
|
-
throw new Error(`Timed out waiting for ${options.packageId} to stop before uninstall`);
|
|
170
|
-
}
|
|
171
|
-
async function waitForPackageUninstalled(readState, options) {
|
|
172
|
-
const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
|
|
173
|
-
const pollMs = options.pollMs ?? 1000;
|
|
174
|
-
while (Date.now() < deadline) {
|
|
175
|
-
const state = await readState();
|
|
176
|
-
if (state?.installed === false) {
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
180
|
-
}
|
|
181
|
-
throw new Error(`Timed out waiting for ${options.packageId} to be uninstalled`);
|
|
182
|
-
}
|
|
183
179
|
function uninstallFailure(packageId, error) {
|
|
184
180
|
const detail = error instanceof Error ? error.message : String(error);
|
|
181
|
+
if (error instanceof CtlError)
|
|
182
|
+
return new CtlError(error.code, `Failed to uninstall ${packageId}: ${detail}`, error.hint, error.exitCode);
|
|
185
183
|
const failure = new Error(`Failed to uninstall ${packageId}: ${detail}`);
|
|
186
184
|
failure.code = "CATALOG_INSTALL_FAILED";
|
|
187
185
|
return failure;
|
|
188
186
|
}
|
|
189
187
|
export async function uninstallPackageFromDsm(options) {
|
|
188
|
+
const deadline = Date.now() + (options.deadlineMs ?? dsmUninstallTimeoutMs());
|
|
189
|
+
const requestMs = dsmRequestTimeoutMs(), operationMs = dsmOperationTimeoutMs();
|
|
190
|
+
const pollMs = options.pollMs ?? dsmPollIntervalMs();
|
|
191
|
+
const progress = options.onProgress ?? (message => process.stderr.write(`${terminalSafe(message)}\n`));
|
|
192
|
+
let phase = 'checking package state';
|
|
193
|
+
const expired = () => new CtlError('DSM_UNINSTALL_TIMEOUT', `Package ${options.packageId}: overall uninstall budget exhausted while ${phase}; DSM may still be stopping/uninstalling.`, 'Check Package Center before retrying; stop/uninstall requests were not replayed');
|
|
194
|
+
const remaining = (limit) => {
|
|
195
|
+
if (Date.now() >= deadline)
|
|
196
|
+
throw expired();
|
|
197
|
+
return Math.min(limit, deadline - Date.now());
|
|
198
|
+
};
|
|
199
|
+
const heartbeat = setInterval(() => progress(`${options.packageId}: ${phase}…`), pollMs);
|
|
190
200
|
try {
|
|
191
201
|
const sid = extractSid(options.cookieHeader);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
202
|
+
const wait = async (done) => {
|
|
203
|
+
while (Date.now() < deadline) {
|
|
204
|
+
let state;
|
|
205
|
+
try {
|
|
206
|
+
state = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId, remaining(requestMs));
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (!isHostTimeout(error))
|
|
210
|
+
throw error;
|
|
211
|
+
progress(`${options.packageId}: status request timed out; ${phase}…`);
|
|
212
|
+
}
|
|
213
|
+
if (state && Date.now() < deadline) {
|
|
214
|
+
if (done(state))
|
|
215
|
+
return state;
|
|
216
|
+
if (installStatusIsBroken(state.status) || (state.error && (state.error.code !== undefined ? state.error.code !== 0 : Boolean(state.error.description))))
|
|
217
|
+
throw new CtlError('DSM_UNINSTALL_FAILED', `DSM reports ${options.packageId} failed while ${phase}: ${state.error?.code ?? state.status ?? ''} ${state.error?.description ?? state.statusDescription ?? ''}`, 'Check the DSM Package Center stop/uninstall log');
|
|
218
|
+
}
|
|
219
|
+
progress(`${options.packageId}: ${phase}… (DSM status ${state?.status ?? 'unavailable'})`);
|
|
220
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
|
|
221
|
+
}
|
|
222
|
+
throw expired();
|
|
223
|
+
};
|
|
224
|
+
const dispatch = async (api, method) => {
|
|
225
|
+
progress(`${options.packageId}: ${phase}…`);
|
|
226
|
+
try {
|
|
227
|
+
await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
228
|
+
api, version: '1', method, id: jsonApiParam(options.packageId), _sid: sid,
|
|
229
|
+
...(method === 'uninstall' ? { extra_values: jsonApiParam('{}') } : {}),
|
|
230
|
+
}, { timeoutMs: remaining(operationMs) });
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (!isHostTimeout(error))
|
|
234
|
+
throw error;
|
|
235
|
+
progress(`${options.packageId}: ${method} request timed out; checking DSM state without replay…`);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const before = await wait(() => true);
|
|
239
|
+
if (!before.installed)
|
|
240
|
+
return;
|
|
196
241
|
if (!packageStatusIsStopped(before)) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
_sid: sid,
|
|
203
|
-
});
|
|
204
|
-
await waitForPackageStopped(readState, options);
|
|
242
|
+
phase = 'stopping';
|
|
243
|
+
await dispatch('SYNO.Core.Package.Control', 'stop');
|
|
244
|
+
const stopped = await wait(state => !state.installed || packageStatusIsStopped(state));
|
|
245
|
+
if (!stopped.installed)
|
|
246
|
+
return;
|
|
205
247
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
method: "uninstall",
|
|
210
|
-
id: jsonApiParam(options.packageId),
|
|
211
|
-
extra_values: jsonApiParam("{}"),
|
|
212
|
-
_sid: sid,
|
|
213
|
-
});
|
|
214
|
-
await waitForPackageUninstalled(readState, options);
|
|
248
|
+
phase = 'uninstalling';
|
|
249
|
+
await dispatch('SYNO.Core.Package.Uninstallation', 'uninstall');
|
|
250
|
+
await wait(state => !state.installed);
|
|
215
251
|
}
|
|
216
252
|
catch (error) {
|
|
217
253
|
throw uninstallFailure(options.packageId, error);
|
|
218
254
|
}
|
|
255
|
+
finally {
|
|
256
|
+
clearInterval(heartbeat);
|
|
257
|
+
}
|
|
219
258
|
}
|
|
220
259
|
export function catalogInstallationRequests(entry, installed, sid, volumePath) {
|
|
221
260
|
const install = {
|
|
@@ -297,7 +336,7 @@ export function catalogCompoundSteps(uploaded, entry, options) {
|
|
|
297
336
|
export function catalogUpgradeCompoundSteps(uploaded, entry, extraValues) {
|
|
298
337
|
return catalogCompoundSteps(uploaded, entry, { blupgrade: true, extraValues });
|
|
299
338
|
}
|
|
300
|
-
async function uploadLocalSpk(options, sid) {
|
|
339
|
+
async function uploadLocalSpk(options, sid, timeoutMs) {
|
|
301
340
|
const bytes = readFileSync(options.spkPath);
|
|
302
341
|
const form = new FormData();
|
|
303
342
|
form.append("api", "SYNO.Core.Package.Installation");
|
|
@@ -309,7 +348,7 @@ async function uploadLocalSpk(options, sid) {
|
|
|
309
348
|
method: "POST",
|
|
310
349
|
headers: { cookie: options.cookieHeader },
|
|
311
350
|
body: form,
|
|
312
|
-
});
|
|
351
|
+
}, timeoutMs);
|
|
313
352
|
const body = (await uploadResponse.json());
|
|
314
353
|
if (!body.success || !body.data?.id || !body.data.task_id) {
|
|
315
354
|
throw new Error(`DSM SYNO.Core.Package.Installation/upload failed: ${Number(body.error?.code) || "invalid response"}`);
|
|
@@ -317,20 +356,69 @@ async function uploadLocalSpk(options, sid) {
|
|
|
317
356
|
return { id: body.data.id, taskId: body.data.task_id };
|
|
318
357
|
}
|
|
319
358
|
export async function installPackageFromLocalSpk(options) {
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
359
|
+
// The same wall budget covers upload, install/upgrade and startup verification.
|
|
360
|
+
const deadline = Date.now() + (options.deadlineMs ?? dsmInstallTimeoutMs());
|
|
361
|
+
const requestMs = dsmRequestTimeoutMs(), operationMs = dsmOperationTimeoutMs();
|
|
362
|
+
const pollMs = options.pollMs ?? dsmPollIntervalMs();
|
|
363
|
+
const progress = options.onProgress ?? (message => process.stderr.write(`${terminalSafe(message)}\n`));
|
|
364
|
+
let phase = 'checking package state';
|
|
365
|
+
const remaining = (limit) => {
|
|
366
|
+
if (Date.now() >= deadline)
|
|
367
|
+
throw installBudgetExpired(options);
|
|
368
|
+
return Math.min(limit, deadline - Date.now());
|
|
369
|
+
};
|
|
370
|
+
const heartbeat = setInterval(() => progress(`${options.packageId}: ${phase}…`), pollMs);
|
|
371
|
+
try {
|
|
372
|
+
const sid = extractSid(options.cookieHeader);
|
|
373
|
+
let before;
|
|
374
|
+
while (!before) {
|
|
375
|
+
try {
|
|
376
|
+
before = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId, remaining(requestMs));
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (!isHostTimeout(error))
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
if (!before) {
|
|
383
|
+
progress(`${options.packageId}: waiting for DSM package state…`);
|
|
384
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, remaining(requestMs))));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
phase = 'uploading';
|
|
388
|
+
progress(`${options.packageId}: uploading…`);
|
|
389
|
+
const uploaded = await uploadLocalSpk(options, sid, remaining(operationMs));
|
|
390
|
+
const compound = JSON.stringify(catalogCompoundSteps(uploaded, { version: options.version, size: readFileSync(options.spkPath).byteLength }, { blupgrade: before.installed, extraValues: options.extraValues }));
|
|
391
|
+
phase = 'installing/starting';
|
|
392
|
+
progress(`${options.packageId}: ${phase}…`);
|
|
393
|
+
let timedOut = false;
|
|
394
|
+
try {
|
|
395
|
+
const response = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
396
|
+
api: 'SYNO.Entry.Request', version: '1', method: 'request', stop_when_error: 'true',
|
|
397
|
+
mode: jsonApiParam('sequential'), compound, _sid: sid,
|
|
398
|
+
}, { allowFailure: true, timeoutMs: remaining(operationMs) });
|
|
399
|
+
assertDsmCompoundResponse(response);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
if (!isHostTimeout(error))
|
|
403
|
+
throw error;
|
|
404
|
+
timedOut = true;
|
|
405
|
+
progress(`${options.packageId}: install/upgrade request timed out; DSM may still be installing/starting; checking state…`);
|
|
406
|
+
}
|
|
407
|
+
await waitForInstalledVersion(budget => fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId, Math.min(requestMs, budget)), { packageId: options.packageId, expectedVersion: options.version,
|
|
408
|
+
deadlineMs: remaining(Number.MAX_SAFE_INTEGER), pollMs, requireRunning: true,
|
|
409
|
+
// A lost same-version reinstall reply must not accept the old running instance.
|
|
410
|
+
requireTransition: timedOut && before.installed && before.version === options.version,
|
|
411
|
+
onProgress: progress });
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
if (isHostTimeout(error)) {
|
|
415
|
+
if (Date.now() >= deadline)
|
|
416
|
+
throw installBudgetExpired(options);
|
|
417
|
+
throw new CtlError('HOST_TIMEOUT', 'SPK upload request timed out; no install request was sent', 'Check the NAS connection and DSM_OPERATION_TIMEOUT_SECONDS before retrying');
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
clearInterval(heartbeat);
|
|
423
|
+
}
|
|
336
424
|
}
|
|
@@ -58,7 +58,7 @@ export function assertDsmCompoundResponse(raw) {
|
|
|
58
58
|
const root = requireRecord(raw, "response");
|
|
59
59
|
if (root.success !== true) {
|
|
60
60
|
const errorCode = numericErrorCode(root.error);
|
|
61
|
-
throw new DsmCompoundError("DSM_REQUEST_FAILED",
|
|
61
|
+
throw new DsmCompoundError("DSM_REQUEST_FAILED", `DSM SYNO.Entry.Request compound request failed${errorSummary(root.error)}`, {
|
|
62
62
|
dsmErrorCode: errorCode,
|
|
63
63
|
detail: root.error ?? root,
|
|
64
64
|
});
|
|
@@ -68,7 +68,7 @@ export function assertDsmCompoundResponse(raw) {
|
|
|
68
68
|
const dependencyConflictIndex = parsed.results.findIndex((result) => result.errorCode === 4552);
|
|
69
69
|
if (dependencyConflictIndex >= 0) {
|
|
70
70
|
const result = parsed.results[dependencyConflictIndex];
|
|
71
|
-
throw new DsmCompoundError("DSM_DEPENDENCY_CONFLICT", `DSM ${result.api}/${result.method} rejected the package because a dependency is running`, {
|
|
71
|
+
throw new DsmCompoundError("DSM_DEPENDENCY_CONFLICT", `DSM ${result.api}/${result.method} rejected the package because a dependency is running (4552)`, {
|
|
72
72
|
api: result.api,
|
|
73
73
|
method: result.method,
|
|
74
74
|
errorCode: result.errorCode,
|
|
@@ -80,7 +80,7 @@ export function assertDsmCompoundResponse(raw) {
|
|
|
80
80
|
if (parsed.hasFail || firstFailureIndex >= 0) {
|
|
81
81
|
const result = firstFailureIndex >= 0 ? parsed.results[firstFailureIndex] : undefined;
|
|
82
82
|
throw new DsmCompoundError("DSM_COMPOUND_FAILED", result
|
|
83
|
-
? `DSM compound step ${result.api}/${result.method} failed`
|
|
83
|
+
? `DSM compound step ${result.api}/${result.method} failed${errorSummary(failureDetail(resultRecords[firstFailureIndex]))}`
|
|
84
84
|
: "DSM compound request reported a failed step", {
|
|
85
85
|
api: result?.api,
|
|
86
86
|
method: result?.method,
|
|
@@ -90,6 +90,13 @@ export function assertDsmCompoundResponse(raw) {
|
|
|
90
90
|
}
|
|
91
91
|
return parsed;
|
|
92
92
|
}
|
|
93
|
+
function errorSummary(error) {
|
|
94
|
+
if (!isRecord(error))
|
|
95
|
+
return '';
|
|
96
|
+
const code = numericErrorCode(error);
|
|
97
|
+
const description = typeof error.description === 'string' ? error.description : '';
|
|
98
|
+
return `${code === undefined ? '' : ` (${code})`}${description ? `: ${description}` : ''}`;
|
|
99
|
+
}
|
|
93
100
|
function failureDetail(rawResult) {
|
|
94
101
|
const result = isRecord(rawResult) ? rawResult : undefined;
|
|
95
102
|
return result?.error ?? rawResult;
|
package/dist/images/pin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { registryBaseUrl } from "../compose/registry-mirrors.js";
|
|
2
3
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { isScalar, parseDocument } from "yaml";
|
|
4
5
|
/**
|
|
@@ -73,18 +74,6 @@ export function parseImageReference(image) {
|
|
|
73
74
|
}
|
|
74
75
|
return { registry, repository, tag, digest, explicitTag };
|
|
75
76
|
}
|
|
76
|
-
function registryBaseUrl(registry) {
|
|
77
|
-
if (registry === "docker.io" || registry === "index.docker.io") {
|
|
78
|
-
return "https://registry-1.docker.io";
|
|
79
|
-
}
|
|
80
|
-
const host = registry.startsWith("[")
|
|
81
|
-
? registry.slice(0, registry.indexOf("]") + 1)
|
|
82
|
-
: registry.split(":")[0];
|
|
83
|
-
if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]") {
|
|
84
|
-
return `http://${registry}`;
|
|
85
|
-
}
|
|
86
|
-
return `https://${registry}`;
|
|
87
|
-
}
|
|
88
77
|
function parseBearerChallenge(header) {
|
|
89
78
|
if (!header) {
|
|
90
79
|
return null;
|
|
@@ -152,12 +141,13 @@ export async function resolveImageDigest(image, deps = {}) {
|
|
|
152
141
|
if (!response.ok) {
|
|
153
142
|
throw new ImageDigestError(`registry answered HTTP ${response.status} for ${image}`);
|
|
154
143
|
}
|
|
144
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
145
|
+
const digest = `sha256:${createHash("sha256").update(body).digest("hex")}`;
|
|
155
146
|
const header = response.headers.get("docker-content-digest");
|
|
156
|
-
if (header && DIGEST_VALUE.test(header)) {
|
|
157
|
-
|
|
147
|
+
if (header && (!DIGEST_VALUE.test(header) || header.toLowerCase() !== digest)) {
|
|
148
|
+
throw new ImageDigestError(`registry content digest mismatch for ${image}`);
|
|
158
149
|
}
|
|
159
|
-
|
|
160
|
-
return `sha256:${createHash("sha256").update(body).digest("hex")}`;
|
|
150
|
+
return digest;
|
|
161
151
|
}
|
|
162
152
|
/** Keep the source's quote style so only the reference text changes. */
|
|
163
153
|
function quoteLike(node, value) {
|
package/dist/nas/auth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CtlError } from '../ctl/errors.js';
|
|
2
2
|
import { createRequest } from './transport.js';
|
|
3
3
|
export async function loginDsm(origin, username, password, options) {
|
|
4
|
-
const request = options.request ?? createRequest(origin,
|
|
4
|
+
const request = options.request ?? createRequest(origin, undefined, options.ca);
|
|
5
5
|
const form = new URLSearchParams({ api: 'SYNO.API.Auth', version: '7', method: 'login', account: username, passwd: password, session: 'DSM', format: 'cookie', enable_syno_token: 'yes', enable_device_token: options.remember === false ? 'no' : 'yes', device_name: 'synopkgland', device_id: options.deviceId ?? '' });
|
|
6
6
|
let result = await request('/webapi/entry.cgi', form);
|
|
7
7
|
if (result?.error?.code === 402) {
|
package/dist/nas/connection.js
CHANGED
|
@@ -6,7 +6,7 @@ export async function connectNas(name) {
|
|
|
6
6
|
const credentials = resolveHostCredentials(name);
|
|
7
7
|
const session = await credentials.loadSession();
|
|
8
8
|
registerDsmSession(session, credentials.hostName, credentials.ca, credentials.fingerprint, credentials.tls);
|
|
9
|
-
const request = createRequest(session.origin,
|
|
9
|
+
const request = createRequest(session.origin, undefined, credentials.ca, credentials.tls);
|
|
10
10
|
return {
|
|
11
11
|
name: credentials.hostName,
|
|
12
12
|
session,
|
package/dist/nas/http.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ import { type DsmSession } from './transport.js';
|
|
|
2
2
|
import type { HostTls } from './tls.js';
|
|
3
3
|
export declare function registerDsmSession(session: DsmSession, name: string, ca?: string, fingerprint?: string, policy?: HostTls): void;
|
|
4
4
|
/** All DSM fetch callers, including session-only automation, use the NAS socket policy. */
|
|
5
|
-
export declare function dsmFetch(url: string, init?: RequestInit): Promise<Response>;
|
|
5
|
+
export declare function dsmFetch(url: string, init?: RequestInit, timeoutMs?: number): Promise<Response>;
|
package/dist/nas/http.js
CHANGED
|
@@ -9,12 +9,12 @@ export function registerDsmSession(session, name, ca, fingerprint, policy) {
|
|
|
9
9
|
policies.set(session.origin, trust);
|
|
10
10
|
}
|
|
11
11
|
/** All DSM fetch callers, including session-only automation, use the NAS socket policy. */
|
|
12
|
-
export async function dsmFetch(url, init = {}) {
|
|
12
|
+
export async function dsmFetch(url, init = {}, timeoutMs) {
|
|
13
13
|
const headers = new Headers(init.headers), origin = new URL(url).origin;
|
|
14
14
|
const cookie = headers.get('cookie') ?? '';
|
|
15
15
|
// App cookies can accompany a DSM cookie; keep its token and TLS policy.
|
|
16
16
|
const binding = [...bindings.values()].find(b => b.session.origin === origin && cookie.split(/;\s*/).includes(b.session.cookie));
|
|
17
|
-
const response = await sessionFetch(url, { ...init, headers }, binding ? { ...binding.session, cookie } : undefined, undefined, binding?.trust ?? policies.get(origin));
|
|
17
|
+
const response = await sessionFetch(url, { ...init, headers }, binding ? { ...binding.session, cookie } : undefined, undefined, binding?.trust ?? policies.get(origin), timeoutMs);
|
|
18
18
|
if (response.status >= 300 && response.status < 400)
|
|
19
19
|
throw new CtlError('CTL_HTTP_ERROR', `HTTP ${response.status}; redirects are never followed`, 'Check the NAS URL', 6);
|
|
20
20
|
if (binding && new URL(url).pathname === '/webapi/entry.cgi') {
|
package/dist/nas/store.js
CHANGED
|
@@ -99,7 +99,7 @@ export class CtlStore {
|
|
|
99
99
|
if (!options.storedOnly && process.env.DSM_USERNAME && process.env.DSM_PASSWORD) {
|
|
100
100
|
if (process.env.DSM_USERNAME !== host.username)
|
|
101
101
|
throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Automation account differs from the registered host.', 'Use the registered account or a separately named host.', 2);
|
|
102
|
-
session = await loginDsm(host.origin, host.username, process.env.DSM_PASSWORD, { remember: false, otp: async () => process.env.DSM_OTP ?? '', request: createRequest(host.origin,
|
|
102
|
+
session = await loginDsm(host.origin, host.username, process.env.DSM_PASSWORD, { remember: false, otp: async () => process.env.DSM_OTP ?? '', request: createRequest(host.origin, undefined, undefined, this.trust(host)) });
|
|
103
103
|
}
|
|
104
104
|
else
|
|
105
105
|
session = await this.credentials.get(this.key(host));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const dsmRequestTimeoutMs: () => number;
|
|
2
|
+
export declare const dsmOperationTimeoutMs: () => number;
|
|
3
|
+
export declare const dsmUninstallTimeoutMs: () => number;
|
|
4
|
+
export declare const dsmInstallTimeoutMs: () => number;
|
|
5
|
+
export declare const dsmPollIntervalMs: () => number;
|
|
6
|
+
export declare function isHostTimeout(error: unknown): boolean;
|
|
7
|
+
export declare function dsmApiTimeoutMs(api: string | null, method: string | null): number;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { CtlError } from '../ctl/errors.js';
|
|
2
|
+
/** Seconds in the existing DSM_* automation environment (also loaded from .env). */
|
|
3
|
+
function seconds(name, fallback) {
|
|
4
|
+
const value = process.env[name];
|
|
5
|
+
if (value === undefined)
|
|
6
|
+
return fallback * 1000;
|
|
7
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1 || Number(value) > 86400)
|
|
8
|
+
throw new CtlError('DSM_TIMEOUT_INVALID', `${name} must be a whole number of seconds from 1 to 86400`, 'Set a positive DSM timeout budget');
|
|
9
|
+
return Number(value) * 1000;
|
|
10
|
+
}
|
|
11
|
+
export const dsmRequestTimeoutMs = () => seconds('DSM_REQUEST_TIMEOUT_SECONDS', 180);
|
|
12
|
+
export const dsmOperationTimeoutMs = () => seconds('DSM_OPERATION_TIMEOUT_SECONDS', 600);
|
|
13
|
+
export const dsmUninstallTimeoutMs = () => seconds('DSM_UNINSTALL_TIMEOUT_SECONDS', 1800);
|
|
14
|
+
export const dsmInstallTimeoutMs = () => seconds('DSM_INSTALL_TIMEOUT_SECONDS', 1800);
|
|
15
|
+
export const dsmPollIntervalMs = () => seconds('DSM_POLL_INTERVAL_SECONDS', 5);
|
|
16
|
+
export function isHostTimeout(error) {
|
|
17
|
+
return error instanceof CtlError && error.code === 'HOST_TIMEOUT';
|
|
18
|
+
}
|
|
19
|
+
export function dsmApiTimeoutMs(api, method) {
|
|
20
|
+
const longOperation = api === 'SYNO.Entry.Request' || api === 'SYNO.Core.Package.Uninstallation' ||
|
|
21
|
+
(api === 'SYNO.Core.Package.Installation' && ['upload', 'install'].includes(method ?? '')) ||
|
|
22
|
+
(api === 'SYNO.Core.Package.Control' && ['start', 'stop'].includes(method ?? ''));
|
|
23
|
+
return longOperation ? dsmOperationTimeoutMs() : dsmRequestTimeoutMs();
|
|
24
|
+
}
|
package/dist/nas/tls.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dsmRequestTimeoutMs } from './timeouts.js';
|
|
1
2
|
import tls from "node:tls";
|
|
2
3
|
import https from "node:https";
|
|
3
4
|
import { isIP } from "node:net";
|
|
@@ -47,7 +48,7 @@ export function probeCertificate(origin, options = {}) {
|
|
|
47
48
|
...socketOptions(origin, options.ca),
|
|
48
49
|
rejectUnauthorized: false,
|
|
49
50
|
});
|
|
50
|
-
const timer = setTimeout(() => socket.destroy(timeoutError()), options.timeoutMs ??
|
|
51
|
+
const timer = setTimeout(() => socket.destroy(timeoutError()), options.timeoutMs ?? dsmRequestTimeoutMs());
|
|
51
52
|
socket.once("error", (e) => reject(networkError(e)));
|
|
52
53
|
socket.once("close", () => clearTimeout(timer));
|
|
53
54
|
socket.once("secureConnect", () => {
|
package/dist/nas/transport.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface DsmSession {
|
|
|
11
11
|
}
|
|
12
12
|
export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
|
|
13
13
|
export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
|
|
14
|
-
export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string, trust?: HostTls): JsonRequest;
|
|
15
|
-
export declare function sessionFetch(url: string, init: RequestInit, session: DsmSession | undefined, ca?: string, trust?: HostTls): Promise<Response>;
|
|
14
|
+
export declare function createRequest(origin: string, timeoutMs?: number | (() => number) | undefined, ca?: string, trust?: HostTls): JsonRequest;
|
|
15
|
+
export declare function sessionFetch(url: string, init: RequestInit, session: DsmSession | undefined, ca?: string, trust?: HostTls, timeoutMs?: number): Promise<Response>;
|
|
16
16
|
/** The only Node HTTP dispatch for NAS endpoints, including streaming transfers. */
|
|
17
17
|
export declare function nasHttpRequest(url: URL, options: http.RequestOptions, timeoutMs: number, trust: HostTls | undefined, listener: (res: http.IncomingMessage) => void): http.ClientRequest;
|
package/dist/nas/transport.js
CHANGED
|
@@ -6,6 +6,7 @@ import { assertSessionActive } from './session.js';
|
|
|
6
6
|
import { nasOptions } from './options.js';
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
8
|
import { CtlError } from '../ctl/errors.js';
|
|
9
|
+
import { dsmRequestTimeoutMs, dsmApiTimeoutMs } from './timeouts.js';
|
|
9
10
|
export const CTL_TIMEOUT_MS = 62000;
|
|
10
11
|
export const APP_COOKIE = Symbol("ctlAppCookie");
|
|
11
12
|
export function canonicalOrigin(input, tunnel = false) {
|
|
@@ -20,11 +21,12 @@ export function canonicalOrigin(input, tunnel = false) {
|
|
|
20
21
|
throw new CtlError('CTL_URL_INVALID', 'Use HTTPS, or explicit --ssh-tunnel for a loopback HTTP tunnel.', 'run: synopkgland host add <name> --url https://<nas>:5001');
|
|
21
22
|
return url.origin;
|
|
22
23
|
}
|
|
23
|
-
export function createRequest(origin, timeoutMs =
|
|
24
|
+
export function createRequest(origin, timeoutMs = undefined, ca, trust) {
|
|
24
25
|
return async (path, body, session) => {
|
|
25
26
|
if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
|
|
26
27
|
throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
|
|
27
|
-
const
|
|
28
|
+
const params = body instanceof URLSearchParams ? body : undefined;
|
|
29
|
+
const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs ?? (path === '/webapi/entry.cgi' ? dsmApiTimeoutMs(params?.get('api') ?? null, params?.get('method') ?? null) : CTL_TIMEOUT_MS);
|
|
28
30
|
if (remaining <= 0)
|
|
29
31
|
throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
|
|
30
32
|
const url = new URL(path, origin);
|
|
@@ -91,7 +93,10 @@ export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca, trust) {
|
|
|
91
93
|
});
|
|
92
94
|
};
|
|
93
95
|
}
|
|
94
|
-
export async function sessionFetch(url, init, session, ca, trust) {
|
|
96
|
+
export async function sessionFetch(url, init, session, ca, trust, timeoutMs) {
|
|
97
|
+
const targetUrl = new URL(url);
|
|
98
|
+
const params = init.body instanceof URLSearchParams ? init.body : targetUrl.searchParams;
|
|
99
|
+
timeoutMs ??= targetUrl.pathname === '/webapi/entry.cgi' ? dsmApiTimeoutMs(params.get('api'), params.get('method')) : dsmRequestTimeoutMs();
|
|
95
100
|
if (session && new URL(url).origin !== session.origin)
|
|
96
101
|
throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins', 'Check the registered NAS origin', 4);
|
|
97
102
|
const headers = new Headers(init.headers);
|
|
@@ -102,14 +107,14 @@ export async function sessionFetch(url, init, session, ca, trust) {
|
|
|
102
107
|
const request = new Request(url, { ...init, headers }), bytes = init.body ? Buffer.from(await request.arrayBuffer()) : undefined;
|
|
103
108
|
return new Promise((resolve, reject) => {
|
|
104
109
|
const target = new URL(url);
|
|
105
|
-
const req = nasHttpRequest(target, { method: request.method, headers: Object.fromEntries(request.headers) },
|
|
110
|
+
const req = nasHttpRequest(target, { method: request.method, headers: Object.fromEntries(request.headers) }, timeoutMs, trustWithCa(trust, ca), res => {
|
|
106
111
|
const chunks = [];
|
|
107
112
|
let length = 0;
|
|
108
113
|
res.on('data', c => { length += c.length; if (length > 16 * 1024 * 1024)
|
|
109
114
|
res.destroy(new CtlError('CTL_RESPONSE_TOO_LARGE', 'DSM response exceeds 16 MiB', 'Use a narrower operation', 5));
|
|
110
115
|
else
|
|
111
116
|
chunks.push(c); });
|
|
112
|
-
res.on('error',
|
|
117
|
+
res.on('error', e => reject(e instanceof CtlError ? e : new CtlError('DSM_NETWORK', 'DSM response interrupted', 'Check the NAS connection', 6)));
|
|
113
118
|
res.on('end', () => { const headers = new Headers(); for (const [k, v] of Object.entries(res.headers))
|
|
114
119
|
for (const x of Array.isArray(v) ? v : [v])
|
|
115
120
|
if (x !== undefined)
|
|
@@ -121,7 +126,7 @@ export async function sessionFetch(url, init, session, ca, trust) {
|
|
|
121
126
|
else
|
|
122
127
|
request.signal.addEventListener('abort', abort, { once: true });
|
|
123
128
|
req.once('close', () => request.signal.removeEventListener('abort', abort));
|
|
124
|
-
const timer = setTimeout(() => req.destroy(timeoutError()),
|
|
129
|
+
const timer = setTimeout(() => req.destroy(timeoutError()), timeoutMs);
|
|
125
130
|
req.once('close', () => clearTimeout(timer));
|
|
126
131
|
req.on('timeout', () => req.destroy(timeoutError()));
|
|
127
132
|
req.on('error', e => reject(networkError(e)));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { composeRegistryMirrors } from "../compose/registry-mirrors.js";
|
|
1
2
|
import { portDefaults } from './install-ports.js';
|
|
2
3
|
import { validComposeAppData } from "../runtime/compose-data.js";
|
|
3
4
|
import { composeDiagnosisMessages, renderComposeDiagnosis } from "../build-spk/compose-diagnosis.js";
|
|
@@ -126,10 +127,7 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
|
|
|
126
127
|
install_settings: context.installSettings,
|
|
127
128
|
install_ports: context.installPorts,
|
|
128
129
|
resolve_images: context.resolveImages === true,
|
|
129
|
-
registry_mirrors: context.resolveImages
|
|
130
|
-
process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS
|
|
131
|
-
? JSON.parse(process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS)
|
|
132
|
-
: undefined,
|
|
130
|
+
registry_mirrors: context.resolveImages ? composeRegistryMirrors() : undefined,
|
|
133
131
|
architecture: context.architecture,
|
|
134
132
|
image_proofs: context.imageProofs,
|
|
135
133
|
}),
|