Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,22 @@ GitHub repos are stored as `opensrc/owner--repo/`.
# List fetched sources
opensrc list

# Update all fetched sources
opensrc update

# Update only repos or a specific registry
opensrc update --repos
opensrc update --pypi

# Remove a source (package or repo)
opensrc remove zod
opensrc remove owner--repo
```

`opensrc update` follows the same behavior as re-running `opensrc <package>` or
`opensrc <owner>/<repo>`: packages resolve to installed/latest versions, and
repos reuse the stored ref.

### File Modifications

On first run, opensrc will ask for permission to modify these files:
Expand Down
94 changes: 94 additions & 0 deletions src/commands/update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { buildUpdateSpecs } from "./update.js";
import type { PackageEntry, RepoEntry } from "../lib/agents.js";

const sources = {
packages: [
{
name: "zod",
version: "3.22.0",
registry: "npm",
path: "zod",
fetchedAt: "2024-01-01T00:00:00.000Z",
},
{
name: "requests",
version: "2.31.0",
registry: "pypi",
path: "requests",
fetchedAt: "2024-01-01T00:00:00.000Z",
},
{
name: "serde",
version: "1.0.0",
registry: "crates",
path: "serde",
fetchedAt: "2024-01-01T00:00:00.000Z",
},
],
repos: [
{
name: "github.com/vercel/ai",
version: "main",
path: "repos/github.com/vercel/ai",
fetchedAt: "2024-01-01T00:00:00.000Z",
},
{
name: "example.com/foo/bar",
version: "HEAD",
path: "repos/example.com/foo/bar",
fetchedAt: "2024-01-01T00:00:00.000Z",
},
],
} satisfies { packages: PackageEntry[]; repos: RepoEntry[] };

describe("buildUpdateSpecs", () => {
it("builds specs for all sources by default", () => {
const { specs, packageCount, repoCount } = buildUpdateSpecs(sources);

expect(packageCount).toBe(3);
expect(repoCount).toBe(2);
expect(specs).toEqual([
"npm:zod",
"pypi:requests",
"crates:serde",
"https://github.com/vercel/ai#main",
"https://example.com/foo/bar",
]);
});

it("filters by registry", () => {
const { specs, packageCount, repoCount } = buildUpdateSpecs(sources, {
registry: "pypi",
});

expect(packageCount).toBe(1);
expect(repoCount).toBe(0);
expect(specs).toEqual(["pypi:requests"]);
});

it("updates only packages when requested", () => {
const { specs, packageCount, repoCount } = buildUpdateSpecs(sources, {
packages: true,
repos: false,
});

expect(packageCount).toBe(3);
expect(repoCount).toBe(0);
expect(specs).toEqual(["npm:zod", "pypi:requests", "crates:serde"]);
});

it("updates only repos when requested", () => {
const { specs, packageCount, repoCount } = buildUpdateSpecs(sources, {
packages: false,
repos: true,
});

expect(packageCount).toBe(0);
expect(repoCount).toBe(2);
expect(specs).toEqual([
"https://github.com/vercel/ai#main",
"https://example.com/foo/bar",
]);
});
});
123 changes: 123 additions & 0 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { listSources } from "../lib/git.js";
import type { Registry } from "../types.js";
import { fetchCommand } from "./fetch.js";

export interface UpdateOptions {
cwd?: string;
/** Only update packages (all registries) */
packages?: boolean;
/** Only update repos */
repos?: boolean;
/** Only update specific registry */
registry?: Registry;
/** Override file modification permission */
allowModifications?: boolean;
}

type Sources = Awaited<ReturnType<typeof listSources>>;

function shouldUpdatePackages(options: UpdateOptions): boolean {
return options.packages || (!options.packages && !options.repos);
}

function shouldUpdateRepos(options: UpdateOptions): boolean {
return (
options.repos || (!options.packages && !options.repos && !options.registry)
);
}

function buildRepoSpec(name: string, version: string): string {
const base = `https://${name}`;
if (!version || version === "HEAD") {
return base;
}
return `${base}#${version}`;
}

export function buildUpdateSpecs(
sources: Sources,
options: UpdateOptions = {},
): { specs: string[]; packageCount: number; repoCount: number } {
const updatePackages = shouldUpdatePackages(options);
const updateRepos = shouldUpdateRepos(options);

let packages = updatePackages ? sources.packages : [];
if (options.registry) {
packages = packages.filter((p) => p.registry === options.registry);
}

const repos = updateRepos ? sources.repos : [];

const specs: string[] = [];
const seen = new Set<string>();

for (const pkg of packages) {
const spec = `${pkg.registry}:${pkg.name}`;
if (!seen.has(spec)) {
specs.push(spec);
seen.add(spec);
}
}

for (const repo of repos) {
const spec = buildRepoSpec(repo.name, repo.version);
if (!seen.has(spec)) {
specs.push(spec);
seen.add(spec);
}
}

return { specs, packageCount: packages.length, repoCount: repos.length };
}

/**
* Update all fetched packages and/or repositories
*/
export async function updateCommand(
options: UpdateOptions = {},
): Promise<void> {
const cwd = options.cwd || process.cwd();
const sources = await listSources(cwd);
const totalCount = sources.packages.length + sources.repos.length;

if (totalCount === 0) {
console.log("No sources fetched yet.");
console.log(
"\nUse `opensrc <package>` to fetch source code for a package.",
);
console.log("Use `opensrc <owner>/<repo>` to fetch a GitHub repository.");
console.log("\nSupported registries:");
console.log(" • npm: opensrc zod, opensrc npm:react");
console.log(" • PyPI: opensrc pypi:requests");
console.log(" • crates: opensrc crates:serde");
return;
}

const { specs, packageCount, repoCount } = buildUpdateSpecs(sources, options);

if (specs.length === 0) {
const updatePackages = shouldUpdatePackages(options);
const updateRepos = shouldUpdateRepos(options);

if (options.registry) {
console.log(`No ${options.registry} packages to update.`);
} else {
if (updatePackages && packageCount === 0) {
console.log("No packages to update");
}
if (updateRepos && repoCount === 0) {
console.log("No repos to update");
}
}

console.log("\nNothing to update.");
return;
}

console.log(`Updating ${specs.length} source(s)...`);

await fetchCommand(specs, {
cwd,
allowModifications: options.allowModifications,
});
}
54 changes: 49 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@
import { Command } from "commander";
import { fetchCommand } from "./commands/fetch.js";
import { listCommand } from "./commands/list.js";
import { updateCommand } from "./commands/update.js";
import { removeCommand } from "./commands/remove.js";
import { cleanCommand } from "./commands/clean.js";
import type { Registry } from "./types.js";

const program = new Command();

function parseModifyOption(val?: string): boolean {
if (val === undefined || val === "" || val === "true") return true;
if (val === "false") return false;
return true;
}

program
.name("opensrc")
.description(
Expand All @@ -26,11 +33,7 @@ program
.option(
"--modify [value]",
"allow/deny modifying .gitignore, tsconfig.json, AGENTS.md",
(val) => {
if (val === undefined || val === "" || val === "true") return true;
if (val === "false") return false;
return true;
},
parseModifyOption,
)
.action(
async (packages: string[], options: { cwd?: string; modify?: boolean }) => {
Expand Down Expand Up @@ -59,6 +62,47 @@ program
});
});

// Update command
program
.command("update")
.description("Update all fetched package sources")
.option("--packages", "only update packages (all registries)")
.option("--repos", "only update repos")
.option("--npm", "only update npm packages")
.option("--pypi", "only update PyPI packages")
.option("--crates", "only update crates.io packages")
.option("--cwd <path>", "working directory (default: current directory)")
.option(
"--modify [value]",
"allow/deny modifying .gitignore, tsconfig.json, AGENTS.md",
parseModifyOption,
)
.action(
async (options: {
packages?: boolean;
repos?: boolean;
npm?: boolean;
pypi?: boolean;
crates?: boolean;
cwd?: string;
modify?: boolean;
}) => {
// Determine registry from flags
let registry: Registry | undefined;
if (options.npm) registry = "npm";
else if (options.pypi) registry = "pypi";
else if (options.crates) registry = "crates";

await updateCommand({
packages: options.packages || !!registry,
repos: options.repos,
registry,
cwd: options.cwd,
allowModifications: options.modify,
});
},
);

// Remove command
program
.command("remove <packages...>")
Expand Down
24 changes: 24 additions & 0 deletions src/lib/repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,26 @@ describe("parseRepoSpec", () => {
});
});

it("parses https://github.com/owner/repo#ref", () => {
const result = parseRepoSpec("https://github.com/vercel/ai#canary");
expect(result).toEqual({
host: "github.com",
owner: "vercel",
repo: "ai",
ref: "canary",
});
});

it("parses https://github.com/owner/repo#ref/with/slash", () => {
const result = parseRepoSpec("https://github.com/vercel/ai#feature/foo");
expect(result).toEqual({
host: "github.com",
owner: "vercel",
repo: "ai",
ref: "feature/foo",
});
});

it("parses https://github.com/owner/repo.git", () => {
const result = parseRepoSpec("https://github.com/vercel/ai.git");
expect(result).toEqual({
Expand Down Expand Up @@ -256,6 +276,10 @@ describe("isRepoSpec", () => {
expect(isRepoSpec("https://bitbucket.org/owner/repo")).toBe(true);
});

it("non-standard host URLs", () => {
expect(isRepoSpec("https://example.com/owner/repo")).toBe(true);
});

it("host/owner/repo format", () => {
expect(isRepoSpec("github.com/vercel/ai")).toBe(true);
});
Expand Down
Loading