使用React js完成web前端
This commit is contained in:
1 parent
9e8c5f1ef0
commit
7600f0f9ed
130 files changed
+12708
-788
No files matched your search
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
// CLI 占位入口:功能尚未实现,仅提示用户。
|
||||
|
||||
console.log('ChuSQL CLI is not implemented yet. Coming soon.');
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer } from 'vite';
|
||||
import { parseArgs } from './lib/args.js';
|
||||
import {
|
||||
defaultConfigPath, getAtPath, parseConfigValue, readConfig, setAtPath, unsetAtPath, writeConfig,
|
||||
} from './lib/config.js';
|
||||
import { openBrowser } from './lib/open.js';
|
||||
|
||||
// ChuSQL 前端 CLI:启动开发服务器并管理本地配置。
|
||||
|
||||
const cyan = (text) => `\u001b[36m${text}\u001b[0m`;
|
||||
const green = (text) => `\u001b[32m${text}\u001b[0m`;
|
||||
const red = (text) => `\u001b[31m${text}\u001b[0m`;
|
||||
|
||||
const help = `Usage: chusql <command> [options]
|
||||
|
||||
Commands:
|
||||
web Start the web server
|
||||
config list List all configuration
|
||||
config get <key> Get a configuration value
|
||||
config set <key> <val> Set a configuration value
|
||||
config unset <key> Remove a configuration value
|
||||
config path Print the configuration file path
|
||||
help Show this help message
|
||||
|
||||
Global options:
|
||||
--port <n> Specify port
|
||||
--host <h> Specify host
|
||||
--no-open Do not open the browser automatically
|
||||
--config <path> Use a custom configuration file
|
||||
--adapter <mock|rest> Specify the data source adapter
|
||||
--help, -h Show help
|
||||
--version, -v Show version
|
||||
|
||||
Run "chusql <command> --help" for command-specific options.`;
|
||||
|
||||
function configCommand(args, configPath) {
|
||||
const config = readConfig(configPath);
|
||||
const [key, rawValue] = args.operands;
|
||||
if (!args.action || args.action === 'list') console.log(JSON.stringify(config, null, 2));
|
||||
else if (args.action === 'path') console.log(configPath);
|
||||
else if (args.action === 'get') {
|
||||
if (!key) throw new Error('Usage: chusql config get <key>');
|
||||
const value = getAtPath(config, key);
|
||||
if (value === undefined) throw new Error(`Configuration key not found: ${key}`);
|
||||
console.log(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
|
||||
} else if (args.action === 'set') {
|
||||
if (!key || rawValue === undefined) throw new Error('Usage: chusql config set <key> <value>');
|
||||
setAtPath(config, key, parseConfigValue(rawValue));
|
||||
writeConfig(configPath, config);
|
||||
console.log(green(`Saved ${key}.`));
|
||||
} else if (args.action === 'unset') {
|
||||
if (!key) throw new Error('Usage: chusql config unset <key>');
|
||||
if (!unsetAtPath(config, key)) throw new Error(`Configuration key not found: ${key}`);
|
||||
writeConfig(configPath, config);
|
||||
console.log(green(`Removed ${key}.`));
|
||||
} else throw new Error(`Unknown config command: ${args.action}`);
|
||||
}
|
||||
|
||||
async function webCommand(args, configPath) {
|
||||
const stored = readConfig(configPath);
|
||||
const config = {
|
||||
...stored,
|
||||
...(args.port === undefined ? {} : { port: args.port }),
|
||||
...(args.host === undefined ? {} : { host: args.host }),
|
||||
...(args.adapter === undefined ? {} : { adapter: args.adapter }),
|
||||
...(args.autoOpenBrowser === undefined ? {} : { autoOpenBrowser: args.autoOpenBrowser }),
|
||||
};
|
||||
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error('Configured port must be an integer between 1 and 65535.');
|
||||
if (!['mock', 'rest'].includes(config.adapter)) throw new Error('Configured adapter must be mock or rest.');
|
||||
process.env.VITE_CHUSQL_ADAPTER = config.adapter;
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const server = await createServer({ root, server: { host: config.host, port: config.port, strictPort: true } });
|
||||
await server.listen();
|
||||
const url = `http://${config.host}:${config.port}/`;
|
||||
console.log(`${cyan('➜')} ${green('ChuSQL Web is running at:')}`);
|
||||
console.log(` ${url}`);
|
||||
console.log('Press Ctrl+C to stop.');
|
||||
if (config.autoOpenBrowser) openBrowser(url);
|
||||
}
|
||||
|
||||
try {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const configPath = path.resolve(args.configPath ?? defaultConfigPath());
|
||||
if (args.help || args.command === 'help') console.log(help);
|
||||
else if (args.command === 'version') console.log('ChuSQL 1.0.0');
|
||||
else if (args.command === 'config') configCommand(args, configPath);
|
||||
else if (args.command === 'web') await webCommand(args, configPath);
|
||||
else throw new Error(`Unknown command: ${args.command}`);
|
||||
} catch (error) {
|
||||
console.error(red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// 命令行参数解析:命令、动作、全局选项与取值校验。
|
||||
|
||||
const HOST_PATTERN = /^[A-Za-z0-9.:[\]-]+$/;
|
||||
|
||||
function valueAfter(argv, index, label) {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) throw new Error(`${label} requires a value.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const result = { command: 'help', action: '', operands: [], autoOpenBrowser: undefined };
|
||||
let index = 0;
|
||||
if (argv[index] && !argv[index].startsWith('-')) {
|
||||
result.command = argv[index];
|
||||
index += 1;
|
||||
} else if (argv.length) {
|
||||
result.command = argv[0] === '--version' || argv[0] === '-v' ? 'version' : 'help';
|
||||
}
|
||||
if (result.command === 'config' && argv[index] && !argv[index].startsWith('-')) {
|
||||
result.action = argv[index];
|
||||
index += 1;
|
||||
}
|
||||
while (index < argv.length) {
|
||||
const token = argv[index];
|
||||
if (token === '--help' || token === '-h') result.help = true;
|
||||
else if (token === '--no-open') result.autoOpenBrowser = false;
|
||||
else if (token === '--port') {
|
||||
const raw = valueAfter(argv, index, 'Port');
|
||||
if (!/^\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) throw new Error('Port must be an integer between 1 and 65535.');
|
||||
result.port = Number(raw);
|
||||
index += 1;
|
||||
} else if (token === '--host') {
|
||||
const host = valueAfter(argv, index, 'Host');
|
||||
if (!HOST_PATTERN.test(host)) throw new Error('Host contains invalid characters.');
|
||||
result.host = host;
|
||||
index += 1;
|
||||
} else if (token === '--adapter') {
|
||||
const adapter = valueAfter(argv, index, 'Adapter');
|
||||
if (adapter !== 'mock' && adapter !== 'rest') throw new Error('Adapter must be mock or rest.');
|
||||
result.adapter = adapter;
|
||||
index += 1;
|
||||
} else if (token === '--config') {
|
||||
result.configPath = valueAfter(argv, index, 'Config');
|
||||
index += 1;
|
||||
} else if (token.startsWith('-')) {
|
||||
throw new Error(`Unknown option: ${token}`);
|
||||
} else {
|
||||
result.operands.push(token);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseArgs } from './args.js';
|
||||
|
||||
// 覆盖 CLI 参数解析与非法取值拒绝。
|
||||
|
||||
describe('parseArgs', () => {
|
||||
it('parses command-specific and global options', () => {
|
||||
expect(parseArgs(['web', '--port', '9000', '--no-open', '--adapter', 'rest'])).toMatchObject({
|
||||
command: 'web', port: 9000, autoOpenBrowser: false, adapter: 'rest',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsafe or invalid values', () => {
|
||||
expect(() => parseArgs(['web', '--port', 'nope'])).toThrow('Port must be an integer');
|
||||
expect(() => parseArgs(['web', '--host', 'x & calc'])).toThrow('Host contains invalid characters');
|
||||
expect(() => parseArgs(['web', '--adapter', 'other'])).toThrow('Adapter must be mock or rest');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
// CLI 配置文件读写:默认值、点号路径访问与类型解析。
|
||||
|
||||
export const DEFAULT_CONFIG = Object.freeze({
|
||||
port: 5173,
|
||||
host: '127.0.0.1',
|
||||
autoOpenBrowser: true,
|
||||
adapter: 'mock',
|
||||
});
|
||||
|
||||
export function defaultConfigPath() {
|
||||
return path.join(os.homedir(), '.chusql', 'config.json');
|
||||
}
|
||||
|
||||
export function readConfig(file = defaultConfigPath()) {
|
||||
if (!fs.existsSync(file)) {
|
||||
writeConfig(file, DEFAULT_CONFIG);
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
throw new Error(`Configuration file is not valid JSON: ${file}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`Configuration must be a JSON object: ${file}`);
|
||||
return { ...DEFAULT_CONFIG, ...parsed };
|
||||
}
|
||||
|
||||
export function writeConfig(file, value) {
|
||||
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export function parseConfigValue(raw) {
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
|
||||
if (raw === 'true') return true;
|
||||
if (raw === 'false') return false;
|
||||
return raw;
|
||||
}
|
||||
|
||||
const FORBIDDEN_PATH_PARTS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
|
||||
function configPathParts(dottedPath) {
|
||||
const parts = dottedPath.split('.').filter(Boolean);
|
||||
if (!parts.length) throw new Error('Configuration key must not be empty.');
|
||||
if (parts.some((part) => FORBIDDEN_PATH_PARTS.has(part))) throw new Error('Configuration key contains a forbidden path segment.');
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function getAtPath(value, dottedPath) {
|
||||
return configPathParts(dottedPath).reduce((current, part) => current && typeof current === 'object' ? current[part] : undefined, value);
|
||||
}
|
||||
|
||||
export function setAtPath(value, dottedPath, nextValue) {
|
||||
const parts = configPathParts(dottedPath);
|
||||
let cursor = value;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (!cursor[part] || typeof cursor[part] !== 'object' || Array.isArray(cursor[part])) cursor[part] = {};
|
||||
cursor = cursor[part];
|
||||
}
|
||||
cursor[parts.at(-1)] = nextValue;
|
||||
}
|
||||
|
||||
export function unsetAtPath(value, dottedPath) {
|
||||
const parts = configPathParts(dottedPath);
|
||||
let cursor = value;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (!cursor[part] || typeof cursor[part] !== 'object') return false;
|
||||
cursor = cursor[part];
|
||||
}
|
||||
return parts.length ? delete cursor[parts.at(-1)] : false;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_CONFIG, getAtPath, parseConfigValue, readConfig, setAtPath, unsetAtPath, writeConfig } from './config.js';
|
||||
|
||||
// 覆盖 CLI 配置读写、取值解析与路径安全。
|
||||
|
||||
const temporaryDirectories = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function temporaryConfig() {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'chusql-cli-'));
|
||||
temporaryDirectories.push(directory);
|
||||
return path.join(directory, 'nested', 'config.json');
|
||||
}
|
||||
|
||||
describe('CLI configuration', () => {
|
||||
it('creates the formatted default configuration on first read', () => {
|
||||
const file = temporaryConfig();
|
||||
expect(readConfig(file)).toEqual(DEFAULT_CONFIG);
|
||||
expect(fs.readFileSync(file, 'utf8')).toBe(`${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
|
||||
});
|
||||
|
||||
it('persists dotted paths and can remove them', () => {
|
||||
const file = temporaryConfig();
|
||||
const config = readConfig(file);
|
||||
setAtPath(config, 'db.host', 'localhost');
|
||||
writeConfig(file, config);
|
||||
expect(getAtPath(readConfig(file), 'db.host')).toBe('localhost');
|
||||
expect(unsetAtPath(config, 'db.host')).toBe(true);
|
||||
expect(getAtPath(config, 'db.host')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses numbers and booleans without changing ordinary text', () => {
|
||||
expect(parseConfigValue('5173')).toBe(5173);
|
||||
expect(parseConfigValue('true')).toBe(true);
|
||||
expect(parseConfigValue('rest')).toBe('rest');
|
||||
});
|
||||
|
||||
it('rejects prototype-polluting configuration paths', () => {
|
||||
const config = {};
|
||||
expect(() => setAtPath(config, '__proto__.polluted', true)).toThrow('forbidden path segment');
|
||||
expect({}.polluted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
// 用系统默认浏览器打开本地地址,并校验 URL 合法性。
|
||||
|
||||
export function openBrowser(url) {
|
||||
if (!/^http:\/\/[A-Za-z0-9.:[\]-]+:\d+\/$/.test(url)) throw new Error('Refusing to open an invalid local URL.');
|
||||
const command = process.platform === 'win32' ? 'cmd.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
||||
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'start', '', url] : [url];
|
||||
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
||||
child.unref();
|
||||
}
|
||||
Reference in new issue
Block a user