diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..16efc01 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,27 @@ +name: Publish to Gitea Registry + +on: + push: + branches: + - master + paths: + - "package.json" + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + + - run: npm ci + + - run: npm run build + + - name: Publish to Gitea npm registry + run: | + echo "//gitea.synoth.com/api/packages/synoth/npm/:_authToken=${{ secrets.GITEA_TOKEN }}" > ~/.npmrc + npm publish diff --git a/.gitignore b/.gitignore index 25c8fdb..897cb9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -package-lock.json \ No newline at end of file +package-lock.json +dist \ No newline at end of file diff --git a/package.json b/package.json index 87ee16d..b8dd808 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,31 @@ "bin": { "brick": "./dist/index.js" }, + "files": [ + "dist/" + ], "scripts": { "build": "tsc", - "dev": "tsc --watch" + "dev": "tsc --watch", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "https://gitea.synoth.com/synoth/brick-cli.git" + }, + "publishConfig": { + "registry": "https://gitea.synoth.com/api/packages/synoth/npm/" }, "engines": { "node": ">=18" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "typescript": "^5.7.0" }, - "license": "MIT" + "license": "MIT", + "dependencies": { + "js-yaml": "^5.0.0" + } } diff --git a/src/commands/list.ts b/src/commands/list.ts new file mode 100644 index 0000000..284567a --- /dev/null +++ b/src/commands/list.ts @@ -0,0 +1,41 @@ +import { resolveConfig } from "../lib/config.js"; +import { loadIndex } from "../lib/index-loader.js"; +import { formatTable } from "../lib/table.js"; +import type { Command } from "../types/index.js"; + +export const listCommand: Command = { + name: "list", + description: "显示可用积木列表", + + run: async (args: string[]) => { + const config = resolveConfig(args); + const index = await loadIndex(config); + + if (index.blocks.length === 0) { + console.log("(no blocks found)"); + return; + } + + const columns = [ + { key: "name", header: "Name", minWidth: 20, maxWidth: 30 }, + { key: "module", header: "Module", minWidth: 8, maxWidth: 12 }, + { key: "variants", header: "Variants", minWidth: 15, maxWidth: 45 }, + { key: "dependsOn", header: "Dependencies", minWidth: 15, maxWidth: 30 }, + { key: "description", header: "Description", minWidth: 15, maxWidth: 55 }, + ]; + + const rows = index.blocks.map((block) => ({ + name: block.name, + module: block.module, + variants: + block.variants.length > 0 + ? block.variants.map((v) => v.label).join(", ") + : "-", + dependsOn: + block.dependsOn.length > 0 ? block.dependsOn.join(", ") : "-", + description: block.description, + })); + + console.log(formatTable(columns, rows)); + }, +}; diff --git a/src/index.ts b/src/index.ts index ad4f71d..b320102 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ #!/usr/bin/env node +import { listCommand } from "./commands/list.js"; +import type { Command } from "./types/index.js"; + const USAGE = ` Brick CLI — 源码级 Spring Boot 脚手架 @@ -14,4 +17,26 @@ Examples: brick list `; -console.log(USAGE); +const commands = new Map(); +commands.set(listCommand.name, listCommand); + +async function main(): Promise { + const args = process.argv.slice(2); + const commandName = args[0]; + + if (!commandName) { + console.log(USAGE); + process.exit(0); + } + + const command = commands.get(commandName); + if (!command) { + console.error(`Unknown command: ${commandName}`); + console.log(USAGE); + process.exit(1); + } + + await command.run(args.slice(1)); +} + +main(); diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..5e5a25e --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,30 @@ +import type { BrickConfig } from "../types/index.js"; + +const DEFAULT_SOURCE = + "https://gitea.synoth.com/synoth/brick-index/raw/branch/master/blocks.yaml"; + +function parseSourceFromArgs(args: string[]): string | undefined { + const idx = args.indexOf("--source"); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + return undefined; +} + +function isRemoteSource(source: string): boolean { + return source.startsWith("http://") || source.startsWith("https://"); +} + +export function resolveConfig(args: string[]): BrickConfig { + const cliSource = parseSourceFromArgs(args); + if (cliSource) { + return { source: cliSource, isRemote: isRemoteSource(cliSource) }; + } + + const envSource = process.env.BRICK_SOURCE; + if (envSource) { + return { source: envSource, isRemote: isRemoteSource(envSource) }; + } + + return { source: DEFAULT_SOURCE, isRemote: true }; +} diff --git a/src/lib/index-loader.ts b/src/lib/index-loader.ts new file mode 100644 index 0000000..93cfd3e --- /dev/null +++ b/src/lib/index-loader.ts @@ -0,0 +1,62 @@ +import { readFile } from "node:fs/promises"; +import { load } from "js-yaml"; +import type { BlockIndex, BrickConfig } from "../types/index.js"; + +export async function loadIndex(config: BrickConfig): Promise { + let raw: string; + + if (config.isRemote) { + let response: Response; + try { + response = await fetch(config.source); + } catch { + process.stderr.write( + `Error: Failed to fetch index from "${config.source}"\n` + ); + process.exit(1); + } + + if (!response.ok) { + process.stderr.write( + `Error: Failed to fetch index from "${config.source}" (HTTP ${response.status})\n` + ); + process.exit(1); + } + + raw = await response.text(); + } else { + try { + raw = await readFile(config.source, "utf-8"); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === "ENOENT") { + process.stderr.write( + `Error: Index file not found at "${config.source}"\n` + ); + } else { + process.stderr.write(`Error: ${(err as Error).message}\n`); + } + process.exit(1); + } + } + + let result: unknown; + try { + result = load(raw); + } catch (err: unknown) { + process.stderr.write( + `Error: Failed to parse index YAML: ${(err as Error).message}\n` + ); + process.exit(1); + } + + const data = result as Record; + if (!data || !Array.isArray(data.blocks)) { + process.stderr.write( + 'Error: Invalid index format — expected a "blocks" array\n' + ); + process.exit(1); + } + + return { blocks: data.blocks as BlockIndex["blocks"] }; +} diff --git a/src/lib/table.ts b/src/lib/table.ts new file mode 100644 index 0000000..5fc3794 --- /dev/null +++ b/src/lib/table.ts @@ -0,0 +1,103 @@ +interface ColumnDef { + key: string; + header: string; + minWidth?: number; + maxWidth?: number; + width?: number; +} + +function visualWidth(str: string): number { + let width = 0; + for (const ch of str) { + const code = ch.codePointAt(0) ?? 0; + // CJK Unified, Compatibility, Extension ranges, plus fullwidth forms + if ( + (code >= 0x4e00 && code <= 0x9fff) || + (code >= 0x3400 && code <= 0x4dbf) || + (code >= 0x20000 && code <= 0x2a6df) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xff01 && code <= 0xff60) || + (code >= 0xffe0 && code <= 0xffe6) + ) { + width += 2; + } else { + width += 1; + } + } + return width; +} + +function padToWidth(str: string, targetWidth: number): string { + let padded = str; + let current = visualWidth(str); + while (current < targetWidth) { + padded += " "; + current += 1; + } + return padded; +} + +function truncateToWidth(str: string, maxWidth: number): string { + let result = ""; + let vw = 0; + for (const ch of str) { + const charW = visualWidth(ch); + if (vw + charW > maxWidth - 3) { + result += "..."; + break; + } + result += ch; + vw += charW; + } + if (visualWidth(result) <= maxWidth && result.length < str.length) { + result += "..."; + } + return result; +} + +export function formatTable(columns: ColumnDef[], rows: Record[]): string { + const minW = 8; + const maxW = 60; + + const resolved = columns.map((col) => { + const headerW = visualWidth(col.header); + let dataW = 0; + for (const row of rows) { + const val = row[col.key] ?? "-"; + dataW = Math.max(dataW, visualWidth(val)); + } + const natural = Math.max(headerW, dataW); + const lo = col.minWidth ?? minW; + const hi = col.maxWidth ?? maxW; + const width = Math.max(lo, Math.min(hi, natural)); + return { ...col, width }; + }); + + const padCell = (col: ColumnDef, text: string): string => { + if (visualWidth(text) > col.width!) { + return truncateToWidth(text, col.width!); + } + return padToWidth(text, col.width!); + }; + + const lines: string[] = []; + + const headerLine = resolved + .map((col) => padCell(col, col.header)) + .join(" "); + lines.push(headerLine); + + const sepLine = resolved + .map((col) => "-".repeat(col.width!)) + .join(" "); + lines.push(sepLine); + + for (const row of rows) { + const line = resolved + .map((col) => padCell(col, row[col.key] ?? "-")) + .join(" "); + lines.push(line); + } + + return lines.join("\n"); +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..fde5e80 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,30 @@ +export interface BlockVariant { + branch: string; + label: string; +} + +export interface Block { + name: string; + version: string; + description: string; + language: string; + defaultBranch: string; + module: string; + variants: BlockVariant[]; + dependsOn: string[]; +} + +export interface BlockIndex { + blocks: Block[]; +} + +export interface BrickConfig { + source: string; + isRemote: boolean; +} + +export interface Command { + name: string; + description: string; + run: (args: string[]) => Promise; +}