chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { hash } from 'crypto';
|
||||
import fs, { existsSync, readFileSync } from 'fs';
|
||||
import { dirname, join, relative } from 'path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
import { ProgressDisplay } from './progress';
|
||||
|
||||
export const WORKSPACE_ROOT = join(__dirname, '../..');
|
||||
|
||||
export function copyBuiltPackage(
|
||||
pkg: { pkgRoot: string; npmName: string },
|
||||
outputWorkspace: string
|
||||
) {
|
||||
const pathInDist = join(WORKSPACE_ROOT, 'dist', pkg.pkgRoot);
|
||||
const copyRoot = existsSync(pathInDist) ? pathInDist : pkg.pkgRoot;
|
||||
|
||||
if (copyRoot === pkg.pkgRoot) {
|
||||
console.warn(`No build artifacts in "${pathInDist}, checking project root`);
|
||||
}
|
||||
|
||||
// Copy all files from dist/packages/<pkg> to the output package root
|
||||
const files = globSync(`**/*.js`, {
|
||||
cwd: copyRoot,
|
||||
dot: true,
|
||||
ignore: ['**/node_modules/**'],
|
||||
});
|
||||
|
||||
if (!files.length) {
|
||||
throw new Error(
|
||||
`Unable to find built artifacts for ${pkg.npmName}. Did you forget to build?`
|
||||
);
|
||||
}
|
||||
|
||||
const outputPkgRoot = join(outputWorkspace, 'node_modules', pkg.npmName);
|
||||
fs.mkdirSync(outputPkgRoot, { recursive: true });
|
||||
|
||||
// Get native files count for accurate total
|
||||
const nativeFiles = getNativeFiles(pkg.pkgRoot);
|
||||
const totalFiles = files.length + nativeFiles.length;
|
||||
const progress = new ProgressDisplay(totalFiles);
|
||||
|
||||
files.forEach((file) => {
|
||||
const destFile = join(outputPkgRoot, file);
|
||||
const destDir = dirname(destFile);
|
||||
const copySrc = join(copyRoot, file);
|
||||
const relativeFilePath = relative(copyRoot, copySrc);
|
||||
progress.update(relativeFilePath);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
safeCopyFileSync(copySrc, destFile, progress);
|
||||
});
|
||||
|
||||
// Copy native files
|
||||
copyNativeFiles(pkg.pkgRoot, outputPkgRoot, nativeFiles, progress);
|
||||
progress.finish();
|
||||
}
|
||||
|
||||
function getNativeFiles(pkgRoot: string): string[] {
|
||||
const copyRoot = join(WORKSPACE_ROOT, pkgRoot);
|
||||
return globSync(`**/*.{node,wasm,js,mjs,cjs}`, {
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'src/command-line/migrate/run-migration-process.js',
|
||||
],
|
||||
cwd: copyRoot,
|
||||
});
|
||||
}
|
||||
|
||||
function copyNativeFiles(
|
||||
pkgRoot: string,
|
||||
outputPkgRoot: string,
|
||||
nativeFiles: string[],
|
||||
progress: ProgressDisplay
|
||||
) {
|
||||
const copyRoot = join(WORKSPACE_ROOT, pkgRoot);
|
||||
nativeFiles.forEach((file) => {
|
||||
const destFile = join(outputPkgRoot, file);
|
||||
const destDir = dirname(destFile);
|
||||
const copySrc = join(copyRoot, file);
|
||||
const relativeFilePath = relative(copyRoot, copySrc);
|
||||
progress.update(relativeFilePath);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
safeCopyFileSync(copySrc, destFile, progress);
|
||||
});
|
||||
}
|
||||
|
||||
function safeCopyFileSync(
|
||||
src: string,
|
||||
dest: string,
|
||||
progress: ProgressDisplay
|
||||
) {
|
||||
const sha = hash('sha256', readFileSync(src));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
fs.copyFileSync(src, dest);
|
||||
const destSha = hash('sha256', readFileSync(dest));
|
||||
if (sha === destSha) {
|
||||
return;
|
||||
} else {
|
||||
progress.insertBefore(
|
||||
`Hash mismatch when copying ${src} to ${dest}, retrying...`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
progress.insertBefore(
|
||||
`Error copying file ${src} to ${dest}: ${(e as Error).message}, retrying...`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to copy ${src} to ${dest} after 3 attempts.`);
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import fs, { Dirent, existsSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { basename, dirname, join, resolve } from 'path';
|
||||
import pc from 'picocolors';
|
||||
import { termSize, truncateEnd } from './ui';
|
||||
|
||||
const SEARCH_DEPTH = 3;
|
||||
|
||||
// Directories to never recurse into. We still detect node_modules
|
||||
// *presence* (marks a workspace root) via a stat, just don't list inside it.
|
||||
const SKIP_RECURSE = new Set([
|
||||
// VCS
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
// Package managers (contents, not the marker)
|
||||
'node_modules',
|
||||
'.yarn',
|
||||
'.pnpm',
|
||||
// Build / framework caches
|
||||
'.cache',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.output',
|
||||
'.turbo',
|
||||
'.angular',
|
||||
// Editor / IDE
|
||||
'.vscode',
|
||||
'.idea',
|
||||
// OS
|
||||
'.Trash',
|
||||
]);
|
||||
|
||||
// ── Keypress parsing ────────────────────────────────────────────────
|
||||
|
||||
interface KeyPress {
|
||||
name: string; // e.g. 'a', 'enter', 'backspace', 'up', 'tab'
|
||||
ctrl: boolean;
|
||||
meta: boolean; // Alt / Option
|
||||
char: string; // printable character, or '' for special keys
|
||||
}
|
||||
|
||||
function parseKeypress(data: Buffer): KeyPress {
|
||||
const raw = data.toString();
|
||||
|
||||
// Meta (Alt/Option) + key: ESC followed by a single character
|
||||
if (raw.length === 2 && raw[0] === '\x1b' && raw[1] !== '[') {
|
||||
const ch = raw[1];
|
||||
if (ch === '\x7f')
|
||||
return { name: 'backspace', ctrl: false, meta: true, char: '' };
|
||||
return { name: ch, ctrl: false, meta: true, char: ch };
|
||||
}
|
||||
|
||||
// CSI sequences: ESC [ <letter>
|
||||
if (raw.startsWith('\x1b[')) {
|
||||
const suffix = raw.slice(2);
|
||||
const csiMap: Record<string, string> = {
|
||||
A: 'up',
|
||||
B: 'down',
|
||||
C: 'right',
|
||||
D: 'left',
|
||||
H: 'home',
|
||||
F: 'end',
|
||||
};
|
||||
const name = csiMap[suffix] ?? `unknown(${suffix})`;
|
||||
return { name, ctrl: false, meta: false, char: '' };
|
||||
}
|
||||
|
||||
// Single-byte control characters
|
||||
if (raw.length === 1 && raw.charCodeAt(0) < 0x20) {
|
||||
const code = raw.charCodeAt(0);
|
||||
const ctrlMap: Record<number, string> = {
|
||||
0x03: 'c', // Ctrl+C
|
||||
0x08: 'backspace', // Ctrl+H (legacy backspace)
|
||||
0x09: 'tab',
|
||||
0x0d: 'enter',
|
||||
0x15: 'u', // Ctrl+U
|
||||
0x17: 'w', // Ctrl+W
|
||||
};
|
||||
const name = ctrlMap[code] ?? String.fromCharCode(code + 0x60);
|
||||
const isCtrl = code !== 0x09 && code !== 0x0d; // tab/enter aren't "ctrl" keys
|
||||
return { name, ctrl: isCtrl, meta: false, char: '' };
|
||||
}
|
||||
|
||||
// DEL (0x7f) — Backspace on most terminals
|
||||
if (raw === '\x7f') {
|
||||
return { name: 'backspace', ctrl: false, meta: false, char: '' };
|
||||
}
|
||||
|
||||
// Printable characters (including pasted multi-char text)
|
||||
return { name: raw, ctrl: false, meta: false, char: raw };
|
||||
}
|
||||
|
||||
// ── Input parsing ────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedInput {
|
||||
resolvedSearchDir: string;
|
||||
filter: string;
|
||||
prefix: string;
|
||||
selfDisplay: string | null;
|
||||
}
|
||||
|
||||
function parseInput(input: string): ParsedInput {
|
||||
let searchDir: string;
|
||||
let filter: string;
|
||||
let prefix: string;
|
||||
|
||||
const expanded = input.startsWith('~/')
|
||||
? (process.env.HOME || '') + input.slice(1)
|
||||
: input;
|
||||
|
||||
if (expanded === '' || expanded.endsWith('/')) {
|
||||
searchDir = expanded || '.';
|
||||
filter = '';
|
||||
prefix = input;
|
||||
} else {
|
||||
const d = dirname(expanded);
|
||||
searchDir = d;
|
||||
filter = basename(expanded).toLowerCase();
|
||||
const inputDir = dirname(input);
|
||||
prefix = inputDir === '.' ? '' : inputDir + '/';
|
||||
}
|
||||
|
||||
const resolvedSearchDir = resolve(searchDir);
|
||||
|
||||
// Self-directory: show the search dir itself when there's no child filter
|
||||
// (empty input, trailing slash, or the literal "." / ".." inputs).
|
||||
let selfDisplay: string | null = null;
|
||||
const showSelf = filter === '' || input === '.' || input === '..';
|
||||
if (showSelf && existsSync(join(resolvedSearchDir, 'node_modules'))) {
|
||||
selfDisplay =
|
||||
input === ''
|
||||
? '.'
|
||||
: input.endsWith('/')
|
||||
? input.replace(/\/+$/, '')
|
||||
: input;
|
||||
}
|
||||
|
||||
return { resolvedSearchDir, filter, prefix, selfDisplay };
|
||||
}
|
||||
|
||||
// ── Parallel directory walker ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk directories in parallel, invoking `onFound` for each workspace root
|
||||
* (directory containing `node_modules`). All entries within a directory
|
||||
* level are explored concurrently via Promise.all, which dramatically
|
||||
* reduces wall-clock time compared to a sequential async generator.
|
||||
*/
|
||||
async function walkWorkspaceDirs(
|
||||
dir: string,
|
||||
maxDepth: number,
|
||||
relPrefix: string,
|
||||
onFound: (relPath: string) => void,
|
||||
isCancelled: () => boolean
|
||||
): Promise<void> {
|
||||
if (maxDepth < 0 || isCancelled()) return;
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCancelled()) return;
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async (e) => {
|
||||
if (isCancelled()) return;
|
||||
if (!e.isDirectory() || SKIP_RECURSE.has(e.name)) return;
|
||||
|
||||
const fullPath = join(dir, e.name);
|
||||
const relPath = relPrefix ? relPrefix + '/' + e.name : e.name;
|
||||
|
||||
// Check for node_modules with a cheap stat rather than readdir
|
||||
try {
|
||||
await fs.promises.access(join(fullPath, 'node_modules'));
|
||||
if (!isCancelled()) onFound(relPath);
|
||||
} catch {
|
||||
// Not a workspace root — keep looking deeper
|
||||
}
|
||||
|
||||
if (maxDepth > 0 && !isCancelled()) {
|
||||
await walkWorkspaceDirs(
|
||||
fullPath,
|
||||
maxDepth - 1,
|
||||
relPath,
|
||||
onFound,
|
||||
isCancelled
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Interactive picker ───────────────────────────────────────────────
|
||||
|
||||
export class DirectoryPicker {
|
||||
private input: string = '';
|
||||
private cursorPos: number = 0;
|
||||
private selectedIndex: number = -1; // -1 = on input line
|
||||
private suggestions: string[] = [];
|
||||
private lastRenderLines = 0;
|
||||
private resolvePromise!: (value: string) => void;
|
||||
|
||||
// ── Scan cache ─────────────────────────────────────────────────────
|
||||
// Key: resolved search directory path
|
||||
// Value: all workspace-root relative paths found under that directory
|
||||
private scanCache = new Map<string, string[]>();
|
||||
private completedScans = new Set<string>();
|
||||
private activeScanKey: string | null = null;
|
||||
private cancelActiveScan: (() => void) | null = null;
|
||||
|
||||
/** Visible suggestion count adapts to the terminal height */
|
||||
private get maxSuggestions(): number {
|
||||
const { rows } = termSize();
|
||||
return Math.max(3, Math.min(Math.floor(rows * 0.3), rows - 4));
|
||||
}
|
||||
|
||||
run(): Promise<string> {
|
||||
return new Promise<string>((res) => {
|
||||
this.resolvePromise = res;
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.on('data', this.onKeypress);
|
||||
this.updateSuggestions();
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private onKeypress = (data: Buffer) => {
|
||||
const key = parseKeypress(data);
|
||||
|
||||
// ── Action keys ───────────────────────────────────────────────
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
this.cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (key.name === 'enter') {
|
||||
const value =
|
||||
this.selectedIndex >= 0
|
||||
? this.suggestions[this.selectedIndex]
|
||||
: this.input;
|
||||
this.cleanup();
|
||||
this.resolvePromise(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'tab') {
|
||||
if (this.suggestions.length > 0) {
|
||||
const idx = Math.max(this.selectedIndex, 0);
|
||||
this.input = this.suggestions[idx];
|
||||
this.cursorPos = this.input.length;
|
||||
this.selectedIndex = -1;
|
||||
this.updateSuggestions();
|
||||
}
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────
|
||||
if (key.name === 'up') {
|
||||
this.selectedIndex = this.selectedIndex > 0 ? this.selectedIndex - 1 : -1;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'down') {
|
||||
if (this.selectedIndex < this.suggestions.length - 1) {
|
||||
this.selectedIndex++;
|
||||
}
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'right') {
|
||||
if (this.cursorPos < this.input.length) this.cursorPos++;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'left') {
|
||||
if (this.cursorPos > 0) this.cursorPos--;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Deletion ──────────────────────────────────────────────────
|
||||
if (key.ctrl && key.name === 'u') {
|
||||
this.input = '';
|
||||
this.cursorPos = 0;
|
||||
this.selectedIndex = -1;
|
||||
this.updateSuggestions();
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+W or Meta+Backspace — delete word backward
|
||||
if (
|
||||
(key.ctrl && key.name === 'w') ||
|
||||
(key.meta && key.name === 'backspace')
|
||||
) {
|
||||
this.deleteWordBackward();
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'backspace') {
|
||||
if (this.cursorPos > 0) {
|
||||
this.input =
|
||||
this.input.slice(0, this.cursorPos - 1) +
|
||||
this.input.slice(this.cursorPos);
|
||||
this.cursorPos--;
|
||||
this.selectedIndex = -1;
|
||||
this.updateSuggestions();
|
||||
}
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Printable text ────────────────────────────────────────────
|
||||
if (key.char) {
|
||||
this.input =
|
||||
this.input.slice(0, this.cursorPos) +
|
||||
key.char +
|
||||
this.input.slice(this.cursorPos);
|
||||
this.cursorPos += key.char.length;
|
||||
this.selectedIndex = -1;
|
||||
this.updateSuggestions();
|
||||
this.render();
|
||||
}
|
||||
};
|
||||
|
||||
private deleteWordBackward() {
|
||||
if (this.cursorPos <= 0) return;
|
||||
const before = this.input.slice(0, this.cursorPos);
|
||||
// Delete trailing slashes, then back to the previous slash or start
|
||||
const trimmed = before.replace(/\/+$/, '');
|
||||
const lastSlash = trimmed.lastIndexOf('/');
|
||||
const newEnd = lastSlash === -1 ? 0 : lastSlash + 1;
|
||||
this.input = this.input.slice(0, newEnd) + this.input.slice(this.cursorPos);
|
||||
this.cursorPos = newEnd;
|
||||
this.selectedIndex = -1;
|
||||
this.updateSuggestions();
|
||||
}
|
||||
|
||||
// ── Suggestion management ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recompute `this.suggestions` from the cache using the current input.
|
||||
* Pure synchronous filter — no I/O.
|
||||
*/
|
||||
private refreshSuggestions() {
|
||||
const parsed = parseInput(this.input);
|
||||
|
||||
this.suggestions = parsed.selfDisplay ? [parsed.selfDisplay] : [];
|
||||
|
||||
const cached = this.scanCache.get(parsed.resolvedSearchDir);
|
||||
if (!cached) return;
|
||||
|
||||
for (const relPath of cached) {
|
||||
if (parsed.filter) {
|
||||
const firstSegment = relPath.split('/')[0].toLowerCase();
|
||||
if (!firstSegment.startsWith(parsed.filter)) continue;
|
||||
}
|
||||
|
||||
const displayPath = parsed.prefix + relPath;
|
||||
|
||||
// Insert in sorted position
|
||||
const insertIdx = this.suggestions.findIndex((s) => s > displayPath);
|
||||
if (insertIdx === -1) {
|
||||
this.suggestions.push(displayPath);
|
||||
} else {
|
||||
this.suggestions.splice(insertIdx, 0, displayPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on every input change. Refreshes suggestions from cache instantly,
|
||||
* then starts an async scan if the search directory hasn't been fully scanned.
|
||||
*/
|
||||
private updateSuggestions() {
|
||||
const parsed = parseInput(this.input);
|
||||
const cacheKey = parsed.resolvedSearchDir;
|
||||
|
||||
// Always refresh from whatever's in the cache (instant)
|
||||
this.refreshSuggestions();
|
||||
|
||||
if (!existsSync(cacheKey)) return;
|
||||
|
||||
// If this directory is fully scanned, we're done — cache has everything
|
||||
if (this.completedScans.has(cacheKey)) return;
|
||||
|
||||
// If there's already an active scan for this same directory, let it run
|
||||
if (this.activeScanKey === cacheKey) return;
|
||||
|
||||
// Different directory (or first time) — cancel any old scan, start new
|
||||
this.cancelActiveScan?.();
|
||||
this.activeScanKey = cacheKey;
|
||||
|
||||
if (!this.scanCache.has(cacheKey)) {
|
||||
this.scanCache.set(cacheKey, []);
|
||||
}
|
||||
|
||||
this.startScan(cacheKey);
|
||||
}
|
||||
|
||||
private async startScan(cacheKey: string) {
|
||||
let cancelled = false;
|
||||
this.cancelActiveScan = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
const cache = this.scanCache.get(cacheKey)!;
|
||||
|
||||
// Schedule suggestion refresh + render as a macrotask so that
|
||||
// keypress events (also macrotasks) get interleaved rather than
|
||||
// starved behind a flood of Promise.all microtask continuations.
|
||||
let renderPending = false;
|
||||
const scheduleRender = () => {
|
||||
if (renderPending) return;
|
||||
renderPending = true;
|
||||
setImmediate(() => {
|
||||
renderPending = false;
|
||||
if (!cancelled) {
|
||||
this.refreshSuggestions();
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
await walkWorkspaceDirs(
|
||||
cacheKey,
|
||||
SEARCH_DEPTH,
|
||||
'',
|
||||
(relPath) => {
|
||||
cache.push(relPath);
|
||||
scheduleRender();
|
||||
},
|
||||
() => cancelled
|
||||
);
|
||||
|
||||
// Final render to pick up any results from the last batch
|
||||
if (!cancelled) {
|
||||
this.refreshSuggestions();
|
||||
this.render();
|
||||
this.completedScans.add(cacheKey);
|
||||
this.activeScanKey = null;
|
||||
this.cancelActiveScan = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────
|
||||
|
||||
private render() {
|
||||
const CLR = '\x1b[K'; // clear to end of line (terminal control, not a color)
|
||||
|
||||
// On subsequent renders the cursor is on the input line (from prior
|
||||
// positioning). Just return the cursor to column 0 so we overwrite in
|
||||
// place. On the first render lastRenderLines is 0, so this is a no-op.
|
||||
if (this.lastRenderLines > 0) {
|
||||
process.stdout.write('\r');
|
||||
}
|
||||
|
||||
const { cols } = termSize();
|
||||
|
||||
// ── Input line ──────────────────────────────────────────────────
|
||||
const promptLabel = ` ${pc.cyan('>')} Repo path: `;
|
||||
const promptVisualLen = ' > Repo path: '.length; // without ANSI
|
||||
const maxInputDisplay = cols - promptVisualLen - 1;
|
||||
const displayInput =
|
||||
this.input.length > maxInputDisplay
|
||||
? '…' + this.input.slice(-(maxInputDisplay - 1))
|
||||
: this.input;
|
||||
process.stdout.write(`${promptLabel}${displayInput}${CLR}\n`);
|
||||
|
||||
// Blank separator between input and suggestions
|
||||
process.stdout.write(`${CLR}\n`);
|
||||
|
||||
// ── Suggestion lines ────────────────────────────────────────────
|
||||
const visible = this.suggestions.slice(0, this.maxSuggestions);
|
||||
const shownSuggestions = visible.length;
|
||||
const pointer = ' › ';
|
||||
const noPointer = ' ';
|
||||
const indent = ' ';
|
||||
const maxSuggestionLen = cols - indent.length - pointer.length - 1;
|
||||
|
||||
for (let i = 0; i < shownSuggestions; i++) {
|
||||
const text = truncateEnd(visible[i], maxSuggestionLen);
|
||||
if (i === this.selectedIndex) {
|
||||
process.stdout.write(
|
||||
`${indent}${pc.cyan(pointer)}${pc.inverse(` ${text} `)}${CLR}\n`
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`${indent}${pc.dim(`${noPointer}${text}`)}${CLR}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hint line ───────────────────────────────────────────────────
|
||||
process.stdout.write(
|
||||
`${pc.dim(' ↑↓ navigate tab complete enter select')}${CLR}\n`
|
||||
);
|
||||
|
||||
// ── Clear leftover lines from previous render ───────────────────
|
||||
const totalLines = 1 + 1 + shownSuggestions + 1; // input + blank + suggestions + hint
|
||||
for (let i = totalLines; i < this.lastRenderLines; i++) {
|
||||
process.stdout.write(`${CLR}\n`);
|
||||
}
|
||||
|
||||
// Track how many physical lines we wrote (including clears)
|
||||
const linesWritten = Math.max(totalLines, this.lastRenderLines);
|
||||
this.lastRenderLines = totalLines;
|
||||
|
||||
// Every line we wrote ended with \n, so the cursor is now
|
||||
// `linesWritten` rows below the input line. Move back up.
|
||||
if (linesWritten > 0) {
|
||||
process.stdout.write(`\x1b[${linesWritten}A`);
|
||||
}
|
||||
|
||||
// Position cursor within the input (account for truncation)
|
||||
const inputOffset =
|
||||
this.input.length > maxInputDisplay
|
||||
? this.cursorPos - (this.input.length - maxInputDisplay) + 1
|
||||
: this.cursorPos;
|
||||
const cursorCol = Math.max(
|
||||
promptVisualLen + 1,
|
||||
promptVisualLen + inputOffset + 1
|
||||
);
|
||||
process.stdout.write(`\x1b[${Math.min(cursorCol, cols)}G`);
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
// Cancel any in-flight scan
|
||||
this.cancelActiveScan?.();
|
||||
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener('data', this.onKeypress);
|
||||
|
||||
// Cursor is on the input line (from render positioning).
|
||||
// Go to column 0, then overwrite with the final value.
|
||||
const finalValue =
|
||||
this.selectedIndex >= 0
|
||||
? this.suggestions[this.selectedIndex]
|
||||
: this.input;
|
||||
process.stdout.write(`\r Repo path: ${finalValue}\x1b[K\n`);
|
||||
|
||||
// Clear all the suggestion / hint lines below
|
||||
for (let i = 1; i < this.lastRenderLines; i++) {
|
||||
process.stdout.write('\x1b[K\n');
|
||||
}
|
||||
|
||||
// Move back up so subsequent output starts right after the input line
|
||||
if (this.lastRenderLines > 1) {
|
||||
process.stdout.write(`\x1b[${this.lastRenderLines - 1}A`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { readJsonFile } from '@nx/devkit';
|
||||
import { execSync } from 'child_process';
|
||||
import { readJsonSync } from 'fs-extra';
|
||||
import { dirname, join } from 'path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import { copyBuiltPackage, WORKSPACE_ROOT } from './copy';
|
||||
import { DirectoryPicker } from './directory-picker';
|
||||
import { asciiBlock, centerStringInWidth, termSize } from './ui';
|
||||
|
||||
const packages: { npmName: string; nxName: string; pkgRoot: string }[] =
|
||||
globSync(`packages/*/package.json`).map((p) => {
|
||||
const pkgJson = readJsonSync(p);
|
||||
const pkgRoot = dirname(p);
|
||||
const projectJson = (() => {
|
||||
try {
|
||||
return readJsonFile(join(WORKSPACE_ROOT, pkgRoot, 'project.json'));
|
||||
} catch {}
|
||||
})();
|
||||
return {
|
||||
npmName: pkgJson.name,
|
||||
nxName: projectJson?.name ?? pkgJson.nx?.name ?? pkgJson.name,
|
||||
pkgRoot,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Score a package name against the user's input for ranking.
|
||||
* Lower score = better match. Returns Infinity for no match.
|
||||
*
|
||||
* Matching is done against the "short name" — the part after the scope
|
||||
* (e.g. "react" for "@nx/react") as well as the full name.
|
||||
*
|
||||
* 0 exact match on short name ("nx" → "nx")
|
||||
* 1 exact match on full name
|
||||
* 2 short name starts with input ("react" matches "@nx/react-native")
|
||||
* 3 full name starts with input
|
||||
* 4 short name contains input
|
||||
* 5 full name contains input
|
||||
*/
|
||||
function packageMatchScore(input: string, name: string): number {
|
||||
const lower = input.toLowerCase();
|
||||
const fullLower = name.toLowerCase();
|
||||
const shortName = fullLower.includes('/')
|
||||
? fullLower.split('/').pop()!
|
||||
: fullLower;
|
||||
|
||||
if (shortName === lower) return 0;
|
||||
if (fullLower === lower) return 1;
|
||||
if (shortName.startsWith(lower)) return 2;
|
||||
if (fullLower.startsWith(lower)) return 3;
|
||||
if (shortName.includes(lower)) return 4;
|
||||
if (fullLower.includes(lower)) return 5;
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
async function promptPackages(): Promise<typeof packages> {
|
||||
const { prompt } = require('enquirer');
|
||||
// Reserve lines for the prompt header, input, footer hint, and breathing room
|
||||
const visibleChoices = Math.max(5, termSize().rows - 4);
|
||||
const result: { packages: string[] } = await prompt({
|
||||
type: 'autocomplete',
|
||||
name: 'packages',
|
||||
message: 'Select packages to copy',
|
||||
choices: packages.map((p) => p.nxName),
|
||||
multiple: true,
|
||||
limit: visibleChoices,
|
||||
suggest(input: string, choices: { name: string; message: string }[]) {
|
||||
if (!input) return choices;
|
||||
return choices
|
||||
.map((ch) => ({ ch, score: packageMatchScore(input, ch.message) }))
|
||||
.filter(({ score }) => score < Infinity)
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.map(({ ch }) => ch);
|
||||
},
|
||||
});
|
||||
if (result.packages.length === 0) {
|
||||
console.error('No packages selected.');
|
||||
process.exit(1);
|
||||
}
|
||||
return result.packages.map(
|
||||
(nxName) => packages.find((p) => p.nxName === nxName)!
|
||||
);
|
||||
}
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.command({
|
||||
command: '$0',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option('package', {
|
||||
type: 'string',
|
||||
choices: packages.flatMap((p) => [p.npmName, p.nxName]),
|
||||
description: 'The package to copy the build outputs from',
|
||||
})
|
||||
.option('repo', {
|
||||
type: 'string',
|
||||
description:
|
||||
'The root path of the repo to copy the built packages to',
|
||||
})
|
||||
.option('build', {
|
||||
type: 'boolean',
|
||||
description: 'Set to false to skip prebuild step.',
|
||||
default: true,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const argvPackage =
|
||||
argv.package &&
|
||||
packages.find(
|
||||
(p) => p.nxName === argv.package || p.npmName === argv.package
|
||||
);
|
||||
const selectedPackages = argvPackage
|
||||
? [argvPackage]
|
||||
: await promptPackages();
|
||||
|
||||
const selectedNxProjects = selectedPackages.map((p) => p.nxName);
|
||||
|
||||
const repo = argv.repo || (await new DirectoryPicker().run());
|
||||
|
||||
if (argv.build) {
|
||||
execSync(
|
||||
'nx run-many --tuiAutoExit=0 -t build -p ' +
|
||||
selectedNxProjects.join(' '),
|
||||
{
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const termWidth = process.stdout.columns || 80;
|
||||
const renderWidth = termWidth - termWidth * 0.2;
|
||||
|
||||
for (const pkg of selectedPackages) {
|
||||
console.log();
|
||||
console.log(
|
||||
asciiBlock(
|
||||
renderWidth,
|
||||
2,
|
||||
1,
|
||||
`Copying ${pkg.npmName} to ${repo}/node_modules/${pkg.npmName}`
|
||||
)
|
||||
.map((line) => centerStringInWidth(line, termWidth))
|
||||
.join('\n')
|
||||
);
|
||||
copyBuiltPackage(pkg, repo);
|
||||
}
|
||||
},
|
||||
})
|
||||
.parseAsync();
|
||||
@@ -0,0 +1,88 @@
|
||||
import { centerStringInWidth } from './ui';
|
||||
|
||||
function getProgressBar(current: number, total: number, width = 40): string {
|
||||
const percentage = total > 0 ? current / total : 0;
|
||||
const filled = Math.round(width * percentage);
|
||||
const empty = width - filled;
|
||||
return '█'.repeat(filled) + '░'.repeat(empty);
|
||||
}
|
||||
|
||||
function truncateStart(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return '...' + str.slice(-(maxLen - 3));
|
||||
}
|
||||
|
||||
export class ProgressDisplay {
|
||||
private current = 0;
|
||||
private total: number;
|
||||
private lastFile = '';
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor(total: number) {
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
insertBefore(str: string) {
|
||||
process.stdout.write('\x1b[J');
|
||||
console.log(str);
|
||||
}
|
||||
|
||||
row(maxWidth: number, minGap: number, ...parts: string[]): string {
|
||||
const totalPartsLength = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const calculatedGap = (maxWidth - totalPartsLength) / (parts.length - 1);
|
||||
if (calculatedGap < minGap) {
|
||||
const neededReduction = (minGap - calculatedGap) * (parts.length - 1);
|
||||
parts[0] = truncateStart(parts[0], parts[0].length - neededReduction);
|
||||
}
|
||||
return parts.join(' '.repeat(Math.max(calculatedGap, minGap)));
|
||||
}
|
||||
|
||||
update(file: string, message?: string): void {
|
||||
this.current++;
|
||||
this.lastFile = file;
|
||||
|
||||
// Move cursor up 3 lines and clear each line
|
||||
if (this.initialized) {
|
||||
process.stdout.write('\x1b[2F');
|
||||
} else {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
this.insertBefore(message);
|
||||
}
|
||||
this.renderCurrentStatus();
|
||||
}
|
||||
|
||||
private renderCurrentStatus() {
|
||||
const termWidth = process.stdout.columns || 80;
|
||||
const adjustedTermWidth = termWidth - Math.floor(0.2 * termWidth);
|
||||
const progressBar = getProgressBar(
|
||||
this.current,
|
||||
this.total,
|
||||
adjustedTermWidth
|
||||
);
|
||||
|
||||
// Line 1: Most recently copied file
|
||||
process.stdout.write(
|
||||
centerStringInWidth(
|
||||
this.row(
|
||||
adjustedTermWidth,
|
||||
4,
|
||||
this.lastFile,
|
||||
`[${this.current} / ${this.total}]`
|
||||
),
|
||||
termWidth
|
||||
) + '\x1b[K\n'
|
||||
);
|
||||
// Line 2: Progress bar
|
||||
process.stdout.write(
|
||||
centerStringInWidth(progressBar, termWidth) + '\x1b[K\n'
|
||||
);
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
// Add a newline after completion to separate from next output
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export function termSize(): { cols: number; rows: number } {
|
||||
return {
|
||||
cols: process.stdout.columns || 80,
|
||||
rows: process.stdout.rows || 24,
|
||||
};
|
||||
}
|
||||
|
||||
export function truncateEnd(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
export const centerStringInWidth = (str: string, width: number): string => {
|
||||
const padding = Math.max(0, width - str.length);
|
||||
const padStart = Math.floor(padding / 2);
|
||||
const padEnd = padding - padStart;
|
||||
return ' '.repeat(padStart) + str + ' '.repeat(padEnd);
|
||||
};
|
||||
|
||||
export function asciiBlock(
|
||||
w: number,
|
||||
px: number,
|
||||
py: number,
|
||||
text: string
|
||||
): string[] {
|
||||
const lines = text.split('\n');
|
||||
const contentWidth = w - px * 2 - 2;
|
||||
const paddedLines = lines.map((line) => {
|
||||
const truncatedLine =
|
||||
line.length > contentWidth
|
||||
? line.slice(0, contentWidth - 3) + '...'
|
||||
: line;
|
||||
return centerStringInWidth(truncatedLine, w - 2);
|
||||
});
|
||||
const emptyLine = ' '.repeat(w - 2);
|
||||
const blockLines = [
|
||||
'┌' + '─'.repeat(w - 2) + '┐',
|
||||
...Array(py).fill('│' + emptyLine + '│'),
|
||||
...paddedLines.map((line) => '│' + line + '│'),
|
||||
...Array(py).fill('│' + emptyLine + '│'),
|
||||
'└' + '─'.repeat(w - 2) + '┘',
|
||||
];
|
||||
return blockLines;
|
||||
}
|
||||
Reference in New Issue
Block a user