@nathapp/nax-agent 0.3.1 → 0.84.0

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +7 -1
  3. package/dist/index.d.ts +3 -2
  4. package/dist/index.js +3 -2
  5. package/dist/internal/atomic-write.d.ts +16 -0
  6. package/dist/internal/atomic-write.js +34 -0
  7. package/dist/internal/command-spec/index.js +1 -1
  8. package/dist/internal/git-env.d.ts +4 -3
  9. package/dist/internal/git-env.js +11 -4
  10. package/dist/internal.d.ts +5 -0
  11. package/dist/internal.js +5 -0
  12. package/dist/native/auth.d.ts +2 -0
  13. package/dist/native/auth.js +11 -1
  14. package/dist/native/index.d.ts +1 -1
  15. package/dist/native/index.js +1 -1
  16. package/dist/native/session/handle-invalid-tool-call.js +1 -0
  17. package/dist/native/session/instruction-file-name.d.ts +4 -0
  18. package/dist/native/session/instruction-file-name.js +21 -0
  19. package/dist/native/session/instruction-interaction.d.ts +7 -0
  20. package/dist/native/session/instruction-interaction.js +22 -0
  21. package/dist/native/session/instruction-render.d.ts +6 -0
  22. package/dist/native/session/instruction-render.js +22 -0
  23. package/dist/native/session/instruction-session.d.ts +4 -0
  24. package/dist/native/session/instruction-session.js +31 -0
  25. package/dist/native/session/instruction-transcript.d.ts +4 -0
  26. package/dist/native/session/instruction-transcript.js +14 -0
  27. package/dist/native/session/repository-instructions.d.ts +35 -0
  28. package/dist/native/session/repository-instructions.js +155 -0
  29. package/dist/native/session/session.d.ts +8 -0
  30. package/dist/native/session/session.js +29 -3
  31. package/dist/native/session/transcript-identity.d.ts +2 -0
  32. package/dist/native/session/transcript-identity.js +1 -1
  33. package/dist/native/session/transcript-store.js +4 -2
  34. package/dist/native/session/transcript-types.d.ts +3 -0
  35. package/dist/native/session/turn-complete-step.js +4 -2
  36. package/dist/native/session/turn-event-emitter.d.ts +4 -0
  37. package/dist/native/session/turn-event-emitter.js +6 -4
  38. package/dist/native/session/turn-loop-round-trip.js +20 -1
  39. package/dist/native/session/turn-loop.js +1 -0
  40. package/dist/native/session-adapter.js +21 -6
  41. package/dist/permissions/approvals-store.d.ts +3 -1
  42. package/dist/permissions/approvals-store.js +18 -5
  43. package/dist/sandbox/session-tmp.js +13 -7
  44. package/dist/session/agent-session-types.d.ts +4 -0
  45. package/dist/session/native-backend-options.d.ts +4 -0
  46. package/dist/session/native-backend-options.js +6 -0
  47. package/dist/session/native-backend.js +16 -4
  48. package/dist/session/session-ask-port.js +2 -1
  49. package/dist/session/session-interaction.js +29 -13
  50. package/dist/session/session-types.d.ts +10 -0
  51. package/dist/terminal-auth/index.d.ts +4 -0
  52. package/dist/terminal-auth/index.js +4 -0
  53. package/dist/terminal-auth/interaction.d.ts +15 -0
  54. package/dist/terminal-auth/interaction.js +55 -0
  55. package/dist/terminal-auth/open-url.d.ts +17 -0
  56. package/dist/terminal-auth/open-url.js +44 -0
  57. package/dist/terminal-auth/prompt.d.ts +51 -0
  58. package/dist/terminal-auth/prompt.js +196 -0
  59. package/dist/tools/instruction-access.d.ts +13 -0
  60. package/dist/tools/instruction-access.js +30 -0
  61. package/dist/tools/runtime.js +6 -1
  62. package/package.json +2 -2
@@ -12,9 +12,11 @@
12
12
  * The parent is `/tmp/nax`, one shared directory that is easier to find and
13
13
  * list than a flat `/tmp/nax-<runId>`. It is shared, so it cannot be created
14
14
  * once and trusted: a `/tmp/nax` made by one OS user is not writable by
15
- * another. The parent is therefore re-resolved on EVERY call from three host
16
- * facts the `_sessionTmpDeps` seam exposes, with no cache, and anything nax
17
- * cannot vouch for falls back to a per-user `/tmp/nax-<uid>`.
15
+ * another. The parent is therefore re-resolved on EVERY call from four host
16
+ * facts the `_sessionTmpDeps` seam exposes — including the uid that owns it,
17
+ * because a world-writable directory another user created is still theirs to
18
+ * plant symlinks in — with no cache, and anything nax cannot vouch for falls
19
+ * back to a per-user `/tmp/nax-<uid>`.
18
20
  *
19
21
  * The root is the literal `/tmp` (not `os.tmpdir()`): callers record and assert
20
22
  * this machine-stable location, and the OS inherits the rest.
@@ -38,16 +40,20 @@ export const _sessionTmpDeps = {
38
40
  uid: () => process.getuid?.() ?? 0,
39
41
  };
40
42
  /**
41
- * True when `/tmp/nax` is a real directory the current user can write and
42
- * search. Absence is NOT this case: an absent parent is a usable shared parent
43
- * (the launcher creates it), and every other failure means the path is there in
44
- * a shape nax cannot vouch for.
43
+ * True when `/tmp/nax` is a real directory the current user OWNS and can write
44
+ * and search. Absence is NOT this case: an absent parent is a usable shared
45
+ * parent (the launcher creates it), and every other failure means the path is
46
+ * there in a shape nax cannot vouch for.
45
47
  */
46
48
  function isUsableSharedParent() {
47
49
  try {
48
50
  const stats = _sessionTmpDeps.lstat(SHARED_TMP_PARENT);
49
51
  if (!stats.isDirectory() || stats.isSymbolicLink())
50
52
  return false;
53
+ // Writability is not trust: a 0777 /tmp/nax another user created lets that user
54
+ // pre-create the run directories or read what sandboxed commands write there.
55
+ if (stats.uid !== _sessionTmpDeps.uid())
56
+ return false;
51
57
  }
52
58
  catch (err) {
53
59
  // ENOENT is the one failure that means "create /tmp/nax".
@@ -117,6 +117,8 @@ export type SessionEventBody = {
117
117
  readonly command?: string;
118
118
  readonly reason: string;
119
119
  readonly expiresAt: string;
120
+ /** `false`: decided already (by the profile); informational. Absent: a person's answer is awaited. */
121
+ readonly answerable?: false;
120
122
  } | {
121
123
  readonly type: "approval_resolved";
122
124
  readonly requestId: string;
@@ -127,6 +129,8 @@ export type SessionEventBody = {
127
129
  readonly requestId: string;
128
130
  readonly text: string;
129
131
  readonly expiresAt: string;
132
+ /** `false`: informational (`noteQuestion`); `answer()` on it does nothing. Absent: an answer is awaited. */
133
+ readonly answerable?: false;
130
134
  } | {
131
135
  readonly type: "usage";
132
136
  readonly round: number;
@@ -5,12 +5,16 @@ import type { LoopHandlerSet } from "#src/native/session/loop-events/types";
5
5
  import type { AgentSessionHostPorts, AgentSessionProfile } from "./agent-session-types.ts";
6
6
  export interface NativeBackendOptions {
7
7
  readonly model: string;
8
+ /** Repository instruction basename; defaults to AGENTS.md. */
9
+ readonly instructionFileName?: string;
8
10
  readonly credentials?: CredentialSource;
9
11
  readonly catalogOverrides?: NativeCatalogOverrides;
10
12
  readonly loopHandlers?: LoopHandlerSet;
11
13
  readonly hostPorts?: AgentSessionHostPorts;
12
14
  readonly bashApproval?: BashApprovalMode;
13
15
  readonly allowUnsandboxed?: boolean;
16
+ /** Keep a resumed or continued conversation when the model differs (ACP server model switch, S5-3). */
17
+ readonly carryHistoryAcrossModels?: boolean;
14
18
  }
15
19
  export interface ResolvedNativeOptions {
16
20
  /** The caller's object, unchanged. */
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { z } from "zod";
8
8
  import { parseNativeModel } from "#src/native/models";
9
+ import { isInstructionFileName } from "#src/native/session/instruction-file-name";
9
10
  import { AgentSessionError } from "./agent-session-errors.js";
10
11
  function isRecord(value) {
11
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -16,6 +17,10 @@ function hasMethods(names) {
16
17
  const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
17
18
  const OptionsSchema = z.strictObject({
18
19
  model: z.string().min(1),
20
+ instructionFileName: z
21
+ .string()
22
+ .refine(isInstructionFileName, "must be a nonhidden Markdown basename without path separators, colon or control characters")
23
+ .optional(),
19
24
  credentials: z
20
25
  .custom((value) => isRecord(value) && (value.kind === "memory" || value.kind === "exec"), "must be a memory or exec source")
21
26
  .optional(),
@@ -31,6 +36,7 @@ const OptionsSchema = z.strictObject({
31
36
  .optional(),
32
37
  bashApproval: z.enum(["raw", "gated", "escalate"]).optional(),
33
38
  allowUnsandboxed: z.boolean().optional(),
39
+ carryHistoryAcrossModels: z.boolean().optional(),
34
40
  });
35
41
  function invalid(message, context = {}) {
36
42
  return new AgentSessionError(`Invalid agent session options: ${message}`, "AGENT_SESSION_INVALID_OPTIONS", context);
@@ -15,7 +15,10 @@ import { createSessionInteractionHandler, embedderToolDescriptor } from "./sessi
15
15
  import { buildSessionToolSupport, defaultProtectedPaths, resolveSessionLauncher } from "./session-tool-support.js";
16
16
  export const NATIVE_BACKEND_KIND = "native";
17
17
  /** nax's agent.native.transportRetry default (S3 spec 5.3). */
18
- const SESSION_TRANSPORT_RETRY = { maxAttempts: 3, baseDelayMs: 2000 };
18
+ const SESSION_TRANSPORT_RETRY = {
19
+ maxAttempts: 3,
20
+ baseDelayMs: 2000,
21
+ };
19
22
  function createAdapter(raw) {
20
23
  const overrides = raw.catalogOverrides ?? [];
21
24
  const owns = overrides.length > 0 || raw.credentials !== undefined;
@@ -32,10 +35,13 @@ function checkResumeModel(doc, sessionId, model) {
32
35
  }
33
36
  async function openNative(resolved, ctx) {
34
37
  const raw = resolved.raw;
35
- if (ctx.resume !== undefined)
38
+ if (ctx.resume !== undefined && raw.carryHistoryAcrossModels !== true)
36
39
  checkResumeModel(ctx.resume.doc, ctx.sessionId, raw.model);
37
40
  const { bashApproval, allowUnsandboxed } = nativeProfileRules(ctx.profile, raw);
38
- const protectedPaths = { ...defaultProtectedPaths(raw.credentials !== undefined), ...raw.hostPorts?.protectedPaths };
41
+ const protectedPaths = {
42
+ ...defaultProtectedPaths(raw.credentials !== undefined),
43
+ ...raw.hostPorts?.protectedPaths,
44
+ };
39
45
  const launcher = await resolveSessionLauncher({
40
46
  profile: ctx.profile,
41
47
  root: ctx.workdir,
@@ -68,6 +74,8 @@ async function openNative(resolved, ctx) {
68
74
  const handle = await adapter.openSession(ctx.sessionId, {
69
75
  agentName: NATIVE_AGENT,
70
76
  workdir: ctx.workdir,
77
+ instructionProtectedPaths: protectedPaths,
78
+ instructionFileName: raw.instructionFileName,
71
79
  resolvedPermissions: { mode: "default", toolGrants: grants, bashApproval },
72
80
  modelDef: { provider: resolved.provider, model: raw.model },
73
81
  timeoutSeconds: ctx.turnTimeoutSeconds,
@@ -76,6 +84,7 @@ async function openNative(resolved, ctx) {
76
84
  resume: ctx.resume !== undefined,
77
85
  spinBreaker: DEFAULT_SPIN_BREAKER_SETTINGS,
78
86
  transportRetry: SESSION_TRANSPORT_RETRY,
87
+ ...(raw.carryHistoryAcrossModels === true ? { carryHistoryAcrossModels: true } : {}),
79
88
  ...(ctx.instructions !== undefined && ctx.instructions !== "" ? { systemPrompt: ctx.instructions } : {}),
80
89
  });
81
90
  const codingTools = [...support.tools, ...ctx.tools.map(embedderToolDescriptor)];
@@ -102,5 +111,8 @@ async function openNative(resolved, ctx) {
102
111
  }
103
112
  export function nativeBackend(options) {
104
113
  const resolved = parseNativeBackendOptions(options);
105
- return { kind: NATIVE_BACKEND_KIND, open: (ctx) => openNative(resolved, ctx) };
114
+ return {
115
+ kind: NATIVE_BACKEND_KIND,
116
+ open: (ctx) => openNative(resolved, ctx),
117
+ };
106
118
  }
@@ -41,6 +41,7 @@ export function createSessionAskPort(deps) {
41
41
  summary: req.summary,
42
42
  reason: req.reason,
43
43
  expiresAt: now(),
44
+ answerable: false,
44
45
  });
45
46
  deps.emit({ type: "approval_resolved", requestId, decision, decidedBy: "profile" });
46
47
  },
@@ -58,7 +59,7 @@ export function createSessionAskPort(deps) {
58
59
  if (deps.turn() === undefined)
59
60
  return;
60
61
  const { requestId } = deps.table.issue("question", ALREADY_ABORTED);
61
- deps.emit({ type: "question", requestId, text, expiresAt: now() });
62
+ deps.emit({ type: "question", requestId, text, expiresAt: now(), answerable: false });
62
63
  },
63
64
  };
64
65
  }
@@ -72,10 +72,29 @@ function runSafely(tool, input, ctx) {
72
72
  return { content: `Tool "${tool.name}" failed: ${cause}`, isError: true };
73
73
  });
74
74
  }
75
+ /**
76
+ * Settle with `work`, or with `onAbort()` the moment `signal` aborts. The tool
77
+ * batch awaits interaction handlers with no abort race of its own, so a tool
78
+ * that ignores its signal would otherwise hang the turn (and `close()`, which
79
+ * awaits the turn). The abandoned work's late settlement is ignored.
80
+ */
81
+ async function raceAbort(work, signal, onAbort) {
82
+ let listener = () => { };
83
+ const aborted = new Promise((resolve) => {
84
+ listener = () => resolve(onAbort());
85
+ signal.addEventListener("abort", listener, { once: true });
86
+ });
87
+ try {
88
+ return await Promise.race([work, aborted]);
89
+ }
90
+ finally {
91
+ signal.removeEventListener("abort", listener);
92
+ }
93
+ }
75
94
  /**
76
95
  * Runs the tool, but answers at once when the turn aborts: the batch awaits
77
96
  * this handler with no abort race of its own, so a run that ignores its signal
78
- * would otherwise hang the turn. The abandoned run's late settlement is ignored.
97
+ * would otherwise hang the turn. See `raceAbort`.
79
98
  */
80
99
  async function invoke(tool, input, ctx) {
81
100
  const abandoned = {
@@ -84,17 +103,7 @@ async function invoke(tool, input, ctx) {
84
103
  };
85
104
  if (ctx.signal.aborted)
86
105
  return abandoned;
87
- let onAbort = () => { };
88
- const aborted = new Promise((resolve) => {
89
- onAbort = () => resolve(abandoned);
90
- ctx.signal.addEventListener("abort", onAbort, { once: true });
91
- });
92
- try {
93
- return await Promise.race([runSafely(tool, input, ctx), aborted]);
94
- }
95
- finally {
96
- ctx.signal.removeEventListener("abort", onAbort);
97
- }
106
+ return raceAbort(runSafely(tool, input, ctx), ctx.signal, () => abandoned);
98
107
  }
99
108
  async function runEmbedderTool(deps, tool, request) {
100
109
  const toolCallId = request.toolCallId ?? "";
@@ -131,7 +140,14 @@ function toolCallContext(request) {
131
140
  async function runCodingTool(deps, request) {
132
141
  deps.setCurrentCallId(request.toolCallId);
133
142
  try {
134
- const outcome = await deps.runtime.callTool(request.name, request.input ?? {}, toolCallContext(request));
143
+ const signal = request.signal ?? deps.turnSignal();
144
+ const abandoned = {
145
+ kind: "error",
146
+ content: `Tool "${request.name}" was abandoned: the turn ended.`,
147
+ };
148
+ const outcome = signal.aborted
149
+ ? abandoned
150
+ : await raceAbort(deps.runtime.callTool(request.name, request.input ?? {}, { ...toolCallContext(request), signal }), signal, () => abandoned);
135
151
  if (outcome.kind === "denied") {
136
152
  return { answer: `Denied: ${outcome.reason}`, denied: { reason: outcome.reason, breach: outcome.breach } };
137
153
  }
@@ -175,6 +175,14 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
175
175
  * facade sets it from `instructions`; nax leaves it unset. ACP ignores it.
176
176
  */
177
177
  systemPrompt?: string;
178
+ /** Native: repository-relative instruction scopes, independent of the file-tool root. */
179
+ instructionDirectories?: readonly string[];
180
+ /** Native: instruction Markdown basename; defaults to AGENTS.md and its legacy fallback chain. */
181
+ instructionFileName?: string;
182
+ /** Native: repository-relative paths excluded from automatic instruction reads. */
183
+ instructionDenyPaths?: readonly string[];
184
+ /** Native: host credential and trust-store paths excluded from instruction discovery. */
185
+ instructionProtectedPaths?: import("#src/tools/protected-paths").ProtectedPathsPolicy;
178
186
  /**
179
187
  * Native: resolved compaction settings. A resolved primitive, never NaxConfig —
180
188
  * src/agents/native/ must not read config (check:adapter-no-config-import).
@@ -192,6 +200,8 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
192
200
  * NaxConfig. ACP ignores it; its loop is bounded by `maxInteractions`.
193
201
  */
194
202
  spinBreaker?: import("#src/infra/spin-breaker/index").ResolvedSpinBreakerSettings;
203
+ /** Native: keep history written by another model (S5-3). */
204
+ readonly carryHistoryAcrossModels?: boolean;
195
205
  }
196
206
  /** Options for sendTurn(). */
197
207
  export interface SendTurnOpts {
@@ -0,0 +1,4 @@
1
+ /** Terminal login UI shared by `nax auth login` and `nax-agent login` (S5-4 M-28). */
2
+ export { createTerminalAuthInteraction, type TerminalAuthOptions } from "./interaction.ts";
3
+ export { _openUrlDeps, openUrl, spawnDetached } from "./open-url.ts";
4
+ export { _terminalPromptDeps, PLAIN_STYLE, PromptCancelledError, type PromptStdin, promptForLine, promptForSecret, promptForSelect, type SelectChoice, type TerminalStyle, } from "./prompt.ts";
@@ -0,0 +1,4 @@
1
+ /** Terminal login UI shared by `nax auth login` and `nax-agent login` (S5-4 M-28). */
2
+ export { createTerminalAuthInteraction } from "./interaction.js";
3
+ export { _openUrlDeps, openUrl, spawnDetached } from "./open-url.js";
4
+ export { _terminalPromptDeps, PLAIN_STYLE, PromptCancelledError, promptForLine, promptForSecret, promptForSelect, } from "./prompt.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The terminal's side of a login (moved from nax's `terminalInteraction`, S5-4
3
+ * M-28). Shared by `nax auth login` and `nax-agent login`. Secrets go through
4
+ * the non-echoing prompt; any prompt type this mirror does not recognise is
5
+ * treated as a secret.
6
+ */
7
+ import type { AuthInteraction } from "#src/native/auth-types";
8
+ import { type TerminalStyle } from "#src/terminal-auth/prompt";
9
+ export interface TerminalAuthOptions {
10
+ /** One line of output (no trailing newline). */
11
+ readonly log: (text: string) => void;
12
+ readonly style?: TerminalStyle;
13
+ readonly openUrl?: (url: string) => void;
14
+ }
15
+ export declare function createTerminalAuthInteraction(options: TerminalAuthOptions): AuthInteraction;
@@ -0,0 +1,55 @@
1
+ import { openUrl as defaultOpenUrl } from "#src/terminal-auth/open-url";
2
+ import { PLAIN_STYLE, promptForLine, promptForSecret, promptForSelect, } from "#src/terminal-auth/prompt";
3
+ export function createTerminalAuthInteraction(options) {
4
+ const style = options.style ?? PLAIN_STYLE;
5
+ const open = options.openUrl ?? defaultOpenUrl;
6
+ // notify() is synchronous and the flow fires auth-url right before racing a
7
+ // manual-code prompt against its callback server, so the URL is parked and
8
+ // spent by the next prompt's Enter-on-empty.
9
+ let pendingUrl;
10
+ const manualCode = (message) => {
11
+ const url = pendingUrl;
12
+ if (url === undefined)
13
+ return promptForLine(message, undefined, style);
14
+ pendingUrl = undefined;
15
+ options.log(style.dim("Press Enter to open it in your browser."));
16
+ return promptForLine(message, () => {
17
+ options.log(style.dim("Opening your browser..."));
18
+ open(url);
19
+ }, style);
20
+ };
21
+ return {
22
+ prompt: async (prompt) => {
23
+ if (prompt.type === "manual-code")
24
+ return manualCode(prompt.message);
25
+ if (prompt.type === "text")
26
+ return promptForLine(prompt.message, undefined, style);
27
+ if (prompt.type === "select") {
28
+ return promptForSelect(prompt.message, prompt.options.map((option) => ({ id: option.id, label: option.label })), style);
29
+ }
30
+ return promptForSecret(prompt.message, style);
31
+ },
32
+ notify: (event) => {
33
+ switch (event.type) {
34
+ case "auth-url":
35
+ // The flow's own instructions are dropped: nothing has opened a browser yet.
36
+ options.log(`\n${style.bold("Open this URL to continue:")}\n ${event.url}`);
37
+ pendingUrl = event.url;
38
+ return;
39
+ case "device-code":
40
+ options.log(`\nGo to ${event.verificationUri} and enter code ${style.bold(event.userCode)}`);
41
+ return;
42
+ case "info":
43
+ options.log(event.message);
44
+ for (const link of event.links ?? [])
45
+ options.log(` ${link.label ?? "Link"}: ${link.url}`);
46
+ return;
47
+ case "progress":
48
+ options.log(style.dim(event.message));
49
+ return;
50
+ default:
51
+ // An event type this mirror does not recognise: say nothing.
52
+ }
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Opening a URL in the user's browser (moved from the nax CLI, S5-4 M-28).
3
+ *
4
+ * Best-effort: the caller always prints the URL first, so a failure costs a
5
+ * copy-paste, not the login. The URL is its own argv entry, never a shell
6
+ * string. Node reports a missing opener asynchronously (an `error` event), so
7
+ * the child gets an error listener; without it a container with no xdg-open
8
+ * would crash the login (S5-4 Review Focus 2).
9
+ */
10
+ import { type ChildProcess } from "node:child_process";
11
+ export declare function spawnDetached(command: readonly string[]): ChildProcess;
12
+ /** Test seam. */
13
+ export declare const _openUrlDeps: {
14
+ spawn: (command: readonly string[]) => void;
15
+ platform: () => string;
16
+ };
17
+ export declare function openUrl(url: string): void;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Opening a URL in the user's browser (moved from the nax CLI, S5-4 M-28).
3
+ *
4
+ * Best-effort: the caller always prints the URL first, so a failure costs a
5
+ * copy-paste, not the login. The URL is its own argv entry, never a shell
6
+ * string. Node reports a missing opener asynchronously (an `error` event), so
7
+ * the child gets an error listener; without it a container with no xdg-open
8
+ * would crash the login (S5-4 Review Focus 2).
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ export function spawnDetached(command) {
12
+ const [file, ...args] = command;
13
+ // nax-git-env-allow: not git: browser opener argv
14
+ const child = spawn(file ?? "", args, { stdio: "ignore", detached: true, windowsHide: true });
15
+ child.on("error", () => {
16
+ // The URL is already on screen; a missing opener is not news.
17
+ });
18
+ child.unref();
19
+ return child;
20
+ }
21
+ /** Test seam. */
22
+ export const _openUrlDeps = {
23
+ spawn: (command) => {
24
+ spawnDetached(command);
25
+ },
26
+ platform: () => process.platform,
27
+ };
28
+ /** The opener for a platform. Windows needs the empty "" title argument. */
29
+ function openerFor(platform, url) {
30
+ if (platform === "darwin")
31
+ return ["open", url];
32
+ if (platform === "win32")
33
+ return ["cmd", "/c", "start", "", url];
34
+ return ["xdg-open", url];
35
+ }
36
+ export function openUrl(url) {
37
+ try {
38
+ // nax-git-env-allow: not git: browser opener argv
39
+ _openUrlDeps.spawn(openerFor(_openUrlDeps.platform(), url));
40
+ }
41
+ catch {
42
+ // Deliberately silent: the URL is already on screen.
43
+ }
44
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Terminal prompts for credential entry (moved from the nax CLI, S5-4 M-28).
3
+ *
4
+ * Nothing typed into a secret prompt is echoed, and the terminal is restored on
5
+ * every exit path: submit, cancel, stream end and stream error. Ctrl+D cancels;
6
+ * it never submits (it once fell through to "submit" in nax's confirm prompt).
7
+ */
8
+ /** Colours for the prompts; the caller supplies them (nax passes chalk). */
9
+ export interface TerminalStyle {
10
+ accent(text: string): string;
11
+ dim(text: string): string;
12
+ bold(text: string): string;
13
+ }
14
+ export declare const PLAIN_STYLE: TerminalStyle;
15
+ /** The slice of process.stdin these prompts drive. Injected so tests can stand one up. */
16
+ export interface PromptStdin {
17
+ isTTY?: boolean;
18
+ setRawMode(mode: boolean): unknown;
19
+ resume(): unknown;
20
+ pause(): unknown;
21
+ setEncoding(encoding: string): unknown;
22
+ on(event: string, listener: (chunk: string) => void): unknown;
23
+ once(event: string, listener: () => void): unknown;
24
+ removeListener(event: string, listener: (...args: never[]) => void): unknown;
25
+ }
26
+ export declare class PromptCancelledError extends Error {
27
+ constructor();
28
+ }
29
+ /**
30
+ * Test seam. `stdin` is unset by default and resolved to `process.stdin` only
31
+ * when a prompt runs: this module is reachable from the public barrel, and
32
+ * touching `process.stdin` at import would open it for every consumer.
33
+ */
34
+ export declare const _terminalPromptDeps: {
35
+ stdin: PromptStdin | undefined;
36
+ write: (text: string) => boolean;
37
+ };
38
+ /** Reads a secret. Nothing is echoed, not even a masking character. */
39
+ export declare function promptForSecret(message: string, style?: TerminalStyle): Promise<string>;
40
+ /** Reads a visible line, for non-secret answers such as a pasted auth code. */
41
+ export declare function promptForLine(message: string, onEmptySubmit?: () => void, style?: TerminalStyle): Promise<string>;
42
+ export interface SelectChoice {
43
+ readonly id: string;
44
+ readonly label: string;
45
+ }
46
+ /**
47
+ * Reads a choice with the arrow keys and returns the chosen option's id. The
48
+ * option block is redrawn in place. Enter commits the highlighted row, so a
49
+ * value that is not an option can never be returned.
50
+ */
51
+ export declare function promptForSelect(message: string, choices: readonly SelectChoice[], style?: TerminalStyle): Promise<string>;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Terminal prompts for credential entry (moved from the nax CLI, S5-4 M-28).
3
+ *
4
+ * Nothing typed into a secret prompt is echoed, and the terminal is restored on
5
+ * every exit path: submit, cancel, stream end and stream error. Ctrl+D cancels;
6
+ * it never submits (it once fell through to "submit" in nax's confirm prompt).
7
+ */
8
+ /** Ctrl+C. */
9
+ const ETX = "\u0003";
10
+ /** Ctrl+D. Conventionally cancel, never submit. */
11
+ const EOT = "\u0004";
12
+ const CR = "\r";
13
+ const LF = "\n";
14
+ const BACKSPACE = "\u007F";
15
+ const ARROW_UP = "\u001b[A";
16
+ const ARROW_DOWN = "\u001b[B";
17
+ const identity = (text) => text;
18
+ export const PLAIN_STYLE = { accent: identity, dim: identity, bold: identity };
19
+ export class PromptCancelledError extends Error {
20
+ constructor() {
21
+ super("Prompt cancelled");
22
+ this.name = "PromptCancelledError";
23
+ }
24
+ }
25
+ /**
26
+ * Test seam. `stdin` is unset by default and resolved to `process.stdin` only
27
+ * when a prompt runs: this module is reachable from the public barrel, and
28
+ * touching `process.stdin` at import would open it for every consumer.
29
+ */
30
+ export const _terminalPromptDeps = {
31
+ stdin: undefined,
32
+ write: (text) => process.stdout.write(text),
33
+ };
34
+ function stdinOf() {
35
+ return _terminalPromptDeps.stdin ?? process.stdin;
36
+ }
37
+ /** What one typed character does to a line prompt. */
38
+ function keyAction(char, bufferEmpty, hasEmptyHook) {
39
+ if (char === ETX || char === EOT)
40
+ return { kind: "cancel" };
41
+ if (char === CR || char === LF) {
42
+ return bufferEmpty && hasEmptyHook ? { kind: "empty-submit" } : { kind: "submit" };
43
+ }
44
+ if (char === BACKSPACE)
45
+ return { kind: "erase" };
46
+ return { kind: "append" };
47
+ }
48
+ /** Applies a non-terminal key to the buffer; echoes when the prompt is visible. */
49
+ function applyEdit(action, char, buffer, echo, onEmptySubmit) {
50
+ if (action.kind === "empty-submit") {
51
+ onEmptySubmit?.();
52
+ return buffer;
53
+ }
54
+ if (action.kind === "erase") {
55
+ if (echo)
56
+ _terminalPromptDeps.write("\b \b");
57
+ return buffer.slice(0, -1);
58
+ }
59
+ if (echo)
60
+ _terminalPromptDeps.write(char);
61
+ return buffer + char;
62
+ }
63
+ /**
64
+ * `onEmptySubmit` turns Enter-on-an-empty-buffer into an action rather than a
65
+ * submission. An empty answer is meaningless for the prompts that use it (a
66
+ * pasted auth code), and spending the keystroke here keeps the whole login on a
67
+ * single stdin reader.
68
+ */
69
+ function read(message, echo, onEmptySubmit, style) {
70
+ const stdin = stdinOf();
71
+ if (stdin.isTTY !== true)
72
+ return Promise.reject(new PromptCancelledError());
73
+ _terminalPromptDeps.write(`${style.accent("?")} ${message} `);
74
+ return new Promise((resolve, reject) => {
75
+ let buffer = "";
76
+ let settled = false;
77
+ const cleanup = () => {
78
+ if (settled)
79
+ return;
80
+ settled = true;
81
+ stdin.removeListener("data", onData);
82
+ stdin.removeListener("end", onEnd);
83
+ stdin.removeListener("error", onEnd);
84
+ stdin.setRawMode(false);
85
+ stdin.pause();
86
+ _terminalPromptDeps.write("\n");
87
+ };
88
+ const onEnd = () => {
89
+ cleanup();
90
+ reject(new PromptCancelledError());
91
+ };
92
+ const onData = (chunk) => {
93
+ for (const char of chunk) {
94
+ const action = keyAction(char, buffer.length === 0, onEmptySubmit !== undefined);
95
+ if (action.kind === "cancel") {
96
+ onEnd();
97
+ return;
98
+ }
99
+ if (action.kind === "submit") {
100
+ cleanup();
101
+ resolve(buffer);
102
+ return;
103
+ }
104
+ buffer = applyEdit(action, char, buffer, echo, onEmptySubmit);
105
+ }
106
+ };
107
+ stdin.setRawMode(true);
108
+ stdin.resume();
109
+ stdin.setEncoding("utf8");
110
+ stdin.on("data", onData);
111
+ stdin.once("end", onEnd);
112
+ stdin.once("error", onEnd);
113
+ });
114
+ }
115
+ /** Reads a secret. Nothing is echoed, not even a masking character. */
116
+ export function promptForSecret(message, style = PLAIN_STYLE) {
117
+ return read(message, false, undefined, style);
118
+ }
119
+ /** Reads a visible line, for non-secret answers such as a pasted auth code. */
120
+ export function promptForLine(message, onEmptySubmit, style = PLAIN_STYLE) {
121
+ return read(message, true, onEmptySubmit, style);
122
+ }
123
+ /**
124
+ * Reads a choice with the arrow keys and returns the chosen option's id. The
125
+ * option block is redrawn in place. Enter commits the highlighted row, so a
126
+ * value that is not an option can never be returned.
127
+ */
128
+ export function promptForSelect(message, choices, style = PLAIN_STYLE) {
129
+ const stdin = stdinOf();
130
+ if (stdin.isTTY !== true)
131
+ return Promise.reject(new PromptCancelledError());
132
+ // Never silently pick for the user: an empty list is a caller bug.
133
+ if (choices.length === 0)
134
+ return Promise.reject(new PromptCancelledError());
135
+ _terminalPromptDeps.write(`${style.accent("?")} ${message}\n`);
136
+ return new Promise((resolve, reject) => {
137
+ let index = 0;
138
+ let settled = false;
139
+ let drawn = false;
140
+ const render = () => {
141
+ if (drawn)
142
+ _terminalPromptDeps.write(`\u001b[${choices.length}A`);
143
+ drawn = true;
144
+ for (const [i, choice] of choices.entries()) {
145
+ const active = i === index;
146
+ const marker = active ? style.accent(">") : " ";
147
+ const label = active ? style.accent(choice.label) : choice.label;
148
+ _terminalPromptDeps.write(`\r\u001b[2K${marker} ${label}\n`);
149
+ }
150
+ };
151
+ const cleanup = () => {
152
+ if (settled)
153
+ return;
154
+ settled = true;
155
+ stdin.removeListener("data", onData);
156
+ stdin.removeListener("end", onEnd);
157
+ stdin.removeListener("error", onEnd);
158
+ stdin.setRawMode(false);
159
+ stdin.pause();
160
+ };
161
+ const onEnd = () => {
162
+ cleanup();
163
+ reject(new PromptCancelledError());
164
+ };
165
+ const onData = (chunk) => {
166
+ // Whole-chunk matching: an arrow key is a three-byte escape sequence.
167
+ if (chunk.includes(ETX) || chunk.includes(EOT)) {
168
+ onEnd();
169
+ return;
170
+ }
171
+ if (chunk.includes(ARROW_UP)) {
172
+ index = (index - 1 + choices.length) % choices.length;
173
+ render();
174
+ return;
175
+ }
176
+ if (chunk.includes(ARROW_DOWN)) {
177
+ index = (index + 1) % choices.length;
178
+ render();
179
+ return;
180
+ }
181
+ if (chunk.includes(CR) || chunk.includes(LF)) {
182
+ const chosen = choices[index];
183
+ cleanup();
184
+ // biome-ignore lint/style/noNonNullAssertion: index is held in range by the modulo above.
185
+ resolve(chosen.id);
186
+ }
187
+ };
188
+ stdin.setRawMode(true);
189
+ stdin.resume();
190
+ stdin.setEncoding("utf8");
191
+ stdin.on("data", onData);
192
+ stdin.once("end", onEnd);
193
+ stdin.once("error", onEnd);
194
+ render();
195
+ });
196
+ }