@dbx-tools/core 0.1.2

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/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @dbx-tools/node-core
2
+
3
+ Node-only core helpers for process execution and project discovery.
4
+
5
+ Import this package when code needs `node:child_process`, `node:fs`, or
6
+ `node:path`. Browser-safe utilities live in
7
+ [`@dbx-tools/shared-core`](../../shared/core).
8
+
9
+ Key features:
10
+
11
+ - Async and sync process execution with consistent stdio handling.
12
+ - AbortSignal support for long-running subprocesses.
13
+ - Small shell-like argument splitting for command strings that must become argv
14
+ arrays.
15
+ - Workspace/project root discovery from package-manager files, git metadata, and
16
+ the current working directory.
17
+ - Safe filesystem stat and project naming helpers for CLIs and projen synth.
18
+
19
+ ## Run Commands
20
+
21
+ ```ts
22
+ import { exec } from "@dbx-tools/node-core";
23
+
24
+ const result = await exec.spawn("git", ["status", "--short"], {
25
+ stdout: "capture",
26
+ stderr: "capture",
27
+ });
28
+
29
+ if (result.exitCode !== 0) {
30
+ throw new Error(result.stderr);
31
+ }
32
+ ```
33
+
34
+ `exec.spawn()` supports inherited, piped, ignored, captured, and line-callback
35
+ stdio. It accepts string stdin and abort signals, making it useful for CLIs and
36
+ watch tasks.
37
+
38
+ Prefer `spawn()` over ad hoc `child_process` calls when command output needs to
39
+ be captured, streamed line-by-line, or aborted consistently from higher-level
40
+ tooling.
41
+
42
+ ## Run Synchronously
43
+
44
+ ```ts
45
+ const rev = exec
46
+ .spawnSync("git", ["rev-parse", "HEAD"], {
47
+ stdout: "capture",
48
+ })
49
+ .stdout.trim();
50
+ ```
51
+
52
+ Use `spawnSync()` during projen synthesis or config discovery where async
53
+ control flow is not available.
54
+
55
+ ## Split Shell-Like Commands
56
+
57
+ ```ts
58
+ const argv = exec.shlex('pnpm exec prettier --write "README.md"');
59
+ ```
60
+
61
+ `shlex()` is a small parser for command strings that need to become argv arrays.
62
+ Prefer explicit argv arrays when possible.
63
+
64
+ ## Discover Project Roots
65
+
66
+ ```ts
67
+ import { project } from "@dbx-tools/node-core";
68
+
69
+ const root = project.root();
70
+ const name = project.name();
71
+ const origins = [...project.resolveProjectRoots(process.cwd())];
72
+ ```
73
+
74
+ `project.root()` checks npm/pnpm workspace roots, git top-level, and cwd.
75
+ `project.name()` prefers package metadata, then git remote name, then directory
76
+ basename. `project.stat()` returns `undefined` instead of throwing.
77
+
78
+ ## Modules
79
+
80
+ - `exec` - async/sync process spawning, stdio handling, abort wiring, and shlex.
81
+ - `project` - root discovery, project naming, git-remote parsing, and safe
82
+ filesystem stat.
package/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+
5
+ export * as exec from "./src/exec";
6
+ export * as project from "./src/project";
7
+ export type { ExecStdio, LineHandler, StdioOption, ExecResult, ExecOptions, SyncExecStdio, SyncExecOptions, SpawnArgs } from "./src/exec";
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@dbx-tools/core",
3
+ "devDependencies": {
4
+ "@types/node": "^24.6.0",
5
+ "tsx": "^4.23.0",
6
+ "typescript": "^5.9.3"
7
+ },
8
+ "dependencies": {
9
+ "@dbx-tools/shared-core": "0.1.2"
10
+ },
11
+ "main": "index.ts",
12
+ "license": "UNLICENSED",
13
+ "version": "0.1.2",
14
+ "types": "index.ts",
15
+ "type": "module",
16
+ "exports": {
17
+ ".": "./index.ts",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "dbxToolsConfig": {
21
+ "tags": [
22
+ "node"
23
+ ]
24
+ },
25
+ "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
26
+ "scripts": {
27
+ "build": "projen build",
28
+ "compile": "projen compile",
29
+ "default": "projen default",
30
+ "package": "projen package",
31
+ "post-compile": "projen post-compile",
32
+ "pre-compile": "projen pre-compile",
33
+ "test": "projen test",
34
+ "watch": "projen watch",
35
+ "projen": "projen"
36
+ }
37
+ }
package/src/exec.ts ADDED
@@ -0,0 +1,643 @@
1
+ /**
2
+ * Portable subprocess helper built on `child_process.spawn` and line streaming.
3
+ *
4
+ * Ported from `dbx-tools-js/packages/cli/src/exec.ts`. Each stdio fd defaults to
5
+ * `"inherit"`. {@link spawn} streams output line-by-line into {@link ExecResult.stdoutLines}
6
+ * / {@link ExecResult.stderrLines}; its `stdout` / `stderr` getters join those lines.
7
+ * {@link spawnSync} keeps the captured string; its `stdout` / `stderr` getters read
8
+ * that string directly (line arrays split lazily on read).
9
+ * Omitted `trim` (default) applies adaptive normalization: {@link spawnSync} drops
10
+ * at most one trailing empty line / newline `spawnSync` adds; {@link spawn} does
11
+ * not (readline never emits that extra line). `trim: true` strips all leading/
12
+ * trailing whitespace in both modes; `trim: false` leaves output unchanged.
13
+ *
14
+ * @example Capture command output
15
+ * ```ts
16
+ * const { stdout } = await exec("git", ["rev-parse", "--show-toplevel"], {
17
+ * stdout: "capture",
18
+ * stderr: "ignore",
19
+ * stdin: "ignore",
20
+ * });
21
+ * ```
22
+ *
23
+ * @example Stream and capture together
24
+ * ```ts
25
+ * await exec("pnpm", ["install"], {
26
+ * stdout: [(line) => console.log(line), "capture"],
27
+ * check: true,
28
+ * });
29
+ * ```
30
+ *
31
+ * @example Synchronous capture (no line callbacks)
32
+ * ```ts
33
+ * const { stdout } = execSync("git", ["rev-parse", "--show-toplevel"], {
34
+ * stdout: "capture",
35
+ * stderr: "ignore",
36
+ * stdin: "ignore",
37
+ * });
38
+ * ```
39
+ */
40
+ import { type ChildProcess, type SpawnOptions, spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process";
41
+ import * as readline from "node:readline";
42
+ import { Readable } from "node:stream";
43
+
44
+ /** Stdio mode for a subprocess fd. */
45
+ export type ExecStdio = "inherit" | "pipe" | "ignore";
46
+
47
+ /** Invoked once per output line when a fd is piped. */
48
+ export type LineHandler = (line: string) => void;
49
+
50
+ /**
51
+ * Stdio config for one fd.
52
+ *
53
+ * - `"inherit"` / `"pipe"` / `"ignore"` — pass through to `spawn`
54
+ * - `"capture"` — pipe the fd and append each line to the result
55
+ * - {@link LineHandler} — pipe and invoke the handler per line (lines are still captured)
56
+ * - `(LineHandler | "capture")[]` — pipe; `"capture"` is a no-op marker, handlers run per line
57
+ */
58
+ export type StdioOption = ExecStdio | LineHandler | "capture" | (LineHandler | "capture")[];
59
+
60
+ /** Outcome of {@link spawn} / {@link spawnSync}: exit code, captured output, and line views. */
61
+ export type ExecResult = {
62
+ exitCode: number;
63
+ /**
64
+ * Captured stdout lines. For {@link spawn} these are built while the process runs;
65
+ * for {@link spawnSync} they are split from the captured string on first read.
66
+ */
67
+ readonly stdoutLines: string[];
68
+ /**
69
+ * Captured stderr lines. For {@link spawn} these are built while the process runs;
70
+ * for {@link spawnSync} they are split from the captured string on first read.
71
+ */
72
+ readonly stderrLines: string[];
73
+ /**
74
+ * Captured stdout text. {@link spawnSync} reads the captured string;
75
+ * {@link spawn} joins {@link stdoutLines}. See `trim` for normalization.
76
+ */
77
+ readonly stdout: string;
78
+ /**
79
+ * Captured stderr text. {@link spawnSync} reads the captured string;
80
+ * {@link spawn} joins {@link stderrLines}. See `trim` for normalization.
81
+ */
82
+ readonly stderr: string;
83
+ };
84
+
85
+ /** Options for {@link spawn}. Extends `SpawnOptions` except `stdio`, which is driven by `stdin` / `stdout` / `stderr`. */
86
+ export type ExecOptions = Omit<SpawnOptions, "stdio"> & {
87
+ /** `"inherit"` by default, or a string written to the process stdin. */
88
+ stdin?: ExecStdio | string;
89
+ stdout?: StdioOption;
90
+ stderr?: StdioOption;
91
+ /** Throw when the process exits with a non-zero code. */
92
+ check?: boolean;
93
+ /**
94
+ * Omitted — adaptive trim ({@link spawnSync} drops one spawn trailing newline,
95
+ * {@link spawn} does not); `true` — strip all leading/trailing whitespace;
96
+ * `false` — leave captured output unchanged.
97
+ */
98
+ trim?: boolean;
99
+ };
100
+
101
+ /** Stdio mode for {@link spawnSync} (no per-line callbacks). */
102
+ export type SyncExecStdio = ExecStdio | "capture";
103
+
104
+ /** Options for {@link spawnSync}. Same shape as {@link ExecOptions} but without line-handler stdio. */
105
+ export type SyncExecOptions = Omit<SpawnOptions, "stdio"> & {
106
+ /** `"inherit"` by default, or a string written to the process stdin. */
107
+ stdin?: ExecStdio | string;
108
+ stdout?: SyncExecStdio;
109
+ stderr?: SyncExecStdio;
110
+ /** Throw when the process exits with a non-zero code. */
111
+ check?: boolean;
112
+ /**
113
+ * Omitted — adaptive trim ({@link spawnSync} drops one spawn trailing newline,
114
+ * {@link spawn} does not); `true` — strip all leading/trailing whitespace;
115
+ * `false` — leave captured output unchanged.
116
+ */
117
+ trim?: boolean;
118
+ };
119
+
120
+ /**
121
+ * Spawn stdio mode plus an optional per-line callback after {@link resolveStdio}
122
+ * maps a {@link StdioOption} into something `spawn` can consume.
123
+ */
124
+ type ResolvedStdio = {
125
+ /** Value passed to `spawn`'s `stdio` tuple for this fd. */
126
+ mode: ExecStdio;
127
+ /** When set, each output line is appended to the capture buffer and forwarded here. */
128
+ onLine?: LineHandler;
129
+ };
130
+
131
+ export type SpawnArgs<T extends SpawnOptions> =
132
+ | [command: string, ...args: string[]]
133
+ | [command: string, args: readonly string[]]
134
+ | [command: string, args: readonly string[], options: T]
135
+ | [command: string, ...argsAndOptions: [...string[], T]];
136
+
137
+
138
+ interface ParsedSpawnArgs<T extends SpawnOptions> {
139
+ command: string;
140
+ commandArgs: string[];
141
+ options?: T;
142
+ }
143
+
144
+
145
+ function parseSpawnArgs<T extends SpawnOptions>(input: SpawnArgs<T>): ParsedSpawnArgs<T> {
146
+ let [value, ...values] = input;
147
+ const [command, ...commandArgs] = shlex(value);
148
+ if (commandArgs.length == 1 && values.length === 0) {
149
+ return {
150
+ command,
151
+ commandArgs: [],
152
+ };
153
+ }
154
+
155
+ const last = values.at(-1);
156
+ const options = (
157
+ last !== null &&
158
+ typeof last === "object" &&
159
+ !Array.isArray(last)
160
+ ) ? last : undefined;
161
+ const argumentValues = options ? values.slice(0, -1) : values;
162
+ const valueArgs: string[] = (
163
+ argumentValues.length === 1 &&
164
+ Array.isArray(argumentValues[0])
165
+ ) ? [...argumentValues[0]] : argumentValues
166
+
167
+
168
+ return {
169
+ command,
170
+ commandArgs: [...commandArgs, ...valueArgs],
171
+ options: options as T,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Drop the single trailing empty entry `spawnSync` adds when splitting on a final
177
+ * newline (`"hi\n"` -> `["hi", ""]`). Does not remove multiple trailing empties.
178
+ */
179
+ function withoutSpawnTrailingEmptyLine(lines: readonly string[]): string[] {
180
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
181
+ return lines.slice(0, -1);
182
+ }
183
+ return lines as string[];
184
+ }
185
+
186
+ /** Remove at most one trailing newline from captured spawn stdout/stderr text. */
187
+ function trimSingleTrailingNewline(text: string): string {
188
+ if (text.endsWith("\r\n")) return text.slice(0, -2);
189
+ if (text.endsWith("\n")) return text.slice(0, -1);
190
+ return text;
191
+ }
192
+
193
+ /** Line-array view of async-captured output. */
194
+ function normalizedAsyncLines(lines: readonly string[], trim: boolean | undefined): string[] {
195
+ if (trim === true) return linesFromCapturedOutput(lines.join("\n").trim());
196
+ return lines as string[];
197
+ }
198
+
199
+ /** Join async-captured lines into stdout/stderr text. */
200
+ function formatAsyncCapturedLines(lines: readonly string[], trim: boolean | undefined): string {
201
+ const joined = lines.join("\n");
202
+ return trim === true ? joined.trim() : joined;
203
+ }
204
+
205
+ /** Line-array view of sync-captured output. */
206
+ function normalizedSyncLines(lines: readonly string[], trim: boolean | undefined): string[] {
207
+ if (trim === false) return lines as string[];
208
+ if (trim === true) return linesFromCapturedOutput(lines.join("\n").trim());
209
+ return withoutSpawnTrailingEmptyLine(lines);
210
+ }
211
+
212
+ /** Format sync-captured stdout/stderr text. */
213
+ function formatSyncCapturedText(
214
+ text: string | undefined,
215
+ trim: boolean | undefined,
216
+ ): string | undefined {
217
+ if (text === undefined) return undefined;
218
+ if (trim === false) return text;
219
+ if (trim === true) return text.trim();
220
+ return trimSingleTrailingNewline(text);
221
+ }
222
+
223
+ /**
224
+ * Human-readable label for a spawned command, used in error messages.
225
+ *
226
+ * @param command - Executable name or path
227
+ * @param args - Arguments passed to the executable
228
+ * @returns Backtick-wrapped `command arg1 arg2 ...` string
229
+ */
230
+ function commandLabel(command: string, args: string[]): string {
231
+ return `\`${command} ${args.join(" ")}\``;
232
+ }
233
+
234
+ /**
235
+ * Build the object returned from {@link spawn} with live line arrays and lazy
236
+ * trimmed `stdout` / `stderr` getters derived from those lines.
237
+ */
238
+ function createExecResult(
239
+ exitCode: number,
240
+ stdoutLines: string[],
241
+ stderrLines: string[],
242
+ trim: boolean | undefined,
243
+ ): ExecResult {
244
+ return {
245
+ exitCode,
246
+ get stdoutLines() {
247
+ return normalizedAsyncLines(stdoutLines, trim);
248
+ },
249
+ get stderrLines() {
250
+ return normalizedAsyncLines(stderrLines, trim);
251
+ },
252
+ get stdout() {
253
+ return formatAsyncCapturedLines(stdoutLines, trim);
254
+ },
255
+ get stderr() {
256
+ return formatAsyncCapturedLines(stderrLines, trim);
257
+ },
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Build the object returned from {@link spawnSync}. Captured strings are stored
263
+ * as-is; `stdout` / `stderr` trim directly, and line arrays split only on read.
264
+ */
265
+ function createSyncExecResult(
266
+ exitCode: number,
267
+ stdoutText: string | undefined,
268
+ stderrText: string | undefined,
269
+ trim: boolean | undefined,
270
+ ): ExecResult {
271
+ let stdoutLinesCache: string[] | undefined;
272
+ let stderrLinesCache: string[] | undefined;
273
+
274
+ const syncLines = (text: string | undefined): string[] | undefined => {
275
+ if (text === undefined) return undefined;
276
+ return normalizedSyncLines(linesFromCapturedOutput(text), trim);
277
+ };
278
+
279
+ return {
280
+ exitCode,
281
+ get stdoutLines() {
282
+ if (stdoutText === undefined) return [];
283
+ stdoutLinesCache ??= syncLines(stdoutText) ?? [];
284
+ return stdoutLinesCache;
285
+ },
286
+ get stderrLines() {
287
+ if (stderrText === undefined) return [];
288
+ stderrLinesCache ??= syncLines(stderrText) ?? [];
289
+ return stderrLinesCache;
290
+ },
291
+ get stdout() {
292
+ return formatSyncCapturedText(stdoutText, trim) ?? "";
293
+ },
294
+ get stderr() {
295
+ return formatSyncCapturedText(stderrText, trim) ?? "";
296
+ },
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Extract user-supplied line handlers from a {@link StdioOption}.
302
+ *
303
+ * The `"capture"` marker is filtered out; capture itself is always handled by
304
+ * pushing into the line buffer inside {@link resolveStdio}.
305
+ *
306
+ * @param option - Stdio option that may embed one or more handlers
307
+ * @returns Handlers to invoke after each captured line (may be empty)
308
+ */
309
+ function lineHandlers(option: StdioOption): LineHandler[] {
310
+ if (typeof option === "function") return [option];
311
+ if (Array.isArray(option)) {
312
+ return option.filter((item): item is LineHandler => item !== "capture");
313
+ }
314
+ return [];
315
+ }
316
+
317
+ /**
318
+ * True when a stdio option is a string spawn mode rather than capture/handlers.
319
+ *
320
+ * {@link resolveStdio} still treats `"pipe"` as pipe-and-capture; only
321
+ * `"inherit"` and `"ignore"` return without a line callback.
322
+ *
323
+ * @param option - Stdio option to classify
324
+ * @returns Whether `option` is `"inherit"`, `"pipe"`, or `"ignore"`
325
+ */
326
+ function isPassthroughMode(option: StdioOption): option is ExecStdio {
327
+ return option === "inherit" || option === "pipe" || option === "ignore";
328
+ }
329
+
330
+ /**
331
+ * Map a {@link StdioOption} into a spawn stdio mode and optional line callback.
332
+ *
333
+ * Piped modes (`"capture"`, `"pipe"`, handlers, arrays) append every line to
334
+ * `lines` and invoke any embedded handlers. Omitted options use `defaultMode`.
335
+ *
336
+ * @param option - Caller stdio preference for one fd
337
+ * @param lines - Mutable buffer that receives each piped line
338
+ * @param defaultMode - Spawn mode when `option` is omitted (`"inherit"` by default)
339
+ * @returns Resolved spawn mode and optional per-line callback
340
+ */
341
+ function resolveStdio(
342
+ option: StdioOption | undefined,
343
+ lines: string[],
344
+ defaultMode: ExecStdio = "inherit",
345
+ ): ResolvedStdio {
346
+ if (option === undefined) return { mode: defaultMode };
347
+ if (isPassthroughMode(option) && option !== "pipe") return { mode: option };
348
+
349
+ const handlers = lineHandlers(option);
350
+ return {
351
+ mode: "pipe",
352
+ onLine: (line) => {
353
+ lines.push(line);
354
+ for (const handler of handlers) handler(line);
355
+ },
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Read a readable stream line-by-line and invoke `onLine` for each chunk.
361
+ *
362
+ * Uses `readline` so `\r\n` and bare `\n` are normalized. The interface is
363
+ * always closed in a `finally` block.
364
+ *
365
+ * @param stream - Subprocess stdout or stderr stream
366
+ * @param onLine - Callback invoked once per output line
367
+ */
368
+ async function readLines(stream: Readable, onLine: LineHandler): Promise<void> {
369
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
370
+ try {
371
+ for await (const line of rl) onLine(line);
372
+ } finally {
373
+ rl.close();
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Start a background line read when the resolved handler pipes a stream.
379
+ *
380
+ * @param reads - Promise list awaited before returning the {@link ExecResult}
381
+ * @param stream - Subprocess stream for this fd (`null` when unavailable)
382
+ * @param handler - Resolved stdio config from {@link resolveStdio}
383
+ */
384
+ function queueLineReads(
385
+ reads: Promise<void>[],
386
+ stream: Readable | null,
387
+ handler: ResolvedStdio,
388
+ ): void {
389
+ if (handler.onLine && stream) reads.push(readLines(stream, handler.onLine));
390
+ }
391
+
392
+ /**
393
+ * Write string stdin to a spawned process and close the stream.
394
+ *
395
+ * No-op unless `stdin` is a string and `proc.stdin` is available.
396
+ *
397
+ * @param proc - Child process returned from `spawn`
398
+ * @param stdin - Stdio mode or string payload from {@link ExecOptions}
399
+ */
400
+ function writeStdin(proc: ChildProcess, stdin: ExecStdio | string | undefined): void {
401
+ if (typeof stdin === "string" && proc.stdin) {
402
+ proc.stdin.write(stdin);
403
+ proc.stdin.end();
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Await process exit and normalize a missing exit code to `1`.
409
+ *
410
+ * Rejects when spawn fails before `close` (e.g. executable not found).
411
+ *
412
+ * @param proc - Child process returned from `spawn`
413
+ * @returns Resolved exit code
414
+ */
415
+ function waitForExit(proc: ChildProcess): Promise<number> {
416
+ return new Promise((resolve, reject) => {
417
+ proc.once("error", reject);
418
+ proc.once("close", (code) => resolve(code ?? 1));
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Build an `Error` for a non-zero exit when {@link ExecOptions.check} is set.
424
+ *
425
+ * Prefers trimmed stderr text, then stdout, in the message body.
426
+ *
427
+ * @param command - Executable name or path
428
+ * @param args - Arguments passed to the executable
429
+ * @param result - Completed exec outcome with captured output
430
+ * @returns Error suitable for throwing from {@link spawn}
431
+ */
432
+ function execError(command: string, args: string[], result: ExecResult): Error {
433
+ const detail = result.stderr || result.stdout;
434
+ return new Error(
435
+ `${commandLabel(command, args)} failed (exit ${result.exitCode})${detail ? `: ${detail}` : ""}`,
436
+ );
437
+ }
438
+
439
+ /**
440
+ * Map a {@link SyncExecStdio} option to a `spawnSync` stdio mode.
441
+ *
442
+ * `"capture"` pipes the fd so output can be read into the {@link ExecResult}.
443
+ *
444
+ * @param option - Caller stdio preference for one fd
445
+ * @param defaultMode - Spawn mode when `option` is omitted (`"inherit"` by default)
446
+ * @returns Value for the `spawnSync` stdio tuple
447
+ */
448
+ function resolveSyncStdio(
449
+ option: SyncExecStdio | undefined,
450
+ defaultMode: ExecStdio = "inherit",
451
+ ): ExecStdio {
452
+ if (option === undefined) return defaultMode;
453
+ if (option === "capture") return "pipe";
454
+ return option;
455
+ }
456
+
457
+ /**
458
+ * Normalize raw `spawnSync` output to a UTF-8 string when capture is enabled.
459
+ */
460
+ function capturedText(output: string | Buffer | null | undefined): string | undefined {
461
+ if (output === null || output === undefined) return undefined;
462
+ return typeof output === "string" ? output : output.toString("utf8");
463
+ }
464
+
465
+ /**
466
+ * Split captured process output into lines for {@link ExecResult.stdoutLines} /
467
+ * {@link ExecResult.stderrLines} ({@link spawnSync} only; invoked lazily).
468
+ */
469
+ function linesFromCapturedOutput(output: string): string[] {
470
+ return output.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
471
+ }
472
+
473
+ /**
474
+ * Spawn a subprocess and wait for exit.
475
+ *
476
+ * @param command - Executable to run (resolved on `PATH` when `shell` is set on options)
477
+ * @param args - Arguments passed verbatim to the executable
478
+ * @param options - Spawn, stdio, and check options
479
+ * @returns Exit code, captured line arrays, and trimmed `stdout` / `stderr` getters
480
+ * @throws When spawn fails, line reads fail, or `check` is true and exit code is non-zero
481
+ */
482
+ export async function spawn(
483
+ ...args: SpawnArgs<ExecOptions>
484
+ ): Promise<ExecResult> {
485
+ const { command, commandArgs, options = {} } = parseSpawnArgs(args);
486
+ const { stdin, stdout, stderr, check, trim, ...spawnOpts } = options
487
+ const stdoutLines: string[] = [];
488
+ const stderrLines: string[] = [];
489
+ const stdoutHandler = resolveStdio(stdout, stdoutLines);
490
+ const stderrHandler = resolveStdio(stderr, stderrLines);
491
+ const stdinMode: ExecStdio = typeof stdin === "string" ? "pipe" : (stdin ?? "inherit");
492
+
493
+ const proc = nodeSpawn(command, commandArgs, {
494
+ ...spawnOpts,
495
+ stdio: [stdinMode, stdoutHandler.mode, stderrHandler.mode],
496
+ });
497
+
498
+ writeStdin(proc, stdin);
499
+
500
+ const reads: Promise<void>[] = [];
501
+ queueLineReads(reads, proc.stdout, stdoutHandler);
502
+ queueLineReads(reads, proc.stderr, stderrHandler);
503
+
504
+ let exitCode = 1;
505
+ try {
506
+ exitCode = await waitForExit(proc);
507
+ await Promise.all(reads);
508
+ } catch (err) {
509
+ await Promise.allSettled(reads);
510
+ throw err;
511
+ }
512
+
513
+ const result = createExecResult(exitCode, stdoutLines, stderrLines, trim);
514
+ if (check && result.exitCode !== 0) throw execError(command, commandArgs, result);
515
+ return result;
516
+ }
517
+
518
+ /**
519
+ * Spawn a subprocess synchronously and wait for exit.
520
+ *
521
+ * Unlike {@link spawn}, stdio options are limited to `"inherit"`, `"pipe"`,
522
+ * `"ignore"`, and `"capture"` — no per-line callbacks.
523
+ *
524
+ * @param command - Executable to run (resolved on `PATH` when `shell` is set on options)
525
+ * @param args - Arguments passed verbatim to the executable
526
+ * @param spawnSync - Spawn, stdio, and check options
527
+ * @returns Exit code, captured line arrays, and trimmed `stdout` / `stderr` getters
528
+ * @throws When spawn fails or `check` is true and exit code is non-zero
529
+ */
530
+ export function spawnSync(
531
+ ...args: SpawnArgs<SyncExecOptions>
532
+ ): ExecResult {
533
+ const { command, commandArgs, options = {} } = parseSpawnArgs(args);
534
+ const { stdin, stdout, stderr, check, trim, ...spawnOpts } = options;
535
+ const stdinMode: ExecStdio = typeof stdin === "string" ? "pipe" : (stdin ?? "inherit");
536
+ const captureStdout = stdout === "capture";
537
+ const captureStderr = stderr === "capture";
538
+ const stdoutMode = resolveSyncStdio(stdout);
539
+ const stderrMode = resolveSyncStdio(stderr);
540
+
541
+ const result = nodeSpawnSync(command, commandArgs, {
542
+ ...spawnOpts,
543
+ encoding: captureStdout || captureStderr ? "utf8" : undefined,
544
+ stdio: [stdinMode, stdoutMode, stderrMode],
545
+ input: typeof stdin === "string" ? stdin : undefined,
546
+ });
547
+
548
+ const exitCode = result.status ?? 1;
549
+ const stdoutText = captureStdout ? capturedText(result.stdout) : undefined;
550
+ const stderrText = captureStderr ? capturedText(result.stderr) : undefined;
551
+ const execResult = createSyncExecResult(exitCode, stdoutText, stderrText, trim);
552
+
553
+ if (result.error) {
554
+ if (check || execResult.exitCode !== 0) {
555
+ const err = execError(command, commandArgs, execResult);
556
+ err.cause = result.error;
557
+ throw err;
558
+ }
559
+ }
560
+
561
+ if (check && execResult.exitCode !== 0) throw execError(command, commandArgs, execResult);
562
+ return execResult;
563
+ }
564
+
565
+ /**
566
+ * Splits a shell-like command into argv.
567
+ *
568
+ * Supports:
569
+ * - whitespace separators
570
+ * - single and double quotes
571
+ * - backslash escaping
572
+ * - escaped spaces
573
+ * - empty quoted strings
574
+ *
575
+ * If the input is malformed (for example, an unterminated quote),
576
+ * returns the original string as a single argument.
577
+ */
578
+ export function shlex(command: string): string[] {
579
+ const args: string[] = [];
580
+
581
+ let current = "";
582
+ let quote: "'" | '"' | undefined;
583
+ let escaped = false;
584
+ let quoted = false;
585
+
586
+ const push = () => {
587
+ if (quoted || current.length > 0) {
588
+ args.push(current);
589
+ }
590
+ current = "";
591
+ quoted = false;
592
+ };
593
+
594
+ for (const ch of command) {
595
+ if (escaped) {
596
+ current += ch;
597
+ escaped = false;
598
+ continue;
599
+ }
600
+
601
+ if (ch === "\\") {
602
+ escaped = true;
603
+ continue;
604
+ }
605
+
606
+ if (quote) {
607
+ if (ch === quote) {
608
+ quote = undefined;
609
+ quoted = true;
610
+ } else {
611
+ current += ch;
612
+ }
613
+ continue;
614
+ }
615
+
616
+ if (ch === "'" || ch === '"') {
617
+ quote = ch;
618
+ quoted = true;
619
+ continue;
620
+ }
621
+
622
+ if (/\s/.test(ch)) {
623
+ push();
624
+ continue;
625
+ }
626
+
627
+ current += ch;
628
+ }
629
+
630
+ // Malformed input: treat as a literal command.
631
+ if (quote) {
632
+ return [command];
633
+ }
634
+
635
+ // Trailing backslash is literal.
636
+ if (escaped) {
637
+ current += "\\";
638
+ }
639
+
640
+ push();
641
+
642
+ return args.length ? args : [command];
643
+ }
package/src/project.ts ADDED
@@ -0,0 +1,214 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { Stats, statSync } from "node:fs";
3
+ import { readFileSync } from "node:fs";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+
6
+ const ROOT_MARKERS = [
7
+ ".projenrc.ts",
8
+ ".projenrc.js",
9
+ ".projenrc.mjs",
10
+ ".projenrc.cjs",
11
+ "package.json",
12
+ ] as const;
13
+
14
+ function statPath(path: string): Stats | undefined {
15
+ if (path) {
16
+ try {
17
+ return statSync(path);
18
+ } catch {}
19
+ }
20
+ return undefined;
21
+ }
22
+
23
+ /**
24
+ * because this is crucial do not use exec.spawnSync
25
+ */
26
+ function directoryCommand(command: string, args: string[], cwd: string): string | undefined {
27
+ const result = spawnSync(command, args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
28
+ if (result.status === 0) {
29
+ const output = result.stdout.toString().trim();
30
+ return statPath(output)?.isDirectory() ? output : undefined;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ const rootDirectoryCommands: Record<string, [string, string[]]> = {
36
+ npm: ["npm", ["prefix"]] as const,
37
+ git: ["git", ["rev-parse", "--show-toplevel"]] as const,
38
+ } as const;
39
+
40
+ const rootDirectoryDefaultCache = new Map<
41
+ keyof typeof rootDirectoryCommands,
42
+ { cwd: string; path?: string }
43
+ >();
44
+
45
+ function rootDirectory(name: keyof typeof rootDirectoryCommands, cwd?: string): string | undefined {
46
+ const [command, args] = rootDirectoryCommands[name];
47
+ let cache: boolean;
48
+ if (cwd === undefined) {
49
+ cwd = process.cwd();
50
+ cache = true;
51
+ } else {
52
+ cache = cwd === process.cwd();
53
+ }
54
+ if (cache) {
55
+ const cached = rootDirectoryDefaultCache.get(name);
56
+ if (cached?.cwd === cwd) {
57
+ return cached!.path;
58
+ }
59
+ }
60
+ const path = directoryCommand(command, args, cwd);
61
+ if (cache) {
62
+ rootDirectoryDefaultCache.set(name, { cwd, path });
63
+ }
64
+ return path;
65
+ }
66
+ function npmRoot(cwd?: string): string | undefined {
67
+ return rootDirectory("npm", cwd);
68
+ }
69
+
70
+ function gitRoot(cwd?: string): string | undefined {
71
+ return rootDirectory("git", cwd);
72
+ }
73
+
74
+ export function root(cwd: string = process.cwd()): string | undefined {
75
+ let current = resolve(cwd);
76
+
77
+ if (!statPath(current)?.isDirectory()) {
78
+ current = dirname(current);
79
+ }
80
+ const boundaries = new Set(
81
+ [npmRoot(cwd), gitRoot(cwd)]
82
+ .filter((path): path is string => path !== undefined)
83
+ .map((path) => resolve(path)),
84
+ );
85
+ const hasBoundary = boundaries.size > 0;
86
+ let best: { dir: string; priority: number } | undefined;
87
+ while (true) {
88
+ for (const [priority, marker] of ROOT_MARKERS.entries()) {
89
+ if (statPath(join(current, marker))?.isFile()) {
90
+ if (
91
+ best === undefined ||
92
+ priority < best.priority ||
93
+ (priority === best.priority && current.length < best.dir.length)
94
+ ) {
95
+ best = { dir: current, priority };
96
+ }
97
+ break;
98
+ }
99
+ }
100
+ if (!hasBoundary && best) {
101
+ return best.dir;
102
+ }
103
+ if (boundaries.has(current)) {
104
+ return best?.dir;
105
+ }
106
+ const parent = dirname(current);
107
+ if (parent === current) {
108
+ return best?.dir;
109
+ }
110
+ current = parent;
111
+ }
112
+ }
113
+
114
+ /** Best-effort `fs.stat` (sync). Returns `undefined` when `path` can't be stat'd. */
115
+ export function stat(path: string): Stats | undefined {
116
+ return statPath(path);
117
+ }
118
+
119
+ /**
120
+ * Parse a git remote URL (`https://...`, `git@host:owner/repo.git`, etc.) and
121
+ * return the repo segment, stripping any `.git` suffix. Returns `undefined` for
122
+ * empty or unparsable input.
123
+ */
124
+ export function parseGitRemote(url: string): string | undefined {
125
+ const trimmed = url.trim();
126
+ if (!trimmed) return undefined;
127
+
128
+ const scp = /^[^@]+@[^:]+:(.+)$/i.exec(trimmed);
129
+ if (scp) return lastPathSegment(scp[1] ?? "");
130
+
131
+ try {
132
+ const normalized = trimmed.replace(/\.git$/i, "");
133
+ const pathname = new URL(normalized).pathname;
134
+ const segment = pathname.split("/").filter(Boolean).at(-1);
135
+ return segment ? lastPathSegment(segment) : undefined;
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ function lastPathSegment(path: string): string {
142
+ const segment = path.split("/").filter(Boolean).at(-1) ?? path;
143
+ return segment.replace(/\.git$/i, "");
144
+ }
145
+
146
+ /**
147
+ * Yield candidate project-root directories for `cwd`, in priority order: the
148
+ * `npm prefix`, the git top-level, then `cwd` itself. Duplicates are skipped;
149
+ * only existing directories are yielded (except the final `cwd` fallback).
150
+ */
151
+ export function* resolveProjectRoots(cwd: string = process.cwd()): Generator<string> {
152
+ const base = resolve(cwd);
153
+ const seen = new Set<string>();
154
+ for (const candidate of [npmRoot(base), gitRoot(base)]) {
155
+ if (!candidate) continue;
156
+ const dir = resolve(candidate);
157
+ if (seen.has(dir)) continue;
158
+ seen.add(dir);
159
+ if (statPath(dir)?.isDirectory()) yield dir;
160
+ }
161
+ if (!seen.has(base)) yield base;
162
+ }
163
+
164
+ /** The nearest ancestor of `cwd` (from {@link resolveProjectRoots}) with a `package.json`. */
165
+ function workspaceRoot(cwd: string = process.cwd()): string {
166
+ let last: string | undefined;
167
+ for (const dir of resolveProjectRoots(cwd)) {
168
+ if (statPath(resolve(dir, "package.json"))?.isFile()) return dir;
169
+ last = dir;
170
+ }
171
+ return last ?? resolve(cwd);
172
+ }
173
+
174
+ /**
175
+ * Resolve a human-friendly project name for the repo rooted at `cwd`:
176
+ * `package.json` `name`, then the git remote's repo name, then the root
177
+ * directory's basename.
178
+ */
179
+ export function name(cwd: string = process.cwd()): string {
180
+ const rootDir = workspaceRoot(cwd);
181
+
182
+ const fromPackage = readPackageName(resolve(rootDir, "package.json"));
183
+ if (fromPackage) return fromPackage;
184
+
185
+ const remote = commandOutput("git", ["-C", rootDir, "remote", "get-url", "origin"], rootDir);
186
+ const fromGit = remote ? parseGitRemote(remote) : undefined;
187
+ if (fromGit) return fromGit;
188
+
189
+ return basename(rootDir);
190
+ }
191
+
192
+ /** Trimmed stdout of a command, or `undefined` when it fails or prints nothing. */
193
+ function commandOutput(command: string, args: string[], cwd: string): string | undefined {
194
+ const result = spawnSync(command, args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
195
+ if (result.status !== 0) return undefined;
196
+ return result.stdout.toString().trim() || undefined;
197
+ }
198
+
199
+ function readPackageName(pkgPath: string): string | undefined {
200
+ if (!statPath(pkgPath)?.isFile()) return undefined;
201
+ try {
202
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string };
203
+ return pkg.name?.trim() || undefined;
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ if (import.meta.main) {
210
+ console.log("npm root:", npmRoot());
211
+ console.log("repo root:", gitRoot());
212
+ console.log("package root:", root());
213
+ console.log("project name:", name());
214
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,41 @@
1
+ // ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
2
+ {
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib",
6
+ "alwaysStrict": true,
7
+ "declaration": true,
8
+ "esModuleInterop": true,
9
+ "experimentalDecorators": true,
10
+ "inlineSourceMap": true,
11
+ "inlineSources": true,
12
+ "lib": [
13
+ "ES2022"
14
+ ],
15
+ "module": "ESNext",
16
+ "noEmitOnError": false,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noImplicitAny": true,
19
+ "noImplicitReturns": true,
20
+ "noImplicitThis": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "resolveJsonModule": true,
24
+ "strict": true,
25
+ "strictNullChecks": true,
26
+ "strictPropertyInitialization": true,
27
+ "stripInternal": true,
28
+ "target": "ES2022",
29
+ "types": [
30
+ "node"
31
+ ],
32
+ "moduleResolution": "bundler",
33
+ "skipLibCheck": true
34
+ },
35
+ "include": [
36
+ "src/**/*.ts"
37
+ ],
38
+ "exclude": [
39
+ "node_modules"
40
+ ]
41
+ }