@synopackageland/cli 0.10.5 → 0.10.7
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 +77 -0
- package/README.md +2 -1
- package/dist/e2e/dsm.d.ts +60 -7
- package/dist/e2e/dsm.js +143 -26
- package/dist/runtime/executor-digests.json +22 -0
- package/dist/standalone/portal-window.d.ts +3 -0
- package/dist/standalone/portal-window.js +29 -3
- package/dist/standalone/root-window.js +9 -3
- package/package.json +2 -2
- package/skills/reference/create.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,82 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.7 (2026-10-15)
|
|
4
|
+
|
|
5
|
+
Builds with SDK 0.10.7 and templates 0.10.8: `init` writes SDK v0.10.7 (Go
|
|
6
|
+
module, Rust crate tag and `backend.sdk`), `@synopackageland/sdk` 0.10.7 and
|
|
7
|
+
`@synopackageland/ui` 0.9.8. The executor digest table adds SDK 0.10.7, whose
|
|
8
|
+
executor and lifecycle digests equal 0.10.6, so an App rebuilt on it keeps its
|
|
9
|
+
approval unless its executor or policy changes. The CLI still needs SDK
|
|
10
|
+
0.10.5 or later: `ctx.portal.lastStatus()` is new and optional, SDK 0.10.7's
|
|
11
|
+
`PortalSettings` keeps its placeholder rows without it, and older SDKs do not
|
|
12
|
+
read it; nothing in the CLI depends on SDK 0.10.7. An App gets the settings
|
|
13
|
+
page without placeholder movement only with both SDK 0.10.7 and a DSM window
|
|
14
|
+
built by CLI 0.10.7.
|
|
15
|
+
|
|
16
|
+
繁中摘要:搭配 SDK 0.10.7、templates 0.10.8(UI 0.9.8);digest 與 0.10.6 相同;SDK 下限維持
|
|
17
|
+
0.10.5(`ctx.portal.lastStatus()` 是選用的新增,舊 SDK 不讀、SDK 0.10.7 沒有它就畫佔位列)。
|
|
18
|
+
e2e 新增 `openUntilLoaded`(`signIn` 遇 `net::ERR_ABORTED` 最多重開約 30 s)、遮蔽 socket.io `sid=`。
|
|
19
|
+
|
|
20
|
+
- `signIn` and `dsmLoginSetup` keep reopening DSM's login page while the
|
|
21
|
+
navigation fails with `net::ERR_ABORTED`, for up to about 30 s, after a saved
|
|
22
|
+
profile is refused (error 119); they then fail with a clear error. Any other
|
|
23
|
+
error still fails at once. The helper is exported as
|
|
24
|
+
`openUntilLoaded(app, path, {timeoutMs, pauseMs})`.
|
|
25
|
+
- e2e output and report masking also masks the `sid=` query parameter of DSM's
|
|
26
|
+
socket.io URLs, which carries the connection id.
|
|
27
|
+
- `ctx.portal` reads the portal status when the DSM window opens and keeps
|
|
28
|
+
the newest one it read (`lastStatus()`); a `status()` asked for while a read
|
|
29
|
+
runs shares it. The SDK's `PortalSettings` starts from it, so a settings
|
|
30
|
+
page no longer moves when its placeholder rows become the real section
|
|
31
|
+
(Reclaim: 100 px, t-0520).
|
|
32
|
+
|
|
33
|
+
## 0.10.6 (2026-10-15)
|
|
34
|
+
|
|
35
|
+
Builds with SDK 0.10.6 and templates 0.10.7: `init` writes SDK v0.10.6 (Go
|
|
36
|
+
module, Rust crate tag and `backend.sdk`), `@synopackageland/sdk` 0.10.6 and
|
|
37
|
+
`@synopackageland/ui` 0.9.8, whose `CellText` takes `wrap`. Templates 0.10.6
|
|
38
|
+
was tagged but not published (a failing delegate frontend test); 0.10.7 is the
|
|
39
|
+
same templates with that test fixed. The executor digest table adds SDK 0.10.6,
|
|
40
|
+
whose executor and lifecycle digests equal 0.10.5, so an App rebuilt on it keeps
|
|
41
|
+
its approval unless its own root programs change. The CLI still needs SDK 0.10.5
|
|
42
|
+
or later: SDK 0.10.5's panels already read `executor_starting` as a starting
|
|
43
|
+
package and check again, and nothing in the CLI depends on SDK 0.10.6.
|
|
44
|
+
|
|
45
|
+
繁中摘要:搭配 SDK 0.10.6、templates 0.10.7(UI 0.9.8);templates 0.10.6 有 tag 但未發佈;
|
|
46
|
+
digest 與 0.10.5 相同;SDK 下限維持 0.10.5。e2e 新增 `openPath`、`handleDialogs`、80/443 通道。
|
|
47
|
+
|
|
48
|
+
- `signIn`, `openDesktop` and `openPortal` open their page once more when
|
|
49
|
+
the first navigation fails with `net::ERR_ABORTED`, which about one run in
|
|
50
|
+
ten met as DSM's desktop navigated on its own (t-0499, t-0500); any other
|
|
51
|
+
failure, or a second abort, still fails. The helper is exported as
|
|
52
|
+
`openPath(app, path)`.
|
|
53
|
+
- The DSM window's `status()` answers a pinned executor that reports
|
|
54
|
+
`state: "starting"` (its `root.start` step) at once, blocked with code
|
|
55
|
+
`executor_starting` and no approval offered, instead of waiting up to 75 s
|
|
56
|
+
for the step. The SDK's panels read that code as a starting package and
|
|
57
|
+
check again on their own, as they do `APP_STARTING` since CLI 0.10.5.
|
|
58
|
+
Approving and `control("start")` still wait for the step.
|
|
59
|
+
- `openApp(app, appId, timeout, browser)` and `openPortal(app, path, browser)`
|
|
60
|
+
take the test's `browser` fixture and accept native alert and confirm dialogs
|
|
61
|
+
through its `onDialog`, recording their text (`handleDialogs(browser)` returns
|
|
62
|
+
the list). DSM's `alert("You are not authorized to use this service.")` no
|
|
63
|
+
longer fails the test as an unhandled dialog. Without `browser` nothing
|
|
64
|
+
changes.
|
|
65
|
+
- `openPortal` registers the session's cookies as e2e secrets too (a portal
|
|
66
|
+
page may have no SynoToken, which is no failure); only `signIn` and
|
|
67
|
+
`openDesktop` did.
|
|
68
|
+
- `signIn` accepts a restored profile only when the desktop holds a SynoToken
|
|
69
|
+
and DSM's `/webman/login.cgi` still accepts the session (`dsmSessionLive`).
|
|
70
|
+
A profile with only the `stay_login` cookie, or an expired session (DSM error
|
|
71
|
+
119), now clears the cookies and signs in again with the account file's
|
|
72
|
+
password; the desktop alone used to be enough, and the app's CGIs then
|
|
73
|
+
answered "authorization unavailable".
|
|
74
|
+
- `runWithTunnel` also forwards the NAS's port 80 and 443 when
|
|
75
|
+
`DSM_E2E_TUNNEL_HTTP_PORT` and `DSM_E2E_TUNNEL_HTTPS_PORT` name local ports
|
|
76
|
+
(portal alias tests), and hands both to the command; the targets default to
|
|
77
|
+
80 and 443 (`DSM_E2E_REMOTE_HTTP_PORT`, `DSM_E2E_REMOTE_HTTPS_PORT`). Without
|
|
78
|
+
them the tunnel is unchanged.
|
|
79
|
+
|
|
3
80
|
## 0.10.5 (2026-10-15)
|
|
4
81
|
|
|
5
82
|
Builds with SDK 0.10.5 and templates 0.10.5: `init` writes SDK v0.10.5 (Go
|
package/README.md
CHANGED
|
@@ -98,7 +98,8 @@ it as root once at the first start of each new version, while the administrator'
|
|
|
98
98
|
live, for at most 60 seconds; a failure is logged and does not block the start, and the executor
|
|
99
99
|
`status` reports the result as `start_step`. While it runs, the executor answers only `status`
|
|
100
100
|
(`state: "starting"`) and refuses other requests with the retryable `EXECUTOR_STARTING`; the DSM
|
|
101
|
-
window's approval waits for it
|
|
101
|
+
window's approval waits for it, while a status read answers `executor_starting` at once and the
|
|
102
|
+
SDK's panels check again on their own. The program cannot declare `run` or `job`, and
|
|
102
103
|
declaring it changes the policy digest, so an administrator approves the App again.
|
|
103
104
|
|
|
104
105
|
## Commands
|
package/dist/e2e/dsm.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ type Env = Readonly<Record<string, string | undefined>>;
|
|
|
3
3
|
type App = {
|
|
4
4
|
open(path?: string): Promise<unknown>;
|
|
5
5
|
};
|
|
6
|
+
type DialogBrowser = {
|
|
7
|
+
onDialog(handler: (dialog: {
|
|
8
|
+
message: string;
|
|
9
|
+
accept(): Promise<void>;
|
|
10
|
+
}) => Promise<void>): Promise<unknown>;
|
|
11
|
+
};
|
|
6
12
|
type StorageState = {
|
|
7
13
|
cookies: unknown[];
|
|
8
14
|
origins: unknown[];
|
|
@@ -11,20 +17,22 @@ type Page = ReturnType<NonNullable<ReturnType<typeof surfaceOf>>["page"]>;
|
|
|
11
17
|
type Locator = ReturnType<Page["locator"]>;
|
|
12
18
|
/** The DSM URL the browser opens: DSM_E2E_URL, else the local end of the SSH tunnel. */
|
|
13
19
|
export declare function dsmUrl(env?: Env): string;
|
|
14
|
-
/** ssh arguments forwarding the loopback
|
|
20
|
+
/** ssh arguments forwarding the loopback ports of `forwards` to the NAS. */
|
|
15
21
|
export declare function tunnelArgs(env?: Env): string[];
|
|
16
22
|
/** A loopback port nothing listens on now, for a tunnel that names none. */
|
|
17
23
|
export declare function freePort(): Promise<number>;
|
|
18
24
|
/**
|
|
19
25
|
* Opens the SSH tunnel on DSM_E2E_TUNNEL_PORT, else on a free port, runs
|
|
20
26
|
* `command` with DSM_E2E_URL pointing at it, and closes the tunnel afterwards.
|
|
27
|
+
* DSM_E2E_TUNNEL_HTTP_PORT and DSM_E2E_TUNNEL_HTTPS_PORT, when set, also
|
|
28
|
+
* forward the NAS's port 80 and 443 (see forwards) and reach the command's env.
|
|
21
29
|
* The command's output reaches this process's stdout and stderr with DSM's
|
|
22
30
|
* secrets masked, and its e2e output directory, DSM_E2E_OUTPUT_DIR (default
|
|
23
31
|
* .e2e), is protected afterwards (see protectE2eOutput). Resolves to the
|
|
24
32
|
* command's exit code.
|
|
25
33
|
*/
|
|
26
34
|
export declare function runWithTunnel(command: readonly string[], outer?: Env): Promise<number>;
|
|
27
|
-
/** `text` with DSM's session cookies, X-SYNO-TOKEN, SynoToken and
|
|
35
|
+
/** `text` with DSM's session cookies, X-SYNO-TOKEN, SynoToken, _sid and sid values replaced by ***. */
|
|
28
36
|
export declare function redactDsmSecrets(text: string): string;
|
|
29
37
|
/**
|
|
30
38
|
* Makes an e2e output directory private (directories 700, files 600) and masks
|
|
@@ -100,6 +108,15 @@ type SessionPage = {
|
|
|
100
108
|
* Playwright call log carries them (t-0498). Returns how many were registered.
|
|
101
109
|
*/
|
|
102
110
|
export declare function registerDsmSecrets(page?: SessionPage, ledger?: SecretLedger): Promise<number>;
|
|
111
|
+
/**
|
|
112
|
+
* Has the attempt's native alert and confirm dialogs accepted and returns the
|
|
113
|
+
* live list of their texts, so a DSM `alert("You are not authorized to use this
|
|
114
|
+
* service.")` does not fail the test and a test can still assert on it. e2e 0.19
|
|
115
|
+
* routes dialogs through the `browser` fixture's onDialog (Playwright's
|
|
116
|
+
* page.on('dialog') does not reach them), so a helper needs that fixture:
|
|
117
|
+
* openApp and openPortal take it and call this. Registers once per browser.
|
|
118
|
+
*/
|
|
119
|
+
export declare function handleDialogs(browser: DialogBrowser): Promise<string[]>;
|
|
103
120
|
/** Waits until the DSM desktop and its signed-in taskbar are shown. */
|
|
104
121
|
export declare function waitForDesktop(page?: Page): Promise<void>;
|
|
105
122
|
/**
|
|
@@ -109,9 +126,36 @@ export declare function waitForDesktop(page?: Page): Promise<void>;
|
|
|
109
126
|
* DSM_E2E_NOTIFICATION_WAIT_MS (default 5000) for one.
|
|
110
127
|
*/
|
|
111
128
|
export declare function dismissNotifications(page?: Page, env?: Env): Promise<number>;
|
|
129
|
+
/**
|
|
130
|
+
* Whether the signed-in desktop's session is usable: it holds a SynoToken
|
|
131
|
+
* (a restored `stay_login` cookie alone yields none, and the app's CGIs then
|
|
132
|
+
* answer 119) and DSM's login.cgi, asked from the page, still accepts the
|
|
133
|
+
* session. Any failure to ask counts as not live.
|
|
134
|
+
*/
|
|
135
|
+
export declare function dsmSessionLive(page: Pick<SessionPage, "evaluate">): Promise<boolean>;
|
|
136
|
+
/**
|
|
137
|
+
* `app.open(path)`, once more after net::ERR_ABORTED: about one first
|
|
138
|
+
* navigation in ten was aborted as DSM's desktop navigated on its own, and the
|
|
139
|
+
* next one loaded (t-0499, t-0500). Any other failure, or a second abort, is
|
|
140
|
+
* thrown.
|
|
141
|
+
*/
|
|
142
|
+
export declare function openPath(app: Pick<App, "open">, path: string): Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* `app.open(path)`, repeated while it fails with net::ERR_ABORTED: a refused
|
|
145
|
+
* session's old desktop JS keeps navigating and aborted several opens in a row.
|
|
146
|
+
* Gives up with a clear error after `timeoutMs` (30 s); any other failure is
|
|
147
|
+
* thrown at once.
|
|
148
|
+
*/
|
|
149
|
+
export declare function openUntilLoaded(app: Pick<App, "open">, path: string, { timeoutMs, pauseMs, now, sleep }?: {
|
|
150
|
+
timeoutMs?: number | undefined;
|
|
151
|
+
pauseMs?: number | undefined;
|
|
152
|
+
now?: (() => number) | undefined;
|
|
153
|
+
sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
154
|
+
}): Promise<void>;
|
|
112
155
|
/**
|
|
113
156
|
* Restores the saved profile of `user`; signs in with the account file only
|
|
114
|
-
* when that profile is missing
|
|
157
|
+
* when that profile is missing, no longer reaches the desktop, or gives a
|
|
158
|
+
* session without a SynoToken or that DSM refuses (dsmSessionLive). Saves the
|
|
115
159
|
* resulting state and returns which path it took.
|
|
116
160
|
*/
|
|
117
161
|
export declare function signIn(app: App, user?: string, env?: Env): Promise<"restored" | "signed-in">;
|
|
@@ -129,9 +173,18 @@ export declare function openDesktop(app: App): Promise<Page>;
|
|
|
129
173
|
/**
|
|
130
174
|
* Opens the DSM app `appId` (for example SYNO.SDS.SynoPkgConnect.Instance) in
|
|
131
175
|
* its desktop window, or brings its open window to the front, and returns it
|
|
132
|
-
* once the app has rendered text into the window.
|
|
176
|
+
* once the app has rendered text into the window. With the `browser` fixture,
|
|
177
|
+
* native dialogs are accepted and recorded (see handleDialogs).
|
|
178
|
+
*/
|
|
179
|
+
export declare function openApp(app: App, appId: string, timeout?: number, browser?: DialogBrowser): Promise<Locator>;
|
|
180
|
+
/**
|
|
181
|
+
* Waits until an Application Portal page has loaded and registers the session's
|
|
182
|
+
* cookies as e2e secrets. A portal page has no SynoToken, which is no failure.
|
|
183
|
+
*/
|
|
184
|
+
export declare function waitForPortal(page?: Page): Promise<void>;
|
|
185
|
+
/**
|
|
186
|
+
* Opens an Application Portal page (for example /download-hub/) as the whole
|
|
187
|
+
* tab. With the `browser` fixture, native dialogs are accepted and recorded (see handleDialogs).
|
|
133
188
|
*/
|
|
134
|
-
export declare function
|
|
135
|
-
/** Opens an Application Portal page (for example /download-hub/) as the whole tab. */
|
|
136
|
-
export declare function openPortal(app: App, path: string): Promise<Page>;
|
|
189
|
+
export declare function openPortal(app: App, path: string, browser?: DialogBrowser): Promise<Page>;
|
|
137
190
|
export {};
|
package/dist/e2e/dsm.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// DSM helpers for package e2e suites on upstream `e2e` + `@e2e-dev/web`.
|
|
2
2
|
// One self-contained file: a package may copy it verbatim to tests/e2e/support/dsm.ts.
|
|
3
3
|
// It signs in once per run (a saved, private profile is restored when still valid),
|
|
4
|
-
// opens DSM app windows and Application Portal pages
|
|
4
|
+
// opens DSM app windows and Application Portal pages (accepting their native
|
|
5
|
+
// dialogs when given the `browser` fixture), dismisses DSM notifications,
|
|
5
6
|
// configures the magpie LLM, and runs a command through an SSH tunnel to the NAS
|
|
6
7
|
// (on a free loopback port unless DSM_E2E_TUNNEL_PORT names one). DSM's session
|
|
7
8
|
// cookies and tokens are masked in what that command prints and leaves in its
|
|
@@ -26,12 +27,25 @@ const loginSelector = 'input[name="username"]';
|
|
|
26
27
|
export function dsmUrl(env = process.env) {
|
|
27
28
|
return env.DSM_E2E_URL || `http://127.0.0.1:${env.DSM_E2E_TUNNEL_PORT || "15000"}/`;
|
|
28
29
|
}
|
|
29
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The loopback ports to forward as [local, remote]: DSM_E2E_TUNNEL_PORT (default 15000)
|
|
32
|
+
* to DSM's web port, and, only when named, DSM_E2E_TUNNEL_HTTP_PORT and
|
|
33
|
+
* DSM_E2E_TUNNEL_HTTPS_PORT to the NAS's port 80 and 443 (DSM_E2E_REMOTE_HTTP_PORT
|
|
34
|
+
* and DSM_E2E_REMOTE_HTTPS_PORT override them), for Application Portal alias pages.
|
|
35
|
+
*/
|
|
36
|
+
function forwards(env) {
|
|
37
|
+
const extra = (local, remote, fallback) => local ? [[local, remote || fallback]] : [];
|
|
38
|
+
return [
|
|
39
|
+
[env.DSM_E2E_TUNNEL_PORT || "15000", env.DSM_E2E_REMOTE_PORT || "5000"],
|
|
40
|
+
...extra(env.DSM_E2E_TUNNEL_HTTP_PORT, env.DSM_E2E_REMOTE_HTTP_PORT, "80"),
|
|
41
|
+
...extra(env.DSM_E2E_TUNNEL_HTTPS_PORT, env.DSM_E2E_REMOTE_HTTPS_PORT, "443"),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
/** ssh arguments forwarding the loopback ports of `forwards` to the NAS. */
|
|
30
45
|
export function tunnelArgs(env = process.env) {
|
|
31
|
-
const local = env.DSM_E2E_TUNNEL_PORT || "15000";
|
|
32
|
-
const remote = env.DSM_E2E_REMOTE_PORT || "5000";
|
|
33
46
|
return ["-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes",
|
|
34
|
-
"-L", `127.0.0.1:${local}:127.0.0.1:${remote}
|
|
47
|
+
...forwards(env).flatMap(([local, remote]) => ["-L", `127.0.0.1:${local}:127.0.0.1:${remote}`]),
|
|
48
|
+
env.DSM_E2E_SSH || "root@vt-nas.syno"];
|
|
35
49
|
}
|
|
36
50
|
/** A loopback port nothing listens on now, for a tunnel that names none. */
|
|
37
51
|
export function freePort() {
|
|
@@ -54,6 +68,8 @@ function portOpen(port) {
|
|
|
54
68
|
/**
|
|
55
69
|
* Opens the SSH tunnel on DSM_E2E_TUNNEL_PORT, else on a free port, runs
|
|
56
70
|
* `command` with DSM_E2E_URL pointing at it, and closes the tunnel afterwards.
|
|
71
|
+
* DSM_E2E_TUNNEL_HTTP_PORT and DSM_E2E_TUNNEL_HTTPS_PORT, when set, also
|
|
72
|
+
* forward the NAS's port 80 and 443 (see forwards) and reach the command's env.
|
|
57
73
|
* The command's output reaches this process's stdout and stderr with DSM's
|
|
58
74
|
* secrets masked, and its e2e output directory, DSM_E2E_OUTPUT_DIR (default
|
|
59
75
|
* .e2e), is protected afterwards (see protectE2eOutput). Resolves to the
|
|
@@ -64,17 +80,22 @@ export async function runWithTunnel(command, outer = process.env) {
|
|
|
64
80
|
throw new Error("runWithTunnel needs a command to run.");
|
|
65
81
|
const port = Number(outer.DSM_E2E_TUNNEL_PORT || await freePort());
|
|
66
82
|
const env = { ...outer, DSM_E2E_TUNNEL_PORT: String(port) };
|
|
67
|
-
|
|
68
|
-
|
|
83
|
+
const ports = forwards(env).map(([local]) => Number(local));
|
|
84
|
+
for (const used of ports) {
|
|
85
|
+
if (await portOpen(used))
|
|
86
|
+
throw new Error(`Port ${used} is already in use; name another DSM_E2E_TUNNEL_* port or close it before opening the tunnel.`);
|
|
87
|
+
}
|
|
69
88
|
const ssh = spawn(env.DSM_E2E_SSH_BIN || "ssh", tunnelArgs(env), { stdio: ["ignore", "ignore", "inherit"] });
|
|
70
89
|
let sshExited = false;
|
|
71
90
|
ssh.once("exit", () => { sshExited = true; });
|
|
72
91
|
try {
|
|
73
92
|
const deadline = Date.now() + Number(env.DSM_E2E_TUNNEL_TIMEOUT_MS || "15000");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
for (const waiting of ports) {
|
|
94
|
+
while (!(await portOpen(waiting))) {
|
|
95
|
+
if (sshExited || Date.now() > deadline)
|
|
96
|
+
throw new Error(`The SSH tunnel to ${tunnelArgs(env).at(-1)} did not open on port ${waiting}.`);
|
|
97
|
+
await new Promise(accept => setTimeout(accept, 100));
|
|
98
|
+
}
|
|
78
99
|
}
|
|
79
100
|
// Piped output loses the terminal, not its colours.
|
|
80
101
|
const color = process.stdout.isTTY && env.FORCE_COLOR === undefined ? { FORCE_COLOR: "1" } : {};
|
|
@@ -117,10 +138,10 @@ const mask = "***";
|
|
|
117
138
|
const cookieHeader = /(\bcookie"?\s*[:=]\s*"?)([^"\\\r\n]*)/gi;
|
|
118
139
|
const sessionCookieNames = ["id", "did", "_SSID"];
|
|
119
140
|
const sessionCookie = new RegExp(`(^|;\\s*)(${sessionCookieNames.join("|")})=[^;\\s]*`, "gi");
|
|
120
|
-
// The X-SYNO-TOKEN header, a SynoToken parameter or field,
|
|
141
|
+
// The X-SYNO-TOKEN header, a SynoToken parameter or field, a web API's _sid parameter and a socket.io connection's sid parameter.
|
|
121
142
|
const tokenValue = /(\b(?:x-syno-token|synotoken)"?\s*[:=]\s*"?)[^"\\\s,;&}]+/gi;
|
|
122
|
-
const sidParameter = /([?&]
|
|
123
|
-
/** `text` with DSM's session cookies, X-SYNO-TOKEN, SynoToken and
|
|
143
|
+
const sidParameter = /([?&]_?sid=)[^&"\\\s]+/gi;
|
|
144
|
+
/** `text` with DSM's session cookies, X-SYNO-TOKEN, SynoToken, _sid and sid values replaced by ***. */
|
|
124
145
|
export function redactDsmSecrets(text) {
|
|
125
146
|
return text
|
|
126
147
|
.replace(cookieHeader, (_match, header, cookies) => header + cookies.replace(sessionCookie, `$1$2=${mask}`))
|
|
@@ -314,6 +335,25 @@ export async function registerDsmSecrets(page = dsmPage(), ledger) {
|
|
|
314
335
|
target.register(name, value);
|
|
315
336
|
return values.length;
|
|
316
337
|
}
|
|
338
|
+
const dialogLogs = new WeakMap();
|
|
339
|
+
/**
|
|
340
|
+
* Has the attempt's native alert and confirm dialogs accepted and returns the
|
|
341
|
+
* live list of their texts, so a DSM `alert("You are not authorized to use this
|
|
342
|
+
* service.")` does not fail the test and a test can still assert on it. e2e 0.19
|
|
343
|
+
* routes dialogs through the `browser` fixture's onDialog (Playwright's
|
|
344
|
+
* page.on('dialog') does not reach them), so a helper needs that fixture:
|
|
345
|
+
* openApp and openPortal take it and call this. Registers once per browser.
|
|
346
|
+
*/
|
|
347
|
+
export async function handleDialogs(browser) {
|
|
348
|
+
let messages = dialogLogs.get(browser);
|
|
349
|
+
if (!messages) {
|
|
350
|
+
const seen = [];
|
|
351
|
+
messages = seen;
|
|
352
|
+
dialogLogs.set(browser, seen);
|
|
353
|
+
await browser.onDialog(async (dialog) => { seen.push(dialog.message); await dialog.accept(); });
|
|
354
|
+
}
|
|
355
|
+
return messages;
|
|
356
|
+
}
|
|
317
357
|
/** Waits until the DSM desktop and its signed-in taskbar are shown. */
|
|
318
358
|
export async function waitForDesktop(page = dsmPage()) {
|
|
319
359
|
await page.locator(desktopSelector).waitFor({ state: "visible", timeout: 60_000 });
|
|
@@ -342,6 +382,20 @@ export async function dismissNotifications(page = dsmPage(), env = process.env)
|
|
|
342
382
|
}
|
|
343
383
|
return closed;
|
|
344
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Whether the signed-in desktop's session is usable: it holds a SynoToken
|
|
387
|
+
* (a restored `stay_login` cookie alone yields none, and the app's CGIs then
|
|
388
|
+
* answer 119) and DSM's login.cgi, asked from the page, still accepts the
|
|
389
|
+
* session. Any failure to ask counts as not live.
|
|
390
|
+
*/
|
|
391
|
+
export async function dsmSessionLive(page) {
|
|
392
|
+
const state = await page.evaluate(async () => {
|
|
393
|
+
const token = window.SYNO?.SDS?.Session?.SynoToken;
|
|
394
|
+
const reply = await fetch("/webman/login.cgi", { credentials: "same-origin" }).then(response => response.json());
|
|
395
|
+
return { token: typeof token === "string" && token !== "", success: reply?.success === true };
|
|
396
|
+
}).catch(() => undefined);
|
|
397
|
+
return !!state?.token && state.success;
|
|
398
|
+
}
|
|
345
399
|
function accountFile(user, env) {
|
|
346
400
|
const key = `DSM_E2E_ACCOUNT_${user.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
|
|
347
401
|
const file = env[key];
|
|
@@ -349,9 +403,53 @@ function accountFile(user, env) {
|
|
|
349
403
|
throw new Error(`Set ${key} to the account file for ${user} (mode 600).`);
|
|
350
404
|
return resolve(file);
|
|
351
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* `app.open(path)`, once more after net::ERR_ABORTED: about one first
|
|
408
|
+
* navigation in ten was aborted as DSM's desktop navigated on its own, and the
|
|
409
|
+
* next one loaded (t-0499, t-0500). Any other failure, or a second abort, is
|
|
410
|
+
* thrown.
|
|
411
|
+
*/
|
|
412
|
+
export async function openPath(app, path) {
|
|
413
|
+
try {
|
|
414
|
+
await app.open(path);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
if (!/net::ERR_ABORTED/.test(String(error?.message ?? error)))
|
|
418
|
+
throw error;
|
|
419
|
+
await app.open(path);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* `app.open(path)`, repeated while it fails with net::ERR_ABORTED: a refused
|
|
424
|
+
* session's old desktop JS keeps navigating and aborted several opens in a row.
|
|
425
|
+
* Gives up with a clear error after `timeoutMs` (30 s); any other failure is
|
|
426
|
+
* thrown at once.
|
|
427
|
+
*/
|
|
428
|
+
export async function openUntilLoaded(app, path, { timeoutMs = 30_000, pauseMs = 1_000, now = Date.now, sleep = (ms) => new Promise(done => setTimeout(done, ms)) } = {}) {
|
|
429
|
+
const deadline = now() + timeoutMs;
|
|
430
|
+
for (;;) {
|
|
431
|
+
try {
|
|
432
|
+
await app.open(path);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
const message = String(error?.message ?? error);
|
|
437
|
+
if (!/net::ERR_ABORTED/.test(message))
|
|
438
|
+
throw error;
|
|
439
|
+
if (now() + pauseMs >= deadline) {
|
|
440
|
+
throw new Error(`Opening ${path} still aborted after ${Math.round(timeoutMs / 1000)} s of retries: ${message}`);
|
|
441
|
+
}
|
|
442
|
+
await sleep(pauseMs);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
async function restoredSessionLive(page) {
|
|
447
|
+
return await desktopOrLogin(page) === "desktop" && await dsmSessionLive(page);
|
|
448
|
+
}
|
|
352
449
|
/**
|
|
353
450
|
* Restores the saved profile of `user`; signs in with the account file only
|
|
354
|
-
* when that profile is missing
|
|
451
|
+
* when that profile is missing, no longer reaches the desktop, or gives a
|
|
452
|
+
* session without a SynoToken or that DSM refuses (dsmSessionLive). Saves the
|
|
355
453
|
* resulting state and returns which path it took.
|
|
356
454
|
*/
|
|
357
455
|
export async function signIn(app, user = "admin", env = process.env) {
|
|
@@ -361,12 +459,15 @@ export async function signIn(app, user = "admin", env = process.env) {
|
|
|
361
459
|
const profile = loadProfile(file, url);
|
|
362
460
|
if (profile)
|
|
363
461
|
await context.setStorageState(profile);
|
|
364
|
-
await app
|
|
462
|
+
await openPath(app, "/");
|
|
365
463
|
const page = dsmPage();
|
|
366
464
|
let outcome = "restored";
|
|
367
|
-
if (!profile || await
|
|
368
|
-
|
|
369
|
-
|
|
465
|
+
if (!profile || !await restoredSessionLive(page)) {
|
|
466
|
+
// A refused session still shows the desktop; dropping its cookies shows the login form.
|
|
467
|
+
if (profile) {
|
|
468
|
+
await context.clearCookies();
|
|
469
|
+
await openUntilLoaded(app, "/");
|
|
470
|
+
}
|
|
370
471
|
const account = readAccount(accountFile(user, env), user);
|
|
371
472
|
await page.locator(loginSelector).fill(account.username);
|
|
372
473
|
await page.keyboard.press("Enter");
|
|
@@ -403,7 +504,7 @@ export async function openDesktop(app) {
|
|
|
403
504
|
} })();
|
|
404
505
|
if (current && await current.locator(desktopSelector).isVisible().catch(() => false))
|
|
405
506
|
return current;
|
|
406
|
-
await app
|
|
507
|
+
await openPath(app, "/");
|
|
407
508
|
const page = dsmPage();
|
|
408
509
|
if (await desktopOrLogin(page) === "login")
|
|
409
510
|
throw new Error("The DSM session is signed out; declare the session that dsmLoginSetup saves.");
|
|
@@ -415,9 +516,12 @@ export async function openDesktop(app) {
|
|
|
415
516
|
/**
|
|
416
517
|
* Opens the DSM app `appId` (for example SYNO.SDS.SynoPkgConnect.Instance) in
|
|
417
518
|
* its desktop window, or brings its open window to the front, and returns it
|
|
418
|
-
* once the app has rendered text into the window.
|
|
519
|
+
* once the app has rendered text into the window. With the `browser` fixture,
|
|
520
|
+
* native dialogs are accepted and recorded (see handleDialogs).
|
|
419
521
|
*/
|
|
420
|
-
export async function openApp(app, appId, timeout = 30_000) {
|
|
522
|
+
export async function openApp(app, appId, timeout = 30_000, browser) {
|
|
523
|
+
if (browser)
|
|
524
|
+
await handleDialogs(browser);
|
|
421
525
|
const page = await openDesktop(app);
|
|
422
526
|
await page.evaluate(id => window.SYNO.SDS.AppLaunch(id), appId);
|
|
423
527
|
const handle = await page.waitForFunction(id => {
|
|
@@ -430,13 +534,26 @@ export async function openApp(app, appId, timeout = 30_000) {
|
|
|
430
534
|
await page.waitForFunction(windowId => document.getElementById(windowId)?.querySelector(".x-window-body")?.innerText.trim(), id, { timeout });
|
|
431
535
|
return page.locator(`#${id}`);
|
|
432
536
|
}
|
|
433
|
-
/**
|
|
434
|
-
|
|
537
|
+
/**
|
|
538
|
+
* Waits until an Application Portal page has loaded and registers the session's
|
|
539
|
+
* cookies as e2e secrets. A portal page has no SynoToken, which is no failure.
|
|
540
|
+
*/
|
|
541
|
+
export async function waitForPortal(page = dsmPage()) {
|
|
542
|
+
await page.waitForLoadState("load");
|
|
543
|
+
await registerDsmSecrets(page);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Opens an Application Portal page (for example /download-hub/) as the whole
|
|
547
|
+
* tab. With the `browser` fixture, native dialogs are accepted and recorded (see handleDialogs).
|
|
548
|
+
*/
|
|
549
|
+
export async function openPortal(app, path, browser) {
|
|
435
550
|
if (!path.startsWith("/") || path.startsWith("//"))
|
|
436
551
|
throw new Error("openPortal takes a same-origin path such as /download-hub/.");
|
|
437
|
-
|
|
552
|
+
if (browser)
|
|
553
|
+
await handleDialogs(browser);
|
|
554
|
+
await openPath(app, path);
|
|
438
555
|
const page = dsmPage();
|
|
439
|
-
await page
|
|
556
|
+
await waitForPortal(page);
|
|
440
557
|
return page;
|
|
441
558
|
}
|
|
442
559
|
// ---- Command line ---------------------------------------------------------
|
|
@@ -64,5 +64,27 @@
|
|
|
64
64
|
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
65
65
|
},
|
|
66
66
|
"goToolchain": "go1.26.9"
|
|
67
|
+
},
|
|
68
|
+
"0.10.6": {
|
|
69
|
+
"x86_64": "4ba7040761a719e74d6b047ef68b9415e658ebb8b373b69555b8c307524e26ba",
|
|
70
|
+
"aarch64": "ba80b389e3291419389b66be7b0a0e1085d3dfec55c9c651fecae427279bb3d5",
|
|
71
|
+
"armv7": "49474467b9c9a7f31b30b5053b70eb4f71a95f2c06fbf3566e8de8f16972a299",
|
|
72
|
+
"lifecycle": {
|
|
73
|
+
"x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
|
|
74
|
+
"aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
|
|
75
|
+
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
76
|
+
},
|
|
77
|
+
"goToolchain": "go1.26.9"
|
|
78
|
+
},
|
|
79
|
+
"0.10.7": {
|
|
80
|
+
"x86_64": "4ba7040761a719e74d6b047ef68b9415e658ebb8b373b69555b8c307524e26ba",
|
|
81
|
+
"aarch64": "ba80b389e3291419389b66be7b0a0e1085d3dfec55c9c651fecae427279bb3d5",
|
|
82
|
+
"armv7": "49474467b9c9a7f31b30b5053b70eb4f71a95f2c06fbf3566e8de8f16972a299",
|
|
83
|
+
"lifecycle": {
|
|
84
|
+
"x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
|
|
85
|
+
"aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
|
|
86
|
+
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
87
|
+
},
|
|
88
|
+
"goToolchain": "go1.26.9"
|
|
67
89
|
}
|
|
68
90
|
}
|
|
@@ -17,6 +17,9 @@ export declare function portalCatalogs(sdkDir: string): PortalCatalogs;
|
|
|
17
17
|
* status or reject `{code}`. With `options.entry` (below DSM 7.2) the
|
|
18
18
|
* backend's `portal.status` also reports the executor's entry, and
|
|
19
19
|
* administrators get `register()` and `unregister()`; `openControlPanel()` opens Login Portal > Applications.
|
|
20
|
+
* `lastStatus()` is the newest status read; the access reads one when it is
|
|
21
|
+
* created, as the window opens, and a `status()` asked for while a read runs
|
|
22
|
+
* shares it.
|
|
20
23
|
* `messages` is the SDK's panel text in the window's locale; `mountPanel(element)`
|
|
21
24
|
* renders a plain panel for UIs without their own.
|
|
22
25
|
*/
|
|
@@ -24,6 +24,9 @@ export function portalCatalogs(sdkDir) {
|
|
|
24
24
|
* status or reject `{code}`. With `options.entry` (below DSM 7.2) the
|
|
25
25
|
* backend's `portal.status` also reports the executor's entry, and
|
|
26
26
|
* administrators get `register()` and `unregister()`; `openControlPanel()` opens Login Portal > Applications.
|
|
27
|
+
* `lastStatus()` is the newest status read; the access reads one when it is
|
|
28
|
+
* created, as the window opens, and a `status()` asked for while a read runs
|
|
29
|
+
* shares it.
|
|
27
30
|
* `messages` is the SDK's panel text in the window's locale; `mountPanel(element)`
|
|
28
31
|
* renders a plain panel for UIs without their own.
|
|
29
32
|
*/
|
|
@@ -88,29 +91,49 @@ export const portalRuntime = (catalogs) => `function createPortalAccess(options)
|
|
|
88
91
|
// The backend reports cleanup-required while a register or unregister runs,
|
|
89
92
|
// so a status read waits for the change, whatever its result.
|
|
90
93
|
var changing = Promise.resolve();
|
|
91
|
-
|
|
94
|
+
// last: the newest status read, which the SDK's PortalSettings shows before it
|
|
95
|
+
// reads again. A read that runs serves every status() asked for meanwhile,
|
|
96
|
+
// until a change starts; the change reads afresh.
|
|
97
|
+
var last, pending, reads = 0;
|
|
98
|
+
function read() {
|
|
99
|
+
var n = ++reads;
|
|
92
100
|
return changing.then(function () {
|
|
93
101
|
if (options.entry) return fromEntry();
|
|
94
102
|
return options.isAdmin() ? fromDsm() : fromBackend();
|
|
103
|
+
}).then(function (out) {
|
|
104
|
+
if (n === reads) last = out;
|
|
105
|
+
return out;
|
|
95
106
|
});
|
|
96
107
|
}
|
|
108
|
+
function status() {
|
|
109
|
+
if (!pending) {
|
|
110
|
+
pending = read();
|
|
111
|
+
var done = function () { pending = undefined; };
|
|
112
|
+
pending.then(done, done);
|
|
113
|
+
}
|
|
114
|
+
return pending;
|
|
115
|
+
}
|
|
97
116
|
function change(method) {
|
|
98
117
|
if (!options.isAdmin()) return Promise.reject({ code: "admin_required" });
|
|
99
118
|
var call = options.call(method, {});
|
|
100
119
|
changing = call.then(function () {}, function () {});
|
|
101
|
-
|
|
120
|
+
pending = undefined;
|
|
121
|
+
return call.then(read, function (error) {
|
|
102
122
|
throw { code: error && error.code === "ADMIN_REQUIRED" ? "admin_required" : "PORTAL_FAILED" };
|
|
103
123
|
});
|
|
104
124
|
}
|
|
105
125
|
function set(alias) {
|
|
106
126
|
if (!options.isAdmin()) return Promise.reject({ code: "admin_required" });
|
|
107
|
-
return options.send("SYNO.Core.AppPortal", "set", 2, { id: options.appId, alias: alias }).then(
|
|
127
|
+
return options.send("SYNO.Core.AppPortal", "set", 2, { id: options.appId, alias: alias }).then(read, function (error) {
|
|
108
128
|
throw { code: ERRORS[error && error.code] || "portal_unavailable" };
|
|
109
129
|
});
|
|
110
130
|
}
|
|
111
131
|
var access = {
|
|
112
132
|
messages: messages,
|
|
113
133
|
status: status,
|
|
134
|
+
lastStatus: function () {
|
|
135
|
+
return last;
|
|
136
|
+
},
|
|
114
137
|
apply: function (alias) {
|
|
115
138
|
if (typeof alias !== "string" || !ALIAS.test(alias)) return Promise.reject({ code: "portal_alias_invalid" });
|
|
116
139
|
if (RESERVED.indexOf(alias) >= 0) return Promise.reject({ code: "portal_alias_reserved" });
|
|
@@ -180,5 +203,8 @@ export const portalRuntime = (catalogs) => `function createPortalAccess(options)
|
|
|
180
203
|
dispose: function () { disposed = true; element.replaceChildren(); }
|
|
181
204
|
};
|
|
182
205
|
}
|
|
206
|
+
// The window reads the status when it opens, so a settings page shows the
|
|
207
|
+
// portal section as it is (t-0520).
|
|
208
|
+
status();
|
|
183
209
|
return access;
|
|
184
210
|
}`;
|
|
@@ -236,7 +236,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
236
236
|
function readSelection(budget, delay) {
|
|
237
237
|
delay = Math.min(delay || 500, 1000);
|
|
238
238
|
verified.state = "ready";
|
|
239
|
-
return health(verified
|
|
239
|
+
return health(verified).then(function (current) {
|
|
240
240
|
if (current.state === "ready" && current.executor.selection && current.executor.selection.scope === selectedScope && Number.isSafeInteger(current.executor.selection.generation) && current.executor.selection.generation > 0) return current;
|
|
241
241
|
if (budget >= delay && options.wait) return options.wait(delay).then(function () { return readSelection(budget - delay, delay * 2); });
|
|
242
242
|
throw fail("executor_selection_unconfirmed");
|
|
@@ -304,9 +304,15 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
304
304
|
throw e;
|
|
305
305
|
});
|
|
306
306
|
}
|
|
307
|
-
|
|
307
|
+
// step: how long to wait for a start step (ms). A status read waits none:
|
|
308
|
+
// executor_starting says the package is starting, which the SDK checks
|
|
309
|
+
// again on its own.
|
|
310
|
+
function health(verified, step) {
|
|
308
311
|
if (verified.state !== "ready") return Promise.resolve(verified);
|
|
309
|
-
return settle(verified, alive(
|
|
312
|
+
return settle(verified, alive(0, undefined, step)).then(function (result) {
|
|
313
|
+
if (result.code === "executor_starting") delete result.retry;
|
|
314
|
+
return result;
|
|
315
|
+
});
|
|
310
316
|
}
|
|
311
317
|
function settle(verified, status) {
|
|
312
318
|
return status.then(function (current) { verified.executor = current; return verified; }, function (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.7",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"prepublishOnly": "npm run build"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@synopackageland/templates": "0.10.
|
|
28
|
+
"@synopackageland/templates": "0.10.8",
|
|
29
29
|
"ajv": "^8.17.1",
|
|
30
30
|
"commander": "^13.1.0",
|
|
31
31
|
"tar": "^7.4.3",
|
|
@@ -112,7 +112,7 @@ root:
|
|
|
112
112
|
migrate: {} # 沒有 run、job:只作為 start 步驟
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
start、stop 步驟與 `uninstall.purge.root` 都以 root、無 session 執行,只在管理員核准有效時跑;這些程式不能宣告 run 或 job(session 要用時以另一個名稱設定,可指向同一份內容)。start 步驟在新版本第一次啟動、executor 開 socket 並回讀核准任務之後執行,最多 60 秒;執行期間 executor 只回應 `status`(`state: "starting"`),其他請求回可重試的 `EXECUTOR_STARTING`,DSM
|
|
115
|
+
start、stop 步驟與 `uninstall.purge.root` 都以 root、無 session 執行,只在管理員核准有效時跑;這些程式不能宣告 run 或 job(session 要用時以另一個名稱設定,可指向同一份內容)。start 步驟在新版本第一次啟動、executor 開 socket 並回讀核准任務之後執行,最多 60 秒;執行期間 executor 只回應 `status`(`state: "starting"`),其他請求回可重試的 `EXECUTOR_STARTING`,DSM 視窗的核准會等它結束,狀態讀取則立即回 `executor_starting`、由 SDK 面板自行重試,失敗只寫進 executor log,不擋啟動;同一版本不重跑,程式要能重複執行。結果由 executor `status` 的 `start_step` 讀取(`pending`、`ok`、`failed`,含 exit 與 output)。DSM 的 start 只寫入要求就返回,所以 `dsm-scripts/postinst`、`postupgrade`、`start` 看不到它的結果;root 的清理或遷移放 `root.start`,非特權的收尾放 `dsm-scripts/`。宣告 `root.start` 會改變 policy digest,需要管理員重新核准;沒有宣告的 App policy 不變。需要 SDK 0.10.3 以上。
|
|
116
116
|
|
|
117
117
|
schema 是 executor 的封閉子集(每個 object 都要 `additionalProperties: false`,key `[a-z0-9_]`,pattern 為 RE2)。這些設定需要支援它們的 SDK executor(`EXECUTOR_ROOT_PROGRAMS_UNSUPPORTED`)。preupgrade 呼叫 `executor/lifecycle stop --upgrade`;舊 SDK 的 helper 不認得此旗標時改用一般 stop。
|
|
118
118
|
|