初始化项目版本
This commit is contained in:
63
scripts/utils/command-runtime.d.ts
vendored
Normal file
63
scripts/utils/command-runtime.d.ts
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
export type DecodeOutputOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
};
|
||||
|
||||
export type RunCommandOptions = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
detached?: boolean;
|
||||
capture?: boolean;
|
||||
stdio?: any;
|
||||
};
|
||||
|
||||
export type RunCommandResult = {
|
||||
command: string;
|
||||
args: string[];
|
||||
spawnCommand: string;
|
||||
spawnArgs: string[];
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdoutBuffer: Buffer;
|
||||
stderrBuffer: Buffer;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export type RunCommandSyncOptions = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
maxBuffer?: number;
|
||||
};
|
||||
|
||||
export type RunCommandSyncResult = {
|
||||
command: string;
|
||||
args: string[];
|
||||
spawnCommand: string;
|
||||
spawnArgs: string[];
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error | null;
|
||||
stdoutBuffer: Buffer;
|
||||
stderrBuffer: Buffer;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export function decodeOutput(value: unknown, options?: DecodeOutputOptions): string;
|
||||
export function runCommand(options: RunCommandOptions): Promise<RunCommandResult>;
|
||||
export function runCommandSync(options: RunCommandSyncOptions): RunCommandSyncResult;
|
||||
export function commandExists(command: string): boolean;
|
||||
export function getPreferredNpmCommand(): string;
|
||||
export function getPreferredNpxCommand(): string;
|
||||
export function getSpawnCommandSpec(command: string, args?: string[], platform?: NodeJS.Platform): {
|
||||
command: string;
|
||||
args: string[];
|
||||
windowsHide: boolean;
|
||||
};
|
||||
export function __resetWindowsCodePageCacheForTests(): void;
|
||||
340
scripts/utils/command-runtime.mjs
Normal file
340
scripts/utils/command-runtime.mjs
Normal file
@@ -0,0 +1,340 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import iconv from 'iconv-lite';
|
||||
|
||||
const WINDOWS_CODEPAGE_TIMEOUT_MS = 1200;
|
||||
let cachedWindowsCodePage = null;
|
||||
|
||||
function getPlatform(overridePlatform) {
|
||||
return overridePlatform || process.platform;
|
||||
}
|
||||
|
||||
function quoteForCmdExec(value) {
|
||||
if (!value) return '""';
|
||||
if (!/[\s"&^|<>]/.test(value)) return value;
|
||||
const escaped = String(value)
|
||||
.replace(/(\\*)"/g, '$1$1\\"')
|
||||
.replace(/(\\+)$/g, '$1$1');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
function buildWindowsCommandLine(command, args) {
|
||||
return [command, ...args].map((part) => quoteForCmdExec(String(part))).join(' ');
|
||||
}
|
||||
|
||||
function getEnvValue(env, key) {
|
||||
if (!env) return undefined;
|
||||
|
||||
const direct = env[key];
|
||||
if (typeof direct === 'string' && direct.length > 0) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const matchedKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === key.toLowerCase());
|
||||
if (!matchedKey) return undefined;
|
||||
|
||||
const value = env[matchedKey];
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function getWindowsPathExtList(env) {
|
||||
const pathExt = getEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD';
|
||||
return pathExt
|
||||
.split(';')
|
||||
.map((ext) => ext.trim())
|
||||
.filter(Boolean)
|
||||
.map((ext) => (ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`));
|
||||
}
|
||||
|
||||
function resolveWindowsCommand(command, env) {
|
||||
if (!command || typeof command !== 'string') return command;
|
||||
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
const hasPathSeparator = /[\\/]/.test(trimmed);
|
||||
const ext = path.extname(trimmed);
|
||||
const pathExts = ext ? [''] : getWindowsPathExtList(env);
|
||||
|
||||
const candidateDirs = hasPathSeparator
|
||||
? ['']
|
||||
: (getEnvValue(env, 'PATH') || '')
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const baseCandidates = hasPathSeparator ? [trimmed] : candidateDirs.map((dir) => path.join(dir, trimmed));
|
||||
|
||||
for (const baseCandidate of baseCandidates) {
|
||||
const suffixes = ext ? [''] : pathExts;
|
||||
for (const suffix of suffixes) {
|
||||
const fullPath = suffix ? `${baseCandidate}${suffix}` : baseCandidate;
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function shouldUseWindowsCmdWrapper(platform, command) {
|
||||
if (platform !== 'win32') return false;
|
||||
return /\.(cmd|bat)$/i.test(command) || !/\.(exe|com)$/i.test(command);
|
||||
}
|
||||
|
||||
function getSpawnSpec(command, args, platform = process.platform, env = process.env) {
|
||||
const resolvedCommand = platform === 'win32' ? resolveWindowsCommand(command, env) : command;
|
||||
|
||||
if (!shouldUseWindowsCmdWrapper(platform, resolvedCommand)) {
|
||||
return {
|
||||
command: resolvedCommand,
|
||||
args,
|
||||
windowsHide: platform === 'win32',
|
||||
};
|
||||
}
|
||||
|
||||
const commandLine = buildWindowsCommandLine(resolvedCommand, args);
|
||||
return {
|
||||
command: 'cmd.exe',
|
||||
args: ['/d', '/s', '/c', commandLine],
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCodePageToEncoding(codePage) {
|
||||
if (codePage === 65001) return 'utf8';
|
||||
if (codePage === 936) return 'gbk';
|
||||
if (codePage === 54936) return 'gb18030';
|
||||
return 'gb18030';
|
||||
}
|
||||
|
||||
function parseWindowsCodePage(text) {
|
||||
if (!text) return null;
|
||||
const match = String(text).match(/(\d{3,5})/);
|
||||
if (!match) return null;
|
||||
const codePage = Number(match[1]);
|
||||
return Number.isFinite(codePage) ? codePage : null;
|
||||
}
|
||||
|
||||
function readWindowsCodePageSync() {
|
||||
if (cachedWindowsCodePage !== null) {
|
||||
return cachedWindowsCodePage;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync('cmd.exe', ['/d', '/s', '/c', 'chcp'], {
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
timeout: WINDOWS_CODEPAGE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
cachedWindowsCodePage = parseWindowsCodePage(output);
|
||||
} catch {
|
||||
cachedWindowsCodePage = null;
|
||||
}
|
||||
|
||||
return cachedWindowsCodePage;
|
||||
}
|
||||
|
||||
function toBuffer(value) {
|
||||
if (!value) return Buffer.alloc(0);
|
||||
if (Buffer.isBuffer(value)) return value;
|
||||
if (typeof value === 'string') return Buffer.from(value);
|
||||
if (value instanceof Uint8Array) return Buffer.from(value);
|
||||
return Buffer.from(String(value));
|
||||
}
|
||||
|
||||
export function decodeOutput(value, options = {}) {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
|
||||
const platform = getPlatform(options.platform);
|
||||
const buffer = toBuffer(value);
|
||||
if (buffer.length === 0) return '';
|
||||
|
||||
try {
|
||||
const strictUtf8 = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
||||
return strictUtf8;
|
||||
} catch {
|
||||
// Fall through to platform fallback decoder.
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const activeCodePage = readWindowsCodePageSync();
|
||||
const preferredEncoding = mapCodePageToEncoding(activeCodePage);
|
||||
|
||||
try {
|
||||
return iconv.decode(buffer, preferredEncoding);
|
||||
} catch {
|
||||
// Fall through to generic fallback.
|
||||
}
|
||||
|
||||
try {
|
||||
return iconv.decode(buffer, 'gb18030');
|
||||
} catch {
|
||||
// Fall through to latin1 fallback.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return buffer.toString('utf8');
|
||||
} catch {
|
||||
return buffer.toString('latin1');
|
||||
}
|
||||
}
|
||||
|
||||
export function runCommandSync(options) {
|
||||
const {
|
||||
command,
|
||||
args = [],
|
||||
cwd,
|
||||
env,
|
||||
timeoutMs,
|
||||
maxBuffer,
|
||||
} = options;
|
||||
|
||||
const platform = process.platform;
|
||||
const mergedEnv = env ? { ...process.env, ...env } : process.env;
|
||||
const spawnSpec = getSpawnSpec(command, args, platform, mergedEnv);
|
||||
const result = spawnSync(spawnSpec.command, spawnSpec.args, {
|
||||
cwd,
|
||||
env: mergedEnv,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer,
|
||||
windowsHide: spawnSpec.windowsHide,
|
||||
encoding: null,
|
||||
});
|
||||
|
||||
const stdoutBuffer = toBuffer(result.stdout);
|
||||
const stderrBuffer = toBuffer(result.stderr);
|
||||
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
spawnCommand: spawnSpec.command,
|
||||
spawnArgs: spawnSpec.args,
|
||||
status: typeof result.status === 'number' ? result.status : null,
|
||||
signal: result.signal || null,
|
||||
error: result.error || null,
|
||||
stdoutBuffer,
|
||||
stderrBuffer,
|
||||
stdout: decodeOutput(stdoutBuffer, { platform }),
|
||||
stderr: decodeOutput(stderrBuffer, { platform }),
|
||||
};
|
||||
}
|
||||
|
||||
export function runCommand(options) {
|
||||
const {
|
||||
command,
|
||||
args = [],
|
||||
cwd,
|
||||
env,
|
||||
timeoutMs,
|
||||
detached = false,
|
||||
capture = true,
|
||||
stdio,
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const platform = process.platform;
|
||||
const mergedEnv = env ? { ...process.env, ...env } : process.env;
|
||||
const spawnSpec = getSpawnSpec(command, args, platform, mergedEnv);
|
||||
|
||||
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
||||
cwd,
|
||||
env: mergedEnv,
|
||||
detached,
|
||||
stdio: stdio || (capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'),
|
||||
windowsHide: spawnSpec.windowsHide,
|
||||
});
|
||||
|
||||
const stdoutChunks = [];
|
||||
const stderrChunks = [];
|
||||
|
||||
if (capture && child.stdout) {
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdoutChunks.push(toBuffer(chunk));
|
||||
});
|
||||
}
|
||||
|
||||
if (capture && child.stderr) {
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderrChunks.push(toBuffer(chunk));
|
||||
});
|
||||
}
|
||||
|
||||
let timeoutId = null;
|
||||
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
child.once('error', (error) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.once('close', (code, signal) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
const stdoutBuffer = Buffer.concat(stdoutChunks);
|
||||
const stderrBuffer = Buffer.concat(stderrChunks);
|
||||
|
||||
resolve({
|
||||
command,
|
||||
args,
|
||||
spawnCommand: spawnSpec.command,
|
||||
spawnArgs: spawnSpec.args,
|
||||
code: typeof code === 'number' ? code : null,
|
||||
signal: signal || null,
|
||||
stdoutBuffer,
|
||||
stderrBuffer,
|
||||
stdout: decodeOutput(stdoutBuffer, { platform }),
|
||||
stderr: decodeOutput(stderrBuffer, { platform }),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function commandExists(command) {
|
||||
if (!command || typeof command !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const checker = process.platform === 'win32' ? 'where' : 'which';
|
||||
const result = runCommandSync({
|
||||
command: checker,
|
||||
args: [command],
|
||||
timeoutMs: 2000,
|
||||
});
|
||||
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
export function getPreferredNpmCommand() {
|
||||
if (process.platform !== 'win32') {
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
return commandExists('npm.cmd') ? 'npm.cmd' : 'npm';
|
||||
}
|
||||
|
||||
export function getPreferredNpxCommand() {
|
||||
if (process.platform !== 'win32') {
|
||||
return 'npx';
|
||||
}
|
||||
|
||||
return commandExists('npx.cmd') ? 'npx.cmd' : 'npx';
|
||||
}
|
||||
|
||||
export function getSpawnCommandSpec(command, args = [], platform = process.platform) {
|
||||
return getSpawnSpec(command, args, platform);
|
||||
}
|
||||
|
||||
export function __resetWindowsCodePageCacheForTests() {
|
||||
cachedWindowsCodePage = null;
|
||||
}
|
||||
41
scripts/utils/generatedTsxValidator.mjs
Normal file
41
scripts/utils/generatedTsxValidator.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import ts from 'typescript';
|
||||
|
||||
function formatDiagnostic(diagnostic) {
|
||||
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
||||
if (!diagnostic.file || typeof diagnostic.start !== 'number') {
|
||||
return message;
|
||||
}
|
||||
|
||||
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
||||
const sourceLine = diagnostic.file.text.split(/\r?\n/u)[line] || '';
|
||||
return `${diagnostic.file.fileName}:${line + 1}:${character + 1} - ${message}\n${sourceLine}`;
|
||||
}
|
||||
|
||||
export function validateGeneratedTsx(source, filePath = 'generated.tsx') {
|
||||
const result = ts.transpileModule(source, {
|
||||
fileName: filePath,
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ESNext,
|
||||
},
|
||||
});
|
||||
|
||||
const diagnostics = (result.diagnostics || []).filter(
|
||||
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: diagnostics.length === 0,
|
||||
diagnostics,
|
||||
formatted: diagnostics.map(formatDiagnostic).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertValidGeneratedTsx(source, filePath = 'generated.tsx') {
|
||||
const validation = validateGeneratedTsx(source, filePath);
|
||||
if (!validation.ok) {
|
||||
throw new Error(`生成的 TSX 语法校验失败:\n${validation.formatted}`);
|
||||
}
|
||||
}
|
||||
26
scripts/utils/serverInfo.d.ts
vendored
Normal file
26
scripts/utils/serverInfo.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export type AxhubServerRole = 'runtime' | 'admin';
|
||||
|
||||
export interface AxhubServerInfo {
|
||||
pid: number;
|
||||
port: number;
|
||||
host: string;
|
||||
origin: string;
|
||||
projectRoot: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface ServerInfoPathOptions {
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
export function readServerInfo(projectRoot: string, role: AxhubServerRole, options?: ServerInfoPathOptions): AxhubServerInfo | null;
|
||||
export function getAdminServerInfoPath(projectRoot?: string, options?: ServerInfoPathOptions): string;
|
||||
export function getRuntimeServerInfoPath(projectRoot: string): string;
|
||||
export function writeServerInfo(
|
||||
projectRoot: string,
|
||||
role: AxhubServerRole,
|
||||
info: AxhubServerInfo,
|
||||
options?: ServerInfoPathOptions,
|
||||
): AxhubServerInfo;
|
||||
export function fetchHealth(origin: string, timeoutMs?: number): Promise<unknown | null>;
|
||||
export function normalizeHealthServerInfo(data: unknown): AxhubServerInfo | null;
|
||||
108
scripts/utils/serverInfo.mjs
Normal file
108
scripts/utils/serverInfo.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const MAKE_STATE_DIR = path.join('.axhub', 'make');
|
||||
const RUNTIME_SERVER_INFO_RELATIVE_PATH = path.join(MAKE_STATE_DIR, '.dev-server-info.json');
|
||||
const ADMIN_SERVER_INFO_RELATIVE_PATH = path.join(MAKE_STATE_DIR, '.admin-server-info.json');
|
||||
const MAKE_HOME_DIR_ENV = 'AXHUB_MAKE_HOME_DIR';
|
||||
|
||||
function resolveProjectRoot(projectRoot) {
|
||||
return path.resolve(projectRoot);
|
||||
}
|
||||
|
||||
function getServerInfoPath(projectRoot, role) {
|
||||
if (role === 'admin') {
|
||||
return getAdminServerInfoPath();
|
||||
}
|
||||
return path.join(resolveProjectRoot(projectRoot), RUNTIME_SERVER_INFO_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function getGlobalHomeDir(options = {}) {
|
||||
return options.homeDir || process.env[MAKE_HOME_DIR_ENV] || os.homedir();
|
||||
}
|
||||
|
||||
export function getAdminServerInfoPath(_projectRoot, options = {}) {
|
||||
return path.join(path.resolve(getGlobalHomeDir(options)), ADMIN_SERVER_INFO_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
export function getRuntimeServerInfoPath(projectRoot) {
|
||||
return getServerInfoPath(projectRoot, 'runtime');
|
||||
}
|
||||
|
||||
function normalizeServerInfo(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof data.pid !== 'number'
|
||||
|| typeof data.port !== 'number'
|
||||
|| typeof data.host !== 'string'
|
||||
|| typeof data.origin !== 'string'
|
||||
|| typeof data.projectRoot !== 'string'
|
||||
|| typeof data.startedAt !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pid: data.pid,
|
||||
port: data.port,
|
||||
host: data.host,
|
||||
origin: data.origin,
|
||||
projectRoot: resolveProjectRoot(data.projectRoot),
|
||||
startedAt: data.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function readServerInfo(projectRoot, role, options = {}) {
|
||||
const infoPath = role === 'admin'
|
||||
? getAdminServerInfoPath(projectRoot, options)
|
||||
: getServerInfoPath(projectRoot, role);
|
||||
if (!fs.existsSync(infoPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeServerInfo(JSON.parse(fs.readFileSync(infoPath, 'utf8')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeServerInfo(projectRoot, role, info, options = {}) {
|
||||
const normalized = {
|
||||
...info,
|
||||
projectRoot: resolveProjectRoot(info.projectRoot),
|
||||
};
|
||||
const infoPath = role === 'admin'
|
||||
? getAdminServerInfoPath(projectRoot, options)
|
||||
: getServerInfoPath(projectRoot, role);
|
||||
fs.mkdirSync(path.dirname(infoPath), { recursive: true });
|
||||
fs.writeFileSync(infoPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function fetchHealth(origin, timeoutMs = 1000) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(new URL('/api/health', origin), {
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHealthServerInfo(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return normalizeServerInfo(data.server ?? data);
|
||||
}
|
||||
Reference in New Issue
Block a user