@zswarm/core 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,58 +1,187 @@
1
1
  import { ZellijError } from "../errors.js";
2
+ import { ListingCache, executorIdentity, listingTtl, routingEnvironment, sharedListings } from "./cache.js";
2
3
  import { buildClosePaneArgs, buildDumpArgs, buildDumpLayoutArgs, buildFocusPaneArgs, buildLaunchPluginArgs, buildListPanesArgs, buildListTabsArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildRenamePaneArgs, buildRenameTabArgs, buildSendEnterArgs, buildSendKeysArgs, buildStackPanesArgs, buildWriteCharsArgs, changedPayload, scrollbackPayload, waitPayload, } from "./args.js";
3
4
  import { DEFAULT_BUS_TIMEOUT_MS, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, } from "./bus.js";
4
5
  import { parseTabList, resolveTab } from "./tabs.js";
5
6
  import { createSshExec } from "../exec.js";
6
- import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, } from "./binary.js";
7
+ import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, ensureZellijProbes, identityCacheKey, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, } from "./binary.js";
7
8
  import { normalizePaneId, parsePaneList, resolvePane, } from "./panes.js";
8
9
  import { isZellijNoSessionsOutput, parseSessionList, resolveSelfPaneId, sessionFromEnv, sessionFromList, } from "./session.js";
9
- /** Thin, stateless wrapper over the `zellij` binary. */
10
+ /** Zellij wrapper with bounded caches of completed discovery observations. */
10
11
  export function createZellijClient(options = {}) {
11
12
  const env = options.env ?? process.env;
12
- // A remote crew never resolves a local binary.
13
+ // A remote crew never resolves a local binary. An injected exec is a unit
14
+ // test (or in-process stand-in) — skip PATH/env resolution so a poisoned
15
+ // ZSWARM_BIN in the host environment cannot fail client construction.
13
16
  const ssh = options.exec ? null : resolveSshTarget(env);
14
17
  const zellijPath = options.zellijPath ??
15
- (ssh ? `ssh://${ssh.host}/${ssh.remoteBin}` : resolveZellijBinary(env));
18
+ (options.exec
19
+ ? "zellij"
20
+ : ssh
21
+ ? `ssh://${ssh.host}/${ssh.remoteBin}`
22
+ : resolveZellijBinary(env));
23
+ const sshExec = options.exec || !ssh
24
+ ? null
25
+ : createSshExec(ssh, sanitizeZellijEnv(env), { pinIpc: true });
16
26
  const rawExec = options.exec ??
17
- (ssh
18
- ? createSshExec(ssh, sanitizeZellijEnv(env))
19
- : defaultExec(zellijPath, env));
27
+ (sshExec ? sshExec : defaultExec(zellijPath, env));
20
28
  const exec = (args, opts) => rawExec(args, { ...opts, signal: opts.signal ?? options.signal });
21
29
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
22
30
  const selfPaneId = resolveSelfPaneId(env);
23
- async function run(args, label) {
24
- const result = await exec(args, { timeoutMs });
31
+ const probeKey = JSON.stringify([identityCacheKey(zellijPath, ssh), routingEnvironment(env)]);
32
+ const now = options.now ?? Date.now;
33
+ const cache = options.cache instanceof ListingCache ? options.cache : sharedListings;
34
+ const ttl = options.cache === false || (options.exec && !options.cache) ? 0 : listingTtl(env);
35
+ const baseContext = JSON.stringify([zellijPath, ssh, executorIdentity(options.exec), routingEnvironment(env)]);
36
+ const scopeFor = (session) => JSON.stringify([baseContext, session ?? null]);
37
+ const contextKey = () => JSON.stringify([baseContext, readTransport().ipc ?? null]);
38
+ const cacheUsable = () => {
39
+ const ipc = readTransport().ipc;
40
+ return ttl > 0 && (ipc?.requested?.toLowerCase() !== "auto" || ipc.status === "resolved");
41
+ };
42
+ function invalidateListings(session) {
43
+ cache.invalidate(scopeFor(session));
44
+ cache.invalidate(scopeFor());
45
+ }
46
+ function observeManifest(session, revision) {
47
+ if (cache.observe(scopeFor(session), revision))
48
+ cache.invalidate(scopeFor());
49
+ }
50
+ async function listing(kind, session, budget, fresh, read) {
51
+ const remaining = operationBudget(budget, `zellij ${kind}`);
52
+ remaining(); // An aborted/expired caller cannot consume a cached value.
53
+ // Resolve auto IPC before selecting the namespace, including cold reads.
54
+ // A sibling can refresh discovery while this read is in flight, so keep
55
+ // the original namespace and refuse to cache under a different one later.
56
+ if (ssh?.tmp?.trim().toLowerCase() === "auto") {
57
+ await sshExec.prepareIpc(remaining(), options.signal);
58
+ remaining();
59
+ }
60
+ const key = () => JSON.stringify([contextKey(), session ?? null, kind]);
61
+ const startedKey = key();
62
+ if (cacheUsable() && !fresh) {
63
+ const hit = cache.get(startedKey, now());
64
+ if (hit !== undefined)
65
+ return hit;
66
+ }
67
+ const scope = scopeFor(session);
68
+ const ticket = cache.begin(scope);
69
+ const value = await read(remaining);
70
+ remaining();
71
+ if (cacheUsable() && key() === startedKey)
72
+ cache.put(startedKey, scope, ticket, value, ttl, now());
73
+ return value;
74
+ }
75
+ function readTransport() {
76
+ if (!ssh)
77
+ return { kind: "local", mode: "local" };
78
+ const ipc = sshExec?.ipcState;
79
+ return {
80
+ kind: "ssh",
81
+ mode: ssh.mode ?? "ssh",
82
+ host: ssh.host,
83
+ remoteBin: ssh.remoteBin,
84
+ tmp: ipc?.tmp ?? ssh.tmp,
85
+ ipc: ipc
86
+ ? {
87
+ requested: ipc.requested,
88
+ status: ipc.status,
89
+ tmp: ipc.tmp,
90
+ socketDir: ipc.socketDir,
91
+ }
92
+ : undefined,
93
+ };
94
+ }
95
+ function operationBudget(budget, label) {
96
+ const deadline = Date.now() + budget;
97
+ return () => {
98
+ if (options.signal?.aborted) {
99
+ throw new ZellijError("cancelled", "operation cancelled");
100
+ }
101
+ const left = deadline - Date.now();
102
+ if (left <= 0) {
103
+ throw new ZellijError("zellij_failed", `${label} timed out after ${budget}ms`);
104
+ }
105
+ return left;
106
+ };
107
+ }
108
+ async function ensureIdentity(remaining) {
109
+ if (options.skipIdentityProbe || options.exec)
110
+ return;
111
+ // Only the helpers' positively verified results are cached. In-flight or
112
+ // unresolved probes must not tie this call to another operation's budget.
113
+ if (sshExec?.prepareIpc)
114
+ await sshExec.prepareIpc(remaining(), options.signal);
115
+ await ensureZellijProbes(exec, zellijPath, remaining(), probeKey, options.signal);
116
+ }
117
+ async function runCommand(args, label, callTimeoutMs = timeoutMs) {
118
+ const remaining = operationBudget(callTimeoutMs, label);
119
+ await ensureIdentity(remaining);
120
+ const result = await exec(args, { timeoutMs: remaining() });
25
121
  if (result.code === NOT_FOUND_EXIT) {
26
122
  throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
27
123
  }
28
124
  if (result.code !== 0) {
29
- throw new ZellijError("zellij_failed", `${label} failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim() || "no output"}`);
125
+ const detail = result.stderr.trim() || result.stdout.trim() || "no output";
126
+ if (/usage:\s*zswarm/i.test(detail) || /unknown arg:/i.test(detail)) {
127
+ throw new ZellijError("zellij_wrong_bin", `resolved binary looks like zswarm, not Zellij (${zellijPath}): ${detail}`);
128
+ }
129
+ throw new ZellijError("zellij_failed", `${label} failed (exit ${result.code}): ${detail}`);
30
130
  }
31
131
  return result;
32
132
  }
33
- async function listSessions() {
34
- const result = await exec(["list-sessions", "--short", "--no-formatting"], {
35
- timeoutMs,
36
- });
37
- if (result.code === NOT_FOUND_EXIT) {
38
- throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
133
+ const mutations = new Set([
134
+ "new-pane", "new-tab", "close-pane", "rename-pane", "rename-tab-by-id",
135
+ "focus-pane-id", "stack-panes", "launch-or-focus-plugin", "paste", "send-keys", "write-chars",
136
+ ]);
137
+ async function run(args, label, callTimeoutMs = timeoutMs) {
138
+ const action = args[args.indexOf("action") + 1];
139
+ const index = args.indexOf("--session");
140
+ const session = index >= 0 ? args[index + 1] : undefined;
141
+ const mutation = session && mutations.has(action ?? "");
142
+ if (mutation)
143
+ invalidateListings(session);
144
+ try {
145
+ return await runCommand(args, label, callTimeoutMs);
39
146
  }
40
- if (result.code !== 0) {
41
- // Nonzero here is Zellij's normal "none running", not a crash. Empty
42
- // lets resolveSession throw zellij_no_session so unworktree can treat
43
- // it as no occupants instead of zellij_failed.
44
- if (isZellijNoSessionsOutput(result.stdout, result.stderr))
45
- return [];
46
- throw new ZellijError("zellij_failed", `zellij list-sessions failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim() || "no output"}`);
147
+ finally {
148
+ // Failed/uncertain writes can still have reached Zellij. Drop both reads
149
+ // from before the write and reads that raced while it was in flight.
150
+ if (mutation)
151
+ invalidateListings(session);
47
152
  }
48
- return parseSessionList(result.stdout);
49
153
  }
50
- async function resolveSession(explicit) {
51
- return sessionFromEnv(env, explicit) ?? sessionFromList(await listSessions());
154
+ async function listSessions(callTimeoutMs = timeoutMs, opts = {}) {
155
+ return listing("list-sessions", undefined, callTimeoutMs, opts.fresh === true, async (remaining) => {
156
+ await ensureIdentity(remaining);
157
+ // Keep annotations (EXITED / current); --short drops them.
158
+ const result = await exec(["list-sessions", "--no-formatting"], {
159
+ timeoutMs: remaining(),
160
+ });
161
+ if (result.code === NOT_FOUND_EXIT) {
162
+ throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
163
+ }
164
+ if (result.code !== 0) {
165
+ const detail = result.stderr.trim() || result.stdout.trim() || "no output";
166
+ if (/usage:\s*zswarm/i.test(detail) || /unknown arg:/i.test(detail)) {
167
+ throw new ZellijError("zellij_wrong_bin", `resolved binary looks like zswarm, not Zellij (${zellijPath}): ${detail}`);
168
+ }
169
+ if (isZellijNoSessionsOutput(result.stdout, result.stderr))
170
+ return [];
171
+ throw new ZellijError("zellij_failed", `zellij list-sessions failed (exit ${result.code}): ${detail}`);
172
+ }
173
+ return parseSessionList(result.stdout);
174
+ });
175
+ }
176
+ async function resolveSession(explicit, callTimeoutMs = timeoutMs) {
177
+ return (sessionFromEnv(env, explicit) ??
178
+ sessionFromList(await listSessions(callTimeoutMs)));
52
179
  }
53
- async function listPanes(session) {
54
- const result = await run(buildListPanesArgs(session), "zellij action list-panes");
55
- return parsePaneList(result.stdout);
180
+ async function listPanes(session, callTimeoutMs = timeoutMs, opts = {}) {
181
+ return listing("list-panes", session, callTimeoutMs, opts.fresh === true, async (remaining) => {
182
+ const result = await run(buildListPanesArgs(session), "zellij action list-panes", remaining());
183
+ return parsePaneList(result.stdout);
184
+ });
56
185
  }
57
186
  async function injectPane(input) {
58
187
  const paneId = normalizePaneId(input.paneId);
@@ -65,7 +194,7 @@ export function createZellijClient(options = {}) {
65
194
  }
66
195
  async function dumpPane(input) {
67
196
  const paneId = normalizePaneId(input.paneId);
68
- const result = await run(buildDumpArgs(input.session, paneId, input.full), "zellij action dump-screen");
197
+ const result = await run(buildDumpArgs(input.session, paneId, input.full), "zellij action dump-screen", input.timeoutMs ?? timeoutMs);
69
198
  return { paneId, session: input.session, text: result.stdout };
70
199
  }
71
200
  async function sendKeys(input) {
@@ -89,7 +218,7 @@ export function createZellijClient(options = {}) {
89
218
  if (!input.name.trim()) {
90
219
  throw new ZellijError("missing_name", "name required");
91
220
  }
92
- await run(buildRenamePaneArgs(input.session, paneId, input.name), "zellij action rename-pane");
221
+ await run(buildRenamePaneArgs(input.session, paneId, input.name), "zellij action rename-pane", input.timeoutMs ?? timeoutMs);
93
222
  return { paneId, session: input.session, name: input.name };
94
223
  }
95
224
  async function renameTab(input) {
@@ -104,9 +233,11 @@ export function createZellijClient(options = {}) {
104
233
  await run(buildFocusPaneArgs(input.session, paneId), "zellij action focus-pane-id");
105
234
  return { paneId, session: input.session };
106
235
  }
107
- async function listTabs(session) {
108
- const result = await run(buildListTabsArgs(session), "zellij action list-tabs");
109
- return parseTabList(result.stdout);
236
+ async function listTabs(session, callTimeoutMs = timeoutMs, opts = {}) {
237
+ return listing("list-tabs", session, callTimeoutMs, opts.fresh === true, async (remaining) => {
238
+ const result = await run(buildListTabsArgs(session), "zellij action list-tabs", remaining());
239
+ return parseTabList(result.stdout);
240
+ });
110
241
  }
111
242
  async function dumpLayout(session) {
112
243
  const result = await run(buildDumpLayoutArgs(session), "zellij action dump-layout");
@@ -129,11 +260,12 @@ export function createZellijClient(options = {}) {
129
260
  }
130
261
  /** `new-pane` prints the created pane id; `new-tab` prints a tab id instead. */
131
262
  function parseCreatedPaneId(stdout) {
132
- const match = /(terminal|plugin)_\d+/i.exec(stdout);
133
- return match ? match[0].toLowerCase() : null;
263
+ const ids = new Set(stdout.split(/\r?\n/).map((line) => line.trim().toLowerCase())
264
+ .filter((line) => /^(terminal|plugin)_\d+$/.test(line)));
265
+ return ids.size === 1 ? [...ids][0] : null;
134
266
  }
135
267
  async function newPane(input) {
136
- const result = await run(buildNewPaneArgs(input), "zellij action new-pane");
268
+ const result = await run(buildNewPaneArgs(input), "zellij action new-pane", input.timeoutMs ?? timeoutMs);
137
269
  return {
138
270
  session: input.session,
139
271
  paneId: parseCreatedPaneId(result.stdout),
@@ -141,11 +273,12 @@ export function createZellijClient(options = {}) {
141
273
  };
142
274
  }
143
275
  async function newTab(input) {
144
- const result = await run(buildNewTabArgs(input), "zellij action new-tab");
145
- const digits = /-?\d+/.exec(result.stdout);
276
+ const result = await run(buildNewTabArgs(input), "zellij action new-tab", input.timeoutMs ?? timeoutMs);
277
+ const ids = new Set(result.stdout.split(/\r?\n/).map((line) => line.trim())
278
+ .filter((line) => /^\d+$/.test(line)).map(Number).filter(Number.isSafeInteger));
146
279
  return {
147
280
  session: input.session,
148
- tabId: digits ? Number(digits[0]) : null,
281
+ tabId: ids.size === 1 ? [...ids][0] : null,
149
282
  stdout: result.stdout.trim(),
150
283
  };
151
284
  }
@@ -201,7 +334,7 @@ export function createZellijClient(options = {}) {
201
334
  configKey: input.configKey,
202
335
  payload: changedPayload(input.panes),
203
336
  }), {
204
- timeoutMs: DEFAULT_BUS_TIMEOUT_MS,
337
+ timeoutMs: input.timeoutMs ?? DEFAULT_BUS_TIMEOUT_MS,
205
338
  until: (stdout) => parseChangedReply(stdout) !== null,
206
339
  });
207
340
  }
@@ -215,11 +348,17 @@ export function createZellijClient(options = {}) {
215
348
  /** Visible prefix so peer CLIs can tell zSwarm injects from human prompts. */
216
349
  function formatPeerMessage(from, body) {
217
350
  const sender = from.trim() || "swarm";
218
- return `[zswarm from=${sender}]\n${body.trim()}`;
351
+ return `[zswarm from=${sender}]\n${body}`;
219
352
  }
220
353
  return {
221
354
  zellijPath,
222
355
  selfPaneId,
356
+ contextKey,
357
+ invalidateListings,
358
+ observeManifest,
359
+ get transport() {
360
+ return readTransport();
361
+ },
223
362
  /** A `file:` plugin url only names a path on the machine running Zellij. */
224
363
  remote: ssh !== null,
225
364
  listSessions,
@@ -68,6 +68,9 @@ export function resolvePane(panes, to) {
68
68
  const byTypedId = panes.find((p) => p.id === key.toLowerCase());
69
69
  if (byTypedId)
70
70
  return byTypedId;
71
+ if (/^(terminal|plugin)_\d+$/i.test(key)) {
72
+ throw new ZellijError("peer_not_found", `pane ${key} is not in the current listing`);
73
+ }
71
74
  if (/^\d+$/.test(key)) {
72
75
  const terminals = panes.filter((p) => !p.isPlugin && String(p.numericId) === key);
73
76
  if (terminals.length === 1)
@@ -2,15 +2,31 @@ export type ZellijSessionResolve = {
2
2
  session: string;
3
3
  source: "arg" | "env_zswarm" | "env_zellij" | "sole_live";
4
4
  };
5
+ /** One row from `zellij list-sessions --no-formatting` (not `--short`). */
6
+ export type ZellijSession = {
7
+ name: string;
8
+ /** True when Zellij marks the row EXITED (resurrectable, no live server). */
9
+ exited: boolean;
10
+ /** True when the listing marks this session as the caller's current one. */
11
+ current: boolean;
12
+ };
5
13
  /**
6
14
  * Pane hosting the caller, so writes can refuse to loop back into it.
7
15
  * `ZSWARM_SELF_PANE` wins; Zellij exports `ZELLIJ_PANE_ID` inside a pane.
8
16
  */
9
17
  export declare function resolveSelfPaneId(env?: NodeJS.ProcessEnv): string | null;
10
- export declare function parseSessionList(stdout: string): string[];
18
+ /**
19
+ * Parse `list-sessions --no-formatting` output.
20
+ *
21
+ * Annotated form: `name [Created …] [(current)| (EXITED - …)]`.
22
+ * Bare names (from `--short` or test mocks) are treated as live.
23
+ */
24
+ export declare function parseSessionList(stdout: string): ZellijSession[];
25
+ /** Live session names only — used when auto-picking a sole session. */
26
+ export declare function liveSessionNames(sessions: ZellijSession[]): string[];
11
27
  /** Zellij exits 1 and prints this when the machine simply has no sessions. */
12
28
  export declare function isZellijNoSessionsOutput(stdout: string, stderr: string): boolean;
13
29
  /** Session from an explicit argument or the environment, before asking Zellij. */
14
30
  export declare function sessionFromEnv(env: NodeJS.ProcessEnv, explicit?: string | null): ZellijSessionResolve | null;
15
- /** Pick a session when the environment gave no answer. */
16
- export declare function sessionFromList(sessions: string[]): ZellijSessionResolve;
31
+ /** Pick a session when the environment gave no answer. EXITED rows do not count. */
32
+ export declare function sessionFromList(sessions: ZellijSession[]): ZellijSessionResolve;
@@ -13,11 +13,40 @@ export function resolveSelfPaneId(env = process.env) {
13
13
  return raw.toLowerCase();
14
14
  return null;
15
15
  }
16
+ /**
17
+ * Parse `list-sessions --no-formatting` output.
18
+ *
19
+ * Annotated form: `name [Created …] [(current)| (EXITED - …)]`.
20
+ * Bare names (from `--short` or test mocks) are treated as live.
21
+ */
16
22
  export function parseSessionList(stdout) {
17
- return stdout
18
- .split(/\r?\n/)
19
- .map((l) => l.trim())
20
- .filter(Boolean);
23
+ const sessions = [];
24
+ for (const raw of stdout.split(/\r?\n/)) {
25
+ // Drop ANSI in case a caller forgot --no-formatting.
26
+ const line = raw.replace(/\x1B\[[0-9;]*m/g, "").trim();
27
+ if (!line)
28
+ continue;
29
+ const created = line.indexOf(" [Created ");
30
+ if (created >= 0) {
31
+ const name = line.slice(0, created).trim();
32
+ if (!name)
33
+ continue;
34
+ const suffix = line.slice(created);
35
+ sessions.push({
36
+ name,
37
+ exited: /\(EXITED\b/i.test(suffix),
38
+ current: /\(current\)/i.test(suffix),
39
+ });
40
+ continue;
41
+ }
42
+ // Bare name from --short or a mock.
43
+ sessions.push({ name: line, exited: false, current: false });
44
+ }
45
+ return sessions;
46
+ }
47
+ /** Live session names only — used when auto-picking a sole session. */
48
+ export function liveSessionNames(sessions) {
49
+ return sessions.filter((s) => !s.exited).map((s) => s.name);
21
50
  }
22
51
  /** Zellij exits 1 and prints this when the machine simply has no sessions. */
23
52
  export function isZellijNoSessionsOutput(stdout, stderr) {
@@ -36,13 +65,14 @@ export function sessionFromEnv(env, explicit) {
36
65
  return { session: zellijEnv, source: "env_zellij" };
37
66
  return null;
38
67
  }
39
- /** Pick a session when the environment gave no answer. */
68
+ /** Pick a session when the environment gave no answer. EXITED rows do not count. */
40
69
  export function sessionFromList(sessions) {
41
- if (sessions.length === 1) {
42
- return { session: sessions[0], source: "sole_live" };
70
+ const live = liveSessionNames(sessions);
71
+ if (live.length === 1) {
72
+ return { session: live[0], source: "sole_live" };
43
73
  }
44
- if (sessions.length === 0) {
74
+ if (live.length === 0) {
45
75
  throw new ZellijError("zellij_no_session", "no live Zellij sessions; start zellij or pass session=");
46
76
  }
47
- throw new ZellijError("zellij_session_ambiguous", `multiple Zellij sessions (${sessions.join(", ")}); pass session=`);
77
+ throw new ZellijError("zellij_session_ambiguous", `multiple Zellij sessions (${live.join(", ")}); pass session=`);
48
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {
@@ -25,10 +25,10 @@
25
25
  "access": "public"
26
26
  },
27
27
  "dependencies": {
28
- "@zswarm/wasm": "0.1.2"
28
+ "@zswarm/wasm": "0.1.3"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc -p tsconfig.json",
32
- "test": "pnpm run build && node --test"
32
+ "test": "pnpm run build && node --import ./test-support/clean-env.mjs --test"
33
33
  }
34
34
  }