diff --git a/README.md b/README.md index 641c1abd..03cd81ca 100644 --- a/README.md +++ b/README.md @@ -104,4 +104,4 @@ runframe each time you'd like to load a new version of runframe. export RUNFRAME_STANDALONE_FILE_PATH=../runframe/dist/standalone.min.js cd ../runframe && bun run build cd ../cli && bun run dev -``` +``` \ No newline at end of file diff --git a/cli/add/register.ts b/cli/add/register.ts index a93da756..3dd7f2b2 100644 --- a/cli/add/register.ts +++ b/cli/add/register.ts @@ -5,10 +5,13 @@ export const registerAdd = (program: Command) => { program .command("add") .description("Add a tscircuit component package to your project") - .argument("", "Component to add (e.g. author/component-name)") - .action(async (componentPath: string) => { + .argument( + "", + "Package to add (e.g. package-name, author/component, https://github.com/user/repo, package@version)", + ) + .action(async (packageSpec: string) => { try { - await addPackage(componentPath) + await addPackage(packageSpec) } catch (error) { process.exit(1) } diff --git a/lib/shared/add-package.ts b/lib/shared/add-package.ts index 1d523b97..2f8afbaf 100644 --- a/lib/shared/add-package.ts +++ b/lib/shared/add-package.ts @@ -5,46 +5,80 @@ import { getPackageManager } from "./get-package-manager" import { resolveTarballUrlFromRegistry } from "./resolve-tarball-url-from-registry" /** - * Normalizes a tscircuit component path to an npm package name. - * @param componentPath - The component identifier (e.g., author/name, @tsci/author.name) - * @returns The normalized npm package name. + * Checks if a package spec is a tscircuit component format and normalizes it. + * Returns null if it's not a tscircuit component format (e.g., regular npm package, URL, etc.) + * @param packageSpec - The package specifier + * @returns The normalized @tsci scoped package name or null if not a tscircuit component */ -export function normalizePackageNameToNpm(componentPath: string): string { - if (componentPath.startsWith("@tscircuit/")) { - return componentPath - } else if (componentPath.startsWith("@tsci/")) { - return componentPath - } else { - const match = componentPath.match(/^([^/.]+)[/.](.+)$/) - if (!match) { - throw new Error( - "Invalid component path. Use format: author/component-name, author.component-name, @tscircuit/package-name, or @tsci/author.component-name", - ) - } +export function normalizeTscircuitPackageName( + packageSpec: string, +): string | null { + // Already a tscircuit scoped package + if ( + packageSpec.startsWith("@tscircuit/") || + packageSpec.startsWith("@tsci/") + ) { + return packageSpec + } + + // Check for URLs or git repos - these are not tscircuit components + if ( + packageSpec.startsWith("http://") || + packageSpec.startsWith("https://") || + packageSpec.startsWith("git+") || + packageSpec.startsWith("git://") + ) { + return null + } + + // Check for npm package with version (e.g., lodash@4.17.21) - not a tscircuit component + if (packageSpec.includes("@") && !packageSpec.startsWith("@")) { + return null + } + + // Check for scoped packages that aren't tscircuit + if ( + packageSpec.startsWith("@") && + !packageSpec.startsWith("@tsci/") && + !packageSpec.startsWith("@tscircuit/") + ) { + return null + } + + // Try to match author/component or author.component format + const match = packageSpec.match(/^([^/.@]+)[/.]([^/.@]+)$/) + if (match) { const [, author, componentName] = match return `@tsci/${author}.${componentName}` } + + // Anything else is treated as a regular package name + return null } /** - * Installs a tscircuit component package. - * Handles different package name formats, ensures .npmrc is configured, - * and uses the appropriate package manager. + * Adds a package to the project (works like bun add). + * Detects tscircuit component formats and handles @tsci registry setup. + * All other package specs are passed directly to the package manager. * - * @param componentPath - The component identifier (e.g., author/name, @tsci/author.name) + * @param packageSpec - Any package specifier (e.g., package-name, author/component, https://github.com/user/repo, package@version) * @param projectDir - The root directory of the project (defaults to process.cwd()) */ export async function addPackage( - componentPath: string, + packageSpec: string, projectDir: string = process.cwd(), ) { - const packageName = normalizePackageNameToNpm(componentPath) + // Check if this is a tscircuit component format + const normalizedName = normalizeTscircuitPackageName(packageSpec) - console.log(`Adding ${packageName}...`) + // Determine what to display and what to install + const displayName = normalizedName || packageSpec + let installTarget = normalizedName || packageSpec - let installTarget = packageName + console.log(`Adding ${displayName}...`) - if (packageName.startsWith("@tsci/")) { + // Only handle @tsci registry setup if it's a tscircuit component + if (normalizedName && normalizedName.startsWith("@tsci/")) { const npmrcPath = path.join(projectDir, ".npmrc") const npmrcContent = fs.existsSync(npmrcPath) ? fs.readFileSync(npmrcPath, "utf-8") @@ -77,19 +111,19 @@ export async function addPackage( } if (!hasTsciRegistry) { - installTarget = await resolveTarballUrlFromRegistry(packageName) + installTarget = await resolveTarballUrlFromRegistry(normalizedName) } } + // For all other cases (URLs, scoped packages, regular npm packages), use packageSpec as-is // Install the package using the detected package manager const packageManager = getPackageManager() try { packageManager.install({ name: installTarget, cwd: projectDir }) - console.log(`Added ${packageName} successfully.`) + console.log(`Added ${displayName} successfully.`) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`Failed to add ${packageName}:`, errorMessage) - // Re-throw the error so the caller can handle it - throw new Error(`Failed to add ${packageName}: ${errorMessage}`) + console.error(`Failed to add ${displayName}:`, errorMessage) + throw new Error(`Failed to add ${displayName}: ${errorMessage}`) } } diff --git a/lib/shared/remove-package.ts b/lib/shared/remove-package.ts index b32dabf9..bb79bedb 100644 --- a/lib/shared/remove-package.ts +++ b/lib/shared/remove-package.ts @@ -1,5 +1,5 @@ import { getPackageManager } from "./get-package-manager" -import { normalizePackageNameToNpm } from "./add-package" +import { normalizeTscircuitPackageName } from "./add-package" import path from "node:path" import fs from "node:fs" /** @@ -13,7 +13,8 @@ export async function removePackage( componentPath: string, projectDir: string = process.cwd(), ) { - const packageName = normalizePackageNameToNpm(componentPath) + const normalizedName = normalizeTscircuitPackageName(componentPath) + const packageName = normalizedName || componentPath console.log(`Removing ${packageName}...`) diff --git a/tests/cli/add/add-package01.test.ts b/tests/cli/add/add-package01.test.ts new file mode 100644 index 00000000..2530eac9 --- /dev/null +++ b/tests/cli/add/add-package01.test.ts @@ -0,0 +1,32 @@ +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" +import { test, expect } from "bun:test" +import { join } from "node:path" +import { existsSync } from "node:fs" + +test("tsci add - adds regular npm package", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + + // Create initial package.json + await Bun.write( + join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + dependencies: {}, + }), + ) + + // Test adding a regular npm package + const { stdout } = await runCommand("tsci add zod") + expect(stdout).toContain("Adding zod") + expect(stdout).toContain("successfully") + + // Verify package.json was updated + const pkgJson = JSON.parse( + await Bun.file(join(tmpDir, "package.json")).text(), + ) + expect(pkgJson.dependencies["zod"]).toBeDefined() + + // Verify package was actually installed in node_modules + const nodeModulesPath = join(tmpDir, "node_modules", "zod") + expect(existsSync(nodeModulesPath)).toBe(true) +}) diff --git a/tests/cli/add/add-package02.test.ts b/tests/cli/add/add-package02.test.ts new file mode 100644 index 00000000..b2a28ec9 --- /dev/null +++ b/tests/cli/add/add-package02.test.ts @@ -0,0 +1,34 @@ +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" +import { test, expect } from "bun:test" +import { join } from "node:path" +import { existsSync } from "node:fs" + +test("tsci add - adds package from GitHub URL", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + + // Create initial package.json + await Bun.write( + join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + dependencies: {}, + }), + ) + + // Test adding from GitHub URL + const { stdout } = await runCommand( + "tsci add https://github.com/lodash/lodash", + ) + expect(stdout).toContain("Adding https://github.com/lodash/lodash") + expect(stdout).toContain("successfully") + + // Verify package.json was updated + const pkgJson = JSON.parse( + await Bun.file(join(tmpDir, "package.json")).text(), + ) + expect(pkgJson.dependencies["lodash"]).toBeDefined() + + // Verify package was actually installed in node_modules + const nodeModulesPath = join(tmpDir, "node_modules", "lodash") + expect(existsSync(nodeModulesPath)).toBe(true) +}) diff --git a/tests/cli/add/add-package03.test.ts b/tests/cli/add/add-package03.test.ts new file mode 100644 index 00000000..4a7868f2 --- /dev/null +++ b/tests/cli/add/add-package03.test.ts @@ -0,0 +1,32 @@ +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" +import { test, expect } from "bun:test" +import { join } from "node:path" +import { existsSync } from "node:fs" + +test("tsci add - adds package with version", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + + // Create initial package.json + await Bun.write( + join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + dependencies: {}, + }), + ) + + // Test adding with version + const { stdout } = await runCommand("tsci add zod@3.22.0") + expect(stdout).toContain("Adding zod@3.22.0") + expect(stdout).toContain("successfully") + + // Verify package.json was updated with specific version + const pkgJson = JSON.parse( + await Bun.file(join(tmpDir, "package.json")).text(), + ) + expect(pkgJson.dependencies["zod"]).toBe("3.22.0") + + // Verify package was actually installed in node_modules + const nodeModulesPath = join(tmpDir, "node_modules", "zod") + expect(existsSync(nodeModulesPath)).toBe(true) +}) diff --git a/tests/cli/add/add-package04.test.ts b/tests/cli/add/add-package04.test.ts new file mode 100644 index 00000000..c7d36676 --- /dev/null +++ b/tests/cli/add/add-package04.test.ts @@ -0,0 +1,25 @@ +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" +import { test, expect } from "bun:test" +import { join } from "node:path" + +test("tsci add - handles author/component format as tscircuit package", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + + // Create initial package.json + await Bun.write( + join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + dependencies: {}, + }), + ) + + // Test author/component format (should convert to @tsci/author.component) + const result = await runCommand("tsci add seveibar/soup") + + // Should show it's trying to add @tsci/seveibar.soup + expect( + result.stdout.includes("@tsci/seveibar.soup") || + result.stderr.includes("@tsci/seveibar.soup"), + ).toBe(true) +}) diff --git a/tests/cli/add/add-package05.test.ts b/tests/cli/add/add-package05.test.ts new file mode 100644 index 00000000..f7a3842c --- /dev/null +++ b/tests/cli/add/add-package05.test.ts @@ -0,0 +1,32 @@ +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" +import { test, expect } from "bun:test" +import { join } from "node:path" +import { existsSync } from "node:fs" + +test("tsci add - handles scoped packages", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + + // Create initial package.json + await Bun.write( + join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + dependencies: {}, + }), + ) + + // Test adding a scoped package + const { stdout } = await runCommand("tsci add @types/node") + expect(stdout).toContain("Adding @types/node") + expect(stdout).toContain("successfully") + + // Verify package.json was updated + const pkgJson = JSON.parse( + await Bun.file(join(tmpDir, "package.json")).text(), + ) + expect(pkgJson.dependencies["@types/node"]).toBeDefined() + + // Verify package was actually installed in node_modules + const nodeModulesPath = join(tmpDir, "node_modules", "@types", "node") + expect(existsSync(nodeModulesPath)).toBe(true) +}) diff --git a/tests/cli/add/add-package06.test.ts b/tests/cli/add/add-package06.test.ts new file mode 100644 index 00000000..60f2699c --- /dev/null +++ b/tests/cli/add/add-package06.test.ts @@ -0,0 +1,25 @@ +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" +import { test, expect } from "bun:test" +import { join } from "node:path" + +test("tsci add - handles author.component format as tscircuit package", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + + // Create initial package.json + await Bun.write( + join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + dependencies: {}, + }), + ) + + // Test author.component format (should convert to @tsci/author.component) + const result = await runCommand("tsci add seveibar.soup") + + // Should show it's trying to add @tsci/seveibar.soup + expect( + result.stdout.includes("@tsci/seveibar.soup") || + result.stderr.includes("@tsci/seveibar.soup"), + ).toBe(true) +})