@synopackageland/cli 0.10.4 → 0.10.6
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 +109 -0
- package/README.md +41 -1
- package/dist/e2e/dsm.d.ts +178 -0
- package/dist/e2e/dsm.js +543 -0
- package/dist/runtime/executor-digests.json +22 -0
- package/dist/runtime/sdk-source.d.ts +6 -2
- package/dist/runtime/sdk-source.js +6 -2
- package/dist/standalone/dsm-window.js +5 -2
- package/dist/standalone/root-window.js +15 -5
- package/package.json +28 -3
- package/skills/reference/create.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,114 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.6 (2026-10-15)
|
|
4
|
+
|
|
5
|
+
Builds with SDK 0.10.6 and templates 0.10.7: `init` writes SDK v0.10.6 (Go
|
|
6
|
+
module, Rust crate tag and `backend.sdk`), `@synopackageland/sdk` 0.10.6 and
|
|
7
|
+
`@synopackageland/ui` 0.9.8, whose `CellText` takes `wrap`. Templates 0.10.6
|
|
8
|
+
was tagged but not published (a failing delegate frontend test); 0.10.7 is the
|
|
9
|
+
same templates with that test fixed. The executor digest table adds SDK 0.10.6,
|
|
10
|
+
whose executor and lifecycle digests equal 0.10.5, so an App rebuilt on it keeps
|
|
11
|
+
its approval unless its own root programs change. The CLI still needs SDK 0.10.5
|
|
12
|
+
or later: SDK 0.10.5's panels already read `executor_starting` as a starting
|
|
13
|
+
package and check again, and nothing in the CLI depends on SDK 0.10.6.
|
|
14
|
+
|
|
15
|
+
繁中摘要:搭配 SDK 0.10.6、templates 0.10.7(UI 0.9.8);templates 0.10.6 有 tag 但未發佈;
|
|
16
|
+
digest 與 0.10.5 相同;SDK 下限維持 0.10.5。e2e 新增 `openPath`、`handleDialogs`、80/443 通道。
|
|
17
|
+
|
|
18
|
+
- `signIn`, `openDesktop` and `openPortal` open their page once more when
|
|
19
|
+
the first navigation fails with `net::ERR_ABORTED`, which about one run in
|
|
20
|
+
ten met as DSM's desktop navigated on its own (t-0499, t-0500); any other
|
|
21
|
+
failure, or a second abort, still fails. The helper is exported as
|
|
22
|
+
`openPath(app, path)`.
|
|
23
|
+
- The DSM window's `status()` answers a pinned executor that reports
|
|
24
|
+
`state: "starting"` (its `root.start` step) at once, blocked with code
|
|
25
|
+
`executor_starting` and no approval offered, instead of waiting up to 75 s
|
|
26
|
+
for the step. The SDK's panels read that code as a starting package and
|
|
27
|
+
check again on their own, as they do `APP_STARTING` since CLI 0.10.5.
|
|
28
|
+
Approving and `control("start")` still wait for the step.
|
|
29
|
+
- `openApp(app, appId, timeout, browser)` and `openPortal(app, path, browser)`
|
|
30
|
+
take the test's `browser` fixture and accept native alert and confirm dialogs
|
|
31
|
+
through its `onDialog`, recording their text (`handleDialogs(browser)` returns
|
|
32
|
+
the list). DSM's `alert("You are not authorized to use this service.")` no
|
|
33
|
+
longer fails the test as an unhandled dialog. Without `browser` nothing
|
|
34
|
+
changes.
|
|
35
|
+
- `openPortal` registers the session's cookies as e2e secrets too (a portal
|
|
36
|
+
page may have no SynoToken, which is no failure); only `signIn` and
|
|
37
|
+
`openDesktop` did.
|
|
38
|
+
- `signIn` accepts a restored profile only when the desktop holds a SynoToken
|
|
39
|
+
and DSM's `/webman/login.cgi` still accepts the session (`dsmSessionLive`).
|
|
40
|
+
A profile with only the `stay_login` cookie, or an expired session (DSM error
|
|
41
|
+
119), now clears the cookies and signs in again with the account file's
|
|
42
|
+
password; the desktop alone used to be enough, and the app's CGIs then
|
|
43
|
+
answered "authorization unavailable".
|
|
44
|
+
- `runWithTunnel` also forwards the NAS's port 80 and 443 when
|
|
45
|
+
`DSM_E2E_TUNNEL_HTTP_PORT` and `DSM_E2E_TUNNEL_HTTPS_PORT` name local ports
|
|
46
|
+
(portal alias tests), and hands both to the command; the targets default to
|
|
47
|
+
80 and 443 (`DSM_E2E_REMOTE_HTTP_PORT`, `DSM_E2E_REMOTE_HTTPS_PORT`). Without
|
|
48
|
+
them the tunnel is unchanged.
|
|
49
|
+
|
|
50
|
+
## 0.10.5 (2026-10-15)
|
|
51
|
+
|
|
52
|
+
Builds with SDK 0.10.5 and templates 0.10.5: `init` writes SDK v0.10.5 (Go
|
|
53
|
+
module, Rust crate tag and `backend.sdk`), `@synopackageland/sdk` 0.10.5 and
|
|
54
|
+
`@synopackageland/ui` 0.9.7, whose `Space` aligns its items to the start. The
|
|
55
|
+
executor digest table adds SDK 0.10.5, whose executor and lifecycle digests
|
|
56
|
+
equal 0.10.4, so an App rebuilt on it keeps its approval unless its own root
|
|
57
|
+
programs change. New: the `@synopackageland/cli/e2e` module for real-NAS e2e
|
|
58
|
+
suites.
|
|
59
|
+
|
|
60
|
+
繁中摘要:搭配 SDK 0.10.5、templates 0.10.5(UI 0.9.7);需要 SDK 0.10.5 以上;
|
|
61
|
+
digest 與 0.10.4 相同;新增 `@synopackageland/cli/e2e`。
|
|
62
|
+
|
|
63
|
+
- **Incompatible:** the CLI needs SDK 0.10.5 or later. `check` and `build` refuse an App
|
|
64
|
+
whose backend requires SDK 0.10.3 or 0.10.4 (`SDK_UNSUPPORTED`, naming 0.10.5); raise
|
|
65
|
+
the Go module, Cargo tag or `backend.sdk` and the frontend's `@synopackageland/sdk` to
|
|
66
|
+
0.10.5. The DSM window now answers the SDK's status reads at once while the package is
|
|
67
|
+
stopped or starting, and only SDK 0.10.5's panels show that state and check again;
|
|
68
|
+
SDK 0.10.4's would show the package as needing approval.
|
|
69
|
+
|
|
70
|
+
繁中摘要:CLI 需要 SDK 0.10.5 以上;App 若仍用 SDK 0.10.3/0.10.4,`check`/`build` 會以
|
|
71
|
+
`SDK_UNSUPPORTED` 拒絕。DSM 視窗的 status 讀取不再替 App 重試,只有 SDK 0.10.5 的面板會自行重試。
|
|
72
|
+
- `@synopackageland/cli/e2e` (`src/e2e/dsm.ts`): DSM helpers for a package's
|
|
73
|
+
real-NAS suite on `e2e` 0.19 and `@e2e-dev/web` 0.14 (optional peers).
|
|
74
|
+
`dsmTarget`, `magpieAgent` (magpie GPT-6 Luna at max reasoning effort,
|
|
75
|
+
`E2E_LLM_*` override), `dsmLoginSetup` and `signIn` (a mode 600 saved profile,
|
|
76
|
+
restored while it still reaches the desktop), `openDesktop`, `openApp`,
|
|
77
|
+
`openPortal`, `dismissNotifications` and `runWithTunnel`
|
|
78
|
+
(`node dsm.js tunnel -- <command>`, an npm script such as `e2e:nas`). The file
|
|
79
|
+
is self-contained, so a package may copy it to `tests/e2e/support/dsm.ts`.
|
|
80
|
+
繁中:新增共用 DSM e2e 模組,App 可改為 import `@synopackageland/cli/e2e`。
|
|
81
|
+
- `runWithTunnel` opens the tunnel on a free loopback port unless
|
|
82
|
+
`DSM_E2E_TUNNEL_PORT` names one, so parallel runs do not collide on 15000.
|
|
83
|
+
- A saved DSM profile is reused by host, not port, and saved atomically, so
|
|
84
|
+
concurrent runs never leave it half written.
|
|
85
|
+
- `dismissNotifications` waits up to `DSM_E2E_NOTIFICATION_WAIT_MS` (default
|
|
86
|
+
5000) for the cards DSM shows after the desktop (the admin's "Admin account
|
|
87
|
+
security" card, about 2 s later on DSM 7.4) and matches DSM 7.4's container.
|
|
88
|
+
- `runWithTunnel` masks DSM's session cookies (`id`, `did`, `_SSID`),
|
|
89
|
+
`X-SYNO-TOKEN`, `SynoToken` and `_sid` as `***` in the command's stdout and
|
|
90
|
+
stderr and in the text files under `DSM_E2E_OUTPUT_DIR` (default `.e2e`), and
|
|
91
|
+
makes that directory private (directories 700, files 600). A Playwright call
|
|
92
|
+
log in a run error had carried the session cookie into `report.json` and the
|
|
93
|
+
run's output; e2e redacts only the secrets its config declares. Saved
|
|
94
|
+
sessions keep their cookies. `redactDsmSecrets` and `protectE2eOutput` are
|
|
95
|
+
exported for a package with its own runner. Before that, `signIn` and
|
|
96
|
+
`openDesktop` (and so `openApp`) register the session's cookies and
|
|
97
|
+
SynoToken as e2e secrets in the test's worker (`registerDsmSecrets`), so e2e
|
|
98
|
+
itself masks them in a run error, `report.json` and its output; e2e 0.19
|
|
99
|
+
keeps that ledger internal, so a later layout without it leaves the second
|
|
100
|
+
mask alone.
|
|
101
|
+
繁中:e2e 輸出與 log 遮蔽 DSM cookie/token,輸出目錄改為 700/600。
|
|
102
|
+
- `ctx.call` no longer repeats the SDK's status reads (`delegate.status`,
|
|
103
|
+
`authorization.status`, `executor.status`) while the package is stopped or
|
|
104
|
+
starting: they reject with the code at once, and SDK 0.10.5's panels show the
|
|
105
|
+
stopped or starting package and check again on their own. A page no longer
|
|
106
|
+
shows nothing for the first seconds after an upgrade (6.7 s in t-0503). Calls
|
|
107
|
+
that change something, approvals and controls among them, still repeat for up
|
|
108
|
+
to 30 s. `ctx.executor.status()` for a user who is not an administrator says
|
|
109
|
+
the package is stopped or starting instead of `admin_required`.
|
|
110
|
+
繁中:status 類讀取遇停止/啟動中立即回傳該碼,交給 SDK 重試;核准等呼叫維持重試。
|
|
111
|
+
|
|
3
112
|
## 0.10.4 (2026-10-14)
|
|
4
113
|
|
|
5
114
|
Builds with SDK 0.10.4 and templates 0.10.4: `init` writes SDK v0.10.4 (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
|
|
@@ -121,6 +122,45 @@ declaring it changes the policy digest, so an administrator approves the App aga
|
|
|
121
122
|
[CLI reference](https://www.varvis.me/docs/reference/cli/) and
|
|
122
123
|
[error codes](https://www.varvis.me/docs/reference/errors/).
|
|
123
124
|
|
|
125
|
+
## E2E helpers
|
|
126
|
+
|
|
127
|
+
`@synopackageland/cli/e2e` holds the DSM helpers for a package's real-NAS
|
|
128
|
+
suite on [`e2e`](https://www.npmjs.com/package/e2e) and `@e2e-dev/web`
|
|
129
|
+
(optional peers, with `ai` and `@ai-sdk/openai`). The same file,
|
|
130
|
+
`src/e2e/dsm.ts`, can be copied verbatim to `tests/e2e/support/dsm.ts`.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// e2e.config.ts
|
|
134
|
+
import {dsmTarget, magpieAgent} from '@synopackageland/cli/e2e';
|
|
135
|
+
export default {targets: [dsmTarget()], output: '.e2e/output', agents: {default: magpieAgent()}};
|
|
136
|
+
|
|
137
|
+
// tests/e2e/dsm.setup.e2e.ts: signs in once (a private saved profile when it still works)
|
|
138
|
+
import {dsmLoginSetup} from '@synopackageland/cli/e2e';
|
|
139
|
+
dsmLoginSetup({session: 'dsm', user: 'admin'});
|
|
140
|
+
|
|
141
|
+
// a test that declares {sessions: ['dsm']}
|
|
142
|
+
import {dismissNotifications, openApp} from '@synopackageland/cli/e2e';
|
|
143
|
+
const window = await openApp(app, 'SYNO.SDS.MyApp.Instance'); // closes DSM's notification cards first
|
|
144
|
+
await dismissNotifications(); // when a card shows later
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
"scripts": {"e2e:nas": "node --env-file-if-exists=.env node_modules/@synopackageland/cli/dist/e2e/dsm.js tunnel -- e2e run"}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`runWithTunnel` (the `tunnel --` command) opens an SSH tunnel to the NAS
|
|
152
|
+
(`DSM_E2E_SSH`, default `root@vt-nas.syno`) on a free loopback port and runs the
|
|
153
|
+
command with `DSM_E2E_URL` set. It masks DSM's session cookies (`id`, `did`,
|
|
154
|
+
`_SSID`), `X-SYNO-TOKEN`, `SynoToken` and `_sid` as `***` in the command's
|
|
155
|
+
output and in the files under `DSM_E2E_OUTPUT_DIR` (default `.e2e`), and makes
|
|
156
|
+
that directory private (700, files 600); saved sessions keep their cookies.
|
|
157
|
+
Accounts come from mode 600 files named by `DSM_E2E_ACCOUNT_<USER>`.
|
|
158
|
+
`signIn` and `openDesktop` also register those values as e2e secrets in the
|
|
159
|
+
test's worker, so e2e masks them in its own report. A test that forwards
|
|
160
|
+
requests with `context.route` and `route.fetch` should end with
|
|
161
|
+
`await context.unrouteAll({behavior: 'ignoreErrors'})`: a request still in
|
|
162
|
+
flight otherwise rejects into a run error that carries its headers.
|
|
163
|
+
|
|
124
164
|
## AI assistants
|
|
125
165
|
|
|
126
166
|
Every project includes `AGENTS.md` and a `CLAUDE.md` pointer. Start with
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { surfaceOf } from "@e2e-dev/web";
|
|
2
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
3
|
+
type App = {
|
|
4
|
+
open(path?: string): Promise<unknown>;
|
|
5
|
+
};
|
|
6
|
+
type DialogBrowser = {
|
|
7
|
+
onDialog(handler: (dialog: {
|
|
8
|
+
message: string;
|
|
9
|
+
accept(): Promise<void>;
|
|
10
|
+
}) => Promise<void>): Promise<unknown>;
|
|
11
|
+
};
|
|
12
|
+
type StorageState = {
|
|
13
|
+
cookies: unknown[];
|
|
14
|
+
origins: unknown[];
|
|
15
|
+
};
|
|
16
|
+
type Page = ReturnType<NonNullable<ReturnType<typeof surfaceOf>>["page"]>;
|
|
17
|
+
type Locator = ReturnType<Page["locator"]>;
|
|
18
|
+
/** The DSM URL the browser opens: DSM_E2E_URL, else the local end of the SSH tunnel. */
|
|
19
|
+
export declare function dsmUrl(env?: Env): string;
|
|
20
|
+
/** ssh arguments forwarding the loopback ports of `forwards` to the NAS. */
|
|
21
|
+
export declare function tunnelArgs(env?: Env): string[];
|
|
22
|
+
/** A loopback port nothing listens on now, for a tunnel that names none. */
|
|
23
|
+
export declare function freePort(): Promise<number>;
|
|
24
|
+
/**
|
|
25
|
+
* Opens the SSH tunnel on DSM_E2E_TUNNEL_PORT, else on a free port, runs
|
|
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.
|
|
29
|
+
* The command's output reaches this process's stdout and stderr with DSM's
|
|
30
|
+
* secrets masked, and its e2e output directory, DSM_E2E_OUTPUT_DIR (default
|
|
31
|
+
* .e2e), is protected afterwards (see protectE2eOutput). Resolves to the
|
|
32
|
+
* command's exit code.
|
|
33
|
+
*/
|
|
34
|
+
export declare function runWithTunnel(command: readonly string[], outer?: Env): Promise<number>;
|
|
35
|
+
/** `text` with DSM's session cookies, X-SYNO-TOKEN, SynoToken and _sid values replaced by ***. */
|
|
36
|
+
export declare function redactDsmSecrets(text: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Makes an e2e output directory private (directories 700, files 600) and masks
|
|
39
|
+
* DSM's secrets in its text files. A `sessions` directory keeps its contents:
|
|
40
|
+
* a saved session must restore the real cookies. Does nothing when `dir` is missing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function protectE2eOutput(dir: string): void;
|
|
43
|
+
/** magpie's GPT-6 Luna at max reasoning effort unless E2E_LLM_* says otherwise. */
|
|
44
|
+
export declare function llmSettings(env?: Env): {
|
|
45
|
+
baseURL: string;
|
|
46
|
+
model: string;
|
|
47
|
+
apiKey: string;
|
|
48
|
+
reasoningEffort: string;
|
|
49
|
+
};
|
|
50
|
+
/** The `agents.default` entry of e2e.config.ts: model plus the effort sent with every request. */
|
|
51
|
+
export declare function magpieAgent(env?: Env): {
|
|
52
|
+
model: import("@ai-sdk/provider").LanguageModelV4;
|
|
53
|
+
providerOptions: {
|
|
54
|
+
openai: {
|
|
55
|
+
reasoningEffort: string;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Reads one account from a private JSON file: `{username, password}` or
|
|
61
|
+
* `{users: {<user>: {username, password}}}`. Errors never include the values.
|
|
62
|
+
*/
|
|
63
|
+
export declare function readAccount(file: string, user: string): {
|
|
64
|
+
username: string;
|
|
65
|
+
password: string;
|
|
66
|
+
};
|
|
67
|
+
/** ~/.cache/synopkgland-e2e/<target>-<user>.json, or under DSM_E2E_PROFILE_DIR. */
|
|
68
|
+
export declare function profilePath(target: string, user: string, env?: Env): string;
|
|
69
|
+
/**
|
|
70
|
+
* The saved browser state for `url`'s DSM host, or null when there is none or
|
|
71
|
+
* it belongs to another host. The port does not count: DSM's cookies do not
|
|
72
|
+
* depend on it, so tunnels on different ports share one profile. A file
|
|
73
|
+
* without `url` (written by another tool) is reused when one of its cookies
|
|
74
|
+
* belongs to the DSM host.
|
|
75
|
+
*/
|
|
76
|
+
export declare function loadProfile(file: string, url: string): StorageState | null;
|
|
77
|
+
/**
|
|
78
|
+
* Saves the browser state (a Playwright storage state plus `url`) mode 600 in
|
|
79
|
+
* a mode 700 directory, atomically: concurrent runs, even threads of one
|
|
80
|
+
* process, each write their own temporary file and rename it into place.
|
|
81
|
+
*/
|
|
82
|
+
export declare function saveProfile(file: string, url: string, state: StorageState): void;
|
|
83
|
+
/** The `targets` entry of e2e.config.ts. */
|
|
84
|
+
export declare function dsmTarget(name?: string): {
|
|
85
|
+
name: string;
|
|
86
|
+
engine: import("e2e/engine").EngineHandle;
|
|
87
|
+
app: {
|
|
88
|
+
url: string;
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
/** The active DSM tab of the current attempt. */
|
|
92
|
+
export declare function dsmPage(): Page;
|
|
93
|
+
type SecretLedger = {
|
|
94
|
+
register(name: string, value: string): void;
|
|
95
|
+
};
|
|
96
|
+
type SessionPage = {
|
|
97
|
+
context(): {
|
|
98
|
+
cookies(): Promise<{
|
|
99
|
+
name: string;
|
|
100
|
+
value: string;
|
|
101
|
+
}[]>;
|
|
102
|
+
};
|
|
103
|
+
evaluate(fn: () => unknown): Promise<unknown>;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Registers the signed-in session's cookies (id, did, _SSID) and SynoToken as
|
|
107
|
+
* e2e secrets, so e2e masks them in its report and output, as a request's
|
|
108
|
+
* Playwright call log carries them (t-0498). Returns how many were registered.
|
|
109
|
+
*/
|
|
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[]>;
|
|
120
|
+
/** Waits until the DSM desktop and its signed-in taskbar are shown. */
|
|
121
|
+
export declare function waitForDesktop(page?: Page): Promise<void>;
|
|
122
|
+
/**
|
|
123
|
+
* Closes every DSM notification card; returns how many were closed. DSM shows
|
|
124
|
+
* them a moment after the desktop (the admin's "Admin account security" card
|
|
125
|
+
* about 2 s later on vt-nas), so it first waits up to
|
|
126
|
+
* DSM_E2E_NOTIFICATION_WAIT_MS (default 5000) for one.
|
|
127
|
+
*/
|
|
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
|
+
* Restores the saved profile of `user`; signs in with the account file only
|
|
145
|
+
* when that profile is missing, no longer reaches the desktop, or gives a
|
|
146
|
+
* session without a SynoToken or that DSM refuses (dsmSessionLive). Saves the
|
|
147
|
+
* resulting state and returns which path it took.
|
|
148
|
+
*/
|
|
149
|
+
export declare function signIn(app: App, user?: string, env?: Env): Promise<"restored" | "signed-in">;
|
|
150
|
+
/**
|
|
151
|
+
* Registers the setup test that signs `user` in and saves the e2e session
|
|
152
|
+
* `session`. Tests then declare `{ session }` and start signed in.
|
|
153
|
+
*/
|
|
154
|
+
export declare function dsmLoginSetup(options?: {
|
|
155
|
+
session?: string;
|
|
156
|
+
user?: string;
|
|
157
|
+
timeout?: number;
|
|
158
|
+
}): void;
|
|
159
|
+
/** Shows the DSM desktop once per attempt: a no-op while it is already shown. */
|
|
160
|
+
export declare function openDesktop(app: App): Promise<Page>;
|
|
161
|
+
/**
|
|
162
|
+
* Opens the DSM app `appId` (for example SYNO.SDS.SynoPkgConnect.Instance) in
|
|
163
|
+
* its desktop window, or brings its open window to the front, and returns it
|
|
164
|
+
* once the app has rendered text into the window. With the `browser` fixture,
|
|
165
|
+
* native dialogs are accepted and recorded (see handleDialogs).
|
|
166
|
+
*/
|
|
167
|
+
export declare function openApp(app: App, appId: string, timeout?: number, browser?: DialogBrowser): Promise<Locator>;
|
|
168
|
+
/**
|
|
169
|
+
* Waits until an Application Portal page has loaded and registers the session's
|
|
170
|
+
* cookies as e2e secrets. A portal page has no SynoToken, which is no failure.
|
|
171
|
+
*/
|
|
172
|
+
export declare function waitForPortal(page?: Page): Promise<void>;
|
|
173
|
+
/**
|
|
174
|
+
* Opens an Application Portal page (for example /download-hub/) as the whole
|
|
175
|
+
* tab. With the `browser` fixture, native dialogs are accepted and recorded (see handleDialogs).
|
|
176
|
+
*/
|
|
177
|
+
export declare function openPortal(app: App, path: string, browser?: DialogBrowser): Promise<Page>;
|
|
178
|
+
export {};
|
package/dist/e2e/dsm.js
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
// DSM helpers for package e2e suites on upstream `e2e` + `@e2e-dev/web`.
|
|
2
|
+
// One self-contained file: a package may copy it verbatim to tests/e2e/support/dsm.ts.
|
|
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 (accepting their native
|
|
5
|
+
// dialogs when given the `browser` fixture), dismisses DSM notifications,
|
|
6
|
+
// configures the magpie LLM, and runs a command through an SSH tunnel to the NAS
|
|
7
|
+
// (on a free loopback port unless DSM_E2E_TUNNEL_PORT names one). DSM's session
|
|
8
|
+
// cookies and tokens are masked in what that command prints and leaves in its
|
|
9
|
+
// e2e output, which is made private (directories 700, files 600).
|
|
10
|
+
//
|
|
11
|
+
// node tests/e2e/support/dsm.ts tunnel -- npx e2e run
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { connect, createServer } from "node:net";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
20
|
+
import { surfaceOf, test, web } from "@e2e-dev/web";
|
|
21
|
+
const safeName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
22
|
+
const desktopSelector = "#sds-desktop";
|
|
23
|
+
const signedInSelector = "#sds-taskbar-tray-item-user";
|
|
24
|
+
const loginSelector = 'input[name="username"]';
|
|
25
|
+
// ---- Connection -----------------------------------------------------------
|
|
26
|
+
/** The DSM URL the browser opens: DSM_E2E_URL, else the local end of the SSH tunnel. */
|
|
27
|
+
export function dsmUrl(env = process.env) {
|
|
28
|
+
return env.DSM_E2E_URL || `http://127.0.0.1:${env.DSM_E2E_TUNNEL_PORT || "15000"}/`;
|
|
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. */
|
|
45
|
+
export function tunnelArgs(env = process.env) {
|
|
46
|
+
return ["-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes",
|
|
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"];
|
|
49
|
+
}
|
|
50
|
+
/** A loopback port nothing listens on now, for a tunnel that names none. */
|
|
51
|
+
export function freePort() {
|
|
52
|
+
return new Promise((accept, reject) => {
|
|
53
|
+
const server = createServer();
|
|
54
|
+
server.once("error", reject);
|
|
55
|
+
server.listen(0, "127.0.0.1", () => {
|
|
56
|
+
const { port } = server.address();
|
|
57
|
+
server.close(() => accept(port));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function portOpen(port) {
|
|
62
|
+
return new Promise(accept => {
|
|
63
|
+
const socket = connect({ host: "127.0.0.1", port });
|
|
64
|
+
socket.once("connect", () => { socket.destroy(); accept(true); });
|
|
65
|
+
socket.once("error", () => accept(false));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Opens the SSH tunnel on DSM_E2E_TUNNEL_PORT, else on a free port, runs
|
|
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.
|
|
73
|
+
* The command's output reaches this process's stdout and stderr with DSM's
|
|
74
|
+
* secrets masked, and its e2e output directory, DSM_E2E_OUTPUT_DIR (default
|
|
75
|
+
* .e2e), is protected afterwards (see protectE2eOutput). Resolves to the
|
|
76
|
+
* command's exit code.
|
|
77
|
+
*/
|
|
78
|
+
export async function runWithTunnel(command, outer = process.env) {
|
|
79
|
+
if (!command.length)
|
|
80
|
+
throw new Error("runWithTunnel needs a command to run.");
|
|
81
|
+
const port = Number(outer.DSM_E2E_TUNNEL_PORT || await freePort());
|
|
82
|
+
const env = { ...outer, DSM_E2E_TUNNEL_PORT: String(port) };
|
|
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
|
+
}
|
|
88
|
+
const ssh = spawn(env.DSM_E2E_SSH_BIN || "ssh", tunnelArgs(env), { stdio: ["ignore", "ignore", "inherit"] });
|
|
89
|
+
let sshExited = false;
|
|
90
|
+
ssh.once("exit", () => { sshExited = true; });
|
|
91
|
+
try {
|
|
92
|
+
const deadline = Date.now() + Number(env.DSM_E2E_TUNNEL_TIMEOUT_MS || "15000");
|
|
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
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Piped output loses the terminal, not its colours.
|
|
101
|
+
const color = process.stdout.isTTY && env.FORCE_COLOR === undefined ? { FORCE_COLOR: "1" } : {};
|
|
102
|
+
const child = spawn(command[0], command.slice(1), {
|
|
103
|
+
stdio: ["inherit", "pipe", "pipe"], env: { ...env, ...color, DSM_E2E_URL: dsmUrl({ ...env, DSM_E2E_URL: undefined }) },
|
|
104
|
+
});
|
|
105
|
+
redactLines(child.stdout, process.stdout);
|
|
106
|
+
redactLines(child.stderr, process.stderr);
|
|
107
|
+
const forward = (signal) => child.kill(signal);
|
|
108
|
+
process.on("SIGINT", forward);
|
|
109
|
+
process.on("SIGTERM", forward);
|
|
110
|
+
try {
|
|
111
|
+
// "close" waits for the output pipes, so every line is through before the files are protected.
|
|
112
|
+
return await new Promise((accept, reject) => {
|
|
113
|
+
child.once("error", reject);
|
|
114
|
+
child.once("close", (code, signal) => accept(code ?? (signal ? 128 : 1)));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
process.off("SIGINT", forward);
|
|
119
|
+
process.off("SIGTERM", forward);
|
|
120
|
+
protectE2eOutput(resolve(env.DSM_E2E_OUTPUT_DIR || ".e2e"));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
if (!sshExited) {
|
|
125
|
+
const closed = new Promise(accept => ssh.once("exit", accept));
|
|
126
|
+
ssh.kill("SIGTERM");
|
|
127
|
+
await closed;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// ---- Secrets in output ----------------------------------------------------
|
|
132
|
+
// e2e redacts only the secrets its config declares. A Playwright call log in a
|
|
133
|
+
// run error lists a request's headers, DSM's session cookie among them, and e2e
|
|
134
|
+
// writes it to the report and stdout (t-0498). A value runs to a quote, a
|
|
135
|
+
// backslash or a line end, so a JSON string's escapes end it too.
|
|
136
|
+
const mask = "***";
|
|
137
|
+
// A Cookie or Set-Cookie header, and in it DSM's session (id, _SSID) and device (did) cookies.
|
|
138
|
+
const cookieHeader = /(\bcookie"?\s*[:=]\s*"?)([^"\\\r\n]*)/gi;
|
|
139
|
+
const sessionCookieNames = ["id", "did", "_SSID"];
|
|
140
|
+
const sessionCookie = new RegExp(`(^|;\\s*)(${sessionCookieNames.join("|")})=[^;\\s]*`, "gi");
|
|
141
|
+
// The X-SYNO-TOKEN header, a SynoToken parameter or field, and a web API's _sid parameter.
|
|
142
|
+
const tokenValue = /(\b(?:x-syno-token|synotoken)"?\s*[:=]\s*"?)[^"\\\s,;&}]+/gi;
|
|
143
|
+
const sidParameter = /([?&]_sid=)[^&"\\\s]+/gi;
|
|
144
|
+
/** `text` with DSM's session cookies, X-SYNO-TOKEN, SynoToken and _sid values replaced by ***. */
|
|
145
|
+
export function redactDsmSecrets(text) {
|
|
146
|
+
return text
|
|
147
|
+
.replace(cookieHeader, (_match, header, cookies) => header + cookies.replace(sessionCookie, `$1$2=${mask}`))
|
|
148
|
+
.replace(tokenValue, `$1${mask}`)
|
|
149
|
+
.replace(sidParameter, `$1${mask}`);
|
|
150
|
+
}
|
|
151
|
+
function redactLines(from, to) {
|
|
152
|
+
let pending = "";
|
|
153
|
+
from.setEncoding("utf8");
|
|
154
|
+
from.on("data", (chunk) => {
|
|
155
|
+
const text = pending + chunk;
|
|
156
|
+
const end = text.lastIndexOf("\n") + 1;
|
|
157
|
+
pending = text.slice(end);
|
|
158
|
+
if (end)
|
|
159
|
+
to.write(redactDsmSecrets(text.slice(0, end)));
|
|
160
|
+
});
|
|
161
|
+
from.on("end", () => { if (pending)
|
|
162
|
+
to.write(redactDsmSecrets(pending)); });
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Makes an e2e output directory private (directories 700, files 600) and masks
|
|
166
|
+
* DSM's secrets in its text files. A `sessions` directory keeps its contents:
|
|
167
|
+
* a saved session must restore the real cookies. Does nothing when `dir` is missing.
|
|
168
|
+
*/
|
|
169
|
+
export function protectE2eOutput(dir) {
|
|
170
|
+
protectTree(dir, true);
|
|
171
|
+
}
|
|
172
|
+
function protectTree(dir, redact) {
|
|
173
|
+
let entries;
|
|
174
|
+
try {
|
|
175
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code === "ENOENT")
|
|
179
|
+
return;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
chmodSync(dir, 0o700);
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
const path = join(dir, entry.name);
|
|
185
|
+
if (entry.isDirectory())
|
|
186
|
+
protectTree(path, redact && entry.name !== "sessions");
|
|
187
|
+
else if (entry.isFile()) {
|
|
188
|
+
chmodSync(path, 0o600);
|
|
189
|
+
if (redact)
|
|
190
|
+
redactFile(path);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function redactFile(file) {
|
|
195
|
+
const content = readFileSync(file);
|
|
196
|
+
if (content.includes(0))
|
|
197
|
+
return;
|
|
198
|
+
const text = content.toString("utf8");
|
|
199
|
+
const masked = redactDsmSecrets(text);
|
|
200
|
+
if (masked !== text)
|
|
201
|
+
writeFileSync(file, masked, { mode: 0o600 });
|
|
202
|
+
}
|
|
203
|
+
// ---- LLM ------------------------------------------------------------------
|
|
204
|
+
/** magpie's GPT-6 Luna at max reasoning effort unless E2E_LLM_* says otherwise. */
|
|
205
|
+
export function llmSettings(env = process.env) {
|
|
206
|
+
return {
|
|
207
|
+
baseURL: env.E2E_LLM_BASE_URL || "http://127.0.0.1:3425/v1",
|
|
208
|
+
model: env.E2E_LLM_MODEL || "codex/gpt-6-luna",
|
|
209
|
+
apiKey: env.E2E_LLM_API_KEY || "magpie-local",
|
|
210
|
+
reasoningEffort: env.E2E_LLM_REASONING_EFFORT || "max",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** The `agents.default` entry of e2e.config.ts: model plus the effort sent with every request. */
|
|
214
|
+
export function magpieAgent(env = process.env) {
|
|
215
|
+
const settings = llmSettings(env);
|
|
216
|
+
return {
|
|
217
|
+
model: createOpenAI({ baseURL: settings.baseURL, apiKey: settings.apiKey }).chat(settings.model),
|
|
218
|
+
providerOptions: { openai: { reasoningEffort: settings.reasoningEffort } },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// ---- Accounts and profiles ------------------------------------------------
|
|
222
|
+
function requirePrivate(file, what) {
|
|
223
|
+
if (statSync(file).mode & 0o077)
|
|
224
|
+
throw new Error(`The ${what} ${file} must have mode 600.`);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Reads one account from a private JSON file: `{username, password}` or
|
|
228
|
+
* `{users: {<user>: {username, password}}}`. Errors never include the values.
|
|
229
|
+
*/
|
|
230
|
+
export function readAccount(file, user) {
|
|
231
|
+
requirePrivate(file, "account file");
|
|
232
|
+
const content = JSON.parse(readFileSync(file, "utf8"));
|
|
233
|
+
const account = content.users ? content.users[user] : content;
|
|
234
|
+
if (typeof account?.username !== "string" || !account.username || typeof account.password !== "string" || !account.password) {
|
|
235
|
+
throw new Error(`The account file has no username/password for ${user}.`);
|
|
236
|
+
}
|
|
237
|
+
return { username: account.username, password: account.password };
|
|
238
|
+
}
|
|
239
|
+
/** ~/.cache/synopkgland-e2e/<target>-<user>.json, or under DSM_E2E_PROFILE_DIR. */
|
|
240
|
+
export function profilePath(target, user, env = process.env) {
|
|
241
|
+
if (!safeName.test(target) || !safeName.test(user))
|
|
242
|
+
throw new Error("The profile target and user must be simple names.");
|
|
243
|
+
return join(env.DSM_E2E_PROFILE_DIR || join(homedir(), ".cache/synopkgland-e2e"), `${target}-${user}.json`);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The saved browser state for `url`'s DSM host, or null when there is none or
|
|
247
|
+
* it belongs to another host. The port does not count: DSM's cookies do not
|
|
248
|
+
* depend on it, so tunnels on different ports share one profile. A file
|
|
249
|
+
* without `url` (written by another tool) is reused when one of its cookies
|
|
250
|
+
* belongs to the DSM host.
|
|
251
|
+
*/
|
|
252
|
+
export function loadProfile(file, url) {
|
|
253
|
+
let raw;
|
|
254
|
+
try {
|
|
255
|
+
raw = readFileSync(file, "utf8");
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error.code === "ENOENT")
|
|
259
|
+
return null;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
requirePrivate(file, "profile");
|
|
263
|
+
const { url: savedUrl, savedAt: _savedAt, ...profile } = JSON.parse(raw);
|
|
264
|
+
const cookies = profile.cookies ?? [];
|
|
265
|
+
const host = new URL(url).hostname;
|
|
266
|
+
const sameHost = savedUrl === undefined ? cookies.some(cookie => cookie.domain === host) : new URL(savedUrl).hostname === host;
|
|
267
|
+
return sameHost ? { ...profile, cookies, origins: profile.origins ?? [] } : null;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Saves the browser state (a Playwright storage state plus `url`) mode 600 in
|
|
271
|
+
* a mode 700 directory, atomically: concurrent runs, even threads of one
|
|
272
|
+
* process, each write their own temporary file and rename it into place.
|
|
273
|
+
*/
|
|
274
|
+
export function saveProfile(file, url, state) {
|
|
275
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
276
|
+
chmodSync(dirname(file), 0o700);
|
|
277
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
278
|
+
writeFileSync(temporary, JSON.stringify({ ...state, url, savedAt: new Date().toISOString() }) + "\n", { mode: 0o600 });
|
|
279
|
+
renameSync(temporary, file);
|
|
280
|
+
}
|
|
281
|
+
// ---- Browser --------------------------------------------------------------
|
|
282
|
+
const engine = web({
|
|
283
|
+
viewport: { width: Number(process.env.DSM_E2E_VIEWPORT_WIDTH || 1280), height: Number(process.env.DSM_E2E_VIEWPORT_HEIGHT || 800) },
|
|
284
|
+
});
|
|
285
|
+
/** The `targets` entry of e2e.config.ts. */
|
|
286
|
+
export function dsmTarget(name = "dsm") {
|
|
287
|
+
return { name, engine, app: { url: dsmUrl() } };
|
|
288
|
+
}
|
|
289
|
+
function surface() {
|
|
290
|
+
const live = surfaceOf(engine);
|
|
291
|
+
if (!live)
|
|
292
|
+
throw new Error("The DSM web engine is unavailable.");
|
|
293
|
+
return live;
|
|
294
|
+
}
|
|
295
|
+
/** The active DSM tab of the current attempt. */
|
|
296
|
+
export function dsmPage() {
|
|
297
|
+
return surface().page();
|
|
298
|
+
}
|
|
299
|
+
async function desktopOrLogin(page, timeout = 30_000) {
|
|
300
|
+
return Promise.race([
|
|
301
|
+
page.locator(desktopSelector).waitFor({ state: "visible", timeout }).then(() => "desktop"),
|
|
302
|
+
page.locator(loginSelector).waitFor({ state: "visible", timeout }).then(() => "login"),
|
|
303
|
+
]);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* e2e's ledger of the secrets this process has seen, which a worker's run
|
|
307
|
+
* errors and output are redacted with. e2e 0.19 keeps it internal and lets a
|
|
308
|
+
* config declare only secrets known up front, so it is reached by its file;
|
|
309
|
+
* under another layout there is none and runWithTunnel's masking remains.
|
|
310
|
+
*/
|
|
311
|
+
async function e2eSecretLedger() {
|
|
312
|
+
try {
|
|
313
|
+
const { processSecrets } = await import(new URL("run/secrecy.js", import.meta.resolve("e2e")).href);
|
|
314
|
+
return typeof processSecrets?.register === "function" ? processSecrets : undefined;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Registers the signed-in session's cookies (id, did, _SSID) and SynoToken as
|
|
322
|
+
* e2e secrets, so e2e masks them in its report and output, as a request's
|
|
323
|
+
* Playwright call log carries them (t-0498). Returns how many were registered.
|
|
324
|
+
*/
|
|
325
|
+
export async function registerDsmSecrets(page = dsmPage(), ledger) {
|
|
326
|
+
const target = ledger ?? await e2eSecretLedger();
|
|
327
|
+
if (!target)
|
|
328
|
+
return 0;
|
|
329
|
+
const cookies = (await page.context().cookies()).filter(c => sessionCookieNames.includes(c.name) && c.value);
|
|
330
|
+
const token = await page.evaluate(() => window.SYNO?.SDS?.Session?.SynoToken).catch(() => undefined);
|
|
331
|
+
const values = cookies.map(c => [`dsm.${c.name}`, c.value]);
|
|
332
|
+
if (typeof token === "string" && token)
|
|
333
|
+
values.push(["dsm.SynoToken", token]);
|
|
334
|
+
for (const [name, value] of values)
|
|
335
|
+
target.register(name, value);
|
|
336
|
+
return values.length;
|
|
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
|
+
}
|
|
357
|
+
/** Waits until the DSM desktop and its signed-in taskbar are shown. */
|
|
358
|
+
export async function waitForDesktop(page = dsmPage()) {
|
|
359
|
+
await page.locator(desktopSelector).waitFor({ state: "visible", timeout: 60_000 });
|
|
360
|
+
await page.locator(signedInSelector).waitFor({ state: "visible", timeout: 15_000 });
|
|
361
|
+
}
|
|
362
|
+
// DSM 7.4: .v-notification-container > .v-notification-panel.show > i.close-btn.
|
|
363
|
+
const notificationCloseSelector = ".v-notification-container .v-notification-panel .close-btn, .v-notification-panel.show .close-btn";
|
|
364
|
+
/**
|
|
365
|
+
* Closes every DSM notification card; returns how many were closed. DSM shows
|
|
366
|
+
* them a moment after the desktop (the admin's "Admin account security" card
|
|
367
|
+
* about 2 s later on vt-nas), so it first waits up to
|
|
368
|
+
* DSM_E2E_NOTIFICATION_WAIT_MS (default 5000) for one.
|
|
369
|
+
*/
|
|
370
|
+
export async function dismissNotifications(page = dsmPage(), env = process.env) {
|
|
371
|
+
const close = page.locator(notificationCloseSelector).first();
|
|
372
|
+
const wait = Number(env.DSM_E2E_NOTIFICATION_WAIT_MS ?? "5000");
|
|
373
|
+
if (!await close.waitFor({ state: "visible", timeout: wait }).then(() => true, () => false))
|
|
374
|
+
return 0;
|
|
375
|
+
let closed = 0;
|
|
376
|
+
while (closed < 20 && await close.isVisible().catch(() => false)) {
|
|
377
|
+
const card = await close.elementHandle();
|
|
378
|
+
await close.click();
|
|
379
|
+
// A closing card stays visible while it fades out; a detached one counts as hidden.
|
|
380
|
+
await card?.waitForElementState("hidden", { timeout: 2_000 }).catch(() => { });
|
|
381
|
+
closed++;
|
|
382
|
+
}
|
|
383
|
+
return closed;
|
|
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
|
+
}
|
|
399
|
+
function accountFile(user, env) {
|
|
400
|
+
const key = `DSM_E2E_ACCOUNT_${user.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
|
|
401
|
+
const file = env[key];
|
|
402
|
+
if (!file)
|
|
403
|
+
throw new Error(`Set ${key} to the account file for ${user} (mode 600).`);
|
|
404
|
+
return resolve(file);
|
|
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
|
+
async function restoredSessionLive(page) {
|
|
423
|
+
return await desktopOrLogin(page) === "desktop" && await dsmSessionLive(page);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Restores the saved profile of `user`; signs in with the account file only
|
|
427
|
+
* when that profile is missing, no longer reaches the desktop, or gives a
|
|
428
|
+
* session without a SynoToken or that DSM refuses (dsmSessionLive). Saves the
|
|
429
|
+
* resulting state and returns which path it took.
|
|
430
|
+
*/
|
|
431
|
+
export async function signIn(app, user = "admin", env = process.env) {
|
|
432
|
+
const url = dsmUrl(env);
|
|
433
|
+
const file = profilePath(env.DSM_E2E_TARGET || "vt-nas", user, env);
|
|
434
|
+
const context = surface().context();
|
|
435
|
+
const profile = loadProfile(file, url);
|
|
436
|
+
if (profile)
|
|
437
|
+
await context.setStorageState(profile);
|
|
438
|
+
await openPath(app, "/");
|
|
439
|
+
const page = dsmPage();
|
|
440
|
+
let outcome = "restored";
|
|
441
|
+
if (!profile || !await restoredSessionLive(page)) {
|
|
442
|
+
// A refused session still shows the desktop; dropping its cookies shows the login form.
|
|
443
|
+
if (profile) {
|
|
444
|
+
await context.clearCookies();
|
|
445
|
+
await openPath(app, "/");
|
|
446
|
+
}
|
|
447
|
+
const account = readAccount(accountFile(user, env), user);
|
|
448
|
+
await page.locator(loginSelector).fill(account.username);
|
|
449
|
+
await page.keyboard.press("Enter");
|
|
450
|
+
const password = page.locator('input[type="password"]:visible');
|
|
451
|
+
await password.fill(account.password);
|
|
452
|
+
await password.press("Enter");
|
|
453
|
+
outcome = "signed-in";
|
|
454
|
+
}
|
|
455
|
+
await waitForDesktop(page);
|
|
456
|
+
await registerDsmSecrets(page);
|
|
457
|
+
saveProfile(file, url, await context.storageState());
|
|
458
|
+
return outcome;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Registers the setup test that signs `user` in and saves the e2e session
|
|
462
|
+
* `session`. Tests then declare `{ session }` and start signed in.
|
|
463
|
+
*/
|
|
464
|
+
export function dsmLoginSetup(options = {}) {
|
|
465
|
+
const name = options.session ?? "dsm";
|
|
466
|
+
const user = options.user ?? "admin";
|
|
467
|
+
test.setup(`DSM sign-in as ${user}`, { sessions: [name], timeout: options.timeout ?? 120_000 }, async ({ app, session }) => {
|
|
468
|
+
await signIn(app, user);
|
|
469
|
+
await session.save(name);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
/** Shows the DSM desktop once per attempt: a no-op while it is already shown. */
|
|
473
|
+
export async function openDesktop(app) {
|
|
474
|
+
// A restored session leaves no page open until the attempt's first app.open.
|
|
475
|
+
const current = (() => { try {
|
|
476
|
+
return dsmPage();
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return undefined;
|
|
480
|
+
} })();
|
|
481
|
+
if (current && await current.locator(desktopSelector).isVisible().catch(() => false))
|
|
482
|
+
return current;
|
|
483
|
+
await openPath(app, "/");
|
|
484
|
+
const page = dsmPage();
|
|
485
|
+
if (await desktopOrLogin(page) === "login")
|
|
486
|
+
throw new Error("The DSM session is signed out; declare the session that dsmLoginSetup saves.");
|
|
487
|
+
await waitForDesktop(page);
|
|
488
|
+
await registerDsmSecrets(page);
|
|
489
|
+
await dismissNotifications(page);
|
|
490
|
+
return page;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Opens the DSM app `appId` (for example SYNO.SDS.SynoPkgConnect.Instance) in
|
|
494
|
+
* its desktop window, or brings its open window to the front, and returns it
|
|
495
|
+
* once the app has rendered text into the window. With the `browser` fixture,
|
|
496
|
+
* native dialogs are accepted and recorded (see handleDialogs).
|
|
497
|
+
*/
|
|
498
|
+
export async function openApp(app, appId, timeout = 30_000, browser) {
|
|
499
|
+
if (browser)
|
|
500
|
+
await handleDialogs(browser);
|
|
501
|
+
const page = await openDesktop(app);
|
|
502
|
+
await page.evaluate(id => window.SYNO.SDS.AppLaunch(id), appId);
|
|
503
|
+
const handle = await page.waitForFunction(id => {
|
|
504
|
+
const instance = (window.SYNO.SDS.AppMgr.getByAppName(id) || [])[0];
|
|
505
|
+
const appWindow = instance?.window;
|
|
506
|
+
return appWindow?.isVisible?.() && appWindow.el?.dom?.id;
|
|
507
|
+
}, appId, { timeout });
|
|
508
|
+
const id = await handle.jsonValue();
|
|
509
|
+
// The window frame shows before the app renders into it.
|
|
510
|
+
await page.waitForFunction(windowId => document.getElementById(windowId)?.querySelector(".x-window-body")?.innerText.trim(), id, { timeout });
|
|
511
|
+
return page.locator(`#${id}`);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Waits until an Application Portal page has loaded and registers the session's
|
|
515
|
+
* cookies as e2e secrets. A portal page has no SynoToken, which is no failure.
|
|
516
|
+
*/
|
|
517
|
+
export async function waitForPortal(page = dsmPage()) {
|
|
518
|
+
await page.waitForLoadState("load");
|
|
519
|
+
await registerDsmSecrets(page);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Opens an Application Portal page (for example /download-hub/) as the whole
|
|
523
|
+
* tab. With the `browser` fixture, native dialogs are accepted and recorded (see handleDialogs).
|
|
524
|
+
*/
|
|
525
|
+
export async function openPortal(app, path, browser) {
|
|
526
|
+
if (!path.startsWith("/") || path.startsWith("//"))
|
|
527
|
+
throw new Error("openPortal takes a same-origin path such as /download-hub/.");
|
|
528
|
+
if (browser)
|
|
529
|
+
await handleDialogs(browser);
|
|
530
|
+
await openPath(app, path);
|
|
531
|
+
const page = dsmPage();
|
|
532
|
+
await waitForPortal(page);
|
|
533
|
+
return page;
|
|
534
|
+
}
|
|
535
|
+
// ---- Command line ---------------------------------------------------------
|
|
536
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
537
|
+
const [verb, separator, ...command] = process.argv.slice(2);
|
|
538
|
+
if (verb !== "tunnel" || separator !== "--" || !command.length) {
|
|
539
|
+
console.error("usage: node dsm.ts tunnel -- <command...>");
|
|
540
|
+
process.exit(2);
|
|
541
|
+
}
|
|
542
|
+
runWithTunnel(command).then(code => process.exit(code), error => { console.error(error.message); process.exit(1); });
|
|
543
|
+
}
|
|
@@ -53,5 +53,27 @@
|
|
|
53
53
|
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
54
54
|
},
|
|
55
55
|
"goToolchain": "go1.26.9"
|
|
56
|
+
},
|
|
57
|
+
"0.10.5": {
|
|
58
|
+
"x86_64": "4ba7040761a719e74d6b047ef68b9415e658ebb8b373b69555b8c307524e26ba",
|
|
59
|
+
"aarch64": "ba80b389e3291419389b66be7b0a0e1085d3dfec55c9c651fecae427279bb3d5",
|
|
60
|
+
"armv7": "49474467b9c9a7f31b30b5053b70eb4f71a95f2c06fbf3566e8de8f16972a299",
|
|
61
|
+
"lifecycle": {
|
|
62
|
+
"x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
|
|
63
|
+
"aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
|
|
64
|
+
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
65
|
+
},
|
|
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"
|
|
56
78
|
}
|
|
57
79
|
}
|
|
@@ -4,8 +4,12 @@ export declare const EXECUTOR_MODULE = "github.com/synopackageland/syno-pkg-land
|
|
|
4
4
|
/** The thin Rust App crate (spec/bridge/v1), a git dependency on a release tag of the SDK repository. */
|
|
5
5
|
export declare const SDK_CRATE = "syno-pkg-land-app";
|
|
6
6
|
export declare const SDK_GIT_URL = "https://github.com/synopackageland/syno-pkg-land-sdk";
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The oldest SDK this CLI supports in its minor line. The DSM window answers
|
|
9
|
+
* the SDK's status reads at once while the package is stopped or starting,
|
|
10
|
+
* which only SDK 0.10.5's panels retry on their own.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SDK_FLOOR = "0.10.5";
|
|
9
13
|
/** Whether version is an SDK patch older than SDK_FLOOR in the floor's minor line. */
|
|
10
14
|
export declare function sdkBelowFloor(version: string): boolean;
|
|
11
15
|
/** Where one SDK version's Go client and executor sources are read from. */
|
|
@@ -8,8 +8,12 @@ export const EXECUTOR_MODULE = `${SDK_MODULE}/executor`;
|
|
|
8
8
|
/** The thin Rust App crate (spec/bridge/v1), a git dependency on a release tag of the SDK repository. */
|
|
9
9
|
export const SDK_CRATE = 'syno-pkg-land-app';
|
|
10
10
|
export const SDK_GIT_URL = `https://${SDK_MODULE}`;
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The oldest SDK this CLI supports in its minor line. The DSM window answers
|
|
13
|
+
* the SDK's status reads at once while the package is stopped or starting,
|
|
14
|
+
* which only SDK 0.10.5's panels retry on their own.
|
|
15
|
+
*/
|
|
16
|
+
export const SDK_FLOOR = '0.10.5';
|
|
13
17
|
/** Whether version is an SDK patch older than SDK_FLOOR in the floor's minor line. */
|
|
14
18
|
export function sdkBelowFloor(version) {
|
|
15
19
|
const [major, minor, patch] = version.split('.').map(Number), [floorMajor, floorMinor, floorPatch] = SDK_FLOOR.split('.').map(Number);
|
|
@@ -195,11 +195,14 @@ ${ns}.callOnce = function (method, params, options, requestTimeout) {
|
|
|
195
195
|
// sent: the package is stopped, or starting. The call repeats on its own, half a
|
|
196
196
|
// second, then doubling up to 8 s, for 30 s of waiting in all; each attempt keeps
|
|
197
197
|
// its own deadline. Past that it rejects with the last refusal, and leaving the
|
|
198
|
-
// page during a wait rejects request_canceled at once.
|
|
198
|
+
// page during a wait rejects request_canceled at once. The SDK's status reads
|
|
199
|
+
// are not repeated: its panels show the stopped or starting package and check
|
|
200
|
+
// again on their own, and a page waiting here shows nothing (t-0503 R1).
|
|
199
201
|
${ns}.retryCodes = ["APP_STOPPED", "APP_STARTING", "EXECUTOR_STARTING"];
|
|
202
|
+
${ns}.statusReads = ["delegate.status", "authorization.status", "executor.status"];
|
|
200
203
|
${ns}.call = function (method, params, options, requestTimeout) {
|
|
201
204
|
var signal = options && options.signal;
|
|
202
|
-
var left = 30000;
|
|
205
|
+
var left = ${ns}.statusReads.indexOf(method) < 0 ? 30000 : 0;
|
|
203
206
|
var delay = 500;
|
|
204
207
|
var attempt = function () {
|
|
205
208
|
return ${ns}.callOnce(method, params, options, requestTimeout).catch(function (error) {
|
|
@@ -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) {
|
|
@@ -330,12 +336,16 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
330
336
|
function publish(result) { listeners.forEach(function (listener) { listener(result); }); return result; }
|
|
331
337
|
// TaskScheduler answers administrators only. Anyone else reads the approved
|
|
332
338
|
// executor through the App's executor.status, which answers every user: the
|
|
333
|
-
// pinned, running executor is ready;
|
|
339
|
+
// pinned, running executor is ready; a stopped or starting package says so,
|
|
340
|
+
// as an administrator's status does; otherwise an administrator is needed.
|
|
341
|
+
var lifecycleCodes = ["APP_STOPPED", "APP_STARTING", "EXECUTOR_STARTING"];
|
|
334
342
|
function userStatus() {
|
|
335
343
|
var needed = block({tasks: {}}, fail("admin_required"));
|
|
336
344
|
return options.call("executor.status", {}).then(function (status) {
|
|
337
345
|
return pinned(status) ? {state: "ready", tasks: {}, executor: status} : needed;
|
|
338
|
-
}, function () {
|
|
346
|
+
}, function (e) {
|
|
347
|
+
return e && lifecycleCodes.indexOf(e.code) >= 0 ? block({tasks: {}}, fail(e.code, "the executor status request failed.")) : needed;
|
|
348
|
+
});
|
|
339
349
|
}
|
|
340
350
|
return {
|
|
341
351
|
status: function () { return (isAdmin() ? read().then(function (verified) { return health(verified, 0); }) : userStatus()).then(publish); },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.6",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,17 +25,41 @@
|
|
|
25
25
|
"prepublishOnly": "npm run build"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@synopackageland/templates": "0.10.
|
|
28
|
+
"@synopackageland/templates": "0.10.7",
|
|
29
29
|
"ajv": "^8.17.1",
|
|
30
30
|
"commander": "^13.1.0",
|
|
31
31
|
"tar": "^7.4.3",
|
|
32
32
|
"yaml": "^2.7.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
+
"@ai-sdk/openai": "^4.0.91",
|
|
36
|
+
"@e2e-dev/web": "^0.14.0",
|
|
35
37
|
"@types/node": "^22.13.10",
|
|
38
|
+
"ai": "^7.0.137",
|
|
39
|
+
"e2e": "^0.19.0",
|
|
36
40
|
"tsx": "^4.19.3",
|
|
37
41
|
"typescript": "^5.8.2"
|
|
38
42
|
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@ai-sdk/openai": ">=4",
|
|
45
|
+
"@e2e-dev/web": ">=0.14",
|
|
46
|
+
"ai": ">=7",
|
|
47
|
+
"e2e": ">=0.19"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@ai-sdk/openai": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"@e2e-dev/web": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"ai": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"e2e": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
39
63
|
"publishConfig": {
|
|
40
64
|
"access": "public",
|
|
41
65
|
"registry": "https://registry.npmjs.org"
|
|
@@ -52,6 +76,7 @@
|
|
|
52
76
|
"exports": {
|
|
53
77
|
"./dist/*": "./dist/*",
|
|
54
78
|
"./package.json": "./package.json",
|
|
55
|
-
"./native-layout": "./dist/standalone/native-layout.js"
|
|
79
|
+
"./native-layout": "./dist/standalone/native-layout.js",
|
|
80
|
+
"./e2e": "./dist/e2e/dsm.js"
|
|
56
81
|
}
|
|
57
82
|
}
|
|
@@ -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
|
|