@synopackageland/cli 0.41.9 → 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 +29 -0
- package/README.md +61 -2
- package/dist/cli.js +2 -4
- package/dist/compose/registry-mirrors.d.ts +3 -0
- package/dist/compose/registry-mirrors.js +38 -0
- package/dist/e2e/app-open.d.ts +5 -0
- package/dist/e2e/dsm.d.ts +3 -1
- package/dist/e2e/dsm.js +109 -7
- package/dist/error-registry.js +8 -0
- package/dist/host/catalog-install.d.ts +12 -3
- package/dist/host/catalog-install.js +202 -82
- 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/dsm-compound.js +10 -3
- 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/images/pin.js +6 -16
- package/dist/log.js +5 -8
- 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 +8 -0
- package/dist/nas/timeouts.js +25 -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/skills/SKILL.md +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
16
|
+
## 0.41.10 (2026-10-30)
|
|
17
|
+
|
|
18
|
+
- Give DSM reads/login three minutes and SPK upload, install/upgrade and package
|
|
19
|
+
start/stop requests ten minutes by default, configurable through `DSM_*`
|
|
20
|
+
timeout variables.
|
|
21
|
+
- After an install/upgrade response timeout, keep polling the requested version
|
|
22
|
+
and running state within a shared 30-minute install budget, with progress on
|
|
23
|
+
stderr. Preserve DSM failures and distinguish an exhausted budget from a
|
|
24
|
+
failed installation; never replay the install request.
|
|
25
|
+
- Apply the same tolerant polling and progress to stop/uninstall, with a shared
|
|
26
|
+
configurable 30-minute budget and no write replay after response timeouts.
|
|
27
|
+
- Load the optional AI provider only when `magpieAgent()` is called, so DSM
|
|
28
|
+
smoke suites import without `@ai-sdk/openai` or `ai` installed.
|
|
29
|
+
- Share Compose registry-mirror configuration with `images pin`; verify returned
|
|
30
|
+
manifest bytes against the registry digest before writing the pin.
|
|
31
|
+
|
|
3
32
|
## 0.41.9 (2026-10-30)
|
|
4
33
|
|
|
5
34
|
- Accept self-signed DSM and redirected gateway certificates by default in the
|
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'})`.
|
|
@@ -446,6 +454,15 @@ installation; official protected ACL/project control and bridge readback remain
|
|
|
446
454
|
blocked. No SSO Server or wrapper container is added. Linux tests do not prove
|
|
447
455
|
DSM readiness; inspect lists explicit acceptance blockers.
|
|
448
456
|
|
|
457
|
+
`images pin` and the Compose planner share the build-host mirror configuration:
|
|
458
|
+
`SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS='{"docker.io":"https://mirror.example.com"}'`.
|
|
459
|
+
Each mirror must be an HTTPS origin. Image names retain their original registry;
|
|
460
|
+
manifest bytes are hashed and checked against the registry's digest before pinning.
|
|
461
|
+
|
|
462
|
+
`appOpenSmoke` and `dsmTarget` need only the optional `e2e` and `@e2e-dev/web`
|
|
463
|
+
peers. The optional `@ai-sdk/openai` provider is loaded when `magpieAgent()` is
|
|
464
|
+
called; install it and `ai` only for AI agent runs.
|
|
465
|
+
|
|
449
466
|
## Control installed Apps
|
|
450
467
|
|
|
451
468
|
Use `synopkgland host add nas --url https://nas.example:5001 --username alice`
|
|
@@ -468,6 +485,48 @@ certificate checks. Strict failures use `HOST_TLS_UNTRUSTED` or `HOST_CERT_CHANG
|
|
|
468
485
|
are sent only over a connection whose certificate was inspected. Public package
|
|
469
486
|
feeds and SPK downloads retain normal HTTPS verification.
|
|
470
487
|
|
|
488
|
+
DSM API requests tolerate slow NAS storage and startup. These environment
|
|
489
|
+
variables also work in `.env`; each accepts integer seconds from 1 to 86400:
|
|
490
|
+
|
|
491
|
+
| Variable | Default | Budget |
|
|
492
|
+
| --- | ---: | --- |
|
|
493
|
+
| `DSM_REQUEST_TIMEOUT_SECONDS` | 180 | One DSM read, login or other API request |
|
|
494
|
+
| `DSM_OPERATION_TIMEOUT_SECONDS` | 600 | One SPK upload, install/upgrade compound, or package start/stop request |
|
|
495
|
+
| `DSM_INSTALL_TIMEOUT_SECONDS` | 1800 | Per-package state check, pre-pull, upload, install/upgrade and startup together, in `install` and `deploy` |
|
|
496
|
+
| `DSM_UNINSTALL_TIMEOUT_SECONDS` | 1800 | State check, stop and uninstall together |
|
|
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 |
|
|
499
|
+
|
|
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
|
+
|
|
517
|
+
An install/upgrade response timeout does not cancel DSM's work: the CLI keeps
|
|
518
|
+
checking for the requested version **and running state**, without replaying the
|
|
519
|
+
write. Progress continues on stderr, including with `--json`. A DSM failure
|
|
520
|
+
reports `DSM_INSTALL_FAILED` and its reported details; an exhausted overall
|
|
521
|
+
budget reports `DSM_INSTALL_TIMEOUT` and warns that DSM may still be working.
|
|
522
|
+
Uninstall also keeps checking after stop/uninstall response or status timeouts,
|
|
523
|
+
without replaying either write. Its exhausted budget reports `DSM_UNINSTALL_TIMEOUT`.
|
|
524
|
+
Check Package Center before retrying. A timed-out same-version reinstall also
|
|
525
|
+
needs an observed state transition before the old running instance can count
|
|
526
|
+
as completion. Upload timeouts are reported separately because no install
|
|
527
|
+
request has been sent yet. Explicit `ctl --timeout` budgets still take priority
|
|
528
|
+
for `ctl` calls; App command/job/transfer deadlines retain their existing defaults.
|
|
529
|
+
|
|
471
530
|
`ctl` preserves these process exit codes, including with `--json`:
|
|
472
531
|
|
|
473
532
|
| Exit code | Meaning and examples |
|
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")
|
|
@@ -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/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
|
@@ -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";
|
|
@@ -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
|
}
|
|
@@ -222,9 +243,21 @@ export function llmSettings(env = process.env) {
|
|
|
222
243
|
}
|
|
223
244
|
/** The `agents.default` entry of e2e.config.ts: model plus the effort sent with every request. */
|
|
224
245
|
export function magpieAgent(env = process.env) {
|
|
246
|
+
// Keep the synchronous config API; load the optional ESM provider only when
|
|
247
|
+
// an agent is requested (Node 22.12+ supports require(esm)). Smoke tests do
|
|
248
|
+
// not need the AI SDK or any model provider installed.
|
|
249
|
+
let provider;
|
|
250
|
+
try {
|
|
251
|
+
provider = createRequire(import.meta.url)('@ai-sdk/openai');
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (!['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND'].includes(error.code ?? ''))
|
|
255
|
+
throw error;
|
|
256
|
+
throw new Error('magpieAgent requires optional @ai-sdk/openai and ai; install them to run AI agents.', { cause: error });
|
|
257
|
+
}
|
|
225
258
|
const settings = llmSettings(env);
|
|
226
259
|
return {
|
|
227
|
-
model: createOpenAI({ baseURL: settings.baseURL, apiKey: settings.apiKey }).chat(settings.model),
|
|
260
|
+
model: provider.createOpenAI({ baseURL: settings.baseURL, apiKey: settings.apiKey }).chat(settings.model),
|
|
228
261
|
providerOptions: { openai: { reasoningEffort: settings.reasoningEffort } },
|
|
229
262
|
};
|
|
230
263
|
}
|
|
@@ -635,6 +668,19 @@ export async function assertSmokePage(page, ready, anonymous = false, publicLogi
|
|
|
635
668
|
/** A denied entry must show the SDK denial page and contain no App markers. */
|
|
636
669
|
export async function assertSmokeAccess(page, root, options, expectation, timeout) {
|
|
637
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
|
+
}
|
|
638
684
|
await marker(root, options.ready).waitFor({ state: 'visible', timeout });
|
|
639
685
|
await assertSmokePage(page, options.ready);
|
|
640
686
|
return;
|
|
@@ -649,6 +695,52 @@ export async function assertSmokeAccess(page, root, options, expectation, timeou
|
|
|
649
695
|
throw new Error('Denied app smoke reached a server error page.');
|
|
650
696
|
}
|
|
651
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
|
+
}
|
|
652
744
|
/** Registers A (admin), N (manifest entry policy), X (fresh anonymous context). */
|
|
653
745
|
export function appOpenSmoke(options) {
|
|
654
746
|
const entry = readSmokeEntry(options);
|
|
@@ -678,6 +770,16 @@ export function appOpenSmoke(options) {
|
|
|
678
770
|
failures.push(r.status()); };
|
|
679
771
|
page.on('response', response);
|
|
680
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
|
+
}
|
|
681
783
|
const root = entry.window ? await openApp(app, entry.appId, timeout, browser)
|
|
682
784
|
: (await handleDialogs(browser), await openPath(app, new URL(entry.entry, dsmUrl()).href), dsmPage());
|
|
683
785
|
await assertSmokeAccess(page, root, options, expectation, timeout);
|
package/dist/error-registry.js
CHANGED
|
@@ -5,6 +5,14 @@ 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' },
|
|
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' },
|
|
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' },
|
|
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' },
|
|
14
|
+
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' },
|
|
15
|
+
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
16
|
HOST_TIMEOUT: { "code": "HOST_TIMEOUT", "description": "The NAS request timed out", "suggestion": "Check connection and firewall; inspect state before retrying writes" },
|
|
9
17
|
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
18
|
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,10 @@ 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;
|
|
75
|
+
containerImages?: string[];
|
|
67
76
|
}
|
|
68
77
|
export declare function installPackageFromLocalSpk(options: LocalSpkInstallOptions): Promise<void>;
|
|
69
78
|
export {};
|