diff --git a/cli/dev/DevServer.ts b/cli/dev/DevServer.ts index f8cc950..f9f0eec 100644 --- a/cli/dev/DevServer.ts +++ b/cli/dev/DevServer.ts @@ -9,6 +9,7 @@ import fs from "node:fs" import type { FileUpdatedEvent } from "lib/file-server/FileServerEvent" import * as chokidar from "chokidar" import { FilesystemTypesHandler } from "lib/dependency-analysis/FilesystemTypesHandler" +import { pushSnippet } from "lib/shared/push-snippet" export class DevServer { port: number @@ -68,6 +69,11 @@ export class DevServer { this.handleFileUpdatedEventFromServer.bind(this), ) + this.eventsWatcher.on( + "REQUEST_TO_SAVE_SNIPPET", + this.saveSnippet.bind(this), + ) + this.filesystemWatcher = chokidar.watch(this.projectDir, { persistent: true, ignoreInitial: true, @@ -159,6 +165,31 @@ circuit.add() } } + private async saveSnippet() { + const postEvent = async ( + event: "FAILED_TO_SAVE_SNIPPET" | "SNIPPET_SAVED", + ) => + this.fsKy.post("api/events/create", { + json: { event_type: event }, + throwHttpErrors: false, + }) + + await pushSnippet({ + filePath: this.componentFilePath, + onExit: (e) => { + console.error("Failed to save snippet", e) + postEvent("FAILED_TO_SAVE_SNIPPET") + }, + onError: (e) => { + console.error("Failed to save snippet", e) + postEvent("FAILED_TO_SAVE_SNIPPET") + }, + onSuccess: () => { + postEvent("SNIPPET_SAVED") + }, + }) + } + async stop() { this.httpServer?.close() this.eventsWatcher?.stop() diff --git a/cli/main.ts b/cli/main.ts index 0a0854b..89284b0 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -16,7 +16,7 @@ import { registerAuthPrintToken } from "./auth/print-token/register" import { registerAuthSetToken } from "./auth/set-token/register" import { registerPush } from "./push/register" -const program = new Command() +export const program = new Command() program .name("tsci") diff --git a/cli/push/register.ts b/cli/push/register.ts index a61710b..1d721f8 100644 --- a/cli/push/register.ts +++ b/cli/push/register.ts @@ -1,9 +1,5 @@ +import { pushSnippet } from "lib/shared/push-snippet" import type { Command } from "commander" -import { cliConfig } from "lib/cli-config" -import { getKy } from "lib/registry-api/get-ky" -import * as fs from "node:fs" -import * as path from "node:path" -import semver from "semver" export const registerPush = (program: Command) => { program @@ -11,189 +7,11 @@ export const registerPush = (program: Command) => { .description("Save snippet code to Registry API") .argument("[file]", "Path to the snippet file") .action(async (filePath?: string) => { - const sessionToken = cliConfig.get("sessionToken") - if (!sessionToken) { - console.error("You need to log in to save snippet.") - process.exit(1) - } - - let snippetFilePath: string | null = null - if (filePath) { - snippetFilePath = path.resolve(filePath) - } else { - const defaultEntrypoint = path.resolve("index.tsx") - if (fs.existsSync(defaultEntrypoint)) { - snippetFilePath = defaultEntrypoint - console.log("No file provided. Using 'index.tsx' as the entrypoint.") - } else { - console.error( - "No entrypoint found. Run 'tsci init' to bootstrap a basic project.", - ) - process.exit(1) - } - } - - const packageJsonPath = path.resolve( - path.join(path.dirname(snippetFilePath), "package.json"), - ) - let packageJson: { name?: string; author?: string; version?: string } = {} - if (fs.existsSync(packageJsonPath)) { - try { - packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString()) - } catch { - console.error("Invalid package.json provided") - process.exit(1) - } - } - - if (!fs.existsSync(snippetFilePath)) { - console.error(`File not found: ${snippetFilePath}`) - process.exit(1) - } - - const ky = getKy() - const packageName = ( - packageJson.name ?? path.parse(snippetFilePath).name - ).replace(/^@/, "") - - const packageAuthor = - packageJson.author?.split(" ")[0] ?? cliConfig.get("githubUsername") - - const packageIdentifier = `${packageAuthor}/${packageName}` - - let packageVersion = - packageJson.version ?? - (await ky - .post<{ - error?: { error_code: string } - package_releases?: { version: string; is_latest: boolean }[] - }>("package_releases/list", { - json: { package_name: packageIdentifier }, - }) - .json() - .then( - (response) => - response.package_releases?.[response.package_releases.length - 1] - ?.version, - ) - .catch((error) => { - console.error("Failed to retrieve latest package version:", error) - process.exit(1) - })) - - if (!packageVersion) { - console.log("Failed to retrieve package version.") - process.exit(1) - } - - const updatePackageJsonVersion = (newVersion?: string) => { - if (packageJson.version) { - try { - packageJson.version = newVersion ?? packageVersion - fs.writeFileSync( - packageJsonPath, - JSON.stringify(packageJson, null, 2), - ) - } catch (error) { - console.error("Failed to update package.json version:", error) - } - } - } - - const doesPackageExist = await ky - .post<{ error?: { error_code: string } }>("packages/get", { - json: { name: packageIdentifier }, - throwHttpErrors: false, - }) - .json() - .then( - (response) => !(response.error?.error_code === "package_not_found"), - ) - - if (!doesPackageExist) { - await ky - .post("packages/create", { - json: { name: packageIdentifier }, - headers: { Authorization: `Bearer ${sessionToken}` }, - }) - .catch((error) => { - console.error("Error creating package:", error) - process.exit(1) - }) - } - - const doesReleaseExist = await ky - .post<{ - error?: { error_code: string } - package_release?: { version: string } - }>("package_releases/get", { - json: { - package_name_with_version: `${packageIdentifier}@${packageVersion}`, - }, - throwHttpErrors: false, - }) - .json() - .then((response) => { - if (response.package_release?.version) { - packageVersion = response.package_release.version - updatePackageJsonVersion(response.package_release.version) - return true - } - return !(response.error?.error_code === "package_release_not_found") - }) - - if (doesReleaseExist) { - const bumpedVersion = semver.inc(packageVersion, "patch")! - console.log( - `Incrementing Package Version ${packageVersion} -> ${bumpedVersion}`, - ) - packageVersion = bumpedVersion - updatePackageJsonVersion(packageVersion) - } - - await ky - .post("package_releases/create", { - json: { - package_name_with_version: `${packageIdentifier}@${packageVersion}`, - }, - throwHttpErrors: false, - }) - .catch((error) => { - console.error("Error creating release:", error) - process.exit(1) - }) - - console.log("\n") - - const directoryFiles = fs.readdirSync(path.dirname(snippetFilePath)) - for (const file of directoryFiles) { - const fileExtension = path.extname(file).replace(".", "") - if (!["json", "tsx", "ts"].includes(fileExtension)) continue - - const fileContent = - fs - .readFileSync(path.join(path.dirname(snippetFilePath), file)) - .toString() ?? "" - await ky - .post("package_files/create", { - json: { - file_path: file, - content_text: fileContent, - package_name_with_version: `${packageIdentifier}@${packageVersion}`, - }, - throwHttpErrors: false, - }) - .then(() => { - console.log(`Uploaded file ${file} to the registry.`) - }) - .catch((error) => { - console.error(`Error uploading file ${file}:`, error) - }) - } - - console.log( - `\nšŸŽ‰ Successfully pushed package ${packageIdentifier}@${packageVersion} to the registry!${Bun.color("blue", "ansi")}`, - `https://tscircuit.com/${packageIdentifier} \x1b[0m`, - ) + await pushSnippet({ + filePath, + onExit: (code) => process.exit(code), + onError: (message) => console.error(message), + onSuccess: (message) => console.log(message), + }) }) } diff --git a/lib/file-server/FileServerRoutes.ts b/lib/file-server/FileServerRoutes.ts index 52054da..ddad0ea 100644 --- a/lib/file-server/FileServerRoutes.ts +++ b/lib/file-server/FileServerRoutes.ts @@ -1,4 +1,6 @@ -export interface FileServerRoutes { +import { EventsRoutes } from "lib/server/EventsRoutes" + +export interface FileServerRoutes extends EventsRoutes { "api/files/get": { GET: { searchParams: { diff --git a/lib/server/EventsRoutes.ts b/lib/server/EventsRoutes.ts new file mode 100644 index 0000000..a2fedd5 --- /dev/null +++ b/lib/server/EventsRoutes.ts @@ -0,0 +1,32 @@ +export interface EventsRoutes { + "api/events/create": { + POST: { + requestJson: { + event_type: string + } + responseJson: { + event: { + event_id: string + event_type: string + } + } + } + } + "api/events/list": { + GET: { + responseJson: { + event_list: Array<{ + event_id: string + event_type: + | "FILE_UPDATED" + | "FAILED_TO_SAVE_SNIPPET" + | "SNIPPET_SAVED" + | "REQUEST_TO_SAVE_SNIPPET" + file_path: string + created_at: string + initiator?: string + }> + } + } + } +} diff --git a/lib/shared/push-snippet.ts b/lib/shared/push-snippet.ts new file mode 100644 index 0000000..b4820a3 --- /dev/null +++ b/lib/shared/push-snippet.ts @@ -0,0 +1,198 @@ +import { cliConfig } from "lib/cli-config" +import { getKy } from "lib/registry-api/get-ky" +import * as fs from "node:fs" +import * as path from "node:path" +import semver from "semver" + +type PushOptions = { + filePath?: string + onExit?: (code: number) => void + onError?: (message: string) => void + onSuccess?: (message: string) => void +} + +export const pushSnippet = async ({ + filePath, + onExit = (code) => process.exit(code), + onError = (message) => console.error(message), + onSuccess = (message) => console.log(message), +}: PushOptions) => { + const sessionToken = cliConfig.get("sessionToken") + if (!sessionToken) { + onError("You need to log in to save snippet.") + return onExit(1) + } + + let snippetFilePath: string | null = null + if (filePath) { + snippetFilePath = path.resolve(filePath) + } else { + const defaultEntrypoint = path.resolve("index.tsx") + if (fs.existsSync(defaultEntrypoint)) { + snippetFilePath = defaultEntrypoint + onSuccess("No file provided. Using 'index.tsx' as the entrypoint.") + } else { + onError( + "No entrypoint found. Run 'tsci init' to bootstrap a basic project.", + ) + return onExit(1) + } + } + + const packageJsonPath = path.resolve( + path.join(path.dirname(snippetFilePath), "package.json"), + ) + let packageJson: { name?: string; author?: string; version?: string } = {} + if (fs.existsSync(packageJsonPath)) { + try { + packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString()) + } catch { + onError("Invalid package.json provided") + return onExit(1) + } + } + + if (!fs.existsSync(snippetFilePath)) { + onError(`File not found: ${snippetFilePath}`) + return onExit(1) + } + + const ky = getKy() + const packageName = ( + packageJson.name ?? path.parse(snippetFilePath).name + ).replace(/^@/, "") + const packageAuthor = + packageJson.author?.split(" ")[0] ?? cliConfig.get("githubUsername") + const packageIdentifier = `${packageAuthor}/${packageName}` + + let packageVersion = + packageJson.version ?? + (await ky + .post<{ + error?: { error_code: string } + package_releases?: { version: string; is_latest: boolean }[] + }>("package_releases/list", { + json: { package_name: packageIdentifier }, + }) + .json() + .then( + (response) => + response.package_releases?.[response.package_releases.length - 1] + ?.version, + ) + .catch((error) => { + onError("Failed to retrieve latest package version:" + error) + return onExit(1) + })) + + if (!packageVersion) { + onError("Failed to retrieve package version.") + return onExit(1) + } + + const updatePackageJsonVersion = (newVersion?: string) => { + if (packageJson.version) { + try { + packageJson.version = newVersion ?? `${packageVersion}` + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)) + } catch (error) { + onError("Failed to update package.json version:" + error) + } + } + } + + const doesPackageExist = await ky + .post<{ error?: { error_code: string } }>("packages/get", { + json: { name: packageIdentifier }, + throwHttpErrors: false, + }) + .json() + .then((response) => !(response.error?.error_code === "package_not_found")) + + if (!doesPackageExist) { + await ky + .post("packages/create", { + json: { name: packageIdentifier }, + headers: { Authorization: `Bearer ${sessionToken}` }, + }) + .catch((error) => { + onError("Error creating package:" + error) + return onExit(1) + }) + } + + const doesReleaseExist = await ky + .post<{ + error?: { error_code: string } + package_release?: { version: string } + }>("package_releases/get", { + json: { + package_name_with_version: `${packageIdentifier}@${packageVersion}`, + }, + throwHttpErrors: false, + }) + .json() + .then((response) => { + if (response.package_release?.version) { + packageVersion = response.package_release.version + updatePackageJsonVersion(response.package_release.version) + return true + } + return !(response.error?.error_code === "package_release_not_found") + }) + + if (doesReleaseExist) { + const bumpedVersion = semver.inc(packageVersion, "patch")! + onSuccess( + `Incrementing Package Version ${packageVersion} -> ${bumpedVersion}`, + ) + packageVersion = bumpedVersion + updatePackageJsonVersion(packageVersion) + } + + await ky + .post("package_releases/create", { + json: { + package_name_with_version: `${packageIdentifier}@${packageVersion}`, + }, + throwHttpErrors: false, + }) + .catch((error) => { + onError("Error creating release:" + error) + return onExit(1) + }) + + onSuccess("\n") + + const directoryFiles = fs.readdirSync(path.dirname(snippetFilePath)) + for (const file of directoryFiles) { + const fileExtension = path.extname(file).replace(".", "") + if (!["json", "tsx", "ts"].includes(fileExtension)) continue + const fileContent = + fs + .readFileSync(path.join(path.dirname(snippetFilePath), file)) + .toString() ?? "" + await ky + .post("package_files/create", { + json: { + file_path: file, + content_text: fileContent, + package_name_with_version: `${packageIdentifier}@${packageVersion}`, + }, + throwHttpErrors: false, + }) + .then(() => { + onSuccess(`Uploaded file ${file} to the registry.`) + }) + .catch((error) => { + onError(`Error uploading file ${file}:` + error) + }) + } + + onSuccess( + [ + `\nšŸŽ‰ Successfully pushed package ${packageIdentifier}@${packageVersion} to the registry!${Bun.color("blue", "ansi")}`, + `https://tscircuit.com/${packageIdentifier} \x1b[0m`, + ].join(" "), + ) +} diff --git a/tests/test5-dev-server-save-snippet.test.ts b/tests/test5-dev-server-save-snippet.test.ts new file mode 100644 index 0000000..ec87f2c --- /dev/null +++ b/tests/test5-dev-server-save-snippet.test.ts @@ -0,0 +1,90 @@ +import { test, expect, afterAll } from "bun:test" +import { DevServer } from "cli/dev/DevServer" +import { getTestFixture } from "tests/fixtures/get-test-fixture" +import { EventsWatcher } from "lib/server/EventsWatcher" +import { getTestSnippetsServer } from "./fixtures/get-test-server" +import { cliConfig } from "lib/cli-config" + +test("test saveSnippet via REQUEST_TO_SAVE_SNIPPET event with CLI token setup", async () => { + afterAll(() => { + eventManager.stop() + devServer.stop() + }) + + // Start snippets server + await getTestSnippetsServer() + + // Set up a temporary directory with a sample snippet file + const { tempDirPath, devServerPort, devServerUrl } = await getTestFixture({ + vfs: { + "snippet.tsx": ` + export const MyCircuit = () => ( + + + + ) + `, + "manual-edits.json": "{}", + "package.json": JSON.stringify({ + version: "0.0.1", + name: "snippet", + author: "test-author", + }), + }, + }) + + // Create and start the DevServer instance + const devServer = new DevServer({ + port: devServerPort, + componentFilePath: `${tempDirPath}/snippet.tsx`, + }) + await devServer.start() + + // Start the EventsWatcher to listen for events + const eventManager = new EventsWatcher(devServerUrl) + await eventManager.start() + + // Emit the REQUEST_TO_SAVE_SNIPPET event + devServer.fsKy.post("api/events/create", { + json: { event_type: "REQUEST_TO_SAVE_SNIPPET" }, + }) + + // Promises to wait for specific events using Promise.withResolvers() + const { + promise: requestToSaveSnippetPromise, + resolve: resolveRequestToSaveSnippet, + } = Promise.withResolvers() + eventManager.on("REQUEST_TO_SAVE_SNIPPET", async () => { + resolveRequestToSaveSnippet() + }) + + const sessionToken = cliConfig.get("sessionToken") + cliConfig.delete("sessionToken") + + const { promise: snippetSavedPromise, resolve: resolveSnippetSaved } = + Promise.withResolvers() + eventManager.on("SNIPPET_SAVED", () => { + resolveSnippetSaved() + }) + + const { + promise: snippetSaveFailedPromise, + resolve: resolveSnippetSaveFailed, + } = Promise.withResolvers() + eventManager.on("FAILED_TO_SAVE_SNIPPET", () => { + resolveSnippetSaveFailed() + cliConfig.set("sessionToken", sessionToken) + devServer.fsKy.post("api/events/create", { + json: { event_type: "REQUEST_TO_SAVE_SNIPPET" }, + }) + }) + + // Wait for the REQUEST_TO_SAVE_SNIPPET event to be detected + expect(requestToSaveSnippetPromise).resolves.toBeUndefined() + + // Wait for the FAILED_TO_SAVE_SNIPPET event to be detected + expect(snippetSaveFailedPromise).resolves.toBeUndefined() + + // Wait for the SNIPPET_SAVED event to be detected + expect(snippetSavedPromise).resolves.toBeUndefined() +}, 20_000)