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
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,8 @@
],
"scripts": {
"test": "bun test",
"lint": "eslint ."
"lint": "eslint .",
"cli": "bun packages/cli/src/main.ts"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
Expand Down
29 changes: 29 additions & 0 deletions packages/builtin/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
/**
* Aggregates every built-in extension's manifest into one array, for
* `@tecode/core`'s `loadExtensions({ builtins, ... })` dependency (Req 2.1;
* design.md §4.1, §4.4: "Built-ins are compiled into the binary as
* ordinary imports [...] their manifest data in a static registry").
*
* `discovery.ts` never scans this package's directories off disk — a
* built-in's manifest reaches the host as a plain compiled-in `import`,
* not through the `user`/`workspace` filesystem-scanning path (which is
* exactly why `discovery.ts`'s `DiscoveryDeps.builtins` exists as a
* separate parameter rather than a third scanned directory).
*
* **Today this is `[]`.** Every `packages/builtin/*` package
* (`command-palette`, `editor-core`, `explorer`, `keybindings-editor`,
* `languages-basic`, `statusbar`, `themes-default`) is still a placeholder
* with no `manifest.ts` (each is its own later task — see tasks.md's Phase
* 2/3 built-in tasks). This module is `packages/cli`'s one composition
* point for the "compiled-in built-ins" list (Task 1.15) so wiring a real
* built-in later is exactly "add its manifest import and push it into
* `builtinManifests` below," not a new call site or a new dependency for
* `cli` to pick up.
*/

import type { Manifest } from "@tecode/api";

/** Every built-in extension's manifest, compiled in as a static import
* (this module's TSDoc). Empty until a `packages/builtin/*` package gains
* a real `manifest.ts` and is added here. */
export const builtinManifests: Manifest[] = [];
2 changes: 2 additions & 0 deletions packages/builtin/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"type": "module",
"main": "index.ts",
"types": "index.ts",
"dependencies": {
"@tecode/api": "workspace:*"
}
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,13 @@
},
"dependencies": {
"@tecode/api": "workspace:*",
"@tecode/core": "workspace:*"
"@tecode/builtin": "workspace:*",
"@tecode/core": "workspace:*",
"@opentui/core": "^0.1.107",
"@opentui/react": "^0.1.107",
"react": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0"
}
}
92 changes: 92 additions & 0 deletions packages/cli/src/argv.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
import { afterEach, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { createHostLog, type HostLogEntry } from "@tecode/core";
import { resolveStartupTarget } from "./argv";

let dir: string | undefined;

afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
dir = undefined;
});

test("no positional argument resolves to cwd with no initial file", async () => {
const log = createHostLog();
const target = await resolveStartupTarget([], "/some/cwd", log);
expect(target).toEqual({ workspaceRoot: "/some/cwd" });
expect(log.entries()).toEqual([]);
});

test("a directory argument becomes workspaceRoot with no initial file", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const log = createHostLog();
const target = await resolveStartupTarget([dir], "/irrelevant", log);
expect(target).toEqual({ workspaceRoot: dir });
});

test("a file argument's parent directory becomes workspaceRoot, and the file is the initial file", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const filePath = join(dir, "notes.txt");
await writeFile(filePath, "hello", "utf8");

const log = createHostLog();
const target = await resolveStartupTarget([filePath], "/irrelevant", log);
expect(target).toEqual({ workspaceRoot: dir, initialFilePath: filePath });
});

test("a relative path argument resolves against cwd", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
await mkdir(join(dir, "sub"), { recursive: true });

const log = createHostLog();
const target = await resolveStartupTarget(["sub"], dir, log);
expect(target).toEqual({ workspaceRoot: join(dir, "sub") });
});

test("a nonexistent path logs a warning and falls back to cwd", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const missing = join(dir, "does-not-exist");

const log = createHostLog();
const target = await resolveStartupTarget([missing], "/fallback-cwd", log);
expect(target).toEqual({ workspaceRoot: "/fallback-cwd" });

const entries: readonly HostLogEntry[] = log.entries();
expect(entries.length).toBe(1);
expect(entries[0]?.level).toBe("warning");
expect(entries[0]?.error.message).toContain(missing);
});

test("only the first non-flag token is treated as the positional argument", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const log = createHostLog();
const target = await resolveStartupTarget(["--verbose", dir], "/irrelevant", log);
expect(target).toEqual({ workspaceRoot: dir });
});

test("uses the injected fs seam instead of touching real disk", async () => {
const log = createHostLog();
const fakeFs = {
stat: async (path: string) => {
expect(path).toBe(join("/cwd", "project"));
return { isDirectory: () => true };
},
};
const target = await resolveStartupTarget(["project"], "/cwd", log, fakeFs);
expect(target).toEqual({ workspaceRoot: join("/cwd", "project") });
});

test("parent directory of a nested file resolves correctly", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const nested = join(dir, "a", "b");
await mkdir(nested, { recursive: true });
const filePath = join(nested, "file.ts");
await writeFile(filePath, "", "utf8");

const log = createHostLog();
const target = await resolveStartupTarget([filePath], "/irrelevant", log);
expect(target.workspaceRoot).toBe(dirname(filePath));
expect(target.initialFilePath).toBe(filePath);
});
90 changes: 90 additions & 0 deletions packages/cli/src/argv.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
/**
* Argv parsing and file/directory resolution for the CLI's startup
* sequence (Req 12.1; design.md §3, §17: "parse argv" is the sync phase's
* first step; tasks.md's Task 1.15 "Argv parsing (file/directory)").
* `--version` is handled by `main.ts` itself, before this module is even
* reached (it must not touch the filesystem or build any services).
*/

import { stat as nodeStat } from "node:fs/promises";
import { dirname, resolve as resolvePath } from "node:path";
import type { HostLog } from "@tecode/core";

/** Where {@link resolveStartupTarget} landed for one CLI invocation. */
export interface StartupTarget {
/** The directory `ConfigService`/`discover()`/`tecode.workspace.rootUri`
* treat as the open workspace. */
workspaceRoot: string;
/** Absolute path to open once the deferred phase's document manager is
* ready (design.md §3's "open the file/directory from argv" step) —
* `undefined` for a directory argument or a no-argument launch. */
initialFilePath?: string;
}

/** The narrow filesystem seam {@link resolveStartupTarget} needs —
* exists as an injectable seam (matches every `core` service's
* `*Fs`-suffixed dependency convention) so tests can simulate a path that
* exists/doesn't without depending on real disk state. Defaults to
* `node:fs/promises`. */
export interface ArgvResolutionFs {
stat(path: string): Promise<{ isDirectory(): boolean }>;
}

function createNodeArgvFs(): ArgvResolutionFs {
return {
stat: async (path) => {
const stats = await nodeStat(path);
return { isDirectory: () => stats.isDirectory() };
},
};
}

/** Render a caught `unknown` value as a message string without risking a
* second throw (matches `core`'s `describeError` convention). */
function describeError(err: unknown): string {
try {
if (err instanceof Error) return err.message;
return String(err);
} catch {
return "Unknown error";
}
}

/**
* Resolve the CLI's one positional argument (CodeRabbit's Phase 1 plan): a
* directory becomes `workspaceRoot` with no initial document; a file's
* parent directory becomes `workspaceRoot` and the file itself is opened
* in the deferred phase; no argument at all defaults to `cwd`. `argv` here
* is expected to already have flags like `--version` handled/stripped by
* the caller — this function only ever looks for the first token that
* does not start with `-`.
*
* Never throws (matches `core`'s never-throwing service boundaries): a
* path that does not exist, or can't be `stat`-ed, is reported to `log` as
* a warning and treated as if no argument had been given (`cwd`) — a
* typo'd path should degrade to an empty workspace rather than abort
* startup, the same "continue starting up" spirit Req 2.4 applies to a bad
* extension.
*/
export async function resolveStartupTarget(
argv: readonly string[],
cwd: string,
log: HostLog,
fs: ArgvResolutionFs = createNodeArgvFs(),
): Promise<StartupTarget> {
const positional = argv.find((arg) => !arg.startsWith("-"));
if (!positional) return { workspaceRoot: cwd };

const resolved = resolvePath(cwd, positional);
try {
const stats = await fs.stat(resolved);
if (stats.isDirectory()) return { workspaceRoot: resolved };
return { workspaceRoot: dirname(resolved), initialFilePath: resolved };
} catch (cause) {
log.append("warning", {
message: `Startup path "${resolved}" does not exist or could not be read (${describeError(cause)}); starting with no workspace.`,
path: resolved,
});
return { workspaceRoot: cwd };
}
}
126 changes: 126 additions & 0 deletions packages/cli/src/extensionRecords.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import { afterEach, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { Manifest } from "@tecode/api";
import type { LoadedExtension } from "@tecode/core";
import { buildExtensionRecord, buildExtensionRecords } from "./extensionRecords";

let tempDirs: string[] = [];

async function makeTempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "tecode-ext-records-"));
tempDirs.push(dir);
return dir;
}

afterEach(async () => {
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })));
tempDirs = [];
});

function fixtureManifest(id: string): Manifest {
return { id, version: "0.0.1", apiVersion: "1.0", activationEvents: ["onStartup"], contributes: {} };
}

test("a user/workspace extension's extensionUri/storagePath derive from its real directory", async () => {
const extensionsDir = await makeTempDir();
const extensionDir = join(extensionsDir, "demo");
await mkdir(extensionDir, { recursive: true });
const manifestPath = join(extensionDir, "manifest.ts");
await writeFile(manifestPath, "export default {}\n", "utf8");

const loaded: LoadedExtension = {
extensionId: "demo",
manifest: fixtureManifest("demo"),
source: "user",
sourcePath: manifestPath,
};

const record = buildExtensionRecord(loaded);
expect(record.id).toBe("demo");
expect(record.extensionUri).toBe(pathToFileURL(extensionDir).href);
expect(record.storagePath.endsWith(join("extension-storage", "demo"))).toBe(true);
});

test("loadModule() dynamically imports index.ts when no index.js exists", async () => {
const extensionsDir = await makeTempDir();
const extensionDir = join(extensionsDir, "demo");
await mkdir(extensionDir, { recursive: true });
const manifestPath = join(extensionDir, "manifest.ts");
await writeFile(manifestPath, "export default {}\n", "utf8");
await writeFile(
join(extensionDir, "index.ts"),
'export const MARKER = "index-ts-loaded";\nexport function activate() {}\n',
"utf8",
);

const loaded: LoadedExtension = {
extensionId: "demo",
manifest: fixtureManifest("demo"),
source: "user",
sourcePath: manifestPath,
};

const record = buildExtensionRecord(loaded);
const mod = (await record.loadModule()) as { MARKER: string; activate: () => void };
expect(mod.MARKER).toBe("index-ts-loaded");
expect(typeof mod.activate).toBe("function");
});

test("loadModule() prefers a pre-bundled index.js over index.ts (design.md §4.4)", async () => {
const extensionsDir = await makeTempDir();
const extensionDir = join(extensionsDir, "demo");
await mkdir(extensionDir, { recursive: true });
const manifestPath = join(extensionDir, "manifest.ts");
await writeFile(manifestPath, "export default {}\n", "utf8");
await writeFile(join(extensionDir, "index.ts"), 'export const MARKER = "ts";\n', "utf8");
await writeFile(join(extensionDir, "index.js"), 'export const MARKER = "js";\n', "utf8");

const loaded: LoadedExtension = {
extensionId: "demo",
manifest: fixtureManifest("demo"),
source: "workspace",
sourcePath: manifestPath,
};

const record = buildExtensionRecord(loaded);
const mod = (await record.loadModule()) as { MARKER: string };
expect(mod.MARKER).toBe("js");
});

test("a builtin extension's loadModule() rejects with a clear, documented error", async () => {
const loaded: LoadedExtension = {
extensionId: "fake-builtin",
manifest: fixtureManifest("fake-builtin"),
source: "builtin",
sourcePath: "<builtin>/fake-builtin",
};

const record = buildExtensionRecord(loaded);
expect(record.extensionUri).toBe("<builtin>/fake-builtin");
await expect(record.loadModule()).rejects.toThrow(/No static module wiring/);
});

test("buildExtensionRecords maps every LoadedExtension", async () => {
const extensionsDir = await makeTempDir();
const records = await Promise.all(
["a", "b"].map(async (id) => {
const extensionDir = join(extensionsDir, id);
await mkdir(extensionDir, { recursive: true });
const manifestPath = join(extensionDir, "manifest.ts");
await writeFile(manifestPath, "export default {}\n", "utf8");
const loaded: LoadedExtension = {
extensionId: id,
manifest: fixtureManifest(id),
source: "user",
sourcePath: manifestPath,
};
return loaded;
}),
);

const built = buildExtensionRecords(records);
expect(built.map((r) => r.id)).toEqual(["a", "b"]);
});
Loading
Loading