@lablup/ui-common-cli 0.2.0-alpha.15

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/cli/diff.mjs ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * A line diff, enough for a dry-run summary (+/- counts) and a unified
3
+ * preview. Plain LCS over the lines that differ after trimming the common
4
+ * head and tail, which is all a codemod's edits ever are.
5
+ */
6
+
7
+ const MAX_CELLS = 25_000_000;
8
+
9
+ /**
10
+ * @param {string} a
11
+ * @param {string} b
12
+ * @returns {Array<{op: ' ' | '-' | '+', line: string}>}
13
+ */
14
+ export function diffLines(a, b) {
15
+ const x = a.split("\n");
16
+ const y = b.split("\n");
17
+ let head = 0;
18
+ while (head < x.length && head < y.length && x[head] === y[head]) head++;
19
+ let tail = 0;
20
+ while (
21
+ tail < x.length - head &&
22
+ tail < y.length - head &&
23
+ x[x.length - 1 - tail] === y[y.length - 1 - tail]
24
+ ) {
25
+ tail++;
26
+ }
27
+ const xs = x.slice(head, x.length - tail);
28
+ const ys = y.slice(head, y.length - tail);
29
+ /** @type {Array<{op: ' ' | '-' | '+', line: string}>} */
30
+ const middle = [];
31
+ if ((xs.length + 1) * (ys.length + 1) > MAX_CELLS) {
32
+ for (const line of xs) middle.push({ op: "-", line });
33
+ for (const line of ys) middle.push({ op: "+", line });
34
+ } else {
35
+ const n = xs.length;
36
+ const m = ys.length;
37
+ const table = new Uint32Array((n + 1) * (m + 1));
38
+ for (let i = n - 1; i >= 0; i--) {
39
+ for (let k = m - 1; k >= 0; k--) {
40
+ table[i * (m + 1) + k] =
41
+ xs[i] === ys[k]
42
+ ? table[(i + 1) * (m + 1) + k + 1] + 1
43
+ : Math.max(table[(i + 1) * (m + 1) + k], table[i * (m + 1) + k + 1]);
44
+ }
45
+ }
46
+ let i = 0;
47
+ let k = 0;
48
+ while (i < n && k < m) {
49
+ if (xs[i] === ys[k]) {
50
+ middle.push({ op: " ", line: xs[i] });
51
+ i++;
52
+ k++;
53
+ } else if (table[(i + 1) * (m + 1) + k] >= table[i * (m + 1) + k + 1]) {
54
+ middle.push({ op: "-", line: xs[i++] });
55
+ } else {
56
+ middle.push({ op: "+", line: ys[k++] });
57
+ }
58
+ }
59
+ while (i < n) middle.push({ op: "-", line: xs[i++] });
60
+ while (k < m) middle.push({ op: "+", line: ys[k++] });
61
+ }
62
+ return [
63
+ ...x.slice(0, head).map((line) => ({ op: /** @type {' '} */ (" "), line })),
64
+ ...middle,
65
+ ...x.slice(x.length - tail).map((line) => ({ op: /** @type {' '} */ (" "), line })),
66
+ ];
67
+ }
68
+
69
+ /** @param {string} a @param {string} b */
70
+ export function diffStat(a, b) {
71
+ let added = 0;
72
+ let removed = 0;
73
+ for (const { op } of diffLines(a, b)) {
74
+ if (op === "+") added++;
75
+ else if (op === "-") removed++;
76
+ }
77
+ return { added, removed };
78
+ }
79
+
80
+ /**
81
+ * A unified diff with three lines of context.
82
+ *
83
+ * @param {string} file
84
+ * @param {string} a before ('' for a new file)
85
+ * @param {string} b after
86
+ */
87
+ export function unifiedDiff(file, a, b, context = 3) {
88
+ const lines = diffLines(a, b);
89
+ const out = [`--- a/${file}`, `+++ b/${file}`];
90
+ let i = 0;
91
+ let oldLine = 1;
92
+ let newLine = 1;
93
+ while (i < lines.length) {
94
+ if (lines[i].op === " ") {
95
+ i++;
96
+ oldLine++;
97
+ newLine++;
98
+ continue;
99
+ }
100
+ const start = Math.max(0, i - context);
101
+ let end = i;
102
+ // Extend while changes are within 2*context of each other.
103
+ for (;;) {
104
+ while (end < lines.length && lines[end].op !== " ") end++;
105
+ let next = end;
106
+ while (next < lines.length && lines[next].op === " " && next - end < context * 2)
107
+ next++;
108
+ if (next < lines.length && lines[next].op !== " ") end = next;
109
+ else break;
110
+ }
111
+ const stop = Math.min(lines.length, end + context);
112
+ const lead = i - start;
113
+ const hunk = lines.slice(start, stop);
114
+ const oldCount = hunk.filter((l) => l.op !== "+").length;
115
+ const newCount = hunk.filter((l) => l.op !== "-").length;
116
+ out.push(`@@ -${oldLine - lead},${oldCount} +${newLine - lead},${newCount} @@`);
117
+ for (const l of hunk) out.push(`${l.op}${l.line}`);
118
+ for (let k = i; k < stop; k++) {
119
+ if (lines[k].op !== "+") oldLine++;
120
+ if (lines[k].op !== "-") newLine++;
121
+ }
122
+ i = stop;
123
+ }
124
+ return out.join("\n");
125
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `@astryxdesign/lab` is a canary with an exact peer on the core canary it was
3
+ * cut from, not on the stable core ui-common pins. Left alone, pnpm installs
4
+ * that canary core beside ui-common's and npm nests one under lab, so
5
+ * `@lablup/ui-common/lab` runs on a second copy of Astryx (two React
6
+ * contexts: lab components stop seeing the theme). One override per package
7
+ * manager points lab's core at ui-common's:
8
+ *
9
+ * - pnpm (`pnpm-workspace.yaml`): `"@astryxdesign/lab>@astryxdesign/core": "<pin>"`
10
+ * - npm (`package.json`): `"overrides": {"@astryxdesign/lab": {"@astryxdesign/core": "<pin>"}}`
11
+ *
12
+ * README carries both recipes at the core pin; `sync-astryx` moves them with
13
+ * the pin, a test fails when they go stale, and `ui-common upgrade` applies
14
+ * the one for the project's package manager when it adds lab.
15
+ */
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+
19
+ import { dependencyDir } from "./paths.mjs";
20
+
21
+ export const LAB = "@astryxdesign/lab";
22
+ export const CORE = "@astryxdesign/core";
23
+ export const PNPM_OVERRIDE_KEY = `${LAB}>${CORE}`;
24
+
25
+ /** The installed lab's declared core peer, or null when lab is not installed. */
26
+ export function labCorePeer() {
27
+ const dir = dependencyDir(LAB);
28
+ if (!dir) return null;
29
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
30
+ return pkg.peerDependencies?.[CORE] ?? null;
31
+ }
32
+
33
+ /** @param {string} pin */
34
+ export const pnpmOverrideLine = (pin) => `"${PNPM_OVERRIDE_KEY}": "${pin}"`;
35
+
36
+ /** @param {string} pin */
37
+ export const npmOverride = (pin) => ({ [LAB]: { [CORE]: pin } });
38
+
39
+ const esc = (/** @type {string} */ s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
40
+
41
+ // The value slot is group 2 in both.
42
+ const PNPM_LINE = new RegExp(
43
+ String.raw`^([ \t]*["']?${esc(PNPM_OVERRIDE_KEY)}["']?[ \t]*:[ \t]*)(["']?[^"'\s#]+["']?)`,
44
+ "m",
45
+ );
46
+ const NPM_ENTRY = new RegExp(
47
+ String.raw`("${esc(LAB)}"\s*:\s*\{\s*"${esc(CORE)}"\s*:\s*)("[^"]*")`,
48
+ );
49
+
50
+ const unquote = (/** @type {string} */ s) => s.replace(/^["']|["']$/g, "");
51
+
52
+ /**
53
+ * What is wrong with the recipes in `text` (README) for core `pin`: each one
54
+ * missing or at another version. Empty when both are current.
55
+ *
56
+ * @param {string} text
57
+ * @param {string} pin
58
+ * @returns {string[]}
59
+ */
60
+ export function labOverrideProblems(text, pin) {
61
+ const problems = [];
62
+ for (const [name, pattern] of /** @type {const} */ ([
63
+ ["pnpm", PNPM_LINE],
64
+ ["npm", NPM_ENTRY],
65
+ ])) {
66
+ const match = pattern.exec(text);
67
+ if (!match)
68
+ problems.push(`the ${name} override for ${LAB}'s ${CORE} peer is missing`);
69
+ else if (unquote(match[2]) !== pin)
70
+ problems.push(
71
+ `the ${name} override pins ${CORE} ${unquote(match[2])}, not ${pin}`,
72
+ );
73
+ }
74
+ return problems;
75
+ }
76
+
77
+ /**
78
+ * `text` with both recipes moved to `pin`. Everything else is left as it is.
79
+ *
80
+ * @param {string} text
81
+ * @param {string} pin
82
+ */
83
+ export function syncLabOverrideDocs(text, pin) {
84
+ return text
85
+ .replace(PNPM_LINE, (_m, head) => `${head}"${pin}"`)
86
+ .replace(NPM_ENTRY, (_m, head) => `${head}"${pin}"`);
87
+ }
88
+
89
+ /**
90
+ * The package manager a project installs with, from `packageManager` and the
91
+ * nearest lockfile or pnpm workspace file at or above `projectDir`.
92
+ *
93
+ * @param {string} projectDir
94
+ * @param {any} pkg the project's package.json
95
+ * @returns {{manager: 'pnpm' | 'npm' | 'yarn' | 'bun' | null, root: string, workspaceYaml: string | null}}
96
+ */
97
+ export function detectPackageManager(projectDir, pkg) {
98
+ const declared = /^(pnpm|npm|yarn|bun)@/.exec(pkg?.packageManager ?? "")?.[1] ?? null;
99
+ const markers = /** @type {const} */ ([
100
+ ["pnpm-workspace.yaml", "pnpm"],
101
+ ["pnpm-lock.yaml", "pnpm"],
102
+ ["package-lock.json", "npm"],
103
+ ["npm-shrinkwrap.json", "npm"],
104
+ ["yarn.lock", "yarn"],
105
+ ["bun.lock", "bun"],
106
+ ["bun.lockb", "bun"],
107
+ ]);
108
+ for (let dir = projectDir; ;) {
109
+ const hit = markers.find(([file]) => existsSync(join(dir, file)));
110
+ if (hit) {
111
+ const manager = declared ?? hit[1];
112
+ const workspaceYaml =
113
+ manager === "pnpm"
114
+ ? (nearestUp(dir, "pnpm-workspace.yaml") ?? join(dir, "pnpm-workspace.yaml"))
115
+ : null;
116
+ return { manager, root: dir, workspaceYaml };
117
+ }
118
+ const parent = dirname(dir);
119
+ if (parent === dir || existsSync(join(dir, ".git"))) break;
120
+ dir = parent;
121
+ }
122
+ return {
123
+ manager: declared,
124
+ root: projectDir,
125
+ workspaceYaml: declared === "pnpm" ? join(projectDir, "pnpm-workspace.yaml") : null,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * @param {string} start
131
+ * @param {string} file
132
+ */
133
+ function nearestUp(start, file) {
134
+ for (let dir = start; ;) {
135
+ if (existsSync(join(dir, file))) return join(dir, file);
136
+ const parent = dirname(dir);
137
+ if (parent === dir || existsSync(join(dir, ".git"))) return null;
138
+ dir = parent;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Both recipes, for a note where neither could be applied.
144
+ *
145
+ * @param {string} pin
146
+ */
147
+ export function recipesNote(pin) {
148
+ return `pnpm: \`overrides: { ${pnpmOverrideLine(pin)} }\` in pnpm-workspace.yaml; npm: \`"overrides": ${JSON.stringify(npmOverride(pin))}\` in the root package.json`;
149
+ }
150
+
151
+ /**
152
+ * Point lab's core peer at `pin` for one package manager.
153
+ *
154
+ * - npm: sets `pkg.overrides` in place (`pkg` is the parsed package.json).
155
+ * - pnpm: returns the edited `workspaceYaml` text (`null` in: a new file), or
156
+ * undefined when it already holds the pin or has a shape this cannot edit.
157
+ * - anything else: only a note, with both recipes.
158
+ *
159
+ * @param {{manager: string | null, pin: string, pkg?: any, workspaceYaml?: string | null}} options
160
+ * @returns {{note: string, workspaceYaml?: string}}
161
+ */
162
+ export function applyLabOverride({ manager, pin, pkg, workspaceYaml }) {
163
+ const why = `${LAB} peers on a core canary, and without it the lab components run on a second copy of ${CORE}`;
164
+ if (manager === "npm" && pkg) {
165
+ const current = pkg.overrides?.[LAB];
166
+ if (current !== undefined) {
167
+ const core = typeof current === "object" ? current?.[CORE] : undefined;
168
+ if (typeof core === "string" && core !== pin && !core.startsWith("$")) {
169
+ current[CORE] = pin;
170
+ return { note: `overrides["${LAB}"]["${CORE}"]: "${core}" → "${pin}".` };
171
+ }
172
+ return {
173
+ note: `overrides["${LAB}"] was left as it is; it must resolve ${LAB}'s ${CORE} to ${pin} (${recipesNote(pin)}).`,
174
+ };
175
+ }
176
+ pkg.overrides = { ...pkg.overrides, ...npmOverride(pin) };
177
+ return { note: `added overrides["${LAB}"]["${CORE}"] = "${pin}": ${why}.` };
178
+ }
179
+ if (manager === "pnpm") {
180
+ const line = pnpmOverrideLine(pin);
181
+ if (workspaceYaml == null) {
182
+ return {
183
+ workspaceYaml: `overrides:\n ${line}\n`,
184
+ note: `wrote pnpm-workspace.yaml with overrides ${line}: ${why}.`,
185
+ };
186
+ }
187
+ // Only the top-level `overrides:` block counts: the same key under
188
+ // `peerDependencyRules.allowedVersions` silences a warning, nothing more.
189
+ const block = /^overrides:[ \t]*(\r?\n)/m.exec(workspaceYaml);
190
+ if (block) {
191
+ const at = block.index + block[0].length;
192
+ const after = workspaceYaml.slice(at);
193
+ const end = /^\S/m.exec(after)?.index ?? after.length;
194
+ const body = after.slice(0, end);
195
+ const existing = PNPM_LINE.exec(body);
196
+ if (existing) {
197
+ if (unquote(existing[2]) === pin) return { note: "" };
198
+ return {
199
+ workspaceYaml: `${workspaceYaml.slice(0, at)}${body.replace(
200
+ PNPM_LINE,
201
+ (_m, head) => `${head}"${pin}"`,
202
+ )}${after.slice(end)}`,
203
+ note: `pnpm-workspace.yaml overrides: ${PNPM_OVERRIDE_KEY} ${unquote(existing[2])} → ${pin}.`,
204
+ };
205
+ }
206
+ const indent = /^([ \t]+)\S/.exec(after)?.[1] ?? " ";
207
+ return {
208
+ workspaceYaml: `${workspaceYaml.slice(0, at)}${indent}${line}\n${after}`,
209
+ note: `added ${line} to the overrides in pnpm-workspace.yaml: ${why}.`,
210
+ };
211
+ }
212
+ if (/^overrides\s*:/m.test(workspaceYaml)) {
213
+ return {
214
+ note: `pnpm-workspace.yaml has an overrides entry this cannot edit; add ${line} to it: ${why}.`,
215
+ };
216
+ }
217
+ const base = workspaceYaml.replace(/\s*$/, "");
218
+ return {
219
+ workspaceYaml: `${base}${base ? "\n\n" : ""}overrides:\n ${line}\n`,
220
+ note: `added overrides: ${line} to pnpm-workspace.yaml: ${why}.`,
221
+ };
222
+ }
223
+ return {
224
+ note: `${manager ? `${manager} projects are not edited for this` : "no package manager was detected"}, so point ${LAB}'s ${CORE} peer at ui-common's by hand (${recipesNote(pin)}) and check that \`why ${CORE}\` lists one version.`,
225
+ };
226
+ }
package/cli/main.mjs ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The `ui-common` command line: a thin wrapper over the exact-pinned Astryx
3
+ * CLI plus ui-common's own commands.
4
+ */
5
+ import { AGENTS_HELP, agentsCommand } from "./agents.mjs";
6
+ import { passthrough } from "./passthrough.mjs";
7
+ import {
8
+ cliDependencyVersion,
9
+ cliPackageJson,
10
+ uiCommonPackageJson,
11
+ uiCommonRoot,
12
+ } from "./paths.mjs";
13
+ import { ASTRYX_COMMANDS } from "./rewrite.mjs";
14
+ import { SYNC_HELP, syncAstryxCommand } from "./sync-astryx.mjs";
15
+ import { UPGRADE_HELP, upgradeCommand } from "./upgrade.mjs";
16
+
17
+ export const HELP = `Usage: ui-common <command> [options]
18
+
19
+ @lablup/ui-common's CLI (@lablup/ui-common-cli). Astryx commands run the
20
+ Astryx CLI it pins, with its output rewritten to ui-common import paths.
21
+
22
+ One-off, before ui-common is installed or bumped (\`@next\` until 0.2.0 is
23
+ published; npm's \`latest\` for this package is its first alpha):
24
+ pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 (npx: npx @lablup/ui-common-cli@next …)
25
+ Installed as a devDependency next to @lablup/ui-common:
26
+ pnpm exec ui-common <command>
27
+
28
+ ui-common commands:
29
+ agents [--write <file>] [--check]
30
+ Print, write or check the ui-common agent block (UI-COMMON markers).
31
+ upgrade [--from <v>] [--to <v>] [--dry-run] [--diff] [--report <path>] [paths…]
32
+ Run the codemods between two ui-common versions and write a report.
33
+ sync-astryx <version> [--lab <version>] [--as <version>] [--dry-run]
34
+ Maintainers, inside the ui-common repository: move to a new Astryx.
35
+
36
+ Astryx commands (rewritten to @lablup/ui-common paths):
37
+ ${ASTRYX_COMMANDS.filter((c) => c !== "upgrade" && c !== "help").join(", ")}
38
+ e.g. ui-common component Button, ui-common search "date picker",
39
+ ui-common docs tokens, ui-common build "settings page"
40
+
41
+ astryx <args…> Run the pinned Astryx CLI as is, with no rewriting
42
+ (e.g. \`ui-common astryx upgrade --from 0.6.0\`).
43
+
44
+ Options:
45
+ -h, --help Show this help (\`ui-common <command> --help\` for one command)
46
+ -V, --version Print the @lablup/ui-common-cli version (the same as the
47
+ @lablup/ui-common it ships with); --verbose adds the
48
+ project's ui-common and the Astryx CLI
49
+
50
+ Exit codes: a passed-through command exits with the Astryx CLI's code. ui-common
51
+ commands exit 0 on success, 1 on a failed check or run, 2 on bad arguments.
52
+ `;
53
+
54
+ /**
55
+ * @param {string[]} argv process.argv.slice(2)
56
+ * @returns {Promise<number>}
57
+ */
58
+ export async function main(argv) {
59
+ const [command, ...rest] = argv;
60
+ switch (command) {
61
+ case undefined:
62
+ case "-h":
63
+ case "--help":
64
+ case "help":
65
+ if (command === "help" && rest[0]) return main([rest[0], "--help"]);
66
+ process.stdout.write(HELP);
67
+ return 0;
68
+ case "-V":
69
+ case "--version":
70
+ process.stdout.write(`${cliPackageJson().version}\n`);
71
+ if (rest.includes("--verbose")) {
72
+ let uiCommon = "not installed";
73
+ try {
74
+ const root = uiCommonRoot();
75
+ uiCommon = `${uiCommonPackageJson(root).version} (${root})`;
76
+ } catch {
77
+ // reported as not installed
78
+ }
79
+ process.stdout.write(
80
+ `@lablup/ui-common ${uiCommon}\nastryx ${cliDependencyVersion("@astryxdesign/cli") ?? "not installed"}\n`,
81
+ );
82
+ }
83
+ return 0;
84
+ case "agents":
85
+ return agentsCommand(rest);
86
+ case "upgrade":
87
+ return upgradeCommand(rest);
88
+ case "sync-astryx":
89
+ return syncAstryxCommand(rest);
90
+ case "astryx":
91
+ return passthrough(rest, { raw: true });
92
+ default:
93
+ return passthrough(argv);
94
+ }
95
+ }
96
+
97
+ export { AGENTS_HELP, SYNC_HELP, UPGRADE_HELP };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `ui-common <astryx command> …`: run the pinned Astryx CLI and hand its
3
+ * output back in ui-common terms.
4
+ *
5
+ * The child's stdout and stderr are buffered, rewritten, then written; the
6
+ * exit code is the child's. `--json` output is rewritten inside its strings
7
+ * and re-checked, so it stays valid JSON. Notes about hidden names go to
8
+ * stderr in that mode, never into the JSON.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import { astryxBin, binInvocation, findProjectDir } from "./paths.mjs";
15
+ import { createShadow, needsShadow } from "./shadow.mjs";
16
+ import { exclusionNotes, rewriteOutput } from "./rewrite.mjs";
17
+
18
+ const UI_COMMON_MARKER = "<!-- UI-COMMON:START -->";
19
+ const AGENT_DOC_FILES = ["AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md", ".cursorrules"];
20
+
21
+ /** @param {string} dir */
22
+ function hasUiCommonBlock(dir) {
23
+ return AGENT_DOC_FILES.some((f) => {
24
+ const file = join(dir, f);
25
+ return existsSync(file) && readFileSync(file, "utf8").includes(UI_COMMON_MARKER);
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Astryx nudges every command toward `astryx init` until an ASTRYX block
31
+ * exists. A ui-common project keeps a UI-COMMON block instead, so point the
32
+ * nudge there, or drop it when that block is present.
33
+ *
34
+ * @param {string} stderr
35
+ * @param {string} cwd
36
+ * @param {string} invocation
37
+ */
38
+ function rewriteSetupNudge(stderr, cwd, invocation) {
39
+ const nudge =
40
+ /\n?Next step: run `[^`]*init` to finish setup and install the Astryx agent prompt\.\n?/;
41
+ if (!nudge.test(stderr)) return stderr;
42
+ const project = findProjectDir(cwd);
43
+ if (project && hasUiCommonBlock(project)) return stderr.replace(nudge, "");
44
+ return stderr.replace(
45
+ nudge,
46
+ `\nNext step: run \`${invocation} agents --write AGENTS.md\` to install the ui-common agent block.\n`,
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Run the pinned astryx bin with `args` and collect its output.
52
+ *
53
+ * @param {string[]} args
54
+ * @param {{cwd?: string, env?: NodeJS.ProcessEnv}} [options]
55
+ * @returns {Promise<{code: number, stdout: string, stderr: string}>}
56
+ */
57
+ export function runAstryx(args, { cwd = process.cwd(), env = process.env } = {}) {
58
+ return new Promise((resolvePromise, reject) => {
59
+ const child = spawn(process.execPath, [astryxBin(), ...args], {
60
+ cwd,
61
+ env,
62
+ stdio: ["inherit", "pipe", "pipe"],
63
+ });
64
+ /** @type {Buffer[]} */
65
+ const out = [];
66
+ /** @type {Buffer[]} */
67
+ const err = [];
68
+ child.stdout.on("data", (chunk) => out.push(chunk));
69
+ child.stderr.on("data", (chunk) => err.push(chunk));
70
+ child.on("error", reject);
71
+ child.on("close", (code, signal) => {
72
+ resolvePromise({
73
+ code: code ?? (signal ? 128 + (signalNumber(signal) ?? 1) : 1),
74
+ stdout: Buffer.concat(out).toString("utf8"),
75
+ stderr: Buffer.concat(err).toString("utf8"),
76
+ });
77
+ });
78
+ });
79
+ }
80
+
81
+ /** @param {NodeJS.Signals} signal */
82
+ function signalNumber(signal) {
83
+ return { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }[signal];
84
+ }
85
+
86
+ /**
87
+ * Rewrite one captured run. Exported for the tests.
88
+ *
89
+ * @param {{stdout: string, stderr: string}} result
90
+ * @param {{args: string[], cwd: string, invocation: string}} ctx
91
+ */
92
+ export function rewriteRun({ stdout, stderr }, { args, cwd, invocation }) {
93
+ const json = args.includes("--json");
94
+ let newStdout = rewriteOutput(stdout, invocation);
95
+ if (json && stdout.trim()) {
96
+ // Belt and braces: the substitutions cannot break JSON, but if they ever
97
+ // did, hand back Astryx's own output rather than something unparseable.
98
+ try {
99
+ JSON.parse(stdout);
100
+ try {
101
+ JSON.parse(newStdout);
102
+ } catch {
103
+ newStdout = stdout;
104
+ }
105
+ } catch {
106
+ // Astryx did not print JSON either; nothing to protect.
107
+ }
108
+ }
109
+ let newStderr = rewriteSetupNudge(rewriteOutput(stderr, invocation), cwd, invocation);
110
+ const notes = exclusionNotes(newStdout, args);
111
+ if (notes.length > 0) {
112
+ if (json) {
113
+ newStderr += `${newStderr && !newStderr.endsWith("\n") ? "\n" : ""}${notes.join("\n")}\n`;
114
+ } else {
115
+ newStdout += `${newStdout && !newStdout.endsWith("\n") ? "\n" : ""}\n${notes.join("\n")}\n`;
116
+ }
117
+ }
118
+ return { stdout: newStdout, stderr: newStderr };
119
+ }
120
+
121
+ /**
122
+ * @param {string[]} args
123
+ * @param {{raw?: boolean}} [options] raw: no rewriting (`ui-common astryx …`)
124
+ * @returns {Promise<number>} exit code
125
+ */
126
+ export async function passthrough(args, { raw = false } = {}) {
127
+ const cwd = process.cwd();
128
+ let result;
129
+ if (needsShadow(args, cwd)) {
130
+ const shadow = createShadow(cwd);
131
+ try {
132
+ result = await runAstryx(args, { cwd: shadow.dir });
133
+ } finally {
134
+ shadow.cleanup();
135
+ }
136
+ // Paths Astryx printed point into the shadow; show the project's.
137
+ for (const key of /** @type {const} */ (["stdout", "stderr"])) {
138
+ result[key] = result[key].split(shadow.dir).join(shadow.projectDir);
139
+ }
140
+ } else {
141
+ result = await runAstryx(args, { cwd });
142
+ }
143
+ if (raw) {
144
+ process.stdout.write(result.stdout);
145
+ process.stderr.write(result.stderr);
146
+ return result.code;
147
+ }
148
+ const invocation = binInvocation(findProjectDir(cwd) ?? cwd);
149
+ const { stdout, stderr } = rewriteRun(result, { args, cwd, invocation });
150
+ process.stdout.write(stdout);
151
+ process.stderr.write(stderr);
152
+ return result.code;
153
+ }