diff --git a/biome.json b/biome.json index 6849e4d..7341e57 100644 --- a/biome.json +++ b/biome.json @@ -29,7 +29,7 @@ "noExplicitAny": "off" }, "complexity": { - "noForEach": "info" + "noForEach": "off" }, "style": { "noUselessElse": "off", diff --git a/bun.lockb b/bun.lockb index 4cd53cb..dc3ebbb 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli/CliContext.ts b/cli/CliContext.ts new file mode 100644 index 0000000..4f3bac3 --- /dev/null +++ b/cli/CliContext.ts @@ -0,0 +1,8 @@ +import type { CliConfig } from "lib/cli-config" + +/** + * The CLI context contains information that is commonly passed to all functions + */ +export interface CliContext { + cliConfig: CliConfig +} diff --git a/cli/auth/login/register.ts b/cli/auth/login/register.ts new file mode 100644 index 0000000..21ca6dd --- /dev/null +++ b/cli/auth/login/register.ts @@ -0,0 +1,73 @@ +import type { Command } from "commander" +import { cliConfig } from "lib/cli-config" +import delay from "delay" +import { getKy } from "lib/registry-api/get-ky" +import type { EndpointResponse } from "lib/registry-api/endpoint-types" + +export const registerAuthLogin = (program: Command) => { + program.commands + .find((c) => c.name() === "auth")! + .command("login") + .description("Authenticate CLI, login to registry") + .action(async (args) => { + const ky = getKy() + + const { login_page } = await ky + .post( + "sessions/login_page/create", + { + json: {}, + }, + ) + .json() + + console.log("Please visit the following URL to log in:") + console.log(login_page.url) + + // Wait until we receive confirmation + while (true) { + const { login_page: new_login_page } = await ky + .post( + "sessions/login_page/get", + { + json: { + login_page_id: login_page.login_page_id, + }, + headers: { + Authorization: `Bearer ${login_page.login_page_auth_token}`, + }, + }, + ) + .json() + + if (new_login_page.was_login_successful) { + console.log("Logged in! Generating token...") + break + } + + if (new_login_page.is_expired) { + throw new Error("Login page expired") + } + + await delay(1000) + } + + const { session } = await ky + .post( + "sessions/login_page/exchange_for_cli_session", + { + json: { + login_page_id: login_page.login_page_id, + }, + headers: { + Authorization: `Bearer ${login_page.login_page_auth_token}`, + }, + }, + ) + .json() + + cliConfig.set("sessionToken", session.token) + + console.log("Ready to use!") + }) +} diff --git a/cli/auth/logout/register.ts b/cli/auth/logout/register.ts new file mode 100644 index 0000000..8ff20e2 --- /dev/null +++ b/cli/auth/logout/register.ts @@ -0,0 +1,11 @@ +import type { Command } from "commander" + +export const registerAuthLogout = (program: Command) => { + program.commands + .find((c) => c.name() === "auth")! + .command("logout") + .description("Logout from registry") + .action((args) => { + console.log("logout") + }) +} diff --git a/cli/auth/register.ts b/cli/auth/register.ts new file mode 100644 index 0000000..3a817ba --- /dev/null +++ b/cli/auth/register.ts @@ -0,0 +1,5 @@ +import type { Command } from "commander" + +export const registerAuth = (program: Command) => { + program.command("auth").description("Login/logout") +} diff --git a/cli/config/print/register.ts b/cli/config/print/register.ts new file mode 100644 index 0000000..27950d6 --- /dev/null +++ b/cli/config/print/register.ts @@ -0,0 +1,12 @@ +import type { Command } from "commander" +import { cliConfig } from "lib/cli-config" + +export const registerConfigPrint = (program: Command) => { + program.commands + .find((c) => c.name() === "config")! + .command("print") + .description("Print the current config") + .action(() => { + console.log(JSON.stringify(cliConfig.all, null, 2)) + }) +} diff --git a/cli/config/register.ts b/cli/config/register.ts new file mode 100644 index 0000000..d00ad4e --- /dev/null +++ b/cli/config/register.ts @@ -0,0 +1,5 @@ +import type { Command } from "commander" + +export const registerConfig = (program: Command) => { + program.command("config").description("Manage tscircuit CLI configuration") +} diff --git a/cli/dev/register.ts b/cli/dev/register.ts new file mode 100644 index 0000000..8a73548 --- /dev/null +++ b/cli/dev/register.ts @@ -0,0 +1,111 @@ +import type { Command } from "commander" +import * as path from "node:path" +import * as chokidar from "chokidar" +import * as fs from "node:fs" +import { createServer } from "lib/server/createServer" +import { getLocalFileDependencies } from "lib/dependency-analysis/getLocalFileDependencies" +import { installTypes } from "../../lib/dependency-analysis/installNodeModuleTypes" +import { EventsWatcher } from "../../lib/server/EventsWatcher" + +export const registerDev = (program: Command) => { + program + .command("dev") + .description("Start development server for a snippet") + .argument("", "Path to the snippet file") + .option("-p, --port ", "Port to run server on", "3000") + .action(async (file: string, options: { port: string }) => { + const absolutePath = path.resolve(file) + const fileDir = path.dirname(absolutePath) + const port = parseInt(options.port) + + try { + console.log("Installing types for imported snippets...") + await installTypes(absolutePath) + console.log("Types installed successfully") + } catch (error) { + console.warn("Failed to install types:", error) + } + + // Start the server + await createServer(port) + + const eventsWatcher = new EventsWatcher(`http://localhost:${port}`) + eventsWatcher.start() + + await fetch(`http://localhost:${port}/api/files/upsert`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file_path: "entrypoint.tsx", + text_content: ` +import MyCircuit from "./snippet.tsx" + +circuit.add() +`, + }), + }) + + // Function to update file content + const updateFile = async (filePath: string) => { + try { + const content = await fs.promises.readFile(filePath, "utf-8") + const response = await fetch( + `http://localhost:${port}/api/files/upsert`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file_path: path.relative(fileDir, filePath), + text_content: content, + }), + }, + ) + if (!response.ok) { + console.error(`Failed to update ${filePath}`) + } + } catch (error) { + console.error(`Error updating ${filePath}:`, error) + } + } + + // Get initial dependencies + const dependencies = new Set([absolutePath]) + try { + const deps = getLocalFileDependencies(absolutePath) + deps.forEach((dep) => dependencies.add(dep)) + } catch (error) { + console.warn("Failed to analyze dependencies:", error) + } + + // Watch the main file and its dependencies + const filesystemWatcher = chokidar.watch(Array.from(dependencies), { + persistent: true, + ignoreInitial: false, + }) + + filesystemWatcher.on("change", async (filePath) => { + console.log(`File ${filePath} changed`) + await updateFile(filePath) + }) + + filesystemWatcher.on("add", async (filePath) => { + console.log(`File ${filePath} added`) + await updateFile(filePath) + }) + + eventsWatcher.on("FILE_UPDATED", async (ev) => { + if (ev.file_path === "manual-edits.json") { + console.log("Manual edits updated, updating on filesystem...") + const { file } = await fetch( + `http://localhost:${port}/api/files/get?file_path=manual-edits.json`, + ).then((r) => r.json()) + fs.writeFileSync( + path.join(fileDir, "manual-edits.json"), + file.text_content, + ) + } + }) + + console.log(`Watching ${file} and its dependencies...`) + }) +} diff --git a/cli/main.ts b/cli/main.ts index ae8ae2d..8e2e7f9 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -1,119 +1,31 @@ #!/usr/bin/env node import { Command } from "commander" -import * as path from "node:path" -import * as chokidar from "chokidar" -import * as fs from "node:fs" -import { createServer } from "../lib/server/createServer" -import { getLocalFileDependencies } from "../lib/dependency-analysis/getLocalFileDependencies" -import { installTypes } from "./installTypes" -import { EventsWatcher } from "../lib/server/EventsWatcher" +import { registerDev } from "./dev/register" +import { registerAuthLogin } from "./auth/login/register" +import { registerAuthLogout } from "./auth/logout/register" +import { registerAuth } from "./auth/register" +import { registerConfig } from "./config/register" +import { registerConfigPrint } from "./config/print/register" +import { perfectCli } from "perfect-cli" const program = new Command() program - .name("snippets") + .name("tsci") .description("CLI for developing tscircuit snippets") .version("1.0.0") -program - .command("dev") - .description("Start development server for a snippet") - .argument("", "Path to the snippet file") - .option("-p, --port ", "Port to run server on", "3000") - .action(async (file: string, options: { port: string }) => { - const absolutePath = path.resolve(file) - const fileDir = path.dirname(absolutePath) - const port = parseInt(options.port) - - try { - console.log("Installing types for imported snippets...") - await installTypes(absolutePath) - console.log("Types installed successfully") - } catch (error) { - console.warn("Failed to install types:", error) - } - - // Start the server - await createServer(port) - - const eventsWatcher = new EventsWatcher(`http://localhost:${port}`) - eventsWatcher.start() - - await fetch(`http://localhost:${port}/api/files/upsert`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - file_path: "entrypoint.tsx", - text_content: ` -import MyCircuit from "./snippet.tsx" - -circuit.add() -`, - }), - }) - - // Function to update file content - const updateFile = async (filePath: string) => { - try { - const content = await fs.promises.readFile(filePath, "utf-8") - const response = await fetch( - `http://localhost:${port}/api/files/upsert`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - file_path: path.relative(fileDir, filePath), - text_content: content, - }), - }, - ) - if (!response.ok) { - console.error(`Failed to update ${filePath}`) - } - } catch (error) { - console.error(`Error updating ${filePath}:`, error) - } - } - - // Get initial dependencies - const dependencies = new Set([absolutePath]) - try { - const deps = getLocalFileDependencies(absolutePath) - deps.forEach((dep) => dependencies.add(dep)) - } catch (error) { - console.warn("Failed to analyze dependencies:", error) - } - - // Watch the main file and its dependencies - const filesystemWatcher = chokidar.watch(Array.from(dependencies), { - persistent: true, - ignoreInitial: false, - }) - - filesystemWatcher.on("change", async (filePath) => { - console.log(`File ${filePath} changed`) - await updateFile(filePath) - }) - - filesystemWatcher.on("add", async (filePath) => { - console.log(`File ${filePath} added`) - await updateFile(filePath) - }) +registerDev(program) - eventsWatcher.on("FILE_UPDATED", async (ev) => { - if (ev.file_path === "manual-edits.json") { - console.log("Manual edits updated, updating on filesystem...") - const { file } = await fetch( - `http://localhost:${port}/api/files/get?file_path=manual-edits.json`, - ).then((r) => r.json()) - fs.writeFileSync( - path.join(fileDir, "manual-edits.json"), - file.text_content, - ) - } - }) +registerAuth(program) +registerAuthLogin(program) +registerAuthLogout(program) - console.log(`Watching ${file} and its dependencies...`) - }) +registerConfig(program) +registerConfigPrint(program) -program.parse() +if (process.argv.length === 2) { + perfectCli(program, process.argv) +} else { + program.parse() +} diff --git a/lib/cli-config/TypedConfigStore.ts b/lib/cli-config/TypedConfigStore.ts new file mode 100644 index 0000000..4f0540f --- /dev/null +++ b/lib/cli-config/TypedConfigStore.ts @@ -0,0 +1,50 @@ +export interface TypedConfigstore> { + /** + * Get the path to the config file. Can be used to show the user + * where it is, or better, open it for them. + */ + path: string + + /** + * Get all items as an object or replace the current config with an object. + */ + all: any + + /** + * Get the item count + */ + size: number + + /** + * Get an item + * @param key The string key to get + * @return The contents of the config from key $key + */ + get(key: keyof T): any + + /** + * Set an item + * @param key The string key + * @param val The value to set + */ + set(key: K, val: T[K]): void + + /** + * Determines if a key is present in the config + * @param key The string key to test for + * @return True if the key is present + */ + has(key: keyof T): boolean + + /** + * Delete an item. + * @param key The key to delete + */ + delete(key: keyof T): void + + /** + * Clear the config. + * Equivalent to Configstore.all = {}; + */ + clear(): void +} diff --git a/lib/cli-config/index.ts b/lib/cli-config/index.ts new file mode 100644 index 0000000..8d55f7b --- /dev/null +++ b/lib/cli-config/index.ts @@ -0,0 +1,16 @@ +import Configstore from "configstore" +import type { TypedConfigstore } from "./TypedConfigStore" + +export interface CliConfig { + sessionToken?: string + githubUsername?: string + registryApiUrl?: string +} + +export const cliConfig: TypedConfigstore = new Configstore( + "tscircuit", +) + +export const getRegistryApiUrl = (): string => { + return cliConfig.get("registryApiUrl") ?? "https://registry-api.tscircuit.com" +} diff --git a/lib/config.ts b/lib/config.ts deleted file mode 100644 index f684784..0000000 --- a/lib/config.ts +++ /dev/null @@ -1,97 +0,0 @@ -import Configstore from "configstore" - -interface ProfileConfigProps { - session_token?: string - registry_url?: string -} - -interface GlobalConfigProps { - current_profile?: string - log_requests?: boolean - runtime?: "bun" | "node" -} - -interface TypedConfigstore> { - /** - * Get the path to the config file. Can be used to show the user - * where it is, or better, open it for them. - */ - path: string - - /** - * Get all items as an object or replace the current config with an object. - */ - all: any - - /** - * Get the item count - */ - size: number - - /** - * Get an item - * @param key The string key to get - * @return The contents of the config from key $key - */ - get(key: keyof T): any - - /** - * Set an item - * @param key The string key - * @param val The value to set - */ - set(key: K, val: T[K]): void - - /** - * Determines if a key is present in the config - * @param key The string key to test for - * @return True if the key is present - */ - has(key: keyof T): boolean - - /** - * Delete an item. - * @param key The key to delete - */ - delete(key: keyof T): void - - /** - * Clear the config. - * Equivalent to Configstore.all = {}; - */ - clear(): void -} - -export interface ContextConfigProps { - profile_config: TypedConfigstore - global_config: TypedConfigstore - current_profile: string -} - -export const createConfigHandler = ({ - profile, -}: { - profile?: string -}): ContextConfigProps => { - const global_config: TypedConfigstore = new Configstore( - "tsci", - ) - const current_profile = - profile ?? global_config.get("current_profile") ?? "default" - - const profile_config: TypedConfigstore = { - get: (key: string) => - (global_config as any).get(`profiles.${current_profile}.${key}`), - set: (key: string, value: any) => - (global_config as any).set(`profiles.${current_profile}.${key}`, value), - clear: () => { - for (const key of Object.keys(global_config.all)) { - if (key.startsWith(`profiles.${current_profile}`)) { - global_config.delete(key as any) - } - } - }, - } as any - - return { profile_config, global_config, current_profile } -} diff --git a/cli/installTypes.ts b/lib/dependency-analysis/installNodeModuleTypes.ts similarity index 100% rename from cli/installTypes.ts rename to lib/dependency-analysis/installNodeModuleTypes.ts diff --git a/lib/project-config/index.ts b/lib/project-config/index.ts new file mode 100644 index 0000000..84b6fe8 --- /dev/null +++ b/lib/project-config/index.ts @@ -0,0 +1,5 @@ +import { cosmiconfigSync } from "cosmiconfig" + +const explorer = cosmiconfigSync("tscircuit", {}) + +export const projectConfigSearchResult = explorer.search() diff --git a/lib/registry-api/endpoint-types.ts b/lib/registry-api/endpoint-types.ts new file mode 100644 index 0000000..2f87f21 --- /dev/null +++ b/lib/registry-api/endpoint-types.ts @@ -0,0 +1,20 @@ +export interface EndpointResponse { + "sessions/login_page/create": { + login_page: { + login_page_id: string + login_page_auth_token: string + url: string + } + } + "sessions/login_page/get": { + login_page: { + was_login_successful: boolean + is_expired: boolean + } + } + "sessions/login_page/exchange_for_cli_session": { + session: { + token: string + } + } +} diff --git a/lib/registry-api/get-ky.ts b/lib/registry-api/get-ky.ts new file mode 100644 index 0000000..75a11bb --- /dev/null +++ b/lib/registry-api/get-ky.ts @@ -0,0 +1,30 @@ +import { getRegistryApiUrl } from "lib/cli-config" +import ky, { type AfterResponseHook } from "ky" + +const prettyResponseErrorHook: AfterResponseHook = async ( + _request, + _options, + response, +) => { + if (!response.ok) { + try { + const errorData = await response.json() + throw new Error( + `FAIL [${response.status}]: ${_request.method} ${ + new URL(_request.url).pathname + } \n\n ${JSON.stringify(errorData, null, 2)}`, + ) + } catch (e) { + //ignore, allow the error to be thrown + } + } +} + +export const getKy = () => { + return ky.create({ + prefixUrl: getRegistryApiUrl(), + hooks: { + afterResponse: [prettyResponseErrorHook], + }, + }) +} diff --git a/package.json b/package.json index cdc96ad..2b82b42 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,18 @@ { - "name": "@tscircuit/snippets-cli", + "name": "@tscircuit/cli", "main": "dist/main.js", "type": "module", - "version": "0.0.5", + "version": "0.1.0", "bin": { - "snippets": "./dist/main.js" + "tsci": "./dist/main.js" }, "scripts": { "start": "bun run dev", - "dev": "bun run --hot ./cli/main.ts dev ./example-dir/snippet.tsx", + "dev": "bun --hot ./cli/main.ts dev ./example-dir/snippet.tsx", "build": "tsup-node cli/main.ts --format esm --sourcemap inline", "format": "biome format --write .", - "format:check": "biome format ." + "format:check": "biome format .", + "cli": "bun ./cli/main.ts" }, "devDependencies": { "@biomejs/biome": "^1.9.4", @@ -29,6 +30,10 @@ "@tscircuit/runframe": "^0.0.47", "chokidar": "^4.0.1", "commander": "^12.1.0", - "configstore": "^7.0.0" + "configstore": "^7.0.0", + "cosmiconfig": "^9.0.0", + "delay": "^6.0.0", + "ky": "^1.7.4", + "perfect-cli": "^1.0.20" } } diff --git a/tsconfig.json b/tsconfig.json index 5165b7d..4f1d51d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "allowJs": true, "baseUrl": ".", "paths": { - "lib/*": ["lib/*"] + "lib/*": ["lib/*"], + "cli/*": ["cli/*"] }, // Bundler mode