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
13 changes: 13 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -263,6 +263,19 @@ live with no restart (Req 9.4). A workspace's own
`samples/settings.json` (in this repository) is a working, commented
starting point covering every key below.

Pass `--config <dir>` at startup (e.g.
`tecode --config /path/to/cfg ./my-project`) to read the user settings
and user keybindings layers from `<dir>/settings.json` and
`<dir>/keybindings.json` instead (Req 9.6) — useful for an isolated
profile or a CI sandbox. Only the user layer moves; a workspace's own
`.tecode/settings.json` still overlays on top exactly as above. A
missing `<dir>` (or a missing file inside it) is treated the same as a
missing home-directory file: an empty layer, not an error. A relative
`<dir>` resolves against the current working directory. `--config` with
no directory argument after it is ignored (no override applied), and it
never consumes the directory/file argument that opens a workspace —
`tecode --config /path/to/cfg ./my-project` still opens `./my-project`.

Req 9.5 names six MVP settings; the table marks which of them a real
`contributes.configuration` schema registers today, and which do not
exist yet:
Expand Down
2 changes: 2 additions & 0 deletions design.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -254,6 +254,8 @@ Pipeline (*Req 8*), in `core` with languages contributed by extensions:
- **JSONC**: a small tolerant parser (strip comments + trailing commas, then `JSON.parse`) with error positions surfaced in the status bar; a broken file keeps the last good configuration.
- **Layering**: defaults (from `contributes.configuration` schemas and core defaults) ← user `settings.json` ← workspace `.tecode/settings.json`. `tecode.config.get(key)` reads the merged view; the schema registry supplies types/defaults and (later) validation.
- **Watch**: `fs.watch` on both settings files and `keybindings.json`; on change, re-parse, diff keys, fire `onDidChangeConfiguration({ affectsConfiguration })`, and notify dependent services (theme service on `workbench.colorTheme`, keymap service rebuilds its table) (*Req 9.4*).
- **`--config <dir>` override** (*Req 9.6*): the CLI's `--config <dir>` flag (`cli/argv.ts`'s `resolveConfigDirOverride`) redirects the USER layer only — `<dir>/settings.json` and `<dir>/keybindings.json` replace the home-directory defaults `ConfigServiceDeps.settingsPath`/`keybindingsPath` otherwise fall back to (`host/paths.ts`'s `getUserSettingsPath`/`getUserKeybindingsPath`); the workspace layer's own resolution is untouched. A directory argument still following `--config <dir>` opens as the workspace exactly as before this flag existed; `--config` with no directory argument at all opens no workspace, same as no arguments given.


## 12. Public API Assembly

Expand Down
83 changes: 82 additions & 1 deletion packages/cli/src/argv.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ 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";
import { resolveConfigDirOverride, resolveStartupTarget } from "./argv";

let dir: string | undefined;

Expand DownExpand Up@@ -90,3 +90,84 @@ test("parent directory of a nested file resolves correctly", async () => {
expect(target.workspaceRoot).toBe(dirname(filePath));
expect(target.initialFilePath).toBe(filePath);
});

// --- resolveConfigDirOverride (Req 9.6, Issue #81 Phase 1) ---

test("resolveConfigDirOverride returns the token immediately after --config", () => {
expect(resolveConfigDirOverride(["--config", "/tmp/cfg"])).toBe("/tmp/cfg");
});

test("resolveConfigDirOverride returns undefined when --config is absent", () => {
expect(resolveConfigDirOverride([])).toBeUndefined();
expect(resolveConfigDirOverride(["./src"])).toBeUndefined();
});

test("resolveConfigDirOverride returns undefined when --config is the last token (no value follows)", () => {
expect(resolveConfigDirOverride(["--config"])).toBeUndefined();
expect(resolveConfigDirOverride(["./src", "--config"])).toBeUndefined();
});

test("resolveConfigDirOverride finds --config regardless of surrounding tokens", () => {
expect(resolveConfigDirOverride(["./src", "--config", "/tmp/cfg"])).toBe("/tmp/cfg");
expect(resolveConfigDirOverride(["--config", "/tmp/cfg", "./src"])).toBe("/tmp/cfg");
});

// --- resolveStartupTarget's --config non-confusion (Req 9.6, Issue #81 Phase 1) ---

test("--config's value is not mistaken for the positional argument: a directory still follows it", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const srcDir = join(dir, "src");
await mkdir(srcDir, { recursive: true });

const log = createHostLog();
const target = await resolveStartupTarget(
["--config", "/tmp/some-config-dir", srcDir],
"/irrelevant",
log,
);
expect(target).toEqual({ workspaceRoot: srcDir });
});

test("a REPEATED --config's value is not mistaken for the positional argument either", async () => {
// Which `--config` wins is a separate question from which tokens are
// values. Excluding only the FIRST occurrence's value left the second one
// looking like a bare positional, so `--config /a --config /b` silently
// opened `/b` as the WORKSPACE — a different thing entirely from what was
// asked (CodeRabbit finding on PR #85).
const dir = await mkdtemp(join(tmpdir(), "tecode-argv-repeat-"));
const otherDir = await mkdtemp(join(tmpdir(), "tecode-argv-repeat-other-"));
const log = createHostLog();
try {
const target = await resolveStartupTarget(
["--config", dir, "--config", otherDir],
process.cwd(),
log,
);
expect(target).toEqual({ workspaceRoot: process.cwd() });
// The override itself still takes the first occurrence.
expect(resolveConfigDirOverride(["--config", dir, "--config", otherDir])).toBe(dir);
} finally {
await rm(dir, { recursive: true, force: true });
await rm(otherDir, { recursive: true, force: true });
}
});

test("--config with no following positional opens nothing (falls back to cwd)", async () => {
const log = createHostLog();
const target = await resolveStartupTarget(
["--config", "/tmp/some-config-dir"],
"/fallback-cwd",
log,
);
expect(target).toEqual({ workspaceRoot: "/fallback-cwd" });
// The config dir's value itself was never treated as a bad startup
// path — no warning should have been logged about it.
expect(log.entries()).toEqual([]);
});

test("a plain positional argument still opens normally when --config is entirely absent", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const log = createHostLog();
const target = await resolveStartupTarget([dir], "/irrelevant", log);
expect(target).toEqual({ workspaceRoot: dir });
});
66 changes: 65 additions & 1 deletion packages/cli/src/argv.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,10 @@
* 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).
* `--config <dir>` (Req 9.6, Issue #81 Phase 1) is parsed here too, by
* {@link resolveConfigDirOverride} — a separate, synchronous, pure helper
* (it does no I/O and never throws) that `main.ts` calls alongside
* {@link resolveStartupTarget}.
*/

import { stat as nodeStat } from "node:fs/promises";
Expand DownExpand Up@@ -50,6 +54,52 @@ function describeError(err: unknown): string {
}
}

/** Every index in `argv` holding a `--config` flag's value — i.e. the token
* immediately after each `--config` occurrence (Issue #81 Phase 1). Shared
* by {@link resolveConfigDirOverride} and {@link resolveStartupTarget} so
* both agree on exactly which tokens are flag values rather than the
* positional argument.
*
* **Every occurrence, not just the first**: which `--config` *wins* is a
* separate question from which tokens are values. The override itself takes
* the first occurrence (see {@link resolveConfigDirOverride}, matching this
* module's "first token wins" treatment of the positional argument below),
* but a repeated flag's value must STILL be excluded from the positional
* scan. Considering only the first occurrence would leave the second value
* looking like a bare positional, so `tecode --config /a --config /b` would
* silently open `/b` as the workspace — a different thing entirely from
* what was asked (CodeRabbit finding on PR #85). */
function findConfigValueIndices(argv: readonly string[]): ReadonlySet<number> {
const indices = new Set<number>();
for (const [index, arg] of argv.entries()) {
if (arg === "--config" && index + 1 < argv.length) indices.add(index + 1);
}
return indices;
}

/**
* Resolve `--config <dir>`'s value from argv (Req 9.6, design.md §11's
* `--config` note; Issue #81 Phase 1). Returns the token immediately
* following the first `--config` flag, or `undefined` when `--config` is
* absent from `argv` entirely, or when it is present but is the very last
* token (no value follows). Never throws (matches this module's
* never-throwing, degrade-to-`undefined` policy) — it does no I/O and
* cannot fail. Does not validate that the returned string names a real,
* readable directory; that check happens where the value is actually used
* (`@tecode/core`'s `ConfigService`, which degrades a missing/unreadable
* settings or keybindings file to an empty layer exactly as it does for
* the un-overridden home-directory default).
*
* `--version` is still handled by `main.ts` itself before this module (or
* `resolveStartupTarget`) ever sees argv (this module's top-of-file
* TSDoc) — nothing here needs to special-case it.
*/
export function resolveConfigDirOverride(argv: readonly string[]): string | undefined {
const flagIndex = argv.indexOf("--config");
if (flagIndex === -1) return undefined;
return argv[flagIndex + 1];
}

/**
* Resolve the CLI's one positional argument (CodeRabbit's Phase 1 plan): a
* directory becomes `workspaceRoot` with no initial document; a file's
Expand All@@ -59,6 +109,17 @@ function describeError(err: unknown): string {
* the caller — this function only ever looks for the first token that
* does not start with `-`.
*
* **`--config <dir>`'s value is never mistaken for the positional
* argument** (Req 9.6, Issue #81 Phase 1): `--config`'s own value token
* (whatever immediately follows it, even a bare directory name with no
* leading `-`) is skipped when scanning for the positional, using the same
* {@link findConfigValueIndices} lookup {@link resolveConfigDirOverride}
* uses — so `tecode --config /tmp/cfg ./src` still opens `./src`, and
* `tecode --config /tmp/cfg` (no further token) opens nothing, exactly as
* if `--config /tmp/cfg` had been omitted. This function does not itself
* read or act on `--config`'s value — that is `resolveConfigDirOverride`'s
* job, called separately by `main.ts`.
*
* 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
Expand All@@ -72,7 +133,10 @@ export async function resolveStartupTarget(
log: HostLog,
fs: ArgvResolutionFs = createNodeArgvFs(),
): Promise<StartupTarget> {
const positional = argv.find((arg) => !arg.startsWith("-"));
const configValueIndices = findConfigValueIndices(argv);
const positional = argv.find(
(arg, index) => !arg.startsWith("-") && !configValueIndices.has(index),
);
if (!positional) return { workspaceRoot: cwd };

const resolved = resolvePath(cwd, positional);
Expand Down
70 changes: 70 additions & 0 deletions packages/cli/src/main.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,6 +163,76 @@ test("buildAssemblyRoot wires every core service and registers the 'tecode' modu
}
});

// --- buildAssemblyRoot's `configDir` deps (Req 9.6, Issue #81 Phase 1's
// `--config <dir>` flag) — the end-to-end proof that a `--config`
// directory's `settings.json`/`keybindings.json` genuinely take effect,
// not just that the string was threaded through unchanged. ---

test("buildAssemblyRoot's configDir makes a --config directory's settings.json genuinely take effect", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "tecode-cli-ws-"));
const configDir = await mkdtemp(join(tmpdir(), "tecode-cli-config-"));
await writeFile(
join(configDir, "settings.json"),
JSON.stringify({ "editor.tabSize": 2 }),
"utf8",
);

let root: ReturnType<typeof buildAssemblyRoot>;
try {
root = buildAssemblyRoot(workspaceDir, { configDir });
await root.config.ready;

// The value actually came from configDir's settings.json, not from
// core's own default (4, `config/coreDefaults.ts`) — proof the
// override was genuinely read, not merely accepted and ignored.
expect(root.config.get<number>("editor.tabSize")).toBe(2);
} finally {
root!.config.dispose();
root!.chordMachine.dispose();
root!.editorSession.dispose();
root!.editorLangIdSync.dispose();
root!.themeConfigSync.dispose();
root!.themeSelectCommand.dispose();
await rm(workspaceDir, { recursive: true, force: true });
await rm(configDir, { recursive: true, force: true });
}
});

test("buildAssemblyRoot's configDir makes a --config directory's keybindings.json genuinely take effect", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "tecode-cli-ws-"));
const configDir = await mkdtemp(join(tmpdir(), "tecode-cli-config-"));
await writeFile(
join(configDir, "keybindings.json"),
JSON.stringify([{ key: "ctrl+alt+k", command: "fixture.fromConfigDir" }]),
"utf8",
);

let root: ReturnType<typeof buildAssemblyRoot>;
try {
root = buildAssemblyRoot(workspaceDir, { configDir });
await root.config.ready;

expect(root.config.getKeybindingEntries()).toEqual([
{ key: "ctrl+alt+k", command: "fixture.fromConfigDir" },
]);
// buildAssemblyRoot wires onKeybindingsChange straight into
// keymap.setUserEntries — this proves the whole chain, not just
// ConfigService's own raw entry array.
const resolved = root.keymap.getTable().lookup("ctrl+alt+k", () => undefined);
expect(resolved?.command).toBe("fixture.fromConfigDir");
expect(resolved?.layer).toBe("user");
} finally {
root!.config.dispose();
root!.chordMachine.dispose();
root!.editorSession.dispose();
root!.editorLangIdSync.dispose();
root!.themeConfigSync.dispose();
root!.themeSelectCommand.dispose();
await rm(workspaceDir, { recursive: true, force: true });
await rm(configDir, { recursive: true, force: true });
}
});

test("forward-referenced activateExtension is a safe no-op before the deferred phase assigns hostRef", async () => {
const dir = await mkdtemp(join(tmpdir(), "tecode-cli-root-"));
const savedHome = process.env["HOME"];
Expand Down
49 changes: 46 additions & 3 deletions packages/cli/src/main.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,7 +81,8 @@ import {
builtinManifests,
builtinThemeAssets,
} from "@tecode/builtin";
import { resolveStartupTarget, type StartupTarget } from "./argv";
import { join as joinPath } from "node:path";
import { resolveConfigDirOverride, resolveStartupTarget, type StartupTarget } from "./argv";
import { buildExtensionDirMap, buildExtensionRecords } from "./extensionRecords";
import { createKeymapState, type KeymapState } from "./keymapState";
import { createBuiltinLanguageAssetsFs } from "./languageAssetsFs";
Expand DownExpand Up@@ -498,6 +499,17 @@ export function buildAssemblyRoot(
* hermeticity" shape.
*/
loadFallbackKeybindings?: () => Promise<KeybindingContribution[]>;
/**
* `--config <dir>`'s resolved value (Req 9.6, Issue #81 Phase 1) —
* `runTecode` threads `RunTecodeOptions.configDir` through to here.
* When set, `<dir>/settings.json` and `<dir>/keybindings.json`
* override `createConfigService`'s USER-layer defaults
* (`getUserSettingsPath()`/`getUserKeybindingsPath()`) — the
* workspace layer (`<workspaceRoot>/.tecode/settings.json`) is
* entirely unaffected. `undefined` (the default) leaves both at their
* ordinary home-directory paths, exactly as before this flag existed.
*/
configDir?: string;
} = {},
): AssemblyRoot {
const log = deps.log ?? createHostLog();
Expand DownExpand Up@@ -641,10 +653,25 @@ export function buildAssemblyRoot(
if (generation !== kittyVerdictGeneration) return;
keymap.setFallbackEntries(entries);
}
// `--config <dir>` (Req 9.6, Issue #81 Phase 1): a caller-supplied
// directory overrides where the USER settings/keybindings layer is
// read from — `deps.configDir` derives both file paths from that ONE
// directory, matching the home-directory default's own "one directory,
// two well-known filenames" shape (`host/paths.ts`'s
// `getUserSettingsPath`/`getUserKeybindingsPath`). `undefined` when
// `deps.configDir` is unset, so `createConfigService` falls through to
// its own `getUserSettingsPath()`/`getUserKeybindingsPath()` defaults
// exactly as before this flag existed.
const settingsPath = deps.configDir ? joinPath(deps.configDir, "settings.json") : undefined;
const keybindingsPath = deps.configDir
? joinPath(deps.configDir, "keybindings.json")
: undefined;
const config = createConfigService({
log,
sink,
workspaceRoot,
settingsPath,
keybindingsPath,
onKeybindingsChange: (entries) => keymap.setUserEntries(entries),
});
// Core's own settings (`editor.lineNumbers`, `editor.tabSize` — Req 9.5,
Expand DownExpand Up@@ -1072,6 +1099,12 @@ export interface RunTecodeOptions {
builtins?: Manifest[];
/** Overrides `process.cwd()` — tests only. */
cwd?: string;
/** `--config <dir>`'s resolved value (Req 9.6, Issue #81 Phase 1),
* already parsed by `main()`'s `resolveConfigDirOverride(argv)` call —
* threaded straight through to {@link buildAssemblyRoot}'s own
* `configDir` deps field (see that field's TSDoc for what it does).
* `undefined` (the default) is the ordinary "no override" case. */
configDir?: string;
}

/** Sets up graceful-shutdown handling (Phase 3's "wire process-exit
Expand DownExpand Up@@ -1197,7 +1230,7 @@ export async function runTecode(
// `renderShell`'s `onCapabilitiesResolved` callback, once the render
// seam has actually opened (or not opened, for `renderShellHeadless`) a
// real terminal.
const root = buildAssemblyRoot(target.workspaceRoot, { log });
const root = buildAssemblyRoot(target.workspaceRoot, { log, configDir: options.configDir });
await root.config.ready;
emitVerboseStep(startedAt, "config-ready");

Expand DownExpand Up@@ -1348,12 +1381,22 @@ export async function runTecode(
return { root, extensionHost: deferred.extensionHost, loadResult: deferred.loadResult, firstFrameMs };
}

/**
* The CLI entry point (Req 12.1, Issue #81 Phase 1's `--config <dir>`
* flag): handles `--version` first, exiting before any other argv
* handling ever runs (`argv.ts`'s top-of-file TSDoc: `resolveStartupTarget`
* must never see it either, for the same reason). `--config` is parsed
* right after, at that same early, synchronous, no-I/O position — via
* `resolveConfigDirOverride(argv)` — and its value is threaded through to
* {@link runTecode} as `RunTecodeOptions.configDir`.
*/
async function main(argv: string[]): Promise<void> {
if (argv.includes("--version")) {
console.log(pkg.version);
process.exit(0);
}
await runTecode(argv);
const configDir = resolveConfigDirOverride(argv);
await runTecode(argv, { configDir });
}

// `import.meta.main` is Bun's "am I the entry point" check (true only when
Expand Down
Loading
Loading