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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# CI only reads the tree — never persist the token into the local
# git config for later steps.
persist-credentials: false
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: contract-tests
run: bun test
- name: lint
run: bun run lint
1 change: 1 addition & 0 deletions bun.lock

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

1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
"tecode": "src/main.ts"
},
"dependencies": {
"@tecode/api": "workspace:*",
"@tecode/core": "workspace:*"
}
}
57 changes: 57 additions & 0 deletions packages/cli/src/main.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
import { expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToUri } from "@tecode/core";
import pkg from "../package.json";
import { buildAssemblyRoot } from "./main";

test("--version prints the package version and exits 0", async () => {
const proc = Bun.spawn(["bun", "run", `${import.meta.dir}/main.ts`, "--version"], {
Expand All@@ -12,3 +17,55 @@ test("--version prints the package version and exits 0", async () => {
expect(stdout.trim()).toBe(pkg.version);
expect(exitCode).toBe(0);
});

test("buildAssemblyRoot wires every core service and registers the 'tecode' module alias", async () => {
// Importing main.ts (above) does not itself run `main()` — see main.ts's
// `import.meta.main` guard — so calling buildAssemblyRoot() directly
// here is safe and does not depend on this test file's own argv.
const dir = await mkdtemp(join(tmpdir(), "tecode-cli-root-"));
// Redirect the user-level config directory into this test's temp dir
// (matches config/service.test.ts's real-filesystem test) so this never
// reads or watches the real user's ~/.config/tecode files.
const savedHome = process.env["HOME"];
const savedAppData = process.env["APPDATA"];
process.env["HOME"] = dir;
process.env["APPDATA"] = dir;
let root: ReturnType<typeof buildAssemblyRoot>;
try {
root = buildAssemblyRoot(dir);
} finally {
if (savedHome === undefined) delete process.env["HOME"];
else process.env["HOME"] = savedHome;
if (savedAppData === undefined) delete process.env["APPDATA"];
else process.env["APPDATA"] = savedAppData;
}

try {
await root.config.ready;

// Every namespace reachable via the assembled api.
expect(Object.keys(root.api)).toEqual([
"commands",
"workspace",
"window",
"editor",
"ui",
"config",
"context",
"languages",
"themes",
]);

expect(root.api.workspace.rootUri).toBe(pathToUri(dir));
expect(Object.isFrozen(root.api)).toBe(true);

// buildAssemblyRoot's own TSDoc documents that registerTecodeAlias runs
// as its last step; `create.contract.test.ts` is where the resulting
// `"tecode"` module-alias resolution is exercised end-to-end (the one
// sanctioned dynamic `import("tecode")` test call site) — this test
// stays focused on cli's composition wiring itself.
} finally {
root.config.dispose();
await rm(dir, { recursive: true, force: true });
}
});
102 changes: 101 additions & 1 deletion packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,110 @@
import pkg from "../package.json";
import type { FileSystem, Tecode } from "@tecode/api";
import {
createCommandRegistry,
createConfigService,
createContextService,
createDocumentManager,
createFileSystem,
createHostLog,
createNoopStatusSink,
createTecodeApi,
pathToUri,
registerTecodeAlias,
type CommandRegistry,
type ConfigService,
type ContextService,
type DocumentManager,
type HostLog,
type StatusSink,
} from "@tecode/core";

/**
* Every core service {@link buildAssemblyRoot} wires together, plus the
* assembled `tecode` object itself — returned so a caller (currently just
* this module's own `main`; Task 1.15's startup sequence next) can hold
* onto `config` for `ready`/`dispose()` without reaching back into the
* module's internals.
*/
export interface AssemblyRoot {
log: HostLog;
sink: StatusSink;
commands: CommandRegistry;
documents: DocumentManager;
fs: FileSystem;
config: ConfigService;
context: ContextService;
api: Tecode;
}

/**
* Build the `tecode` composition root and register the `"tecode"` module
* alias (Req 10.1, 10.2; design.md §12, §17; Task 1.13's "Bun module alias
* registration" note). `packages/cli` is the one place allowed to import
* `@tecode/core` directly (`eslint.config.mjs`'s layering rule) — this
* function is that wiring.
*
* **This is deliberately a small slice of design.md §17's full startup
* sequence**, not that sequence itself: argv parsing (file vs. directory),
* the sync-before-first-frame phase, rendering the UI shell, deferred
* extension discovery/activation, the initial file open, and startup-timing
* instrumentation are all Task 1.15's job. That task should *call* this
* function (or extend it) rather than duplicate its ordering — the one
* invariant it establishes and Task 1.15 must preserve is
* {@link registerTecodeAlias} running immediately after
* {@link createTecodeApi} and strictly before any extension module is
* imported (Req 1.4, design.md §2): an extension's `import ... from
* "tecode"` resolves only once the alias is registered.
*
* `workspaceRoot` defaults to `process.cwd()` as a placeholder for Task
* 1.15's real argv-driven file/directory resolution (design.md §17's
* "Argv parsing (file/directory)" step) — nothing here interprets `argv`
* yet.
*/
export function buildAssemblyRoot(workspaceRoot: string = process.cwd()): AssemblyRoot {
const log = createHostLog();
// No UI shell exists yet (Task 1.14) to back a real StatusSink — matches
// every other core composition point that hasn't reached its UI task.
const sink = createNoopStatusSink();

const commands = createCommandRegistry({ log, sink });
const documents = createDocumentManager({ log, sink });
const fs = createFileSystem({ log });
const config = createConfigService({ log, sink, workspaceRoot });
const context = createContextService();

const api = createTecodeApi({
commands,
documents,
fs,
rootUri: pathToUri(workspaceRoot),
config,
context,
sink,
});

// Must run before any extension module is imported (see this function's
// TSDoc) — no extension loading exists yet (Task 1.15/2.x), so this is
// simply the last step here today.
registerTecodeAlias(api);

return { log, sink, commands, documents, fs, config, context, api };
}

function main(argv: string[]): void {
if (argv.includes("--version")) {
console.log(pkg.version);
process.exit(0);
}
buildAssemblyRoot();
}

main(process.argv.slice(2));
// `import.meta.main` is Bun's "am I the entry point" check (true only when
// this file itself was executed, e.g. `bun run main.ts`; false when another
// module — such as this file's own test — imports it). Without this guard,
// importing `main.ts` for testing `buildAssemblyRoot` would also run
// `main(process.argv.slice(2))` as an unwanted side effect, against the
// *importing* process's real argv and real `HOME`.
if (import.meta.main) {
main(process.argv.slice(2));
}
72 changes: 72 additions & 0 deletions packages/core/src/api/alias.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
/**
* `registerTecodeAlias`: makes `import ... from "tecode"` resolve at
* runtime (Req 10.1, design.md §2, §12; Task 1.13) using `Bun.plugin`'s
* virtual-module hook. Every extension is written against `@tecode/api`'s
* *types* but reaches the live implementation through the `"tecode"`
* module specifier (design.md §2) — this is the one place that binding is
* actually wired up, and it must run once, after {@link createTecodeApi}
* has built the object and *before* any extension module is imported
* (`cli/main.ts`'s startup wiring, Task 1.15, is the intended call site;
* `discovery.ts`'s manifest-only dynamic import runs before this and never
* touches `index.ts`, so ordering there is unaffected).
*
* **Static typing for `"tecode"`**: `Bun.plugin`'s `builder.module(...)` is
* a runtime-only hook — TypeScript has no way to see that the specifier
* `"tecode"` will resolve to anything without help. `api/tecode-module.d.ts`
* supplies that help with an ambient `declare module "tecode"` re-exporting
* each namespace's type from `@tecode/api`; that file's own TSDoc explains
* why it works across every package in one `bunx tsc --noEmit` run despite
* living in `core`.
*
* **Compiled-mode (`bun build --compile`) note**: `Bun.plugin` registration
* must still run before any extension module import inside the compiled
* binary's own entry point — nothing about this changes for a compiled
* build (`Bun.plugin` is a runtime call, not a bundler transform), but the
* *build entry file* (design.md §17's `scripts/release.ts`-driven build,
* not yet written) must be the one that calls
* {@link createTecodeApi}/{@link registerTecodeAlias}, exactly like
* `cli/main.ts` does in dev. No build script changes are needed for this
* task; this note exists so Task whichever-wires-`--compile` doesn't have
* to rediscover the constraint.
*/

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

/** The `api` object most recently registered via {@link registerTecodeAlias}
* — tracked so a repeat call with the exact same object is a cheap no-op
* (idempotent) while a call with a genuinely different object (e.g. a test
* building a fresh composition root) still takes effect: `Bun.plugin`
* itself is fine with re-registering the same module specifier (last
* registration wins, verified empirically — it does not throw or warn), so
* there is no correctness reason to refuse that case, only a cheap
* optimization for the common one. */
let registeredApi: Tecode | undefined;

/**
* Register the `"tecode"` virtual module so `import ... from "tecode"`
* resolves to `api`'s namespaces as named exports (`commands`, `workspace`,
* `window`, `editor`, `ui`, `config`, `context`, `languages`, `themes` —
* matching `Tecode`'s own shape, since `Bun.plugin`'s `loader: "object"`
* projects an object's own enumerable properties onto the module's named
* exports). Call this exactly once per `api` object, after
* {@link createTecodeApi} and before any extension module loads.
*/
export function registerTecodeAlias(api: Tecode): void {
if (registeredApi === api) return;
registeredApi = api;
Bun.plugin({
name: "tecode-module-alias",
setup(builder) {
builder.module("tecode", () => ({
// `OnLoadResultObject.exports` is typed `Record<string, unknown>`
// (an index signature `Tecode` deliberately does not declare — its
// nine namespaces are named, not open-ended). The cast is safe:
// `api`'s own enumerable properties genuinely are exactly what
// `tecode-module.d.ts`'s ambient declaration promises callers of
// `import ... from "tecode"`.
exports: api as unknown as Record<string, unknown>,
loader: "object",
}));
},
});
}
Loading
Loading