diff --git a/bun.lockb b/bun.lockb index 228c46d..805f408 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli/main.ts b/cli/main.ts index 59b0858..0a0854b 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -14,6 +14,7 @@ import semver from "semver" import { registerExport } from "./export/register" import { registerAuthPrintToken } from "./auth/print-token/register" import { registerAuthSetToken } from "./auth/set-token/register" +import { registerPush } from "./push/register" const program = new Command() @@ -28,6 +29,7 @@ registerInit(program) registerDev(program) registerClone(program) +registerPush(program) registerAuth(program) registerAuthLogin(program) diff --git a/cli/push/register.ts b/cli/push/register.ts new file mode 100644 index 0000000..a61710b --- /dev/null +++ b/cli/push/register.ts @@ -0,0 +1,199 @@ +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 + .command("push") + .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`, + ) + }) +} diff --git a/lib/registry-api/get-ky.ts b/lib/registry-api/get-ky.ts index 75a11bb..fc0b4d6 100644 --- a/lib/registry-api/get-ky.ts +++ b/lib/registry-api/get-ky.ts @@ -1,7 +1,7 @@ import { getRegistryApiUrl } from "lib/cli-config" import ky, { type AfterResponseHook } from "ky" -const prettyResponseErrorHook: AfterResponseHook = async ( +export const prettyResponseErrorHook: AfterResponseHook = async ( _request, _options, response, diff --git a/package.json b/package.json index 1c0e881..006e774 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@tscircuit/core": "^0.0.249", + "@tscircuit/fake-snippets": "^0.0.5", "@types/bun": "^1.1.15", "@types/configstore": "^6.0.2", "@types/react": "^19.0.1", @@ -41,6 +42,7 @@ "ky": "^1.7.4", "make-vfs": "^1.0.15", "perfect-cli": "^1.0.20", + "redaxios": "^0.5.1", "semver": "^7.6.3" } } diff --git a/tests/fake-snippets-api.d.ts b/tests/fake-snippets-api.d.ts new file mode 100644 index 0000000..64398b1 --- /dev/null +++ b/tests/fake-snippets-api.d.ts @@ -0,0 +1,3 @@ +declare module "fake-snippets-api/lib/db/db-client" { + export function createDatabase(): any +} diff --git a/tests/fixtures/get-test-server.ts b/tests/fixtures/get-test-server.ts new file mode 100644 index 0000000..09125a0 --- /dev/null +++ b/tests/fixtures/get-test-server.ts @@ -0,0 +1,54 @@ +import { afterAll } from "bun:test" +import { startServer } from "@tscircuit/fake-snippets/bun-tests/fake-snippets-api/fixtures/start-server" +import { DbClient } from "@tscircuit/fake-snippets/fake-snippets-api/lib/db/db-client" +import ky from "ky" +import { prettyResponseErrorHook } from "lib/registry-api/get-ky" +import { cliConfig } from "lib/cli-config" +import { seed as seedDB } from "@tscircuit/fake-snippets/fake-snippets-api/lib/db/seed" + +interface TestFixture { + url: string + server: any + ky: typeof ky + db: DbClient +} + +export const getTestSnippetsServer = async (): Promise => { + const port = 3789 + const testInstanceId = Math.random().toString(36).substring(2, 15) + const testDbName = `testdb${testInstanceId}` + + const { server, db } = await startServer({ + port, + testDbName, + }) + + const url = `http://localhost:${port}/api` + seedDB(db) + const kyInstance = ky.create({ + prefixUrl: url, + hooks: { + afterResponse: [prettyResponseErrorHook], + }, + headers: { + Authorization: `Bearer ${db.accounts[0].account_id}`, + }, + }) + + cliConfig.set("sessionToken", db.accounts[0].account_id) + cliConfig.set("registryApiUrl", url) + + afterAll(async () => { + if (server && typeof server.stop === "function") { + await server.stop() + } + cliConfig.clear() + }) + + return { + url, + server, + ky: kyInstance, + db, + } +} diff --git a/tests/test4-push-api.test.ts b/tests/test4-push-api.test.ts new file mode 100644 index 0000000..e5c51fb --- /dev/null +++ b/tests/test4-push-api.test.ts @@ -0,0 +1,85 @@ +import { test, expect, beforeEach, afterEach } from "bun:test" +import { getTestSnippetsServer } from "./fixtures/get-test-server" +import { getCliTestFixture } from "./fixtures/get-cli-test-fixture" +import * as fs from "node:fs" +import * as path from "node:path" + +const { ky } = await getTestSnippetsServer() +const { tmpDir, runCommand } = await getCliTestFixture() + +beforeEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + fs.mkdirSync(tmpDir, { recursive: true }) + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-package", + version: "1.0.0", + }), + ) +}) +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +test("should fail if no entrypoint file is found", async () => { + await runCommand("tsci push").catch((e) => { + expect(e.message).toInclude( + "No entrypoint found. Run 'tsci init' to bootstrap a basic project.", + ) + }) +}) + +test("should use default entrypoint if no file is provided", async () => { + const defaultEntrypoint = path.resolve(tmpDir, "index.tsx") + fs.writeFileSync(defaultEntrypoint, "// Default entrypoint!") + const { stdout } = await runCommand(`tsci push`) + expect(stdout).toContain( + "No file provided. Using 'index.tsx' as the entrypoint.", + ) +}) + +test("should fail if package.json is missing or invalid", async () => { + const snippetFilePath = path.resolve(tmpDir, "snippet.tsx") + fs.writeFileSync(snippetFilePath, "// Snippet content") + + try { + await runCommand(`tsci push ${snippetFilePath}`) + } catch (error) { + expect(console.error).toHaveBeenCalledWith( + "Failed to retrieve package version.", + ) + } +}) + +test("should create package if it does not exist", async () => { + const snippetFilePath = path.resolve(tmpDir, "snippet.tsx") + + fs.writeFileSync(snippetFilePath, "// Snippet content") + + const { stdout } = await runCommand(`tsci push ${snippetFilePath}`) + expect(stdout).toContain("Successfully pushed package") +}) + +test("should bump version if release already exists", async () => { + const snippetFilePath = path.resolve(tmpDir, "snippet.tsx") + const packageJsonPath = path.resolve(tmpDir, "package.json") + + fs.writeFileSync(snippetFilePath, "// Snippet content") + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ name: "test-package", version: "1.0.0" }), + ) + + const { stdout } = await runCommand(`tsci push ${snippetFilePath}`) + expect(stdout).toContain("Incrementing Package Version 1.0.0 -> 1.0.1") +}) + +test("should upload files to the registry", async () => { + const snippetFilePath = path.resolve(tmpDir, "snippet.tsx") + + fs.writeFileSync(snippetFilePath, "// Snippet content") + + const { stdout } = await runCommand(`tsci push ${snippetFilePath}`) + expect(stdout).toContain("Uploaded file snippet.tsx to the registry.") +})