premanmcp 1.1.11 → 1.1.13

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/bin/shared.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  readFileSync,
15
15
  realpathSync,
16
16
  rmSync,
17
+ statSync,
17
18
  writeFileSync,
18
19
  mkdirSync,
19
20
  } from "node:fs";
@@ -107,6 +108,19 @@ export function pathPremanOwner() {
107
108
  if (isEphemeralBin(realPath)) continue;
108
109
  const owner = owningPackage(realPath);
109
110
  if (owner) return owner;
111
+ // Only a symlink resolves to the file inside the package. npm on Windows
112
+ // installs `preman.cmd`, and pnpm installs a launcher shim on every
113
+ // platform, both of which are real files that merely *name* the target --
114
+ // so realpath ends at the shim, no manifest is found above it, and a
115
+ // perfectly good global install was reported as not owning `preman`. The
116
+ // cost of that wrong answer is the hook and every printed instruction
117
+ // switching to `npm exec -y premanmcp@latest --`, which re-resolves the
118
+ // package from the network on every git push.
119
+ const named = shimTarget(realPath);
120
+ if (named) {
121
+ const shimOwner = owningPackage(named);
122
+ if (shimOwner) return shimOwner;
123
+ }
110
124
  } catch {
111
125
  // A dangling link or an unreadable manifest: try the next match.
112
126
  }
@@ -114,6 +128,24 @@ export function pathPremanOwner() {
114
128
  return "";
115
129
  }
116
130
 
131
+ /** The path a launcher shim points at, or "" if this is not one. */
132
+ function shimTarget(shimPath) {
133
+ try {
134
+ // A real bundle is megabytes; a shim is a few hundred bytes. The cap is
135
+ // what keeps this from reading an executable into memory to look for paths.
136
+ if (statSync(shimPath).size > 16 * 1024) return "";
137
+ const body = readFileSync(shimPath, "utf8");
138
+ // Covers npm's .cmd and its shell wrapper, and pnpm's, all of which name
139
+ // the entry point relative to the shim or absolutely.
140
+ const match = body.match(/["']?([^"'\s]*node_modules[/\\][^"'\s]+\.(?:js|mjs|cjs))["']?/);
141
+ if (!match) return "";
142
+ const target = match[1].replace(/^%~dp0/, "").replace(/^\$basedir/, "");
143
+ return realpathSync(path.resolve(path.dirname(shimPath), target));
144
+ } catch {
145
+ return "";
146
+ }
147
+ }
148
+
117
149
  export function cliInvocation() {
118
150
  return (_invocation ||=
119
151
  pathPremanOwner() === "premanmcp" ? "preman" : "npm exec -y premanmcp@latest --");
@@ -208,6 +240,26 @@ export function readStoredCredentials() {
208
240
  export function saveStoredCredentials(creds) {
209
241
  mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
210
242
  writeFileSync(CREDENTIALS_FILE, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
243
+ warnIfModesAreDecorative();
244
+ }
245
+
246
+ /**
247
+ * Say so where 0600 does not mean 0600.
248
+ *
249
+ * Windows ignores the POSIX mode bits entirely, so the file holding a live
250
+ * pm_live_ key inherits whatever the parent directory grants -- which under a
251
+ * roaming or shared profile can be other accounts on the machine. The mode is
252
+ * still passed, because it costs nothing and is correct everywhere else; what
253
+ * was missing is anyone being told that on this platform it is decorative.
254
+ */
255
+ let modeWarningShown = false;
256
+ function warnIfModesAreDecorative() {
257
+ if (process.platform !== "win32" || modeWarningShown) return;
258
+ modeWarningShown = true;
259
+ process.stdout.write(
260
+ `Note: ${CREDENTIALS_FILE} holds an API key, and Windows does not apply the\n` +
261
+ ` owner-only permissions PreMan asks for. Keep this profile to yourself.\n`
262
+ );
211
263
  }
212
264
 
213
265
  /** Forget the stored key. Returns whether there was one. */
@@ -371,15 +423,124 @@ export function openUrl(url, { app = "" } = {}) {
371
423
  detached: true,
372
424
  shell: process.platform === "win32",
373
425
  });
426
+ // spawn reports a missing opener through an asynchronous 'error' event, not
427
+ // a throw, so this try/catch never saw it and an EventEmitter with no
428
+ // 'error' listener rethrows: a Linux box without `xdg-open` took the whole
429
+ // process down from `login --browser`, from the integration flows, and from
430
+ // /app inside a session. Headless boxes and locked-down shells are normal,
431
+ // so this is the expected case rather than a fault.
432
+ child.once("error", () => {});
374
433
  child.unref();
375
434
  return true;
376
435
  } catch {
377
- // Headless boxes and locked-down shells are normal.
378
436
  return false;
379
437
  }
380
438
  }
381
439
 
440
+ /**
441
+ * Piped answers, read as a stream and kept in order.
442
+ *
443
+ * A readline interface built per prompt consumed the line it was handed and
444
+ * then threw away everything readline had already buffered behind it, so piped
445
+ * input survived exactly one question. `printf 'sure\nn\n' | preman onboard`
446
+ * spent "sure" on the first prompt, lost the "n" with the interface, and every
447
+ * later prompt fell through to its default -- and the defaults are yes. A
448
+ * script written to decline things accepted them instead, the git hook
449
+ * included, and it did so silently.
450
+ *
451
+ * Lines are therefore consumed from stdin directly and queued. stdin is only
452
+ * referenced while a prompt is actually outstanding: holding it open would
453
+ * leave a finished run waiting on a writer that has nothing left to say, and
454
+ * unreferencing it permanently would let the process exit mid-walk on a
455
+ * question it had already drawn.
456
+ */
457
+ let pipedAnswers = null;
458
+
459
+ function pipedReader() {
460
+ if (pipedAnswers) return pipedAnswers;
461
+
462
+ const ready = [];
463
+ const waiting = [];
464
+ const state = { ended: false, held: false };
465
+ let buffer = "";
466
+
467
+ const hold = (on) => {
468
+ if (state.held === on) return;
469
+ const fn = on ? process.stdin.ref : process.stdin.unref;
470
+ if (typeof fn === "function") {
471
+ fn.call(process.stdin);
472
+ state.held = on;
473
+ }
474
+ };
475
+
476
+ const deliver = (value) => {
477
+ const next = waiting.shift();
478
+ if (!next) {
479
+ ready.push(value);
480
+ return;
481
+ }
482
+ next(value);
483
+ if (!waiting.length) hold(false);
484
+ };
485
+
486
+ const end = () => {
487
+ if (state.ended) return;
488
+ state.ended = true;
489
+ if (buffer.length) deliver(buffer);
490
+ buffer = "";
491
+ while (waiting.length) waiting.shift()("");
492
+ hold(false);
493
+ };
494
+
495
+ // Nothing read stdin before now, so an already-ended stream will not emit
496
+ // 'end' again and every take() would wait on a line that cannot arrive.
497
+ if (process.stdin.readableEnded || process.stdin.destroyed) state.ended = true;
498
+ process.stdin.setEncoding("utf8");
499
+ process.stdin.on("data", (chunk) => {
500
+ buffer += chunk;
501
+ let cut = buffer.indexOf("\n");
502
+ while (cut !== -1) {
503
+ deliver(buffer.slice(0, cut).replace(/\r$/, ""));
504
+ buffer = buffer.slice(cut + 1);
505
+ cut = buffer.indexOf("\n");
506
+ }
507
+ });
508
+ process.stdin.once("end", end);
509
+ process.stdin.once("error", end);
510
+ hold(false);
511
+
512
+ pipedAnswers = {
513
+ take() {
514
+ if (ready.length) return Promise.resolve(ready.shift());
515
+ if (state.ended) return Promise.resolve("");
516
+ hold(true);
517
+ return new Promise((resolve) => waiting.push(resolve));
518
+ },
519
+ };
520
+ return pipedAnswers;
521
+ }
522
+
382
523
  export async function promptText(question) {
524
+ // Piped input is answered from the queue and nowhere else. Testing
525
+ // `readableEnded` first looked like the same thing and was not: a pipe
526
+ // delivers every answer in one write and then closes, so by the second
527
+ // question the stream has ended while its remaining lines are still sitting
528
+ // in the queue unread -- and this returned "" on top of them. It only showed
529
+ // up where something slow sat between two prompts, which is why the walk hit
530
+ // it and a tight loop of prompts did not: the account check is an HTTP round
531
+ // trip, and during it stdin ends. The prompt after it was the confirmation
532
+ // before creating an account, so a piped "n" was read as the default, and
533
+ // the default is yes.
534
+ if (!process.stdin.isTTY) {
535
+ process.stdout.write(question);
536
+ const answer = await pipedReader().take();
537
+ // A piped answer is never echoed back, so without this the next thing
538
+ // written lands on the prompt line and reads as one broken sentence:
539
+ // `Email: [preman] Email is required.`
540
+ process.stdout.write(`${answer}\n`);
541
+ return answer.trim();
542
+ }
543
+
383
544
  // A stream that has already ended will never deliver a line, and readline does
384
545
  // not emit 'close' for an 'end' that happened before the interface existed --
385
546
  // so the second prompt after stdin ran out resolved nothing, kept nothing
@@ -390,7 +551,17 @@ export async function promptText(question) {
390
551
  process.stdout.write(`${question}\n`);
391
552
  return "";
392
553
  }
554
+
393
555
  const rl = createInterface({ input: process.stdin, output: process.stdout });
556
+ // Readline owns raw mode while it has a question open, so ^C never reaches
557
+ // Node as a signal: the prompt resolved empty, empty is the step's default,
558
+ // and the default is yes. Interrupting a question used to accept it.
559
+ const interrupt = () => {
560
+ process.stdout.write("\n");
561
+ rl.close();
562
+ process.exit(130);
563
+ };
564
+ rl.once("SIGINT", interrupt);
394
565
  try {
395
566
  // An exhausted or closed stdin never answers, and rl.question waits for an
396
567
  // answer that cannot come. That is how a run given fewer answers than it had
@@ -403,6 +574,7 @@ export async function promptText(question) {
403
574
  ]);
404
575
  return answer.trim();
405
576
  } finally {
577
+ rl.off("SIGINT", interrupt);
406
578
  rl.close();
407
579
  }
408
580
  }
@@ -645,9 +817,10 @@ function authSessionFrom(result, email) {
645
817
  }
646
818
 
647
819
  async function verifyUnconfirmedAccount(args, email) {
648
- process.stdout.write("This email exists but is not verified. Sending a new OTP.\n");
820
+ process.stdout.write("This account still needs its email verified. Sending a new code.\n");
649
821
  const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
650
822
  assertOk(resend, "resend OTP");
823
+ if (!reportCodeSent(resend, "New code sent. Check your email.")) throw undeliveredCode();
651
824
  const otp = await promptText("Verification code: ");
652
825
  const verified = await callBackendJson(args, "POST", "/auth/verify-otp", { json: { email, otp } });
653
826
  assertOk(verified, "verify OTP");
@@ -703,7 +876,7 @@ async function createAccountFromTerminal(args, email) {
703
876
  });
704
877
 
705
878
  if (signup.ok) {
706
- process.stdout.write("Verification code sent. Check your email.\n");
879
+ if (!reportCodeSent(signup)) throw undeliveredCode();
707
880
  const otp = await promptText("Verification code: ");
708
881
  return completeWithCode(args, email, otp, password);
709
882
  }
@@ -722,7 +895,7 @@ async function createAccountFromTerminal(args, email) {
722
895
  json: { email, password: legacyPassword },
723
896
  });
724
897
  assertOk(legacySignup, "signup");
725
- process.stdout.write("Verification code sent. Check your email.\n");
898
+ if (!reportCodeSent(legacySignup)) throw undeliveredCode();
726
899
  const otp = await promptText("Verification code: ");
727
900
  const verified = await callBackendJson(args, "POST", "/auth/verify-otp", {
728
901
  json: { email, otp },
@@ -731,6 +904,79 @@ async function createAccountFromTerminal(args, email) {
731
904
  return authSessionFrom(verified, email);
732
905
  }
733
906
 
907
+ /** Read the address back before an account exists on it. */
908
+ async function confirmNewAccount(email) {
909
+ for (let attempt = 0; attempt < 3; attempt += 1) {
910
+ const answer = (await promptText(`No PreMan account for ${email}. Create one? [Y/n]: `))
911
+ .trim()
912
+ .toLowerCase();
913
+ if (answer === "" || answer === "y" || answer === "yes") return true;
914
+ if (answer === "n" || answer === "no") return false;
915
+ process.stdout.write(" Please answer y or n.\n");
916
+ }
917
+ return false;
918
+ }
919
+
920
+ /**
921
+ * The stored key, if it is for this backend and the backend still honours it.
922
+ *
923
+ * Revoked and stale keys have to fall through to a real sign-in rather than
924
+ * being handed back, or the walk would carry on to the steps that need a
925
+ * working key and fail at each of them in turn.
926
+ */
927
+ async function reusableCredentials(args) {
928
+ const stored = readStoredCredentials();
929
+ if (!stored?.api_key) return null;
930
+ if (stored.backend_url && stored.backend_url !== backendUrl(args)) return null;
931
+ const wanted = args.value("--email", "").trim().toLowerCase();
932
+ if (wanted && String(stored.user_email || "").toLowerCase() !== wanted) return null;
933
+ if (args.has("--relogin")) return null;
934
+
935
+ try {
936
+ const status = await callBackendJson(args, "GET", "/cli/status", { token: stored.api_key });
937
+ return status.ok ? stored : null;
938
+ } catch {
939
+ // Offline or a backend that is down is not evidence the key is bad, but it
940
+ // is also not a sign-in, and prompting for an email we cannot check is
941
+ // worse than saying what actually happened.
942
+ return null;
943
+ }
944
+ }
945
+
946
+ /**
947
+ * Say whether a code is actually on its way.
948
+ *
949
+ * The backend reports delivery in `email_sent` and answers 200 either way, so
950
+ * every one of these lines used to send somebody to an inbox that was never
951
+ * going to receive anything -- including on any deployment with no sender
952
+ * configured, where the code only ever reached the server log. The dashboard
953
+ * has read this field since it existed; the terminal never did.
954
+ */
955
+ function reportCodeSent(result, sent = "Verification code sent. Check your email.") {
956
+ // Only ever present on a deployment that has deliberately switched it on,
957
+ // which is how dev stays usable without a mail sender.
958
+ if (result?.dev_otp) {
959
+ process.stdout.write(`Verification code (dev): ${result.dev_otp}\n`);
960
+ return true;
961
+ }
962
+ if (result?.email_sent === false) {
963
+ process.stdout.write(
964
+ "PreMan could not send the code — the email service rejected it.\n" +
965
+ " Nothing is wrong with your account. Try again shortly, or contact support.\n"
966
+ );
967
+ return false;
968
+ }
969
+ process.stdout.write(`${sent}\n`);
970
+ return true;
971
+ }
972
+
973
+ /** The reason a code never went out, phrased as something to do about it. */
974
+ function undeliveredCode() {
975
+ return new Error(
976
+ "PreMan could not email a verification code, so there is nothing to enter. Try again shortly."
977
+ );
978
+ }
979
+
734
980
  /**
735
981
  * Get a usable pm_live_ key: an explicit one, or by logging in / signing up
736
982
  * right here in the terminal. Always persists to ~/.preman/credentials.json.
@@ -758,8 +1004,26 @@ export async function authenticateTerminal(args) {
758
1004
  return creds;
759
1005
  }
760
1006
 
761
- const email = (args.value("--email", "") || await promptText("Email: ")).trim().toLowerCase();
1007
+ // A key is minted per call, so a second `onboard` used to leave a second
1008
+ // `PreMan MCP CLI (host)` key behind and ask for an email and a code to do
1009
+ // it. Re-running setup is ordinary -- it is how anyone re-checks their own
1010
+ // machine -- so a credential that still answers is the answer.
1011
+ const reusable = await reusableCredentials(args);
1012
+ if (reusable) {
1013
+ process.stdout.write(`Already signed in as ${reusable.user_email}.\n`);
1014
+ return reusable;
1015
+ }
1016
+
1017
+ const supplied = args.value("--email", "");
1018
+ const email = (supplied || (await promptText("Email: "))).trim().toLowerCase();
762
1019
  if (!email) throw new Error("Email is required.");
1020
+ // Nothing downstream validates this and the backend takes what it is given,
1021
+ // so a mistyped address became an account with a code sent to somebody
1022
+ // else's inbox. Deliberately permissive: the only job here is to catch the
1023
+ // shapes that cannot be an address at all.
1024
+ if (!/^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(email)) {
1025
+ throw new Error(`${email} does not look like an email address.`);
1026
+ }
763
1027
 
764
1028
  process.stdout.write(`Checking PreMan account for ${email}...\n`);
765
1029
  const account = await callBackendJson(args, "GET", "/auth/needs-password", { query: { email } });
@@ -768,14 +1032,22 @@ export async function authenticateTerminal(args) {
768
1032
  let session = { access_token: "", user: { email } };
769
1033
 
770
1034
  if (!account.exists) {
771
- process.stdout.write("No PreMan account found. Creating one now.\n");
1035
+ // Creating the account was the silent half of a fork the customer never
1036
+ // chose: one typo in the domain and the next prompt asked for a code that
1037
+ // had gone to an address they do not own, with an account left behind on
1038
+ // it. The address is worth reading back before any of that happens.
1039
+ if (!args.has("--yes") && !supplied && !(await confirmNewAccount(email))) {
1040
+ throw new Error("Stopped before creating an account. Run it again with the right address.");
1041
+ }
1042
+ process.stdout.write(`No PreMan account found for ${email}. Creating one now.\n`);
772
1043
  session = await createAccountFromTerminal(args, email);
773
1044
  } else if (account.needs_password) {
774
1045
  // No password on this account, so a code is the only way in.
775
1046
  const password = await optionalPassword(args);
776
- process.stdout.write("Sending a sign-in code to your email.\n");
1047
+ process.stdout.write("This account signs in with an email code.\n");
777
1048
  const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
778
1049
  assertOk(resend, "resend OTP");
1050
+ if (!reportCodeSent(resend, "Sign-in code sent. Check your email.")) throw undeliveredCode();
779
1051
  const otp = await promptText("Verification code: ");
780
1052
  session = await completeWithCode(args, email, otp, password);
781
1053
  } else {
@@ -783,14 +1055,20 @@ export async function authenticateTerminal(args) {
783
1055
  if (!password) {
784
1056
  const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
785
1057
  assertOk(resend, "resend OTP");
786
- process.stdout.write("Sign-in code sent. Check your email.\n");
1058
+ if (!reportCodeSent(resend, "Sign-in code sent. Check your email.")) throw undeliveredCode();
787
1059
  const otp = await promptText("Verification code: ");
788
1060
  session = await completeWithCode(args, email, otp, "");
789
1061
  } else {
790
1062
  const login = await callBackendJson(args, "POST", "/auth/login", {
791
1063
  json: { email, password },
792
1064
  });
793
- if (!login.ok && login.status_code === 403 && String(login.detail || "").toLowerCase().includes("not verified")) {
1065
+ // The backend now names this case in a `code` field. The prose test stays
1066
+ // as the fallback, because a customer on an older deployment still has to
1067
+ // be able to get past an unverified account.
1068
+ const unverified =
1069
+ login.code === "email_not_verified" ||
1070
+ /not verified/i.test(describeFailure(login, ""));
1071
+ if (!login.ok && login.status_code === 403 && unverified) {
794
1072
  session = await verifyUnconfirmedAccount(args, email);
795
1073
  } else {
796
1074
  assertOk(login, "login");
@@ -882,6 +1160,49 @@ export function resolveApiKey(args) {
882
1160
  export const LAUNCHER_COMMAND = "npm";
883
1161
  export const LAUNCHER_ARGS = ["exec", "-y", "premanmcp@latest", "--"];
884
1162
 
1163
+ /**
1164
+ * How this platform has to be told to start the server.
1165
+ *
1166
+ * `npm` on Windows is `npm.cmd`, and a batch file is not an executable: a host
1167
+ * that spawns the command directly -- which is what every MCP host does, none
1168
+ * of them go through a shell -- fails with EINVAL before any of this code runs.
1169
+ * The config therefore has to name the interpreter, and `cmd /c` is the form
1170
+ * every Windows host accepts. Nothing changes anywhere else, so the constants
1171
+ * stay the plain answer they have always been.
1172
+ *
1173
+ * The verifier reads this too. It used to compare the written command against
1174
+ * LAUNCHER_COMMAND, which on Windows would have meant warning about a config
1175
+ * that was right and accepting one that could not start.
1176
+ */
1177
+ export function launcherFor(platform = process.platform) {
1178
+ if (platform === "win32") {
1179
+ return { command: "cmd", args: ["/c", LAUNCHER_COMMAND, ...LAUNCHER_ARGS] };
1180
+ }
1181
+ return { command: LAUNCHER_COMMAND, args: [...LAUNCHER_ARGS] };
1182
+ }
1183
+
1184
+ /**
1185
+ * The oldest Node that can run this CLI, checked wherever it is about to matter.
1186
+ *
1187
+ * `engines` in package.json is advice: npm prints a warning and installs
1188
+ * anyway, so it stops nobody. The real floor is `fetch`, which arrived in Node
1189
+ * 18 and which every backend call goes through -- on Node 16 the first of them
1190
+ * dies with `fetch is not defined`, a message that names nothing a customer
1191
+ * could act on and appears several steps into a walk rather than at the start.
1192
+ * Only the MCP server preflight checked this; every other command did not.
1193
+ */
1194
+ export const MIN_NODE_MAJOR = 18;
1195
+
1196
+ export function nodeTooOld() {
1197
+ const major = Number(process.versions.node.split(".")[0]);
1198
+ if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) return "";
1199
+ return (
1200
+ `PreMan needs Node ${MIN_NODE_MAJOR} or newer. This is Node ${process.versions.node}.\n` +
1201
+ ` Upgrade Node, then run the same command again.\n` +
1202
+ ` With nvm: nvm install ${MIN_NODE_MAJOR} && nvm use ${MIN_NODE_MAJOR}\n`
1203
+ );
1204
+ }
1205
+
885
1206
  export function buildServerConfig(args, { pairCode = "", codingAgent = "" } = {}) {
886
1207
  const env = {
887
1208
  PREMAN_BACKEND: backendUrl(args),
@@ -892,9 +1213,5 @@ export function buildServerConfig(args, { pairCode = "", codingAgent = "" } = {}
892
1213
  if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
893
1214
  if (codingAgent) env.PREMAN_CODING_AGENT = codingAgent;
894
1215
 
895
- return {
896
- command: LAUNCHER_COMMAND,
897
- args: [...LAUNCHER_ARGS],
898
- env,
899
- };
1216
+ return { ...launcherFor(), env };
900
1217
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "test:proxy": "node --test scripts/smoke-proxy.mjs",
17
17
  "test:connect": "node scripts/smoke-connect.mjs",
18
18
  "test:connect-heartbeat": "node scripts/smoke-connect-heartbeat.mjs",
19
- "test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-agent-session.mjs scripts/smoke-agent-dock.mjs scripts/smoke-terminal-lifecycle.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-cli-update.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-eval.mjs scripts/smoke-eval-run-watch.mjs scripts/smoke-eval-behaviors.mjs scripts/smoke-eval-runtime.mjs scripts/smoke-eval-entry.mjs scripts/smoke-eval-entry-placeholder.mjs scripts/smoke-eval-discover.mjs scripts/smoke-eval-templates.mjs scripts/smoke-eval-approval-record.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-onboard-opening.mjs scripts/smoke-local-detect.mjs scripts/smoke-agent-target.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs scripts/smoke-shared-errors.mjs scripts/smoke-process-title.mjs scripts/smoke-predict.mjs",
19
+ "test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-agent-session.mjs scripts/smoke-agent-dock.mjs scripts/smoke-terminal-lifecycle.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-cli-update.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-eval.mjs scripts/smoke-eval-start.mjs scripts/smoke-eval-run-watch.mjs scripts/smoke-eval-behaviors.mjs scripts/smoke-eval-runtime.mjs scripts/smoke-eval-entry.mjs scripts/smoke-eval-entry-placeholder.mjs scripts/smoke-eval-discover.mjs scripts/smoke-eval-templates.mjs scripts/smoke-eval-approval-record.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-onboard-opening.mjs scripts/smoke-local-detect.mjs scripts/smoke-agent-target.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs scripts/smoke-shared-errors.mjs scripts/smoke-prompts.mjs scripts/smoke-process-title.mjs scripts/smoke-predict.mjs",
20
20
  "test:dmg": "node --test --test-timeout=300000 scripts/smoke-install-desktop-volume.mjs"
21
21
  },
22
22
  "devDependencies": {