Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
```
9 changes: 6 additions & 3 deletions cli/add/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ export const registerAdd = (program: Command) => {
program
.command("add")
.description("Add a tscircuit component package to your project")
.argument("<component>", "Component to add (e.g. author/component-name)")
.action(async (componentPath: string) => {
.argument(
"<packageSpec>",
"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)
}
Expand Down
92 changes: 63 additions & 29 deletions lib/shared/add-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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}`)
}
}
5 changes: 3 additions & 2 deletions lib/shared/remove-package.ts
Original file line number Diff line number Diff line change
@@ -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"
/**
Expand All @@ -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}...`)

Expand Down
32 changes: 32 additions & 0 deletions tests/cli/add/add-package01.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
34 changes: 34 additions & 0 deletions tests/cli/add/add-package02.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
32 changes: 32 additions & 0 deletions tests/cli/add/add-package03.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
25 changes: 25 additions & 0 deletions tests/cli/add/add-package04.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
32 changes: 32 additions & 0 deletions tests/cli/add/add-package05.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
25 changes: 25 additions & 0 deletions tests/cli/add/add-package06.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading