@synopackageland/cli 0.10.3 → 0.10.5

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 CHANGED
@@ -1,5 +1,111 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.5 (2026-10-15)
4
+
5
+ Builds with SDK 0.10.5 and templates 0.10.5: `init` writes SDK v0.10.5 (Go
6
+ module, Rust crate tag and `backend.sdk`), `@synopackageland/sdk` 0.10.5 and
7
+ `@synopackageland/ui` 0.9.7, whose `Space` aligns its items to the start. The
8
+ executor digest table adds SDK 0.10.5, whose executor and lifecycle digests
9
+ equal 0.10.4, so an App rebuilt on it keeps its approval unless its own root
10
+ programs change. New: the `@synopackageland/cli/e2e` module for real-NAS e2e
11
+ suites.
12
+
13
+ 繁中摘要:搭配 SDK 0.10.5、templates 0.10.5(UI 0.9.7);需要 SDK 0.10.5 以上;
14
+ digest 與 0.10.4 相同;新增 `@synopackageland/cli/e2e`。
15
+
16
+ - **Incompatible:** the CLI needs SDK 0.10.5 or later. `check` and `build` refuse an App
17
+ whose backend requires SDK 0.10.3 or 0.10.4 (`SDK_UNSUPPORTED`, naming 0.10.5); raise
18
+ the Go module, Cargo tag or `backend.sdk` and the frontend's `@synopackageland/sdk` to
19
+ 0.10.5. The DSM window now answers the SDK's status reads at once while the package is
20
+ stopped or starting, and only SDK 0.10.5's panels show that state and check again;
21
+ SDK 0.10.4's would show the package as needing approval.
22
+
23
+ 繁中摘要:CLI 需要 SDK 0.10.5 以上;App 若仍用 SDK 0.10.3/0.10.4,`check`/`build` 會以
24
+ `SDK_UNSUPPORTED` 拒絕。DSM 視窗的 status 讀取不再替 App 重試,只有 SDK 0.10.5 的面板會自行重試。
25
+ - `@synopackageland/cli/e2e` (`src/e2e/dsm.ts`): DSM helpers for a package's
26
+ real-NAS suite on `e2e` 0.19 and `@e2e-dev/web` 0.14 (optional peers).
27
+ `dsmTarget`, `magpieAgent` (magpie GPT-6 Luna at max reasoning effort,
28
+ `E2E_LLM_*` override), `dsmLoginSetup` and `signIn` (a mode 600 saved profile,
29
+ restored while it still reaches the desktop), `openDesktop`, `openApp`,
30
+ `openPortal`, `dismissNotifications` and `runWithTunnel`
31
+ (`node dsm.js tunnel -- <command>`, an npm script such as `e2e:nas`). The file
32
+ is self-contained, so a package may copy it to `tests/e2e/support/dsm.ts`.
33
+ 繁中:新增共用 DSM e2e 模組,App 可改為 import `@synopackageland/cli/e2e`。
34
+ - `runWithTunnel` opens the tunnel on a free loopback port unless
35
+ `DSM_E2E_TUNNEL_PORT` names one, so parallel runs do not collide on 15000.
36
+ - A saved DSM profile is reused by host, not port, and saved atomically, so
37
+ concurrent runs never leave it half written.
38
+ - `dismissNotifications` waits up to `DSM_E2E_NOTIFICATION_WAIT_MS` (default
39
+ 5000) for the cards DSM shows after the desktop (the admin's "Admin account
40
+ security" card, about 2 s later on DSM 7.4) and matches DSM 7.4's container.
41
+ - `runWithTunnel` masks DSM's session cookies (`id`, `did`, `_SSID`),
42
+ `X-SYNO-TOKEN`, `SynoToken` and `_sid` as `***` in the command's stdout and
43
+ stderr and in the text files under `DSM_E2E_OUTPUT_DIR` (default `.e2e`), and
44
+ makes that directory private (directories 700, files 600). A Playwright call
45
+ log in a run error had carried the session cookie into `report.json` and the
46
+ run's output; e2e redacts only the secrets its config declares. Saved
47
+ sessions keep their cookies. `redactDsmSecrets` and `protectE2eOutput` are
48
+ exported for a package with its own runner. Before that, `signIn` and
49
+ `openDesktop` (and so `openApp`) register the session's cookies and
50
+ SynoToken as e2e secrets in the test's worker (`registerDsmSecrets`), so e2e
51
+ itself masks them in a run error, `report.json` and its output; e2e 0.19
52
+ keeps that ledger internal, so a later layout without it leaves the second
53
+ mask alone.
54
+ 繁中:e2e 輸出與 log 遮蔽 DSM cookie/token,輸出目錄改為 700/600。
55
+ - `ctx.call` no longer repeats the SDK's status reads (`delegate.status`,
56
+ `authorization.status`, `executor.status`) while the package is stopped or
57
+ starting: they reject with the code at once, and SDK 0.10.5's panels show the
58
+ stopped or starting package and check again on their own. A page no longer
59
+ shows nothing for the first seconds after an upgrade (6.7 s in t-0503). Calls
60
+ that change something, approvals and controls among them, still repeat for up
61
+ to 30 s. `ctx.executor.status()` for a user who is not an administrator says
62
+ the package is stopped or starting instead of `admin_required`.
63
+ 繁中:status 類讀取遇停止/啟動中立即回傳該碼,交給 SDK 重試;核准等呼叫維持重試。
64
+
65
+ ## 0.10.4 (2026-10-14)
66
+
67
+ Builds with SDK 0.10.4 and templates 0.10.4: `init` writes SDK v0.10.4 (Go
68
+ module, Rust crate tag and `backend.sdk`), `@synopackageland/sdk` 0.10.4 and
69
+ `@synopackageland/ui` 0.9.6. `check` and `build` still accept SDK 0.10.3 or
70
+ later: nothing in this CLI needs 0.10.4, and an App on 0.10.3 never sees the
71
+ stop and start codes the window now repeats. The executor digest table adds
72
+ SDK 0.10.4, whose executor and lifecycle digests equal 0.10.3, so an App
73
+ rebuilt on it keeps its approval unless its own root programs change. The
74
+ gate refuses a stale SDK `ts/dist` and builds the executor flow's race binary
75
+ before its timeout.
76
+
77
+ 繁中摘要:使用 SDK 0.10.4 與 templates 0.10.4(UI 0.9.6);`check`/`build` 仍接受 SDK
78
+ 0.10.3 以上。`ctx.call` 自動重試套件停止/啟動中的碼;非管理員的
79
+ `ctx.executor.status()` 可讀到已核准的 executor。digest 表新增 SDK 0.10.4(與 0.10.3
80
+ 相同),重建的 App 不需重新核准(除非自己的 root 程式改變)。
81
+
82
+ - `ctx.call` repeats a call the package refused with `APP_STOPPED`,
83
+ `APP_STARTING` or `EXECUTOR_STARTING` (the executor op was not sent): after
84
+ 0.5 s, doubling to at most 8 s, for 30 s of waiting in all, so an App comes
85
+ back by itself once the package has started. Other codes reject at once; past
86
+ the budget the last refusal rejects; leaving the page cancels the wait.
87
+ 繁中:套件停止/啟動中時 `ctx.call` 自動有界重試(最多等 30 s)。
88
+ - The window approval of an upgrade (outdated tasks) makes 11 scheduler calls
89
+ instead of 12: the outdated boot task is deleted by the approval's own first
90
+ EventScheduler get, made moments before the password, instead of a second
91
+ get (2.9 s on vt-nas in t-0484). The control task is still read again by id
92
+ before its delete (spec/scheduler/v1). A fresh approval stays at 8.
93
+ 繁中:升級核准的排程呼叫由 12 降為 11(boot 任務不再於刪除前重讀)。
94
+ - A scope approval reads the executor's selection back every second at most
95
+ (it backed off to 2 s), so the window sees the landed scope within a second
96
+ of `scope.confirm` (the Dialog closed 4.8 s after it in t-0482). Removing
97
+ the temporary selection task still lists Task Scheduler first, as
98
+ spec/scheduler/v1 requires of a delete.
99
+ 繁中:範圍核准讀回間隔上限 2 s → 1 s,confirmed 後 1 s 內看到結果。
100
+ - `ctx.executor.status()` for a user who is not an administrator asks the
101
+ App's `executor.status` (open to every signed-in user) instead of refusing
102
+ before any request: the pinned, running executor reads `ready`; anything
103
+ else stays `blocked` with `admin_required`. Task Scheduler is still read only
104
+ for administrators. A user no longer sees an approved executor as waiting
105
+ for approval (t-0482 F7).
106
+ 繁中:非管理員的 `ctx.executor.status()` 會向 App 的 `executor.status` 詢問,已核准且
107
+ 執行中即為 `ready`,不再一律回 `admin_required`。
108
+
3
109
  ## 0.10.3 (2026-10-14)
4
110
 
5
111
  Builds with SDK 0.10.3 and templates 0.10.3: `init` writes SDK v0.10.3 (Go
package/README.md CHANGED
@@ -121,6 +121,45 @@ declaring it changes the policy digest, so an administrator approves the App aga
121
121
  [CLI reference](https://www.varvis.me/docs/reference/cli/) and
122
122
  [error codes](https://www.varvis.me/docs/reference/errors/).
123
123
 
124
+ ## E2E helpers
125
+
126
+ `@synopackageland/cli/e2e` holds the DSM helpers for a package's real-NAS
127
+ suite on [`e2e`](https://www.npmjs.com/package/e2e) and `@e2e-dev/web`
128
+ (optional peers, with `ai` and `@ai-sdk/openai`). The same file,
129
+ `src/e2e/dsm.ts`, can be copied verbatim to `tests/e2e/support/dsm.ts`.
130
+
131
+ ```ts
132
+ // e2e.config.ts
133
+ import {dsmTarget, magpieAgent} from '@synopackageland/cli/e2e';
134
+ export default {targets: [dsmTarget()], output: '.e2e/output', agents: {default: magpieAgent()}};
135
+
136
+ // tests/e2e/dsm.setup.e2e.ts: signs in once (a private saved profile when it still works)
137
+ import {dsmLoginSetup} from '@synopackageland/cli/e2e';
138
+ dsmLoginSetup({session: 'dsm', user: 'admin'});
139
+
140
+ // a test that declares {sessions: ['dsm']}
141
+ import {dismissNotifications, openApp} from '@synopackageland/cli/e2e';
142
+ const window = await openApp(app, 'SYNO.SDS.MyApp.Instance'); // closes DSM's notification cards first
143
+ await dismissNotifications(); // when a card shows later
144
+ ```
145
+
146
+ ```json
147
+ "scripts": {"e2e:nas": "node --env-file-if-exists=.env node_modules/@synopackageland/cli/dist/e2e/dsm.js tunnel -- e2e run"}
148
+ ```
149
+
150
+ `runWithTunnel` (the `tunnel --` command) opens an SSH tunnel to the NAS
151
+ (`DSM_E2E_SSH`, default `root@vt-nas.syno`) on a free loopback port and runs the
152
+ command with `DSM_E2E_URL` set. It masks DSM's session cookies (`id`, `did`,
153
+ `_SSID`), `X-SYNO-TOKEN`, `SynoToken` and `_sid` as `***` in the command's
154
+ output and in the files under `DSM_E2E_OUTPUT_DIR` (default `.e2e`), and makes
155
+ that directory private (700, files 600); saved sessions keep their cookies.
156
+ Accounts come from mode 600 files named by `DSM_E2E_ACCOUNT_<USER>`.
157
+ `signIn` and `openDesktop` also register those values as e2e secrets in the
158
+ test's worker, so e2e masks them in its own report. A test that forwards
159
+ requests with `context.route` and `route.fetch` should end with
160
+ `await context.unrouteAll({behavior: 'ignoreErrors'})`: a request still in
161
+ flight otherwise rejects into a run error that carries its headers.
162
+
124
163
  ## AI assistants
125
164
 
126
165
  Every project includes `AGENTS.md` and a `CLAUDE.md` pointer. Start with
@@ -0,0 +1,137 @@
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 StorageState = {
7
+ cookies: unknown[];
8
+ origins: unknown[];
9
+ };
10
+ type Page = ReturnType<NonNullable<ReturnType<typeof surfaceOf>>["page"]>;
11
+ type Locator = ReturnType<Page["locator"]>;
12
+ /** The DSM URL the browser opens: DSM_E2E_URL, else the local end of the SSH tunnel. */
13
+ export declare function dsmUrl(env?: Env): string;
14
+ /** ssh arguments forwarding the loopback port DSM_E2E_TUNNEL_PORT (default 15000) to DSM's HTTP port on the NAS. */
15
+ export declare function tunnelArgs(env?: Env): string[];
16
+ /** A loopback port nothing listens on now, for a tunnel that names none. */
17
+ export declare function freePort(): Promise<number>;
18
+ /**
19
+ * Opens the SSH tunnel on DSM_E2E_TUNNEL_PORT, else on a free port, runs
20
+ * `command` with DSM_E2E_URL pointing at it, and closes the tunnel afterwards.
21
+ * The command's output reaches this process's stdout and stderr with DSM's
22
+ * secrets masked, and its e2e output directory, DSM_E2E_OUTPUT_DIR (default
23
+ * .e2e), is protected afterwards (see protectE2eOutput). Resolves to the
24
+ * command's exit code.
25
+ */
26
+ export declare function runWithTunnel(command: readonly string[], outer?: Env): Promise<number>;
27
+ /** `text` with DSM's session cookies, X-SYNO-TOKEN, SynoToken and _sid values replaced by ***. */
28
+ export declare function redactDsmSecrets(text: string): string;
29
+ /**
30
+ * Makes an e2e output directory private (directories 700, files 600) and masks
31
+ * DSM's secrets in its text files. A `sessions` directory keeps its contents:
32
+ * a saved session must restore the real cookies. Does nothing when `dir` is missing.
33
+ */
34
+ export declare function protectE2eOutput(dir: string): void;
35
+ /** magpie's GPT-6 Luna at max reasoning effort unless E2E_LLM_* says otherwise. */
36
+ export declare function llmSettings(env?: Env): {
37
+ baseURL: string;
38
+ model: string;
39
+ apiKey: string;
40
+ reasoningEffort: string;
41
+ };
42
+ /** The `agents.default` entry of e2e.config.ts: model plus the effort sent with every request. */
43
+ export declare function magpieAgent(env?: Env): {
44
+ model: import("@ai-sdk/provider").LanguageModelV4;
45
+ providerOptions: {
46
+ openai: {
47
+ reasoningEffort: string;
48
+ };
49
+ };
50
+ };
51
+ /**
52
+ * Reads one account from a private JSON file: `{username, password}` or
53
+ * `{users: {<user>: {username, password}}}`. Errors never include the values.
54
+ */
55
+ export declare function readAccount(file: string, user: string): {
56
+ username: string;
57
+ password: string;
58
+ };
59
+ /** ~/.cache/synopkgland-e2e/<target>-<user>.json, or under DSM_E2E_PROFILE_DIR. */
60
+ export declare function profilePath(target: string, user: string, env?: Env): string;
61
+ /**
62
+ * The saved browser state for `url`'s DSM host, or null when there is none or
63
+ * it belongs to another host. The port does not count: DSM's cookies do not
64
+ * depend on it, so tunnels on different ports share one profile. A file
65
+ * without `url` (written by another tool) is reused when one of its cookies
66
+ * belongs to the DSM host.
67
+ */
68
+ export declare function loadProfile(file: string, url: string): StorageState | null;
69
+ /**
70
+ * Saves the browser state (a Playwright storage state plus `url`) mode 600 in
71
+ * a mode 700 directory, atomically: concurrent runs, even threads of one
72
+ * process, each write their own temporary file and rename it into place.
73
+ */
74
+ export declare function saveProfile(file: string, url: string, state: StorageState): void;
75
+ /** The `targets` entry of e2e.config.ts. */
76
+ export declare function dsmTarget(name?: string): {
77
+ name: string;
78
+ engine: import("e2e/engine").EngineHandle;
79
+ app: {
80
+ url: string;
81
+ };
82
+ };
83
+ /** The active DSM tab of the current attempt. */
84
+ export declare function dsmPage(): Page;
85
+ type SecretLedger = {
86
+ register(name: string, value: string): void;
87
+ };
88
+ type SessionPage = {
89
+ context(): {
90
+ cookies(): Promise<{
91
+ name: string;
92
+ value: string;
93
+ }[]>;
94
+ };
95
+ evaluate(fn: () => unknown): Promise<unknown>;
96
+ };
97
+ /**
98
+ * Registers the signed-in session's cookies (id, did, _SSID) and SynoToken as
99
+ * e2e secrets, so e2e masks them in its report and output, as a request's
100
+ * Playwright call log carries them (t-0498). Returns how many were registered.
101
+ */
102
+ export declare function registerDsmSecrets(page?: SessionPage, ledger?: SecretLedger): Promise<number>;
103
+ /** Waits until the DSM desktop and its signed-in taskbar are shown. */
104
+ export declare function waitForDesktop(page?: Page): Promise<void>;
105
+ /**
106
+ * Closes every DSM notification card; returns how many were closed. DSM shows
107
+ * them a moment after the desktop (the admin's "Admin account security" card
108
+ * about 2 s later on vt-nas), so it first waits up to
109
+ * DSM_E2E_NOTIFICATION_WAIT_MS (default 5000) for one.
110
+ */
111
+ export declare function dismissNotifications(page?: Page, env?: Env): Promise<number>;
112
+ /**
113
+ * Restores the saved profile of `user`; signs in with the account file only
114
+ * when that profile is missing or no longer reaches the desktop. Saves the
115
+ * resulting state and returns which path it took.
116
+ */
117
+ export declare function signIn(app: App, user?: string, env?: Env): Promise<"restored" | "signed-in">;
118
+ /**
119
+ * Registers the setup test that signs `user` in and saves the e2e session
120
+ * `session`. Tests then declare `{ session }` and start signed in.
121
+ */
122
+ export declare function dsmLoginSetup(options?: {
123
+ session?: string;
124
+ user?: string;
125
+ timeout?: number;
126
+ }): void;
127
+ /** Shows the DSM desktop once per attempt: a no-op while it is already shown. */
128
+ export declare function openDesktop(app: App): Promise<Page>;
129
+ /**
130
+ * Opens the DSM app `appId` (for example SYNO.SDS.SynoPkgConnect.Instance) in
131
+ * its desktop window, or brings its open window to the front, and returns it
132
+ * once the app has rendered text into the window.
133
+ */
134
+ export declare function openApp(app: App, appId: string, timeout?: number): Promise<Locator>;
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>;
137
+ export {};
@@ -0,0 +1,450 @@
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, dismisses DSM notifications,
5
+ // configures the magpie LLM, and runs a command through an SSH tunnel to the NAS
6
+ // (on a free loopback port unless DSM_E2E_TUNNEL_PORT names one). DSM's session
7
+ // cookies and tokens are masked in what that command prints and leaves in its
8
+ // e2e output, which is made private (directories 700, files 600).
9
+ //
10
+ // node tests/e2e/support/dsm.ts tunnel -- npx e2e run
11
+ import { spawn } from "node:child_process";
12
+ import { randomUUID } from "node:crypto";
13
+ import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
14
+ import { connect, createServer } from "node:net";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { createOpenAI } from "@ai-sdk/openai";
19
+ import { surfaceOf, test, web } from "@e2e-dev/web";
20
+ const safeName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
21
+ const desktopSelector = "#sds-desktop";
22
+ const signedInSelector = "#sds-taskbar-tray-item-user";
23
+ const loginSelector = 'input[name="username"]';
24
+ // ---- Connection -----------------------------------------------------------
25
+ /** The DSM URL the browser opens: DSM_E2E_URL, else the local end of the SSH tunnel. */
26
+ export function dsmUrl(env = process.env) {
27
+ return env.DSM_E2E_URL || `http://127.0.0.1:${env.DSM_E2E_TUNNEL_PORT || "15000"}/`;
28
+ }
29
+ /** ssh arguments forwarding the loopback port DSM_E2E_TUNNEL_PORT (default 15000) to DSM's HTTP port on the NAS. */
30
+ 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
+ return ["-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes",
34
+ "-L", `127.0.0.1:${local}:127.0.0.1:${remote}`, env.DSM_E2E_SSH || "root@vt-nas.syno"];
35
+ }
36
+ /** A loopback port nothing listens on now, for a tunnel that names none. */
37
+ export function freePort() {
38
+ return new Promise((accept, reject) => {
39
+ const server = createServer();
40
+ server.once("error", reject);
41
+ server.listen(0, "127.0.0.1", () => {
42
+ const { port } = server.address();
43
+ server.close(() => accept(port));
44
+ });
45
+ });
46
+ }
47
+ function portOpen(port) {
48
+ return new Promise(accept => {
49
+ const socket = connect({ host: "127.0.0.1", port });
50
+ socket.once("connect", () => { socket.destroy(); accept(true); });
51
+ socket.once("error", () => accept(false));
52
+ });
53
+ }
54
+ /**
55
+ * Opens the SSH tunnel on DSM_E2E_TUNNEL_PORT, else on a free port, runs
56
+ * `command` with DSM_E2E_URL pointing at it, and closes the tunnel afterwards.
57
+ * The command's output reaches this process's stdout and stderr with DSM's
58
+ * secrets masked, and its e2e output directory, DSM_E2E_OUTPUT_DIR (default
59
+ * .e2e), is protected afterwards (see protectE2eOutput). Resolves to the
60
+ * command's exit code.
61
+ */
62
+ export async function runWithTunnel(command, outer = process.env) {
63
+ if (!command.length)
64
+ throw new Error("runWithTunnel needs a command to run.");
65
+ const port = Number(outer.DSM_E2E_TUNNEL_PORT || await freePort());
66
+ const env = { ...outer, DSM_E2E_TUNNEL_PORT: String(port) };
67
+ if (await portOpen(port))
68
+ throw new Error(`Port ${port} is already in use; set DSM_E2E_TUNNEL_PORT or close it before opening the tunnel.`);
69
+ const ssh = spawn(env.DSM_E2E_SSH_BIN || "ssh", tunnelArgs(env), { stdio: ["ignore", "ignore", "inherit"] });
70
+ let sshExited = false;
71
+ ssh.once("exit", () => { sshExited = true; });
72
+ try {
73
+ const deadline = Date.now() + Number(env.DSM_E2E_TUNNEL_TIMEOUT_MS || "15000");
74
+ while (!(await portOpen(port))) {
75
+ if (sshExited || Date.now() > deadline)
76
+ throw new Error(`The SSH tunnel to ${tunnelArgs(env).at(-1)} did not open on port ${port}.`);
77
+ await new Promise(accept => setTimeout(accept, 100));
78
+ }
79
+ // Piped output loses the terminal, not its colours.
80
+ const color = process.stdout.isTTY && env.FORCE_COLOR === undefined ? { FORCE_COLOR: "1" } : {};
81
+ const child = spawn(command[0], command.slice(1), {
82
+ stdio: ["inherit", "pipe", "pipe"], env: { ...env, ...color, DSM_E2E_URL: dsmUrl({ ...env, DSM_E2E_URL: undefined }) },
83
+ });
84
+ redactLines(child.stdout, process.stdout);
85
+ redactLines(child.stderr, process.stderr);
86
+ const forward = (signal) => child.kill(signal);
87
+ process.on("SIGINT", forward);
88
+ process.on("SIGTERM", forward);
89
+ try {
90
+ // "close" waits for the output pipes, so every line is through before the files are protected.
91
+ return await new Promise((accept, reject) => {
92
+ child.once("error", reject);
93
+ child.once("close", (code, signal) => accept(code ?? (signal ? 128 : 1)));
94
+ });
95
+ }
96
+ finally {
97
+ process.off("SIGINT", forward);
98
+ process.off("SIGTERM", forward);
99
+ protectE2eOutput(resolve(env.DSM_E2E_OUTPUT_DIR || ".e2e"));
100
+ }
101
+ }
102
+ finally {
103
+ if (!sshExited) {
104
+ const closed = new Promise(accept => ssh.once("exit", accept));
105
+ ssh.kill("SIGTERM");
106
+ await closed;
107
+ }
108
+ }
109
+ }
110
+ // ---- Secrets in output ----------------------------------------------------
111
+ // e2e redacts only the secrets its config declares. A Playwright call log in a
112
+ // run error lists a request's headers, DSM's session cookie among them, and e2e
113
+ // writes it to the report and stdout (t-0498). A value runs to a quote, a
114
+ // backslash or a line end, so a JSON string's escapes end it too.
115
+ const mask = "***";
116
+ // A Cookie or Set-Cookie header, and in it DSM's session (id, _SSID) and device (did) cookies.
117
+ const cookieHeader = /(\bcookie"?\s*[:=]\s*"?)([^"\\\r\n]*)/gi;
118
+ const sessionCookieNames = ["id", "did", "_SSID"];
119
+ const sessionCookie = new RegExp(`(^|;\\s*)(${sessionCookieNames.join("|")})=[^;\\s]*`, "gi");
120
+ // The X-SYNO-TOKEN header, a SynoToken parameter or field, and a web API's _sid parameter.
121
+ const tokenValue = /(\b(?:x-syno-token|synotoken)"?\s*[:=]\s*"?)[^"\\\s,;&}]+/gi;
122
+ const sidParameter = /([?&]_sid=)[^&"\\\s]+/gi;
123
+ /** `text` with DSM's session cookies, X-SYNO-TOKEN, SynoToken and _sid values replaced by ***. */
124
+ export function redactDsmSecrets(text) {
125
+ return text
126
+ .replace(cookieHeader, (_match, header, cookies) => header + cookies.replace(sessionCookie, `$1$2=${mask}`))
127
+ .replace(tokenValue, `$1${mask}`)
128
+ .replace(sidParameter, `$1${mask}`);
129
+ }
130
+ function redactLines(from, to) {
131
+ let pending = "";
132
+ from.setEncoding("utf8");
133
+ from.on("data", (chunk) => {
134
+ const text = pending + chunk;
135
+ const end = text.lastIndexOf("\n") + 1;
136
+ pending = text.slice(end);
137
+ if (end)
138
+ to.write(redactDsmSecrets(text.slice(0, end)));
139
+ });
140
+ from.on("end", () => { if (pending)
141
+ to.write(redactDsmSecrets(pending)); });
142
+ }
143
+ /**
144
+ * Makes an e2e output directory private (directories 700, files 600) and masks
145
+ * DSM's secrets in its text files. A `sessions` directory keeps its contents:
146
+ * a saved session must restore the real cookies. Does nothing when `dir` is missing.
147
+ */
148
+ export function protectE2eOutput(dir) {
149
+ protectTree(dir, true);
150
+ }
151
+ function protectTree(dir, redact) {
152
+ let entries;
153
+ try {
154
+ entries = readdirSync(dir, { withFileTypes: true });
155
+ }
156
+ catch (error) {
157
+ if (error.code === "ENOENT")
158
+ return;
159
+ throw error;
160
+ }
161
+ chmodSync(dir, 0o700);
162
+ for (const entry of entries) {
163
+ const path = join(dir, entry.name);
164
+ if (entry.isDirectory())
165
+ protectTree(path, redact && entry.name !== "sessions");
166
+ else if (entry.isFile()) {
167
+ chmodSync(path, 0o600);
168
+ if (redact)
169
+ redactFile(path);
170
+ }
171
+ }
172
+ }
173
+ function redactFile(file) {
174
+ const content = readFileSync(file);
175
+ if (content.includes(0))
176
+ return;
177
+ const text = content.toString("utf8");
178
+ const masked = redactDsmSecrets(text);
179
+ if (masked !== text)
180
+ writeFileSync(file, masked, { mode: 0o600 });
181
+ }
182
+ // ---- LLM ------------------------------------------------------------------
183
+ /** magpie's GPT-6 Luna at max reasoning effort unless E2E_LLM_* says otherwise. */
184
+ export function llmSettings(env = process.env) {
185
+ return {
186
+ baseURL: env.E2E_LLM_BASE_URL || "http://127.0.0.1:3425/v1",
187
+ model: env.E2E_LLM_MODEL || "codex/gpt-6-luna",
188
+ apiKey: env.E2E_LLM_API_KEY || "magpie-local",
189
+ reasoningEffort: env.E2E_LLM_REASONING_EFFORT || "max",
190
+ };
191
+ }
192
+ /** The `agents.default` entry of e2e.config.ts: model plus the effort sent with every request. */
193
+ export function magpieAgent(env = process.env) {
194
+ const settings = llmSettings(env);
195
+ return {
196
+ model: createOpenAI({ baseURL: settings.baseURL, apiKey: settings.apiKey }).chat(settings.model),
197
+ providerOptions: { openai: { reasoningEffort: settings.reasoningEffort } },
198
+ };
199
+ }
200
+ // ---- Accounts and profiles ------------------------------------------------
201
+ function requirePrivate(file, what) {
202
+ if (statSync(file).mode & 0o077)
203
+ throw new Error(`The ${what} ${file} must have mode 600.`);
204
+ }
205
+ /**
206
+ * Reads one account from a private JSON file: `{username, password}` or
207
+ * `{users: {<user>: {username, password}}}`. Errors never include the values.
208
+ */
209
+ export function readAccount(file, user) {
210
+ requirePrivate(file, "account file");
211
+ const content = JSON.parse(readFileSync(file, "utf8"));
212
+ const account = content.users ? content.users[user] : content;
213
+ if (typeof account?.username !== "string" || !account.username || typeof account.password !== "string" || !account.password) {
214
+ throw new Error(`The account file has no username/password for ${user}.`);
215
+ }
216
+ return { username: account.username, password: account.password };
217
+ }
218
+ /** ~/.cache/synopkgland-e2e/<target>-<user>.json, or under DSM_E2E_PROFILE_DIR. */
219
+ export function profilePath(target, user, env = process.env) {
220
+ if (!safeName.test(target) || !safeName.test(user))
221
+ throw new Error("The profile target and user must be simple names.");
222
+ return join(env.DSM_E2E_PROFILE_DIR || join(homedir(), ".cache/synopkgland-e2e"), `${target}-${user}.json`);
223
+ }
224
+ /**
225
+ * The saved browser state for `url`'s DSM host, or null when there is none or
226
+ * it belongs to another host. The port does not count: DSM's cookies do not
227
+ * depend on it, so tunnels on different ports share one profile. A file
228
+ * without `url` (written by another tool) is reused when one of its cookies
229
+ * belongs to the DSM host.
230
+ */
231
+ export function loadProfile(file, url) {
232
+ let raw;
233
+ try {
234
+ raw = readFileSync(file, "utf8");
235
+ }
236
+ catch (error) {
237
+ if (error.code === "ENOENT")
238
+ return null;
239
+ throw error;
240
+ }
241
+ requirePrivate(file, "profile");
242
+ const { url: savedUrl, savedAt: _savedAt, ...profile } = JSON.parse(raw);
243
+ const cookies = profile.cookies ?? [];
244
+ const host = new URL(url).hostname;
245
+ const sameHost = savedUrl === undefined ? cookies.some(cookie => cookie.domain === host) : new URL(savedUrl).hostname === host;
246
+ return sameHost ? { ...profile, cookies, origins: profile.origins ?? [] } : null;
247
+ }
248
+ /**
249
+ * Saves the browser state (a Playwright storage state plus `url`) mode 600 in
250
+ * a mode 700 directory, atomically: concurrent runs, even threads of one
251
+ * process, each write their own temporary file and rename it into place.
252
+ */
253
+ export function saveProfile(file, url, state) {
254
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
255
+ chmodSync(dirname(file), 0o700);
256
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
257
+ writeFileSync(temporary, JSON.stringify({ ...state, url, savedAt: new Date().toISOString() }) + "\n", { mode: 0o600 });
258
+ renameSync(temporary, file);
259
+ }
260
+ // ---- Browser --------------------------------------------------------------
261
+ const engine = web({
262
+ viewport: { width: Number(process.env.DSM_E2E_VIEWPORT_WIDTH || 1280), height: Number(process.env.DSM_E2E_VIEWPORT_HEIGHT || 800) },
263
+ });
264
+ /** The `targets` entry of e2e.config.ts. */
265
+ export function dsmTarget(name = "dsm") {
266
+ return { name, engine, app: { url: dsmUrl() } };
267
+ }
268
+ function surface() {
269
+ const live = surfaceOf(engine);
270
+ if (!live)
271
+ throw new Error("The DSM web engine is unavailable.");
272
+ return live;
273
+ }
274
+ /** The active DSM tab of the current attempt. */
275
+ export function dsmPage() {
276
+ return surface().page();
277
+ }
278
+ async function desktopOrLogin(page, timeout = 30_000) {
279
+ return Promise.race([
280
+ page.locator(desktopSelector).waitFor({ state: "visible", timeout }).then(() => "desktop"),
281
+ page.locator(loginSelector).waitFor({ state: "visible", timeout }).then(() => "login"),
282
+ ]);
283
+ }
284
+ /**
285
+ * e2e's ledger of the secrets this process has seen, which a worker's run
286
+ * errors and output are redacted with. e2e 0.19 keeps it internal and lets a
287
+ * config declare only secrets known up front, so it is reached by its file;
288
+ * under another layout there is none and runWithTunnel's masking remains.
289
+ */
290
+ async function e2eSecretLedger() {
291
+ try {
292
+ const { processSecrets } = await import(new URL("run/secrecy.js", import.meta.resolve("e2e")).href);
293
+ return typeof processSecrets?.register === "function" ? processSecrets : undefined;
294
+ }
295
+ catch {
296
+ return undefined;
297
+ }
298
+ }
299
+ /**
300
+ * Registers the signed-in session's cookies (id, did, _SSID) and SynoToken as
301
+ * e2e secrets, so e2e masks them in its report and output, as a request's
302
+ * Playwright call log carries them (t-0498). Returns how many were registered.
303
+ */
304
+ export async function registerDsmSecrets(page = dsmPage(), ledger) {
305
+ const target = ledger ?? await e2eSecretLedger();
306
+ if (!target)
307
+ return 0;
308
+ const cookies = (await page.context().cookies()).filter(c => sessionCookieNames.includes(c.name) && c.value);
309
+ const token = await page.evaluate(() => window.SYNO?.SDS?.Session?.SynoToken).catch(() => undefined);
310
+ const values = cookies.map(c => [`dsm.${c.name}`, c.value]);
311
+ if (typeof token === "string" && token)
312
+ values.push(["dsm.SynoToken", token]);
313
+ for (const [name, value] of values)
314
+ target.register(name, value);
315
+ return values.length;
316
+ }
317
+ /** Waits until the DSM desktop and its signed-in taskbar are shown. */
318
+ export async function waitForDesktop(page = dsmPage()) {
319
+ await page.locator(desktopSelector).waitFor({ state: "visible", timeout: 60_000 });
320
+ await page.locator(signedInSelector).waitFor({ state: "visible", timeout: 15_000 });
321
+ }
322
+ // DSM 7.4: .v-notification-container > .v-notification-panel.show > i.close-btn.
323
+ const notificationCloseSelector = ".v-notification-container .v-notification-panel .close-btn, .v-notification-panel.show .close-btn";
324
+ /**
325
+ * Closes every DSM notification card; returns how many were closed. DSM shows
326
+ * them a moment after the desktop (the admin's "Admin account security" card
327
+ * about 2 s later on vt-nas), so it first waits up to
328
+ * DSM_E2E_NOTIFICATION_WAIT_MS (default 5000) for one.
329
+ */
330
+ export async function dismissNotifications(page = dsmPage(), env = process.env) {
331
+ const close = page.locator(notificationCloseSelector).first();
332
+ const wait = Number(env.DSM_E2E_NOTIFICATION_WAIT_MS ?? "5000");
333
+ if (!await close.waitFor({ state: "visible", timeout: wait }).then(() => true, () => false))
334
+ return 0;
335
+ let closed = 0;
336
+ while (closed < 20 && await close.isVisible().catch(() => false)) {
337
+ const card = await close.elementHandle();
338
+ await close.click();
339
+ // A closing card stays visible while it fades out; a detached one counts as hidden.
340
+ await card?.waitForElementState("hidden", { timeout: 2_000 }).catch(() => { });
341
+ closed++;
342
+ }
343
+ return closed;
344
+ }
345
+ function accountFile(user, env) {
346
+ const key = `DSM_E2E_ACCOUNT_${user.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
347
+ const file = env[key];
348
+ if (!file)
349
+ throw new Error(`Set ${key} to the account file for ${user} (mode 600).`);
350
+ return resolve(file);
351
+ }
352
+ /**
353
+ * Restores the saved profile of `user`; signs in with the account file only
354
+ * when that profile is missing or no longer reaches the desktop. Saves the
355
+ * resulting state and returns which path it took.
356
+ */
357
+ export async function signIn(app, user = "admin", env = process.env) {
358
+ const url = dsmUrl(env);
359
+ const file = profilePath(env.DSM_E2E_TARGET || "vt-nas", user, env);
360
+ const context = surface().context();
361
+ const profile = loadProfile(file, url);
362
+ if (profile)
363
+ await context.setStorageState(profile);
364
+ await app.open("/");
365
+ const page = dsmPage();
366
+ let outcome = "restored";
367
+ if (!profile || await desktopOrLogin(page) === "login") {
368
+ if (profile)
369
+ await app.open("/");
370
+ const account = readAccount(accountFile(user, env), user);
371
+ await page.locator(loginSelector).fill(account.username);
372
+ await page.keyboard.press("Enter");
373
+ const password = page.locator('input[type="password"]:visible');
374
+ await password.fill(account.password);
375
+ await password.press("Enter");
376
+ outcome = "signed-in";
377
+ }
378
+ await waitForDesktop(page);
379
+ await registerDsmSecrets(page);
380
+ saveProfile(file, url, await context.storageState());
381
+ return outcome;
382
+ }
383
+ /**
384
+ * Registers the setup test that signs `user` in and saves the e2e session
385
+ * `session`. Tests then declare `{ session }` and start signed in.
386
+ */
387
+ export function dsmLoginSetup(options = {}) {
388
+ const name = options.session ?? "dsm";
389
+ const user = options.user ?? "admin";
390
+ test.setup(`DSM sign-in as ${user}`, { sessions: [name], timeout: options.timeout ?? 120_000 }, async ({ app, session }) => {
391
+ await signIn(app, user);
392
+ await session.save(name);
393
+ });
394
+ }
395
+ /** Shows the DSM desktop once per attempt: a no-op while it is already shown. */
396
+ export async function openDesktop(app) {
397
+ // A restored session leaves no page open until the attempt's first app.open.
398
+ const current = (() => { try {
399
+ return dsmPage();
400
+ }
401
+ catch {
402
+ return undefined;
403
+ } })();
404
+ if (current && await current.locator(desktopSelector).isVisible().catch(() => false))
405
+ return current;
406
+ await app.open("/");
407
+ const page = dsmPage();
408
+ if (await desktopOrLogin(page) === "login")
409
+ throw new Error("The DSM session is signed out; declare the session that dsmLoginSetup saves.");
410
+ await waitForDesktop(page);
411
+ await registerDsmSecrets(page);
412
+ await dismissNotifications(page);
413
+ return page;
414
+ }
415
+ /**
416
+ * Opens the DSM app `appId` (for example SYNO.SDS.SynoPkgConnect.Instance) in
417
+ * its desktop window, or brings its open window to the front, and returns it
418
+ * once the app has rendered text into the window.
419
+ */
420
+ export async function openApp(app, appId, timeout = 30_000) {
421
+ const page = await openDesktop(app);
422
+ await page.evaluate(id => window.SYNO.SDS.AppLaunch(id), appId);
423
+ const handle = await page.waitForFunction(id => {
424
+ const instance = (window.SYNO.SDS.AppMgr.getByAppName(id) || [])[0];
425
+ const appWindow = instance?.window;
426
+ return appWindow?.isVisible?.() && appWindow.el?.dom?.id;
427
+ }, appId, { timeout });
428
+ const id = await handle.jsonValue();
429
+ // The window frame shows before the app renders into it.
430
+ await page.waitForFunction(windowId => document.getElementById(windowId)?.querySelector(".x-window-body")?.innerText.trim(), id, { timeout });
431
+ return page.locator(`#${id}`);
432
+ }
433
+ /** Opens an Application Portal page (for example /download-hub/) as the whole tab. */
434
+ export async function openPortal(app, path) {
435
+ if (!path.startsWith("/") || path.startsWith("//"))
436
+ throw new Error("openPortal takes a same-origin path such as /download-hub/.");
437
+ await app.open(path);
438
+ const page = dsmPage();
439
+ await page.waitForLoadState("load");
440
+ return page;
441
+ }
442
+ // ---- Command line ---------------------------------------------------------
443
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
444
+ const [verb, separator, ...command] = process.argv.slice(2);
445
+ if (verb !== "tunnel" || separator !== "--" || !command.length) {
446
+ console.error("usage: node dsm.ts tunnel -- <command...>");
447
+ process.exit(2);
448
+ }
449
+ runWithTunnel(command).then(code => process.exit(code), error => { console.error(error.message); process.exit(1); });
450
+ }
@@ -42,5 +42,27 @@
42
42
  "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
43
43
  },
44
44
  "goToolchain": "go1.26.9"
45
+ },
46
+ "0.10.4": {
47
+ "x86_64": "4ba7040761a719e74d6b047ef68b9415e658ebb8b373b69555b8c307524e26ba",
48
+ "aarch64": "ba80b389e3291419389b66be7b0a0e1085d3dfec55c9c651fecae427279bb3d5",
49
+ "armv7": "49474467b9c9a7f31b30b5053b70eb4f71a95f2c06fbf3566e8de8f16972a299",
50
+ "lifecycle": {
51
+ "x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
52
+ "aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
53
+ "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
54
+ },
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"
45
67
  }
46
68
  }
@@ -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
- /** The oldest SDK this CLI supports in its minor line; older patches lack spec/protocol/v1/timeouts.json. */
8
- export declare const SDK_FLOOR = "0.10.3";
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
- /** The oldest SDK this CLI supports in its minor line; older patches lack spec/protocol/v1/timeouts.json. */
12
- export const SDK_FLOOR = '0.10.3';
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);
@@ -144,7 +144,7 @@ Ext.ns(${JSON.stringify(ns)});
144
144
  // The SDK's clientTimeout: past the backend's request deadline
145
145
  // (ctx.requestTimeout, from backend.timeout_seconds), so its request_timeout
146
146
  // reply arrives first. options.signal aborts the fetch, for a page the user leaves.
147
- ${ns}.call = function (method, params, options, requestTimeout) {
147
+ ${ns}.callOnce = function (method, params, options, requestTimeout) {
148
148
  var signal = options && options.signal;
149
149
  var canceled = { code: "request_canceled", message: "The request was canceled" };
150
150
  if (signal && signal.aborted) {
@@ -191,6 +191,42 @@ ${ns}.call = function (method, params, options, requestTimeout) {
191
191
  });
192
192
  };
193
193
 
194
+ // APP_STOPPED, APP_STARTING and EXECUTOR_STARTING mean the executor op was not
195
+ // sent: the package is stopped, or starting. The call repeats on its own, half a
196
+ // second, then doubling up to 8 s, for 30 s of waiting in all; each attempt keeps
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. 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).
201
+ ${ns}.retryCodes = ["APP_STOPPED", "APP_STARTING", "EXECUTOR_STARTING"];
202
+ ${ns}.statusReads = ["delegate.status", "authorization.status", "executor.status"];
203
+ ${ns}.call = function (method, params, options, requestTimeout) {
204
+ var signal = options && options.signal;
205
+ var left = ${ns}.statusReads.indexOf(method) < 0 ? 30000 : 0;
206
+ var delay = 500;
207
+ var attempt = function () {
208
+ return ${ns}.callOnce(method, params, options, requestTimeout).catch(function (error) {
209
+ if (left <= 0 || !error || ${ns}.retryCodes.indexOf(error.code) < 0) throw error;
210
+ var wait = Math.min(delay, left);
211
+ left -= wait;
212
+ delay = Math.min(delay * 2, 8000);
213
+ return new Promise(function (resolve, reject) {
214
+ var leave = function () {
215
+ clearTimeout(timer);
216
+ reject({ code: "request_canceled", message: "The request was canceled" });
217
+ };
218
+ var timer = setTimeout(function () {
219
+ if (signal) signal.removeEventListener("abort", leave);
220
+ resolve(attempt());
221
+ }, wait);
222
+ if (signal && signal.aborted) leave();
223
+ else if (signal) signal.addEventListener("abort", leave);
224
+ });
225
+ });
226
+ };
227
+ return attempt();
228
+ };
229
+
194
230
  ${ns}.messages = ${JSON.stringify(manifest.i18n?.catalogs ?? { en: {} })};
195
231
  ${ns}.createI18n = (function () {
196
232
  ${I18N_RUNTIME}
@@ -58,7 +58,8 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
58
58
  var boot = task.kind === "boot";
59
59
  return send(boot ? eventAPI : taskAPI, "delete", boot ? 1 : 2, boot ? ref : {tasks: [ref]});
60
60
  }
61
- function admin() { if (!options.user() || options.user().admin !== true) throw fail("admin_required"); }
61
+ function isAdmin() { return !!options.user() && options.user().admin === true; }
62
+ function admin() { if (!isAdmin()) throw fail("admin_required"); }
62
63
  function missing() { return {state: "needs-admin-approval", reason: "Task is missing.", code: "task_missing"}; }
63
64
  // EventScheduler keys a task by its name (DSM refuses a second one with 117
64
65
  // DUPLICATE_PRIMARY_KEY) and get answers no data for a missing name, so get
@@ -155,16 +156,19 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
155
156
  var errors = {}, created = [], removed = [], withdrawFailed;
156
157
  // Delete only a verified, same-header outdated task, then create the pinned
157
158
  // replacement. set has no measured wire contract, and a same-named task
158
- // cannot be created first. A task to delete is read again by get just
159
- // before; a missing one keeps the first read: DSM refuses a second boot
160
- // task name, and the readback after the writes lists control tasks for
161
- // duplicates. Stop at the first error.
159
+ // cannot be created first. The boot task keeps this approval's first
160
+ // read: EventScheduler get by name is the whole lookup, it was made in
161
+ // this step moments ago, and delete and create address the name. A
162
+ // control task to delete is read again by the id that read listed
163
+ // (spec/scheduler/v1); a missing one keeps the first read: DSM refuses a
164
+ // second boot task name, and the readback after the writes lists control
165
+ // tasks for duplicates. Stop at the first error.
162
166
  var chain = Promise.resolve();
163
167
  options.tasks.forEach(function (task) {
164
168
  chain = chain.then(function () {
165
169
  if (Object.keys(errors).length) return;
166
170
  var first = before.tasks[task.kind];
167
- return (first.ref ? find(task, true) : Promise.resolve(first)).then(function (current) {
171
+ return (first.ref && task.kind !== "boot" ? find(task, true) : Promise.resolve(first)).then(function (current) {
168
172
  if (current.state === "ready") return;
169
173
  if (current.state === "blocked") throw refused(current);
170
174
  var deleted = current.ref ? remove(task, current.ref).then(function () { removed.push({kind: task.kind, name: task.name, header: task.header, script: current.script}); }) : Promise.resolve();
@@ -227,9 +231,10 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
227
231
  }
228
232
  // The task waits up to 35 s for the executor, whose account, AppPriv and
229
233
  // share lookups took 16-20 s on a measured NAS: read back for 40 s,
230
- // backing off from 500 ms to 2 s like health.
234
+ // backing off from 500 ms to 1 s, so the window closes within a second
235
+ // of the selection landing.
231
236
  function readSelection(budget, delay) {
232
- delay = Math.min(delay || 500, 2000);
237
+ delay = Math.min(delay || 500, 1000);
233
238
  verified.state = "ready";
234
239
  return health(verified, 0).then(function (current) {
235
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;
@@ -323,8 +328,21 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
323
328
  // result reaches every view so neither shows a stale state.
324
329
  var listeners = [];
325
330
  function publish(result) { listeners.forEach(function (listener) { listener(result); }); return result; }
331
+ // TaskScheduler answers administrators only. Anyone else reads the approved
332
+ // executor through the App's executor.status, which answers every user: the
333
+ // pinned, running executor is ready; a stopped or starting package says so,
334
+ // as an administrator's status does; otherwise an administrator is needed.
335
+ var lifecycleCodes = ["APP_STOPPED", "APP_STARTING", "EXECUTOR_STARTING"];
336
+ function userStatus() {
337
+ var needed = block({tasks: {}}, fail("admin_required"));
338
+ return options.call("executor.status", {}).then(function (status) {
339
+ return pinned(status) ? {state: "ready", tasks: {}, executor: status} : needed;
340
+ }, function (e) {
341
+ return e && lifecycleCodes.indexOf(e.code) >= 0 ? block({tasks: {}}, fail(e.code, "the executor status request failed.")) : needed;
342
+ });
343
+ }
326
344
  return {
327
- status: function () { return read().then(function (verified) { return health(verified, 0); }).then(publish); },
345
+ status: function () { return (isAdmin() ? read().then(function (verified) { return health(verified, 0); }) : userStatus()).then(publish); },
328
346
  authorize: function (password, selectedScope) {
329
347
  if (!busy) busy = Promise.resolve().then(function () { return approve(password, selectedScope); }).catch(function (e) { var r = block({tasks: {}}, e); r.retry = !!(e && e.passwordConfirm); return r; }).then(function (r) { busy = null; return publish(r); });
330
348
  return busy;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,22 +19,47 @@
19
19
  "scripts": {
20
20
  "build": "rm -rf dist && tsc",
21
21
  "test": "npm run build && node --import tsx --test test/**/*.test.ts",
22
- "test:scripts": "node --test scripts/test/gate.test.mjs scripts/test/release.test.mjs scripts/test/pack-docker.test.mjs scripts/test/npm-pack.test.mjs scripts/test/executor-bump.test.mjs scripts/test/smoke-exports.test.mjs scripts/test/executor-digests.test.mjs",
23
- "gates": "bash scripts/ci/gate.sh npm && bash scripts/ci/gate.sh native-go && bash scripts/ci/gate.sh daemon-gateway",
22
+ "test:scripts": "node --test scripts/test/gate.test.mjs scripts/test/release.test.mjs scripts/test/pack-docker.test.mjs scripts/test/npm-pack.test.mjs scripts/test/executor-bump.test.mjs scripts/test/smoke-exports.test.mjs scripts/test/executor-digests.test.mjs scripts/test/parallel.test.mjs scripts/test/scaffold-matrix.test.mjs scripts/test/frontend-deps.test.mjs scripts/test/docker-gate.test.mjs scripts/test/gate-plan.test.mjs scripts/test/gates.test.mjs",
23
+ "gates": "bash scripts/ci/gates.sh",
24
+ "gates:fast": "bash scripts/ci/gates.sh --fast",
24
25
  "prepublishOnly": "npm run build"
25
26
  },
26
27
  "dependencies": {
27
- "@synopackageland/templates": "0.10.3",
28
+ "@synopackageland/templates": "0.10.5",
28
29
  "ajv": "^8.17.1",
29
30
  "commander": "^13.1.0",
30
31
  "tar": "^7.4.3",
31
32
  "yaml": "^2.7.0"
32
33
  },
33
34
  "devDependencies": {
35
+ "@ai-sdk/openai": "^4.0.91",
36
+ "@e2e-dev/web": "^0.14.0",
34
37
  "@types/node": "^22.13.10",
38
+ "ai": "^7.0.137",
39
+ "e2e": "^0.19.0",
35
40
  "tsx": "^4.19.3",
36
41
  "typescript": "^5.8.2"
37
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
+ },
38
63
  "publishConfig": {
39
64
  "access": "public",
40
65
  "registry": "https://registry.npmjs.org"
@@ -51,6 +76,7 @@
51
76
  "exports": {
52
77
  "./dist/*": "./dist/*",
53
78
  "./package.json": "./package.json",
54
- "./native-layout": "./dist/standalone/native-layout.js"
79
+ "./native-layout": "./dist/standalone/native-layout.js",
80
+ "./e2e": "./dist/e2e/dsm.js"
55
81
  }
56
82
  }