@synopackageland/cli 0.41.10 → 0.41.11
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 +13 -0
- package/README.md +28 -3
- package/dist/cli.js +2 -4
- package/dist/e2e/app-open.d.ts +5 -0
- package/dist/e2e/dsm.d.ts +3 -1
- package/dist/e2e/dsm.js +95 -5
- package/dist/error-registry.js +3 -0
- package/dist/host/catalog-install.d.ts +1 -0
- package/dist/host/catalog-install.js +39 -7
- package/dist/host/container-pull.d.ts +18 -0
- package/dist/host/container-pull.js +120 -0
- package/dist/host/dsm-client.js +3 -0
- package/dist/host/package-commands.js +4 -0
- package/dist/host/package-diagnostics.d.ts +5 -0
- package/dist/host/package-diagnostics.js +62 -0
- package/dist/log.js +5 -8
- package/dist/nas/timeouts.d.ts +1 -0
- package/dist/nas/timeouts.js +1 -0
- package/package.json +1 -1
- package/skills/SKILL.md +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.41.11 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
- Pre-pull inspected Compose image references on the NAS with byte progress, a
|
|
6
|
+
configurable 20-minute no-progress watchdog and image/registry/cause diagnostics.
|
|
7
|
+
Keep the shared tolerant install budget and never replay timed-out writes.
|
|
8
|
+
- Uninstall broken packages directly, tolerate only their pre-existing install
|
|
9
|
+
failure while waiting for removal, and preserve new DSM worker failures.
|
|
10
|
+
- Accept explicit DSM denial signals for admin-only window smoke; add optional
|
|
11
|
+
`preReady.clickIfVisible` for one-click upstream interstitials.
|
|
12
|
+
- Force IPv4 and noninteractive SSH tunnels with bounded connection and shutdown.
|
|
13
|
+
- Add `logs --json`, DSM worker messages and masked, timestamped last pull errors.
|
|
14
|
+
Clarify that smoke needs e2e/web/Chromium but no AI provider peers.
|
|
15
|
+
|
|
3
16
|
## 0.41.10 (2026-10-30)
|
|
4
17
|
|
|
5
18
|
- Give DSM reads/login three minutes and SPK upload, install/upgrade and package
|
package/README.md
CHANGED
|
@@ -223,7 +223,8 @@ not change how the App runs or its privilege tier. See `synopkgland skill show b
|
|
|
223
223
|
|
|
224
224
|
`@synopackageland/cli/e2e` holds the DSM helpers for a package's real-NAS
|
|
225
225
|
suite on [`e2e`](https://www.npmjs.com/package/e2e) and `@e2e-dev/web`
|
|
226
|
-
(optional peers
|
|
226
|
+
(optional peers). Smoke needs `e2e`, `@e2e-dev/web` and its Chromium browser;
|
|
227
|
+
`ai` and `@ai-sdk/openai` are needed only when calling `magpieAgent()`.
|
|
227
228
|
|
|
228
229
|
`dsmTarget()` uses Chromium and accepts self-signed DSM and gateway HTTPS
|
|
229
230
|
certificates by default, including redirects from an HTTP DSM URL. It configures
|
|
@@ -276,12 +277,19 @@ import { appOpenSmoke } from '@synopackageland/cli/e2e';
|
|
|
276
277
|
appOpenSmoke({ ready: '[data-app-ready]' });
|
|
277
278
|
```
|
|
278
279
|
|
|
280
|
+
Declare an optional one-click upstream interstitial in the smoke config, for example
|
|
281
|
+
`preReady: {clickIfVisible: {text: 'Continue on this device'}, timeout: 5000}`.
|
|
282
|
+
It runs before the ready wait for permitted users; absent controls are skipped,
|
|
283
|
+
and click failures remain failures. Denied and anonymous tests do not run it.
|
|
284
|
+
|
|
279
285
|
`ready` is an app-content CSS selector or `{text: 'Exact text'}`. The helper
|
|
280
286
|
reads `dsm-package.yaml` (`app` overrides its filename) and derives the launcher
|
|
281
287
|
and entry using the packaging defaults. `entry` overrides a configured Portal
|
|
282
288
|
alias or ingress URL. Three tests cover administrator (A), normal user (N),
|
|
283
289
|
and a fresh anonymous browser context (X). A must reach `ready`. For
|
|
284
|
-
`entry: admin-only`, N must see the SDK gateway's access-denied page
|
|
290
|
+
`entry: admin-only`, N must see the SDK gateway's access-denied page or an explicit
|
|
291
|
+
DSM denial/launcher privilege signal without an App window. A timeout alone is
|
|
292
|
+
not proof of denial. Both paths require
|
|
285
293
|
the `ready` and App content markers absent from the DOM. Set `content` to
|
|
286
294
|
an App shell selector or text marker when it differs from `ready`, for example
|
|
287
295
|
`appOpenSmoke({ready: '[data-app-ready]', content: '#app'})`.
|
|
@@ -484,11 +492,28 @@ variables also work in `.env`; each accepts integer seconds from 1 to 86400:
|
|
|
484
492
|
| --- | ---: | --- |
|
|
485
493
|
| `DSM_REQUEST_TIMEOUT_SECONDS` | 180 | One DSM read, login or other API request |
|
|
486
494
|
| `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` |
|
|
495
|
+
| `DSM_INSTALL_TIMEOUT_SECONDS` | 1800 | Per-package state check, pre-pull, upload, install/upgrade and startup together, in `install` and `deploy` |
|
|
488
496
|
| `DSM_UNINSTALL_TIMEOUT_SECONDS` | 1800 | State check, stop and uninstall together |
|
|
489
497
|
| `DSM_POLL_INTERVAL_SECONDS` | 5 | Package-state polling and progress interval |
|
|
498
|
+
| `DSM_IMAGE_PULL_STALL_TIMEOUT_SECONDS` | 1200 | No increase in NAS pull bytes; status heartbeats do not reset this budget |
|
|
490
499
|
|
|
491
500
|
For example, set `DSM_INSTALL_TIMEOUT_SECONDS=3600` for an hour per package.
|
|
501
|
+
Container Apps pre-pull their inspected, locked image references on the NAS before
|
|
502
|
+
SPK upload. An exact local repository digest skips the registry pull; an image
|
|
503
|
+
configuration ID or a remote update digest is not sufficient proof. Progress includes bytes; failures name the image, requested registry
|
|
504
|
+
and daemon cause/endpoint when DSM provides them. Configure a reachable mirror,
|
|
505
|
+
trust an inspecting CA in Container Manager, or use `images pin` and preload the
|
|
506
|
+
pinned image. Pinning alone does not download layers onto the NAS. Pull tasks
|
|
507
|
+
are not cancelled or replayed when their response is lost or a budget expires.
|
|
508
|
+
Broken packages skip stop during uninstall; their existing install error does not
|
|
509
|
+
block removal, while new uninstall errors still fail clearly.
|
|
510
|
+
|
|
511
|
+
`logs --json` emits the usual single JSON envelope, including DSM `worker_msg`,
|
|
512
|
+
status details and the last reported Compose error. A locally retained pre-pull
|
|
513
|
+
failure includes its source and timestamp; it is scoped to NAS origin and package
|
|
514
|
+
and cleared after a successful pre-pull. An unavailable lower-level cause stays
|
|
515
|
+
unknown; a generic `install_corruption` does not prove a registry failure.
|
|
516
|
+
|
|
492
517
|
An install/upgrade response timeout does not cancel DSM's work: the CLI keeps
|
|
493
518
|
checking for the requested version **and running state**, without replaying the
|
|
494
519
|
write. Progress continues on stderr, including with `--json`. A DSM failure
|
package/dist/cli.js
CHANGED
|
@@ -694,6 +694,7 @@ async function runCliWithin(argv) {
|
|
|
694
694
|
});
|
|
695
695
|
program
|
|
696
696
|
.command("logs")
|
|
697
|
+
.option("--json", "Print a single JSON object")
|
|
697
698
|
.description("Status diagnosis: DSM package status and masked diagnostics; does not stream application logs")
|
|
698
699
|
.option("--host <name>", "Registered NAS host")
|
|
699
700
|
.option("--dev", "Development Package Identity")
|
|
@@ -714,10 +715,7 @@ async function runCliWithin(argv) {
|
|
|
714
715
|
});
|
|
715
716
|
if (!result.ok)
|
|
716
717
|
throw failure("NOT_INSTALLED", "The package is not installed. Run synopkgland deploy --host first.");
|
|
717
|
-
|
|
718
|
-
stdout(line);
|
|
719
|
-
if (result.diagnostics)
|
|
720
|
-
stdout(JSON.stringify(result.diagnostics, null, 2));
|
|
718
|
+
emit('logs', result, [...result.lines, ...(result.diagnostics ? [JSON.stringify(result.diagnostics, null, 2)] : [])].join('\n'));
|
|
721
719
|
});
|
|
722
720
|
const skill = program
|
|
723
721
|
.command("skill")
|
package/dist/e2e/app-open.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export interface AppOpenSmokeOptions {
|
|
|
16
16
|
admin?: string;
|
|
17
17
|
normal?: string;
|
|
18
18
|
timeout?: number;
|
|
19
|
+
/** Optional one-click upstream interstitial; only used on entries expected ready. */
|
|
20
|
+
preReady?: {
|
|
21
|
+
clickIfVisible: SmokeMarker;
|
|
22
|
+
timeout?: number;
|
|
23
|
+
};
|
|
19
24
|
}
|
|
20
25
|
export interface SmokeEntry {
|
|
21
26
|
package: string;
|
package/dist/e2e/dsm.d.ts
CHANGED
|
@@ -194,6 +194,8 @@ export declare function normalSmokeSkip(user?: string, env?: Env): string | unde
|
|
|
194
194
|
/** Checks rendered errors as well as HTTP errors; ready alone never proves success. */
|
|
195
195
|
export declare function assertSmokePage(page: Page, ready: SmokeMarker, anonymous?: boolean, publicLogin?: SmokeMarker): Promise<void>;
|
|
196
196
|
/** A denied entry must show the SDK denial page and contain no App markers. */
|
|
197
|
-
export declare function assertSmokeAccess(page: Page, root: Page | Locator, options: Pick<AppOpenSmokeOptions, 'ready' | 'content'>, expectation: 'ready' | 'denied', timeout: number): Promise<void>;
|
|
197
|
+
export declare function assertSmokeAccess(page: Page, root: Page | Locator, options: Pick<AppOpenSmokeOptions, 'ready' | 'content' | 'preReady'>, expectation: 'ready' | 'denied', timeout: number): Promise<void>;
|
|
198
|
+
/** A window may be denied by DSM before any App frame is created. */
|
|
199
|
+
export declare function assertDsmWindowDenied(page: Page, appId: string, options: Pick<AppOpenSmokeOptions, 'ready' | 'content'>, dialogs: string[], timeout: number, dialogStart?: number): Promise<void>;
|
|
198
200
|
/** Registers A (admin), N (manifest entry policy), X (fresh anonymous context). */
|
|
199
201
|
export declare function appOpenSmoke(options: AppOpenSmokeOptions): void;
|
package/dist/e2e/dsm.js
CHANGED
|
@@ -52,10 +52,18 @@ export function tunnelArgs(env = process.env) {
|
|
|
52
52
|
const target = env.DSM_E2E_SSH?.trim();
|
|
53
53
|
if (!target)
|
|
54
54
|
throw new Error("DSM_E2E_SSH is required to open a NAS tunnel; set it to user@nas.example (using your NAS host).");
|
|
55
|
-
|
|
55
|
+
const timeout = tunnelTimeoutMs(env);
|
|
56
|
+
return ["-4", "-n", "-N", "-o", "BatchMode=yes", "-o", `ConnectTimeout=${Math.max(1, Math.ceil(timeout / 1000))}`,
|
|
57
|
+
"-o", "ConnectionAttempts=1", "-o", "ExitOnForwardFailure=yes",
|
|
56
58
|
...forwards(env).flatMap(([local, remote]) => ["-L", `127.0.0.1:${local}:127.0.0.1:${remote}`]),
|
|
57
59
|
target];
|
|
58
60
|
}
|
|
61
|
+
function tunnelTimeoutMs(env) {
|
|
62
|
+
const value = Number(env.DSM_E2E_TUNNEL_TIMEOUT_MS ?? '15000');
|
|
63
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 86400000)
|
|
64
|
+
throw new Error('DSM_E2E_TUNNEL_TIMEOUT_MS must be a positive timeout of at most 86400000 ms.');
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
59
67
|
/** A loopback port nothing listens on now, for a tunnel that names none. */
|
|
60
68
|
export function freePort() {
|
|
61
69
|
return new Promise((accept, reject) => {
|
|
@@ -70,6 +78,7 @@ export function freePort() {
|
|
|
70
78
|
function portOpen(port) {
|
|
71
79
|
return new Promise(accept => {
|
|
72
80
|
const socket = connect({ host: "127.0.0.1", port });
|
|
81
|
+
socket.setTimeout(500, () => { socket.destroy(); accept(false); });
|
|
73
82
|
socket.once("connect", () => { socket.destroy(); accept(true); });
|
|
74
83
|
socket.once("error", () => accept(false));
|
|
75
84
|
});
|
|
@@ -97,13 +106,19 @@ export async function runWithTunnel(command, outer = process.env) {
|
|
|
97
106
|
}
|
|
98
107
|
const ssh = spawn(env.DSM_E2E_SSH_BIN || "ssh", args, { stdio: ["ignore", "ignore", "inherit"] });
|
|
99
108
|
let sshExited = false;
|
|
109
|
+
let sshError;
|
|
110
|
+
ssh.once('error', error => { sshError = error; sshExited = true; });
|
|
100
111
|
ssh.once("exit", () => { sshExited = true; });
|
|
101
112
|
try {
|
|
102
|
-
const deadline = Date.now() +
|
|
113
|
+
const deadline = Date.now() + tunnelTimeoutMs(env);
|
|
103
114
|
for (const waiting of ports) {
|
|
104
115
|
while (!(await portOpen(waiting))) {
|
|
105
|
-
if (
|
|
106
|
-
throw new Error(`
|
|
116
|
+
if (sshError)
|
|
117
|
+
throw new Error(`SSH tunnel spawn failed: ${sshError.message}`, { cause: sshError });
|
|
118
|
+
if (sshExited)
|
|
119
|
+
throw new Error(`SSH tunnel exited before port ${waiting} opened; check the IPv4 host, SSH key and BatchMode authentication.`);
|
|
120
|
+
if (Date.now() >= deadline)
|
|
121
|
+
throw new Error(`SSH tunnel connect timed out after ${tunnelTimeoutMs(env)} ms on port ${waiting}; check IPv4 resolution, routing and SSH key authentication.`);
|
|
107
122
|
await new Promise(accept => setTimeout(accept, 100));
|
|
108
123
|
}
|
|
109
124
|
}
|
|
@@ -134,7 +149,13 @@ export async function runWithTunnel(command, outer = process.env) {
|
|
|
134
149
|
if (!sshExited) {
|
|
135
150
|
const closed = new Promise(accept => ssh.once("exit", accept));
|
|
136
151
|
ssh.kill("SIGTERM");
|
|
137
|
-
|
|
152
|
+
const kill = setTimeout(() => ssh.kill('SIGKILL'), 500);
|
|
153
|
+
try {
|
|
154
|
+
await closed;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
clearTimeout(kill);
|
|
158
|
+
}
|
|
138
159
|
}
|
|
139
160
|
}
|
|
140
161
|
}
|
|
@@ -647,6 +668,19 @@ export async function assertSmokePage(page, ready, anonymous = false, publicLogi
|
|
|
647
668
|
/** A denied entry must show the SDK denial page and contain no App markers. */
|
|
648
669
|
export async function assertSmokeAccess(page, root, options, expectation, timeout) {
|
|
649
670
|
if (expectation === 'ready') {
|
|
671
|
+
if (options.preReady) {
|
|
672
|
+
const step = marker(root, options.preReady.clickIfVisible);
|
|
673
|
+
try {
|
|
674
|
+
await step.waitFor({ state: 'visible', timeout: Math.min(options.preReady.timeout ?? 5000, timeout) });
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
// An absent optional interstitial is fine; a closed page or transport error is not.
|
|
678
|
+
if (!(error instanceof Error) || !/timeout|not visible/i.test(error.message))
|
|
679
|
+
throw error;
|
|
680
|
+
}
|
|
681
|
+
if (await step.isVisible())
|
|
682
|
+
await step.click({ timeout });
|
|
683
|
+
}
|
|
650
684
|
await marker(root, options.ready).waitFor({ state: 'visible', timeout });
|
|
651
685
|
await assertSmokePage(page, options.ready);
|
|
652
686
|
return;
|
|
@@ -661,6 +695,52 @@ export async function assertSmokeAccess(page, root, options, expectation, timeou
|
|
|
661
695
|
throw new Error('Denied app smoke reached a server error page.');
|
|
662
696
|
}
|
|
663
697
|
}
|
|
698
|
+
/** A window may be denied by DSM before any App frame is created. */
|
|
699
|
+
export async function assertDsmWindowDenied(page, appId, options, dialogs, timeout, dialogStart = 0) {
|
|
700
|
+
const deadline = Date.now() + timeout;
|
|
701
|
+
do {
|
|
702
|
+
if (await dsmLoginVisible(page))
|
|
703
|
+
throw new Error('Denied app smoke reached a login gate.');
|
|
704
|
+
if (await marker(page, options.ready).count() || await marker(page, options.content ?? options.ready).count())
|
|
705
|
+
throw new Error('Denied app smoke exposed app content or its ready marker.');
|
|
706
|
+
const signal = await page.evaluate(async (id) => {
|
|
707
|
+
const sds = window.SYNO?.SDS;
|
|
708
|
+
const instance = sds?.AppMgr?.getByAppName?.(id)?.[0];
|
|
709
|
+
const appWindow = instance?.window;
|
|
710
|
+
let privileges = sds?.AppPrivilege;
|
|
711
|
+
if (!privileges) {
|
|
712
|
+
const body = new URLSearchParams({ api: 'SYNO.Core.Desktop.Initdata', method: 'get_user_service', version: '1' });
|
|
713
|
+
if (sds?.Session?.SynoToken)
|
|
714
|
+
body.set('SynoToken', sds.Session.SynoToken);
|
|
715
|
+
const response = await fetch('/webapi/entry.cgi', { method: 'POST', body, signal: AbortSignal.timeout(3000) }).then(r => r.json()).catch(() => undefined);
|
|
716
|
+
if (response?.success === true)
|
|
717
|
+
privileges = response.data?.AppPrivilege;
|
|
718
|
+
}
|
|
719
|
+
return { normal: sds?.Session?.is_admin === false,
|
|
720
|
+
windowId: appWindow?.isVisible?.() ? appWindow.el?.dom?.id : null,
|
|
721
|
+
privilegeKnown: Boolean(privileges && typeof privileges === 'object' && !Array.isArray(privileges)),
|
|
722
|
+
allowed: privileges?.[id] === true };
|
|
723
|
+
}, appId);
|
|
724
|
+
if (!signal.normal)
|
|
725
|
+
throw new Error('Denied window smoke requires an explicit normal-user DSM session.');
|
|
726
|
+
if (signal.windowId) {
|
|
727
|
+
await assertSmokeAccess(page, page.locator(`#${signal.windowId}`), options, 'denied', Math.max(1, deadline - Date.now()));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (await page.locator('body').innerText().then(text => /(?:^|\n)\s*(?:50[0-9]\b|internal server error|bad gateway|service unavailable)/i.test(text)))
|
|
731
|
+
throw new Error('Denied window smoke reached a server error page.');
|
|
732
|
+
const denial = dialogs.slice(dialogStart).some(text => /not authorized|access denied|permission denied|no permission|not permitted|\u6c92\u6709\u6b0a\u9650|\u65e0\u6743|\u7121\u6b0a|\u6743\u9650\u4e0d\u8db3|\u6b0a\u9650\u4e0d\u8db3/i.test(text));
|
|
733
|
+
if (denial)
|
|
734
|
+
return;
|
|
735
|
+
if (Date.now() >= deadline) {
|
|
736
|
+
if (signal.privilegeKnown && !signal.allowed)
|
|
737
|
+
return;
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100, deadline - Date.now())));
|
|
741
|
+
} while (true);
|
|
742
|
+
throw new Error('No App window appeared, but DSM supplied no explicit denial or launcher privilege signal.');
|
|
743
|
+
}
|
|
664
744
|
/** Registers A (admin), N (manifest entry policy), X (fresh anonymous context). */
|
|
665
745
|
export function appOpenSmoke(options) {
|
|
666
746
|
const entry = readSmokeEntry(options);
|
|
@@ -690,6 +770,16 @@ export function appOpenSmoke(options) {
|
|
|
690
770
|
failures.push(r.status()); };
|
|
691
771
|
page.on('response', response);
|
|
692
772
|
try {
|
|
773
|
+
if (entry.window && expectation === 'denied') {
|
|
774
|
+
const dialogs = await handleDialogs(browser);
|
|
775
|
+
const start = dialogs.length;
|
|
776
|
+
await openDesktop(app);
|
|
777
|
+
await page.evaluate(id => window.SYNO.SDS.AppLaunch(id), entry.appId);
|
|
778
|
+
await assertDsmWindowDenied(page, entry.appId, options, dialogs, timeout, start);
|
|
779
|
+
if (failures.length)
|
|
780
|
+
throw new Error(`App smoke received HTTP ${failures.join(', ')}.`);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
693
783
|
const root = entry.window ? await openApp(app, entry.appId, timeout, browser)
|
|
694
784
|
: (await handleDialogs(browser), await openPath(app, new URL(entry.entry, dsmUrl()).href), dsmPage());
|
|
695
785
|
await assertSmokeAccess(page, root, options, expectation, timeout);
|
package/dist/error-registry.js
CHANGED
|
@@ -5,6 +5,9 @@ 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
|
+
CONTAINER_PULL_FAILED: { code: 'CONTAINER_PULL_FAILED', description: 'NAS image pull failed or its outcome is unknown', suggestion: 'Check the image and registry detail; configure a mirror, trust the CA, or pin and preload the image' },
|
|
9
|
+
CONTAINER_PULL_STALLED: { code: 'CONTAINER_PULL_STALLED', description: 'NAS image download stopped making byte progress', suggestion: 'Check the registry mirror; inspect Container Manager before retrying or adjust DSM_IMAGE_PULL_STALL_TIMEOUT_SECONDS' },
|
|
10
|
+
CONTAINER_PULL_TIMEOUT: { code: 'CONTAINER_PULL_TIMEOUT', description: 'The overall install budget expired during image pull', suggestion: 'Inspect Container Manager before retrying or increase DSM_INSTALL_TIMEOUT_SECONDS' },
|
|
8
11
|
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
12
|
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
13
|
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' },
|
|
@@ -72,6 +72,7 @@ export interface LocalSpkInstallOptions {
|
|
|
72
72
|
deadlineMs?: number;
|
|
73
73
|
pollMs?: number;
|
|
74
74
|
onProgress?: (message: string) => void;
|
|
75
|
+
containerImages?: string[];
|
|
75
76
|
}
|
|
76
77
|
export declare function installPackageFromLocalSpk(options: LocalSpkInstallOptions): Promise<void>;
|
|
77
78
|
export {};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CtlError } from '../ctl/errors.js';
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { terminalSafe } from '../terminal-text.js';
|
|
4
|
+
import { pullContainerImages } from './container-pull.js';
|
|
5
|
+
import { savePullFailure } from './package-diagnostics.js';
|
|
4
6
|
import { dsmFetch } from "./dsm-http.js";
|
|
5
7
|
import { assertDsmCompoundResponse } from "./dsm-compound.js";
|
|
6
8
|
import { dsmRequestTimeoutMs, dsmOperationTimeoutMs, dsmInstallTimeoutMs, dsmUninstallTimeoutMs, dsmPollIntervalMs, isHostTimeout } from '../nas/timeouts.js';
|
|
@@ -72,7 +74,8 @@ export function packageStateFromRecord(value) {
|
|
|
72
74
|
const installedPath = stringValue(installedInfo.path, match.installedPath);
|
|
73
75
|
const version = stringValue(match.version);
|
|
74
76
|
const status = stringValue(additional.status, match.rawStatus, match.status);
|
|
75
|
-
const error = packageError(additional.error ?? match.error)
|
|
77
|
+
const error = packageError({ ...record(additional.error ?? match.error),
|
|
78
|
+
worker_msg: record(additional.error ?? match.error).worker_msg ?? record(additional.error ?? match.error).workerMsg ?? additional.worker_msg ?? match.worker_msg });
|
|
76
79
|
const state = {
|
|
77
80
|
installed: Boolean(installedPath) || match.installed === true,
|
|
78
81
|
};
|
|
@@ -135,6 +138,18 @@ function installStatusIsBroken(status) {
|
|
|
135
138
|
}
|
|
136
139
|
return /(broken|error|fail|150)/i.test(status);
|
|
137
140
|
}
|
|
141
|
+
function packageFailure(state) {
|
|
142
|
+
return installStatusIsBroken(state.status) || state.statusCode === 150 ||
|
|
143
|
+
Boolean(state.statusCode && /broken|error|fail|corruption/i.test(state.statusDescription ?? '')) ||
|
|
144
|
+
Boolean(state.error && (state.error.code !== undefined ? state.error.code !== 0 : state.error.description));
|
|
145
|
+
}
|
|
146
|
+
function failureDetail(state) {
|
|
147
|
+
return [state.error?.code ?? state.statusCode, state.error?.description ?? state.statusDescription,
|
|
148
|
+
...(state.error?.workerMsg ?? []).map(value => typeof value === 'string' ? value : JSON.stringify(value))].filter(value => value !== undefined).join(' ');
|
|
149
|
+
}
|
|
150
|
+
function installInProgress(state) {
|
|
151
|
+
return /^(installing|upgrading|starting)$/i.test(state.status ?? '');
|
|
152
|
+
}
|
|
138
153
|
function installBudgetExpired(options) {
|
|
139
154
|
const target = options.expectedVersion ? ` to reach version ${options.expectedVersion}` : ' to install from catalog';
|
|
140
155
|
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');
|
|
@@ -153,9 +168,8 @@ export async function waitForInstalledVersion(readState, options) {
|
|
|
153
168
|
throw error;
|
|
154
169
|
options.onProgress?.(`${options.packageId}: status request timed out; still installing/starting…`);
|
|
155
170
|
}
|
|
156
|
-
if (state && (
|
|
157
|
-
|
|
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');
|
|
171
|
+
if (state && !installInProgress(state) && packageFailure(state)) {
|
|
172
|
+
throw new CtlError('DSM_INSTALL_FAILED', `DSM reports package ${options.packageId} install did not complete (Package Center status ${state.status ?? 'unknown'}): ${failureDetail(state)}`.trim(), 'Check the DSM Package Center install/start log');
|
|
159
173
|
}
|
|
160
174
|
const running = /^(run|running)$/i.test(state?.status ?? '');
|
|
161
175
|
if (state && (!state.installed || (state.status !== undefined && !running) ||
|
|
@@ -190,6 +204,7 @@ export async function uninstallPackageFromDsm(options) {
|
|
|
190
204
|
const pollMs = options.pollMs ?? dsmPollIntervalMs();
|
|
191
205
|
const progress = options.onProgress ?? (message => process.stderr.write(`${terminalSafe(message)}\n`));
|
|
192
206
|
let phase = 'checking package state';
|
|
207
|
+
let previousFailure;
|
|
193
208
|
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
209
|
const remaining = (limit) => {
|
|
195
210
|
if (Date.now() >= deadline)
|
|
@@ -213,8 +228,8 @@ export async function uninstallPackageFromDsm(options) {
|
|
|
213
228
|
if (state && Date.now() < deadline) {
|
|
214
229
|
if (done(state))
|
|
215
230
|
return state;
|
|
216
|
-
if (
|
|
217
|
-
throw new CtlError('DSM_UNINSTALL_FAILED', `DSM reports ${options.packageId} failed while ${phase}: ${state
|
|
231
|
+
if (packageFailure(state) && !(phase === 'uninstalling' && previousFailure === failureDetail(state)))
|
|
232
|
+
throw new CtlError('DSM_UNINSTALL_FAILED', `DSM reports ${options.packageId} failed while ${phase}: ${failureDetail(state) || state.status}`, 'Check the DSM Package Center stop/uninstall log');
|
|
218
233
|
}
|
|
219
234
|
progress(`${options.packageId}: ${phase}… (DSM status ${state?.status ?? 'unavailable'})`);
|
|
220
235
|
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
|
|
@@ -238,7 +253,12 @@ export async function uninstallPackageFromDsm(options) {
|
|
|
238
253
|
const before = await wait(() => true);
|
|
239
254
|
if (!before.installed)
|
|
240
255
|
return;
|
|
241
|
-
|
|
256
|
+
const broken = packageFailure(before) && !installInProgress(before);
|
|
257
|
+
if (broken) {
|
|
258
|
+
previousFailure = failureDetail(before);
|
|
259
|
+
progress(`${options.packageId}: broken package (${previousFailure || before.status}); uninstalling directly without stop…`);
|
|
260
|
+
}
|
|
261
|
+
if (!broken && !packageStatusIsStopped(before)) {
|
|
242
262
|
phase = 'stopping';
|
|
243
263
|
await dispatch('SYNO.Core.Package.Control', 'stop');
|
|
244
264
|
const stopped = await wait(state => !state.installed || packageStatusIsStopped(state));
|
|
@@ -384,6 +404,18 @@ export async function installPackageFromLocalSpk(options) {
|
|
|
384
404
|
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, remaining(requestMs))));
|
|
385
405
|
}
|
|
386
406
|
}
|
|
407
|
+
if (options.containerImages?.length) {
|
|
408
|
+
phase = 'pulling container images';
|
|
409
|
+
try {
|
|
410
|
+
await pullContainerImages({ ...options, images: options.containerImages,
|
|
411
|
+
deadlineMs: remaining(Number.MAX_SAFE_INTEGER), pollMs, onProgress: progress });
|
|
412
|
+
savePullFailure(options.dsmOrigin, options.packageId);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
savePullFailure(options.dsmOrigin, options.packageId, error instanceof Error ? error.message : 'Unknown pull error');
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
387
419
|
phase = 'uploading';
|
|
388
420
|
progress(`${options.packageId}: uploading…`);
|
|
389
421
|
const uploaded = await uploadLocalSpk(options, sid, remaining(operationMs));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Use inspected effective references: these include the lock's digest and mirror. */
|
|
2
|
+
export declare function containerImageReferences(report: {
|
|
3
|
+
compose?: {
|
|
4
|
+
images: unknown[];
|
|
5
|
+
};
|
|
6
|
+
}): string[];
|
|
7
|
+
interface PullOptions {
|
|
8
|
+
dsmOrigin: string;
|
|
9
|
+
cookieHeader: string;
|
|
10
|
+
images: string[];
|
|
11
|
+
deadlineMs?: number;
|
|
12
|
+
stallMs?: number;
|
|
13
|
+
pollMs?: number;
|
|
14
|
+
onProgress?: (message: string) => void;
|
|
15
|
+
}
|
|
16
|
+
/** Pull on the NAS, before Package Center can turn a registry failure into broken/150. */
|
|
17
|
+
export declare function pullContainerImages(options: PullOptions): Promise<void>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { CtlError } from '../ctl/errors.js';
|
|
2
|
+
import { parseImageReference } from '../images/pin.js';
|
|
3
|
+
import { registryBaseUrl } from '../compose/registry-mirrors.js';
|
|
4
|
+
import { terminalSafe } from '../terminal-text.js';
|
|
5
|
+
import { maskDiagnostic } from './package-diagnostics.js';
|
|
6
|
+
import { dsmFetch } from './dsm-http.js';
|
|
7
|
+
import { dsmRequestTimeoutMs, dsmInstallTimeoutMs, dsmPollIntervalMs, dsmPullStallTimeoutMs, isHostTimeout } from '../nas/timeouts.js';
|
|
8
|
+
/** Use inspected effective references: these include the lock's digest and mirror. */
|
|
9
|
+
export function containerImageReferences(report) {
|
|
10
|
+
return [...new Set((report.compose?.images ?? []).map(value => {
|
|
11
|
+
const reference = value && typeof value === 'object' ? value.reference : undefined;
|
|
12
|
+
if (typeof reference !== 'string' || !parseImageReference(reference))
|
|
13
|
+
throw new CtlError('CONTAINER_PULL_FAILED', 'Invalid container image reference in inspected SPK', 'Rebuild and inspect the SPK');
|
|
14
|
+
return reference;
|
|
15
|
+
}))];
|
|
16
|
+
}
|
|
17
|
+
const hint = 'Configure a reachable registry mirror, trust the inspecting CA in Container Manager, or use images pin on a trusted host and preload the pinned image on the NAS. Check Container Manager before retrying; pull tasks may still be running.';
|
|
18
|
+
const causePattern = /error|fail|denied|forbidden|unauthorized|x509|certificate|tls|timeout|timed out|refused|no such host|not found|too many requests|429|403|unexpected.*status/i;
|
|
19
|
+
/** Pull on the NAS, before Package Center can turn a registry failure into broken/150. */
|
|
20
|
+
export async function pullContainerImages(options) {
|
|
21
|
+
const deadline = Date.now() + (options.deadlineMs ?? dsmInstallTimeoutMs());
|
|
22
|
+
const stallMs = options.stallMs ?? dsmPullStallTimeoutMs();
|
|
23
|
+
const pollMs = options.pollMs ?? dsmPollIntervalMs();
|
|
24
|
+
const progress = options.onProgress ?? (line => process.stderr.write(`${terminalSafe(line)}\n`));
|
|
25
|
+
for (const image of new Set(options.images)) {
|
|
26
|
+
const parsed = parseImageReference(image);
|
|
27
|
+
if (!parsed)
|
|
28
|
+
throw new CtlError('CONTAINER_PULL_FAILED', 'Invalid container image reference', hint);
|
|
29
|
+
// Do not apply build-host mirror overrides here: the inspected reference is authoritative.
|
|
30
|
+
const requested = registryBaseUrl(parsed.registry, {});
|
|
31
|
+
const context = `Image ${image} (requested registry ${requested}; actual endpoint appears in the daemon detail when available)`;
|
|
32
|
+
const fail = (code, detail) => new CtlError(code, maskDiagnostic(`${context}: ${detail}`), hint);
|
|
33
|
+
let lastAdvance = Date.now(), downloaded = -1;
|
|
34
|
+
const remaining = () => {
|
|
35
|
+
if (Date.now() >= deadline)
|
|
36
|
+
throw fail('CONTAINER_PULL_TIMEOUT', 'overall install/pull budget exhausted; NAS pull outcome may still be pending');
|
|
37
|
+
if (Date.now() - lastAdvance >= stallMs)
|
|
38
|
+
throw fail('CONTAINER_PULL_STALLED', `no download progress for ${stallMs / 1000} seconds; NAS pull may still be running`);
|
|
39
|
+
return Math.max(1, Math.min(dsmRequestTimeoutMs(), deadline - Date.now(), stallMs - (Date.now() - lastAdvance)));
|
|
40
|
+
};
|
|
41
|
+
const call = async (method, params) => {
|
|
42
|
+
const response = await dsmFetch(`${options.dsmOrigin}/webapi/entry.cgi`, {
|
|
43
|
+
method: 'POST', headers: { cookie: options.cookieHeader, 'content-type': 'application/x-www-form-urlencoded' },
|
|
44
|
+
body: new URLSearchParams({ api: 'SYNO.Docker.Image', version: '1', method, ...params }),
|
|
45
|
+
}, remaining());
|
|
46
|
+
const body = await response.json();
|
|
47
|
+
if (!response.ok || body?.success !== true) {
|
|
48
|
+
const error = body?.error;
|
|
49
|
+
throw fail('CONTAINER_PULL_FAILED', `Container Manager ${method}: HTTP ${response.status}, DSM ${error?.code ?? 'unknown'} ${typeof error?.description === 'string' ? error.description : ''}; cause unknown if not supplied by DSM`);
|
|
50
|
+
}
|
|
51
|
+
return body.data;
|
|
52
|
+
};
|
|
53
|
+
progress(`${context}: starting NAS image pull…`);
|
|
54
|
+
// Even while a NAS request is pending, keep stderr alive.
|
|
55
|
+
const heartbeat = setInterval(() => progress(`${context}: pulling… (${Math.max(0, downloaded)} bytes)`), pollMs);
|
|
56
|
+
try {
|
|
57
|
+
if (parsed.digest) {
|
|
58
|
+
const inventory = await call('list', { offset: '0', limit: '-1' });
|
|
59
|
+
const cached = Array.isArray(inventory?.images) && inventory.images.some((entry) => {
|
|
60
|
+
if (typeof entry?.repository !== 'string' || typeof entry.digest !== 'string')
|
|
61
|
+
return false;
|
|
62
|
+
const local = parseImageReference(`${entry.repository}@${entry.digest}`);
|
|
63
|
+
return local?.registry === parsed.registry && local.repository === parsed.repository && local.digest === parsed.digest;
|
|
64
|
+
});
|
|
65
|
+
if (cached) {
|
|
66
|
+
progress(`${context}: pinned digest already present on NAS; no registry pull needed`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
let task;
|
|
71
|
+
try {
|
|
72
|
+
task = await call('pull_start', { repository: `${parsed.registry}/${parsed.repository}`, tag: parsed.digest ?? parsed.tag });
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (isHostTimeout(error))
|
|
76
|
+
throw fail('CONTAINER_PULL_FAILED', 'pull_start response timed out; outcome unknown, request not replayed and package install not sent');
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (typeof task?.task_id !== 'string' || !task.task_id)
|
|
80
|
+
throw fail('CONTAINER_PULL_FAILED', 'invalid pull_start response: task_id missing');
|
|
81
|
+
lastAdvance = Date.now();
|
|
82
|
+
while (true) {
|
|
83
|
+
remaining();
|
|
84
|
+
let state;
|
|
85
|
+
try {
|
|
86
|
+
state = await call('pull_status', { task_id: task.task_id });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!isHostTimeout(error))
|
|
90
|
+
throw error;
|
|
91
|
+
progress(`${context}: status request timed out; checking existing pull task…`);
|
|
92
|
+
}
|
|
93
|
+
if (state !== undefined) {
|
|
94
|
+
if (typeof state?.finished !== 'boolean')
|
|
95
|
+
throw fail('CONTAINER_PULL_FAILED', 'invalid pull_status response: finished missing');
|
|
96
|
+
const detail = typeof state.description === 'string' ? state.description : '';
|
|
97
|
+
if (state.error || state.downloaded < 0 || causePattern.test(detail))
|
|
98
|
+
throw fail('CONTAINER_PULL_FAILED', detail || 'daemon reported a pull error; cause unknown');
|
|
99
|
+
if (typeof state.downloaded === 'number' && Number.isFinite(state.downloaded) && state.downloaded > downloaded) {
|
|
100
|
+
downloaded = state.downloaded;
|
|
101
|
+
lastAdvance = Date.now();
|
|
102
|
+
}
|
|
103
|
+
progress(maskDiagnostic(`${context}: ${state.finished ? 'pull complete' : 'pulling'} (${Math.max(0, downloaded)} bytes)${detail ? ` — ${detail}` : ''}`));
|
|
104
|
+
remaining();
|
|
105
|
+
if (state.finished)
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, remaining())));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof CtlError && error.code.startsWith('CONTAINER_PULL_'))
|
|
113
|
+
throw error;
|
|
114
|
+
throw fail('CONTAINER_PULL_FAILED', `NAS image pull failed: ${error instanceof Error ? error.message : 'unknown transport error'}`);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
clearInterval(heartbeat);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { sessionForCredentials } from "../nas/credentials.js";
|
|
2
2
|
import { CtlError } from '../ctl/errors.js';
|
|
3
|
+
import { inspectAnySpk } from '../spk/inspect.js';
|
|
4
|
+
import { containerImageReferences } from './container-pull.js';
|
|
3
5
|
import { listShareNames, lookupSharePath, } from "./share-lookup.js";
|
|
4
6
|
import { createDsmApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
|
|
5
7
|
import { fetchPortInfo } from "./port-info-lookup.js";
|
|
@@ -122,6 +124,7 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
122
124
|
version: input.version,
|
|
123
125
|
profileName,
|
|
124
126
|
extraValues: input.extraValues,
|
|
127
|
+
containerImages: containerImageReferences(await inspectAnySpk(input.spkPath)),
|
|
125
128
|
});
|
|
126
129
|
},
|
|
127
130
|
async getExecutorStatus(packageIdentity) {
|
|
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
|
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { CtlError } from "../ctl/errors.js";
|
|
6
6
|
import { inspectAnySpk } from "../spk/inspect.js";
|
|
7
|
+
import { containerImageReferences } from './container-pull.js';
|
|
7
8
|
import { connectNas, requireAdministrator, } from "./connection.js";
|
|
8
9
|
import { fetchPackageSource } from "../package-source.js";
|
|
9
10
|
import { fetchInstalledPackageState, installPackageFromLocalSpk, uninstallPackageFromDsm, } from "./catalog-install.js";
|
|
@@ -142,6 +143,7 @@ export async function installPackage(spec, options = {}, connection) {
|
|
|
142
143
|
await confirm(`Install ${plan.map((e) => e.package + "@" + e.version).join(", ")}`, options.yes);
|
|
143
144
|
// Download, verify and inspect the entire plan before the first NAS write.
|
|
144
145
|
const files = new Map();
|
|
146
|
+
const images = new Map();
|
|
145
147
|
for (const entry of plan) {
|
|
146
148
|
let file = localFile;
|
|
147
149
|
if (!local || entry.package !== root.package) {
|
|
@@ -159,6 +161,7 @@ export async function installPackage(spec, options = {}, connection) {
|
|
|
159
161
|
checked.version !== entry.version)
|
|
160
162
|
throw fail("PACKAGE_IDENTITY_MISMATCH", "Downloaded SPK identity differs from the selected release");
|
|
161
163
|
files.set(entry.package, file);
|
|
164
|
+
images.set(entry.package, containerImageReferences(checked));
|
|
162
165
|
}
|
|
163
166
|
for (const entry of plan)
|
|
164
167
|
await installPackageFromLocalSpk({
|
|
@@ -167,6 +170,7 @@ export async function installPackage(spec, options = {}, connection) {
|
|
|
167
170
|
packageId: entry.package,
|
|
168
171
|
version: entry.version,
|
|
169
172
|
spkPath: files.get(entry.package),
|
|
173
|
+
containerImages: images.get(entry.package),
|
|
170
174
|
extraValues: entry.package === root.package ? options.param : undefined,
|
|
171
175
|
});
|
|
172
176
|
return {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { InstalledPackageState } from './catalog-install.js';
|
|
2
|
+
/** Diagnostic text is untrusted and can contain daemon URLs or echoed credentials. */
|
|
3
|
+
export declare function maskDiagnostic(text: string): string;
|
|
4
|
+
export declare function savePullFailure(origin: string, packageId: string, message?: string): void;
|
|
5
|
+
export declare function packageDiagnostics(packageId: string, state: InstalledPackageState | undefined, origin: string): Record<string, unknown>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/** Diagnostic text is untrusted and can contain daemon URLs or echoed credentials. */
|
|
6
|
+
export function maskDiagnostic(text) {
|
|
7
|
+
return text.replace(/https?:\/\/[^\s"'<>]+/gi, value => {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
if (!url.username && !url.password && !url.search && !url.hash)
|
|
11
|
+
return value;
|
|
12
|
+
url.username = '';
|
|
13
|
+
url.password = '';
|
|
14
|
+
url.search = '';
|
|
15
|
+
url.hash = '';
|
|
16
|
+
return url.href;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return '<redacted URL>';
|
|
20
|
+
}
|
|
21
|
+
}).replace(/\b(password|passwd|token|synotoken|_sid|authorization|cookie|set-cookie)\s*[:=]\s*[^\r\n]+/gi, '$1=<redacted>');
|
|
22
|
+
}
|
|
23
|
+
function pathFor(origin, packageId) {
|
|
24
|
+
const key = createHash('sha256').update(new URL(origin).origin + '\0' + packageId).digest('hex');
|
|
25
|
+
return join(process.env.SYNOPKGLAND_DIAGNOSTICS_DIR ?? join(homedir(), '.cache/synopkgland/diagnostics'), key + '.json');
|
|
26
|
+
}
|
|
27
|
+
export function savePullFailure(origin, packageId, message) {
|
|
28
|
+
try {
|
|
29
|
+
const file = pathFor(origin, packageId);
|
|
30
|
+
if (!message) {
|
|
31
|
+
rmSync(file, { force: true });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const dir = file.slice(0, file.lastIndexOf('/'));
|
|
35
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
36
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
37
|
+
writeFileSync(temporary, JSON.stringify({ source: 'CLI NAS pre-pull', observedAt: new Date().toISOString(), message: maskDiagnostic(message) }), { mode: 0o600 });
|
|
38
|
+
renameSync(temporary, file);
|
|
39
|
+
}
|
|
40
|
+
catch { /* Optional diagnostic retention must not mask the install result. */ }
|
|
41
|
+
}
|
|
42
|
+
function readPullFailure(origin, packageId) {
|
|
43
|
+
try {
|
|
44
|
+
const value = JSON.parse(readFileSync(pathFor(origin, packageId), 'utf8'));
|
|
45
|
+
return { source: value.source, observedAt: value.observedAt, message: maskDiagnostic(String(value.message)) };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function packageDiagnostics(packageId, state, origin) {
|
|
52
|
+
const clean = (value) => typeof value === 'string' ? maskDiagnostic(value) : Array.isArray(value) ? value.map(clean)
|
|
53
|
+
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, v]) => [key, /password|passwd|token|sid|authorization|cookie/i.test(key) ? '<redacted>' : clean(v)])) : value;
|
|
54
|
+
const worker = clean(state?.error?.workerMsg ?? []);
|
|
55
|
+
const messages = (value) => typeof value === 'string' ? [value] : Array.isArray(value) ? value.flatMap(messages)
|
|
56
|
+
: value && typeof value === 'object' ? Object.values(value).flatMap(messages) : [];
|
|
57
|
+
const lastComposeError = messages(worker).filter(line => /compose|pull|docker|x509|tls|certificate|registry/i.test(line)).at(-1);
|
|
58
|
+
return { packageIdentity: packageId, readiness: state?.status, error: clean(state?.error), worker_msg: worker,
|
|
59
|
+
statusCode: state?.statusCode, statusDescription: clean(state?.statusDescription),
|
|
60
|
+
...(lastComposeError ? { lastComposeError } : {}),
|
|
61
|
+
...(state?.status === 'broken' || state?.error?.code ? { lastPullError: readPullFailure(origin, packageId) } : {}), redacted: true };
|
|
62
|
+
}
|
package/dist/log.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadStandaloneManifest } from "./standalone/manifest.js";
|
|
2
2
|
import { resolveDeployIdentity } from "./deploy/identity.js";
|
|
3
|
+
import { maskDiagnostic, packageDiagnostics } from './host/package-diagnostics.js';
|
|
3
4
|
export async function followProjectLogs(options) {
|
|
4
5
|
const identity = resolveDeployIdentity(options.project, { dev: options.dev });
|
|
5
6
|
const installed = await options.host.isPackageInstalled(identity.packageId);
|
|
@@ -10,12 +11,8 @@ export async function followProjectLogs(options) {
|
|
|
10
11
|
const info = await options.host.getInstalledPackageState?.(identity.packageId);
|
|
11
12
|
return {
|
|
12
13
|
ok: true,
|
|
13
|
-
lines,
|
|
14
|
-
diagnostics:
|
|
15
|
-
packageIdentity: identity.packageId,
|
|
16
|
-
readiness: info?.status,
|
|
17
|
-
redacted: true,
|
|
18
|
-
},
|
|
14
|
+
lines: lines.map(maskDiagnostic),
|
|
15
|
+
diagnostics: packageDiagnostics(identity.packageId, info, options.host.dsmOrigin),
|
|
19
16
|
};
|
|
20
17
|
}
|
|
21
18
|
export async function followStandaloneLogs(options) {
|
|
@@ -31,7 +28,7 @@ export async function followStandaloneLogs(options) {
|
|
|
31
28
|
const info = await options.host.getInstalledPackageState?.(packageIdentity);
|
|
32
29
|
return {
|
|
33
30
|
ok: true,
|
|
34
|
-
lines,
|
|
35
|
-
diagnostics:
|
|
31
|
+
lines: lines.map(maskDiagnostic),
|
|
32
|
+
diagnostics: packageDiagnostics(packageIdentity, info, options.host.dsmOrigin),
|
|
36
33
|
};
|
|
37
34
|
}
|
package/dist/nas/timeouts.d.ts
CHANGED
|
@@ -3,5 +3,6 @@ export declare const dsmOperationTimeoutMs: () => number;
|
|
|
3
3
|
export declare const dsmUninstallTimeoutMs: () => number;
|
|
4
4
|
export declare const dsmInstallTimeoutMs: () => number;
|
|
5
5
|
export declare const dsmPollIntervalMs: () => number;
|
|
6
|
+
export declare const dsmPullStallTimeoutMs: () => number;
|
|
6
7
|
export declare function isHostTimeout(error: unknown): boolean;
|
|
7
8
|
export declare function dsmApiTimeoutMs(api: string | null, method: string | null): number;
|
package/dist/nas/timeouts.js
CHANGED
|
@@ -13,6 +13,7 @@ export const dsmOperationTimeoutMs = () => seconds('DSM_OPERATION_TIMEOUT_SECOND
|
|
|
13
13
|
export const dsmUninstallTimeoutMs = () => seconds('DSM_UNINSTALL_TIMEOUT_SECONDS', 1800);
|
|
14
14
|
export const dsmInstallTimeoutMs = () => seconds('DSM_INSTALL_TIMEOUT_SECONDS', 1800);
|
|
15
15
|
export const dsmPollIntervalMs = () => seconds('DSM_POLL_INTERVAL_SECONDS', 5);
|
|
16
|
+
export const dsmPullStallTimeoutMs = () => seconds('DSM_IMAGE_PULL_STALL_TIMEOUT_SECONDS', 1200);
|
|
16
17
|
export function isHostTimeout(error) {
|
|
17
18
|
return error instanceof CtlError && error.code === 'HOST_TIMEOUT';
|
|
18
19
|
}
|
package/package.json
CHANGED
package/skills/SKILL.md
CHANGED
|
@@ -316,8 +316,12 @@ Use `appOpenSmoke` from `@synopackageland/cli/e2e`; keep one content-specific
|
|
|
316
316
|
reuses the existing DSM credentials/profiles/tunnel, and registers administrator,
|
|
317
317
|
normal-user and fresh anonymous tests. Configure `DSM_E2E_ACCOUNT_ADMIN` and
|
|
318
318
|
`DSM_E2E_ACCOUNT_NORMAL` as private account files; a missing normal profile and
|
|
319
|
-
credentials is a reported skip.
|
|
320
|
-
|
|
319
|
+
credentials is a reported skip. Smoke needs `e2e`, `@e2e-dev/web` and Chromium;
|
|
320
|
+
`ai` and `@ai-sdk/openai` are required only for `magpieAgent()`. Declare an optional
|
|
321
|
+
interstitial as `preReady: {clickIfVisible: {text: "Continue on this device"}}`.
|
|
322
|
+
For `entry: admin-only`, N must see the SDK gateway denial page or an explicit
|
|
323
|
+
DSM denial/launcher privilege signal without an App window. Timeout alone is not
|
|
324
|
+
denial. No App content or ready marker may be present in the DOM; use
|
|
321
325
|
`content` for a separate App shell marker. Other entry kinds require N to
|
|
322
326
|
reach `ready`; grant Application Privileges as needed. Preserve the manifest's
|
|
323
327
|
entry policy when running the smoke.
|