@neuralnomads/codenomad 0.5.1 → 0.7.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.
@@ -0,0 +1,134 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { hashPassword, verifyPassword } from "./password-hash";
4
+ export class AuthStore {
5
+ constructor(authFilePath, logger) {
6
+ this.authFilePath = authFilePath;
7
+ this.logger = logger;
8
+ this.cachedFile = null;
9
+ this.overrideAuth = null;
10
+ this.bootstrapUsername = null;
11
+ }
12
+ getAuthFilePath() {
13
+ return this.authFilePath;
14
+ }
15
+ load() {
16
+ if (this.overrideAuth) {
17
+ return this.overrideAuth;
18
+ }
19
+ if (this.cachedFile) {
20
+ return this.cachedFile;
21
+ }
22
+ try {
23
+ if (!fs.existsSync(this.authFilePath)) {
24
+ return null;
25
+ }
26
+ const raw = fs.readFileSync(this.authFilePath, "utf-8");
27
+ const parsed = JSON.parse(raw);
28
+ if (!parsed || parsed.version !== 1) {
29
+ this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version");
30
+ return null;
31
+ }
32
+ this.cachedFile = parsed;
33
+ return parsed;
34
+ }
35
+ catch (error) {
36
+ this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file");
37
+ return null;
38
+ }
39
+ }
40
+ ensureInitialized(params) {
41
+ const password = params.password?.trim();
42
+ if (password) {
43
+ const now = new Date().toISOString();
44
+ const runtime = {
45
+ version: 1,
46
+ username: params.username,
47
+ password: hashPassword(password),
48
+ userProvided: true,
49
+ updatedAt: now,
50
+ };
51
+ this.overrideAuth = runtime;
52
+ this.cachedFile = null;
53
+ this.bootstrapUsername = null;
54
+ this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file");
55
+ return;
56
+ }
57
+ const existing = this.load();
58
+ if (existing) {
59
+ if (existing.username !== params.username) {
60
+ // Keep existing username unless explicitly overridden later.
61
+ this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested");
62
+ }
63
+ this.bootstrapUsername = null;
64
+ return;
65
+ }
66
+ if (params.allowBootstrapWithoutPassword) {
67
+ this.bootstrapUsername = params.username;
68
+ this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled");
69
+ return;
70
+ }
71
+ throw new Error(`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`);
72
+ }
73
+ validateCredentials(username, password) {
74
+ const auth = this.load();
75
+ if (!auth) {
76
+ return false;
77
+ }
78
+ if (username !== auth.username) {
79
+ return false;
80
+ }
81
+ return verifyPassword(password, auth.password);
82
+ }
83
+ setPassword(params) {
84
+ if (this.overrideAuth) {
85
+ throw new Error("Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.");
86
+ }
87
+ const current = this.load();
88
+ if (!current) {
89
+ if (!this.bootstrapUsername) {
90
+ throw new Error("Auth is not initialized");
91
+ }
92
+ const created = {
93
+ version: 1,
94
+ username: this.bootstrapUsername,
95
+ password: hashPassword(params.password),
96
+ userProvided: params.markUserProvided,
97
+ updatedAt: new Date().toISOString(),
98
+ };
99
+ this.persist(created);
100
+ this.bootstrapUsername = null;
101
+ return { username: created.username, passwordUserProvided: created.userProvided };
102
+ }
103
+ const next = {
104
+ ...current,
105
+ password: hashPassword(params.password),
106
+ userProvided: params.markUserProvided,
107
+ updatedAt: new Date().toISOString(),
108
+ };
109
+ this.persist(next);
110
+ return { username: next.username, passwordUserProvided: next.userProvided };
111
+ }
112
+ getStatus() {
113
+ const current = this.load();
114
+ if (current) {
115
+ return { username: current.username, passwordUserProvided: current.userProvided };
116
+ }
117
+ if (this.bootstrapUsername) {
118
+ return { username: this.bootstrapUsername, passwordUserProvided: false };
119
+ }
120
+ throw new Error("Auth is not initialized");
121
+ }
122
+ persist(auth) {
123
+ try {
124
+ fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true });
125
+ fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8");
126
+ this.cachedFile = auth;
127
+ this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file");
128
+ }
129
+ catch (error) {
130
+ this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file");
131
+ throw error;
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,37 @@
1
+ export function parseCookies(header) {
2
+ const result = {};
3
+ if (!header)
4
+ return result;
5
+ const parts = header.split(";");
6
+ for (const part of parts) {
7
+ const index = part.indexOf("=");
8
+ if (index < 0)
9
+ continue;
10
+ const key = part.slice(0, index).trim();
11
+ const value = part.slice(index + 1).trim();
12
+ if (!key)
13
+ continue;
14
+ result[key] = decodeURIComponent(value);
15
+ }
16
+ return result;
17
+ }
18
+ export function isLoopbackAddress(remoteAddress) {
19
+ if (!remoteAddress)
20
+ return false;
21
+ if (remoteAddress === "127.0.0.1" || remoteAddress === "::1")
22
+ return true;
23
+ if (remoteAddress === "::ffff:127.0.0.1")
24
+ return true;
25
+ return false;
26
+ }
27
+ export function wantsHtml(request) {
28
+ const accept = (request.headers["accept"] ?? "").toString().toLowerCase();
29
+ return accept.includes("text/html") || accept.includes("application/xhtml");
30
+ }
31
+ export function sendUnauthorized(request, reply) {
32
+ if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
33
+ reply.redirect("/login");
34
+ return;
35
+ }
36
+ reply.code(401).send({ error: "Unauthorized" });
37
+ }
@@ -0,0 +1,87 @@
1
+ import path from "path";
2
+ import { AuthStore } from "./auth-store";
3
+ import { TokenManager } from "./token-manager";
4
+ import { SessionManager } from "./session-manager";
5
+ import { isLoopbackAddress, parseCookies } from "./http-auth";
6
+ export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:";
7
+ export const DEFAULT_AUTH_USERNAME = "codenomad";
8
+ export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session";
9
+ export class AuthManager {
10
+ constructor(init, logger) {
11
+ this.init = init;
12
+ this.logger = logger;
13
+ this.sessionManager = new SessionManager();
14
+ this.cookieName = DEFAULT_AUTH_COOKIE_NAME;
15
+ const authFilePath = resolveAuthFilePath(init.configPath);
16
+ this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }));
17
+ // Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
18
+ this.authStore.ensureInitialized({
19
+ username: init.username,
20
+ password: init.password,
21
+ allowBootstrapWithoutPassword: init.generateToken,
22
+ });
23
+ this.tokenManager = init.generateToken ? new TokenManager(60000) : null;
24
+ }
25
+ getCookieName() {
26
+ return this.cookieName;
27
+ }
28
+ isTokenBootstrapEnabled() {
29
+ return Boolean(this.tokenManager);
30
+ }
31
+ issueBootstrapToken() {
32
+ if (!this.tokenManager)
33
+ return null;
34
+ return this.tokenManager.generate();
35
+ }
36
+ consumeBootstrapToken(token) {
37
+ if (!this.tokenManager)
38
+ return false;
39
+ return this.tokenManager.consume(token);
40
+ }
41
+ validateLogin(username, password) {
42
+ return this.authStore.validateCredentials(username, password);
43
+ }
44
+ createSession(username) {
45
+ return this.sessionManager.createSession(username);
46
+ }
47
+ getStatus() {
48
+ return this.authStore.getStatus();
49
+ }
50
+ setPassword(password) {
51
+ return this.authStore.setPassword({ password, markUserProvided: true });
52
+ }
53
+ isLoopbackRequest(request) {
54
+ return isLoopbackAddress(request.socket.remoteAddress);
55
+ }
56
+ getSessionFromRequest(request) {
57
+ const cookies = parseCookies(request.headers.cookie);
58
+ const sessionId = cookies[this.cookieName];
59
+ const session = this.sessionManager.getSession(sessionId);
60
+ if (!session)
61
+ return null;
62
+ return { username: session.username, sessionId: session.id };
63
+ }
64
+ setSessionCookie(reply, sessionId) {
65
+ reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId));
66
+ }
67
+ clearSessionCookie(reply) {
68
+ reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }));
69
+ }
70
+ }
71
+ function resolveAuthFilePath(configPath) {
72
+ const resolvedConfigPath = resolvePath(configPath);
73
+ return path.join(path.dirname(resolvedConfigPath), "auth.json");
74
+ }
75
+ function resolvePath(filePath) {
76
+ if (filePath.startsWith("~/")) {
77
+ return path.join(process.env.HOME ?? "", filePath.slice(2));
78
+ }
79
+ return path.resolve(filePath);
80
+ }
81
+ function buildSessionCookie(name, value, options) {
82
+ const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"];
83
+ if (options?.maxAgeSeconds !== undefined) {
84
+ parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`);
85
+ }
86
+ return parts.join("; ");
87
+ }
@@ -0,0 +1,32 @@
1
+ import crypto from "crypto";
2
+ const DEFAULT_SCRYPT_PARAMS = {
3
+ N: 16384,
4
+ r: 8,
5
+ p: 1,
6
+ maxmem: 32 * 1024 * 1024,
7
+ };
8
+ export function hashPassword(password) {
9
+ const salt = crypto.randomBytes(16);
10
+ const params = DEFAULT_SCRYPT_PARAMS;
11
+ const keyLength = 64;
12
+ const derived = crypto.scryptSync(password, salt, keyLength, params);
13
+ return {
14
+ algorithm: "scrypt",
15
+ saltBase64: salt.toString("base64"),
16
+ hashBase64: Buffer.from(derived).toString("base64"),
17
+ keyLength,
18
+ params,
19
+ };
20
+ }
21
+ export function verifyPassword(password, record) {
22
+ if (record.algorithm !== "scrypt") {
23
+ return false;
24
+ }
25
+ const salt = Buffer.from(record.saltBase64, "base64");
26
+ const expected = Buffer.from(record.hashBase64, "base64");
27
+ const derived = crypto.scryptSync(password, salt, record.keyLength, record.params);
28
+ if (expected.length !== derived.length) {
29
+ return false;
30
+ }
31
+ return crypto.timingSafeEqual(expected, Buffer.from(derived));
32
+ }
@@ -0,0 +1,17 @@
1
+ import crypto from "crypto";
2
+ export class SessionManager {
3
+ constructor() {
4
+ this.sessions = new Map();
5
+ }
6
+ createSession(username) {
7
+ const id = crypto.randomBytes(32).toString("base64url");
8
+ const info = { id, createdAt: Date.now(), username };
9
+ this.sessions.set(id, info);
10
+ return info;
11
+ }
12
+ getSession(id) {
13
+ if (!id)
14
+ return undefined;
15
+ return this.sessions.get(id);
16
+ }
17
+ }
@@ -0,0 +1,27 @@
1
+ import crypto from "crypto";
2
+ export class TokenManager {
3
+ constructor(ttlMs) {
4
+ this.ttlMs = ttlMs;
5
+ this.token = null;
6
+ }
7
+ generate() {
8
+ const token = crypto.randomBytes(32).toString("base64url");
9
+ this.token = { token, createdAt: Date.now(), consumed: false };
10
+ return token;
11
+ }
12
+ consume(token) {
13
+ if (!this.token)
14
+ return false;
15
+ if (this.token.consumed)
16
+ return false;
17
+ if (Date.now() - this.token.createdAt > this.ttlMs)
18
+ return false;
19
+ if (token !== this.token.token)
20
+ return false;
21
+ this.token.consumed = true;
22
+ return true;
23
+ }
24
+ peek() {
25
+ return this.token?.token ?? null;
26
+ }
27
+ }
@@ -6,6 +6,7 @@ const ROOT_DIR = ".codenomad/background_processes";
6
6
  const INDEX_FILE = "index.json";
7
7
  const OUTPUT_FILE = "output.txt";
8
8
  const STOP_TIMEOUT_MS = 2000;
9
+ const EXIT_WAIT_TIMEOUT_MS = 5000;
9
10
  const MAX_OUTPUT_BYTES = 20 * 1024;
10
11
  const OUTPUT_PUBLISH_INTERVAL_MS = 1000;
11
12
  export class BackgroundProcessManager {
@@ -35,6 +36,10 @@ export class BackgroundProcessManager {
35
36
  const child = spawn("bash", ["-c", command], {
36
37
  cwd: workspace.path,
37
38
  stdio: ["ignore", "pipe", "pipe"],
39
+ detached: process.platform !== "win32",
40
+ });
41
+ child.on("exit", () => {
42
+ this.killProcessTree(child, "SIGTERM");
38
43
  });
39
44
  const record = {
40
45
  id,
@@ -60,7 +65,7 @@ export class BackgroundProcessManager {
60
65
  resolve();
61
66
  });
62
67
  });
63
- this.running.set(id, { child, outputPath, exitPromise, workspaceId });
68
+ this.running.set(id, { id, child, outputPath, exitPromise, workspaceId });
64
69
  let lastPublishAt = 0;
65
70
  const maybePublishSize = () => {
66
71
  const now = Date.now();
@@ -92,7 +97,7 @@ export class BackgroundProcessManager {
92
97
  }
93
98
  const running = this.running.get(processId);
94
99
  if (running?.child && !running.child.killed) {
95
- running.child.kill("SIGTERM");
100
+ this.killProcessTree(running.child, "SIGTERM");
96
101
  await this.waitForExit(running);
97
102
  }
98
103
  if (record.status === "running") {
@@ -110,7 +115,7 @@ export class BackgroundProcessManager {
110
115
  return;
111
116
  const running = this.running.get(processId);
112
117
  if (running?.child && !running.child.killed) {
113
- running.child.kill("SIGTERM");
118
+ this.killProcessTree(running.child, "SIGTERM");
114
119
  await this.waitForExit(running);
115
120
  }
116
121
  await this.removeFromIndex(workspaceId, processId);
@@ -197,22 +202,57 @@ export class BackgroundProcessManager {
197
202
  for (const [, running] of this.running.entries()) {
198
203
  if (running.workspaceId !== workspaceId)
199
204
  continue;
200
- running.child.kill("SIGTERM");
205
+ this.killProcessTree(running.child, "SIGTERM");
201
206
  await this.waitForExit(running);
202
207
  }
203
208
  await this.removeWorkspaceDir(workspaceId);
204
209
  }
210
+ killProcessTree(child, signal) {
211
+ const pid = child.pid;
212
+ if (!pid)
213
+ return;
214
+ if (process.platform !== "win32") {
215
+ try {
216
+ process.kill(-pid, signal);
217
+ return;
218
+ }
219
+ catch {
220
+ // Fall back to killing the direct child.
221
+ }
222
+ }
223
+ try {
224
+ child.kill(signal);
225
+ }
226
+ catch {
227
+ // ignore
228
+ }
229
+ }
205
230
  async waitForExit(running) {
206
- let resolved = false;
207
- const timeout = setTimeout(() => {
208
- if (!resolved) {
209
- running.child.kill("SIGKILL");
231
+ let exited = false;
232
+ const exitPromise = running.exitPromise.finally(() => {
233
+ exited = true;
234
+ });
235
+ const killTimeout = setTimeout(() => {
236
+ if (!exited) {
237
+ this.killProcessTree(running.child, "SIGKILL");
210
238
  }
211
239
  }, STOP_TIMEOUT_MS);
212
- await running.exitPromise.finally(() => {
213
- resolved = true;
214
- clearTimeout(timeout);
215
- });
240
+ try {
241
+ await Promise.race([
242
+ exitPromise,
243
+ new Promise((resolve) => {
244
+ setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS);
245
+ }),
246
+ ]);
247
+ if (!exited) {
248
+ this.killProcessTree(running.child, "SIGKILL");
249
+ this.running.delete(running.id);
250
+ this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit");
251
+ }
252
+ }
253
+ finally {
254
+ clearTimeout(killTimeout);
255
+ }
216
256
  }
217
257
  statusFromExit(code) {
218
258
  if (code === null)
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { InstanceEventBridge } from "./workspaces/instance-events";
17
17
  import { createLogger } from "./logger";
18
18
  import { launchInBrowser } from "./launcher";
19
19
  import { startReleaseMonitor } from "./releases/release-monitor";
20
+ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager";
20
21
  const require = createRequire(import.meta.url);
21
22
  const packageJson = require("../package.json");
22
23
  const __filename = fileURLToPath(import.meta.url);
@@ -40,7 +41,14 @@ function parseCliOptions(argv) {
40
41
  .addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
41
42
  .addOption(new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR))
42
43
  .addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
43
- .addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false));
44
+ .addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
45
+ .addOption(new Option("--username <username>", "Username for server authentication")
46
+ .env("CODENOMAD_SERVER_USERNAME")
47
+ .default(DEFAULT_AUTH_USERNAME))
48
+ .addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
49
+ .addOption(new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
50
+ .env("CODENOMAD_GENERATE_TOKEN")
51
+ .default(false));
44
52
  program.parse(argv, { from: "user" });
45
53
  const parsed = program.opts();
46
54
  const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd();
@@ -56,6 +64,9 @@ function parseCliOptions(argv) {
56
64
  uiStaticDir: parsed.uiDir,
57
65
  uiDevServer: parsed.uiDevServer,
58
66
  launch: Boolean(parsed.launch),
67
+ authUsername: parsed.username,
68
+ authPassword: parsed.password,
69
+ generateToken: Boolean(parsed.generateToken),
59
70
  };
60
71
  }
61
72
  function parsePort(input) {
@@ -77,7 +88,11 @@ async function main() {
77
88
  const workspaceLogger = logger.child({ component: "workspace" });
78
89
  const configLogger = logger.child({ component: "config" });
79
90
  const eventLogger = logger.child({ component: "events" });
80
- logger.info({ options }, "Starting CodeNomad CLI server");
91
+ const logOptions = {
92
+ ...options,
93
+ authPassword: options.authPassword ? "[REDACTED]" : undefined,
94
+ };
95
+ logger.info({ options: logOptions }, "Starting CodeNomad CLI server");
81
96
  const eventBus = new EventBus(eventLogger);
82
97
  const serverMeta = {
83
98
  httpBaseUrl: `http://${options.host}:${options.port}`,
@@ -89,6 +104,18 @@ async function main() {
89
104
  workspaceRoot: options.rootDir,
90
105
  addresses: [],
91
106
  };
107
+ const authManager = new AuthManager({
108
+ configPath: options.configPath,
109
+ username: options.authUsername,
110
+ password: options.authPassword,
111
+ generateToken: options.generateToken,
112
+ }, logger.child({ component: "auth" }));
113
+ if (options.generateToken) {
114
+ const token = authManager.issueBootstrapToken();
115
+ if (token) {
116
+ console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`);
117
+ }
118
+ }
92
119
  const configStore = new ConfigStore(options.configPath, eventBus, configLogger);
93
120
  const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger);
94
121
  const workspaceManager = new WorkspaceManager({
@@ -129,6 +156,7 @@ async function main() {
129
156
  eventBus,
130
157
  serverMeta,
131
158
  instanceStore,
159
+ authManager,
132
160
  uiStaticDir: options.uiStaticDir,
133
161
  uiDevServerUrl: options.uiDevServer,
134
162
  logger,
@@ -3,6 +3,6 @@
3
3
  "version": "0.5.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@opencode-ai/plugin": "1.1.1"
6
+ "@opencode-ai/plugin": "1.1.12"
7
7
  }
8
8
  }
@@ -1,5 +1,6 @@
1
1
  import path from "path"
2
2
  import { tool } from "@opencode-ai/plugin/tool"
3
+ import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
3
4
 
4
5
  type BackgroundProcess = {
5
6
  id: string
@@ -12,11 +13,6 @@ type BackgroundProcess = {
12
13
  outputSizeBytes?: number
13
14
  }
14
15
 
15
- type CodeNomadConfig = {
16
- instanceId: string
17
- baseUrl: string
18
- }
19
-
20
16
  type BackgroundProcessOptions = {
21
17
  baseDir: string
22
18
  }
@@ -27,30 +23,10 @@ type ParsedCommand = {
27
23
  }
28
24
 
29
25
  export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
30
- const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
31
-
32
- const base = config.baseUrl.replace(/\/+$/, "")
33
- const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
34
- const headers = normalizeHeaders(init?.headers)
35
- if (init?.body !== undefined) {
36
- headers["Content-Type"] = "application/json"
37
- }
38
-
39
- const response = await fetch(url, {
40
- ...init,
41
- headers,
42
- })
26
+ const requester = createCodeNomadRequester(config)
43
27
 
44
- if (!response.ok) {
45
- const message = await response.text()
46
- throw new Error(message || `Request failed with ${response.status}`)
47
- }
48
-
49
- if (response.status === 204) {
50
- return undefined as T
51
- }
52
-
53
- return (await response.json()) as T
28
+ const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
29
+ return requester.requestJson<T>(`/background-processes${path}`, init)
54
30
  }
55
31
 
56
32
  return {
@@ -249,13 +225,7 @@ function tokenize(input: string): string[] {
249
225
 
250
226
  if (char === "|" || char === "&" || char === ";") {
251
227
  flush()
252
- const next = input[index + 1]
253
- if ((char === "|" || char === "&") && next === char) {
254
- tokens.push(char + next)
255
- index += 1
256
- } else {
257
- tokens.push(char)
258
- }
228
+ tokens.push(char)
259
229
  continue
260
230
  }
261
231
 
@@ -266,44 +236,18 @@ function tokenize(input: string): string[] {
266
236
  return tokens
267
237
  }
268
238
 
269
- function isSeparator(token: string) {
270
- return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
239
+ function isSeparator(token: string): boolean {
240
+ return token === "|" || token === "&" || token === ";"
271
241
  }
272
242
 
273
- function unquote(value: string) {
274
- if (value.length >= 2) {
275
- const first = value[0]
276
- const last = value[value.length - 1]
277
- if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
278
- return value.slice(1, -1)
279
- }
243
+ function unquote(token: string): string {
244
+ if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
245
+ return token.slice(1, -1)
280
246
  }
281
- return value
247
+ return token
282
248
  }
283
249
 
284
- function isWithinBase(baseDir: string, target: string) {
285
- const relative = path.relative(baseDir, target)
286
- if (!relative) return true
287
- return !relative.startsWith("..") && !path.isAbsolute(relative)
288
- }
289
-
290
- function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
291
- const output: Record<string, string> = {}
292
- if (!headers) return output
293
-
294
- if (headers instanceof Headers) {
295
- headers.forEach((value, key) => {
296
- output[key] = value
297
- })
298
- return output
299
- }
300
-
301
- if (Array.isArray(headers)) {
302
- for (const [key, value] of headers) {
303
- output[key] = value
304
- }
305
- return output
306
- }
307
-
308
- return { ...headers }
250
+ function isWithinBase(base: string, candidate: string): boolean {
251
+ const relative = path.relative(base, candidate)
252
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
309
253
  }