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)