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
10 changes: 10 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,16 @@ checkout are the only two ways to run it.
(`scripts/release.ts`'s TSDoc, "Why there is no extra bundler config for
embedding"), so nothing else needs to be on the machine.

`<path>` does not need to exist yet: a file argument that isn't there on
disk opens as a new, empty, editable buffer, and saving it (`ctrl+s`)
creates the file — `tecode README2.md` on a fresh checkout opens an
empty `README2.md` you can start typing into immediately (Req 5.6, Req
12.4, Issue #88). This only applies when the path's parent directory
already exists and the argument doesn't end in a trailing `/` or `\`
(which always means "directory", never "file"); a typo'd deep path
(`notes/nested/todo.md` with no `notes/nested` directory) still warns
and starts with no file open, exactly as it always has.

macOS Gatekeeper will likely quarantine an unsigned downloaded binary on
first run (`xattr -d com.apple.quarantine tecode-darwin-<arch>` clears
it, or use the Finder's "Open" right-click override) — this repo does not
Expand Down
6 changes: 6 additions & 0 deletions design.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,12 @@ class Document {

Owns the `Map<UriString, Document>`, exposes `tecode.workspace.openDocument/documents` and the open/close/save events, resolves language IDs via the language registry on open, and fires `onLanguage:*` activation events. Save writes atomically (write temp + rename) and clears `dirty`.

**Opening a path that doesn't exist yet** (*Req 5.6*, Issue #88): `openDocument` treats an `ENOENT` from its initial `stat` as "a new file the user hasn't saved yet", not a failure — it builds the `Document` with `text: ""`, `readonly: false` instead of rejecting. Because `dirty` starts `false` and only flips on a real edit, an untouched new buffer prompts no save-on-quit confirmation and writes nothing to disk merely from having been opened — but `save()` itself does not gate on `dirty` (a pre-existing property, not new here), so an explicit save on a still-untouched new document does create an empty file. Every other `stat`/read failure (`EACCES`, `EIO`, ...) keeps the pre-Issue-#88 contract: reject and report through `log`/`sink`, never silently degrading to an empty buffer — that would let a save quietly overwrite a file the caller was never able to read.

This ENOENT branch is unconditional: it does not itself check whether the path's parent directory exists. `cli/argv.ts`'s `resolveStartupTarget` (§3) layers a stricter guard in front of it for CLI startup specifically — see below — but `DocumentManager` stays the simple, single-policy primitive every caller (CLI startup and `tecode.workspace.openDocument` alike) shares; a path with a missing parent still gets a clear, specific error, just at `save()` time (the temp-file write's own `ENOENT`) rather than at `openDocument()` time.

**CLI startup's stricter guard** (`resolveStartupTarget`, Req 12.4, Issue #88): a non-existent positional argument opens as a new file only when (a) the argv token itself does not end in `/` or `\` — a trailing separator unambiguously names a directory, and `path.resolve` would otherwise silently discard that signal — and (b) `dirname(resolved)` exists and is itself a directory. Failing either check falls back to the pre-existing "warn and start with no workspace" behavior. The asymmetry with `DocumentManager` above is deliberate: a CLI entry point can react to a typo'd deep path (`a/b/c.txt` with no `a/b`) with an immediate warning naming the exact path, which is a better first signal than a silently-opened empty editor whose eventual save failure no longer even mentions the typo.

## 8. UI Shell

### 8.1 Component tree
Expand Down
76 changes: 74 additions & 2 deletions packages/cli/src/argv.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,9 +45,19 @@ test("a relative path argument resolves against cwd", async () => {
expect(target).toEqual({ workspaceRoot: join(dir, "sub") });
});

test("a nonexistent path logs a warning and falls back to cwd", async () => {
test("a nonexistent path whose parent directory exists opens as a new file (Req 5.6, Issue #88)", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const missing = join(dir, "does-not-exist");
const missing = join(dir, "does-not-exist.txt");

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

test("a nonexistent path whose PARENT directory is also missing logs a warning and falls back to cwd (Issue #88 design note)", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const missing = join(dir, "no-such-parent", "does-not-exist.txt");

const log = createHostLog();
const target = await resolveStartupTarget([missing], "/fallback-cwd", log);
Expand All@@ -59,6 +69,68 @@ test("a nonexistent path logs a warning and falls back to cwd", async () => {
expect(entries[0]?.error.message).toContain(missing);
});

test("a nonexistent path whose immediate parent is a FILE (not a directory) logs a warning and falls back to cwd", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const notADir = join(dir, "notadir.txt");
await writeFile(notADir, "x", "utf8");
const missing = join(notADir, "does-not-exist.txt");

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

test("a nonexistent DIRECTORY-shaped path (trailing slash) logs a warning and falls back to cwd rather than opening as a file (Issue #88 design note)", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const missingDirArg = join(dir, "newdir") + "/";

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

test.skipIf(process.platform === "win32")(
"on POSIX a trailing backslash is an ordinary filename character, so such a path still opens as a new file (CodeRabbit finding on PR #89)",
async () => {
// `\\` is a separator only on Windows. On POSIX `path.resolve` leaves it
// inside the basename — `resolve("/tmp", "draft\\")` is `/tmp/draft\\`,
// whose `dirname` is `/tmp` — so `draft\\` names a perfectly valid file
// that does not exist yet, and rejecting it would warn-and-fall-back on
// a path the user could legitimately create.
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const backslashName = join(dir, "draft") + "\\";

const log = createHostLog();
const target = await resolveStartupTarget([backslashName], "/fallback-cwd", log);
expect(target).toEqual({ workspaceRoot: dir, initialFilePath: backslashName });
expect(log.entries().length).toBe(0);
},
);

test("an EACCES (or any non-ENOENT) stat failure still logs a warning and falls back to cwd, never opens a new file", async () => {
// Deliberately makes the PARENT directory's stat succeed (return a real
// directory) while only the target path's own stat fails with EACCES —
// if `resolveStartupTarget` ever stopped checking the error code before
// trying the new-file path, this would open `some/path` as a new file
// instead of warning, since the parent-exists guard alone would pass.
const log = createHostLog();
const fakeFs = {
stat: async (path: string) => {
if (path.endsWith("path")) {
const err = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
throw err;
}
return { isDirectory: () => true };
},
};
const target = await resolveStartupTarget(["some/path"], "/fallback-cwd", log, fakeFs);
expect(target).toEqual({ workspaceRoot: "/fallback-cwd" });
expect(log.entries().length).toBe(1);
expect(log.entries()[0]?.level).toBe("warning");
});

test("only the first non-flag token is treated as the positional argument", async () => {
dir = await mkdtemp(join(tmpdir(), "tecode-argv-"));
const log = createHostLog();
Expand Down
94 changes: 89 additions & 5 deletions packages/cli/src/argv.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,79 @@ function describeError(err: unknown): string {
}
}

/** Extract an errno-style `code` (e.g. `"ENOENT"`) from a caught unknown,
* or `undefined` when it carries none. Duplicated per-module rather than
* imported (matches `@tecode/core`'s own convention of a private
* `errorCode` in each module that needs one — e.g.
* `buffer/documentManager.ts`'s, `config/service.ts`'s — instead of a
* shared export; this module is `cli`, which may import `@tecode/core`,
* but this helper is a two-line leaf with no state, so duplicating it
* avoids a public export whose only purpose would be this one call). */
function errorCode(err: unknown): string | undefined {
if (typeof err === "object" && err !== null && "code" in err) {
const code = (err as { code?: unknown }).code;
if (typeof code === "string") return code;
}
return undefined;
}

/**
* Decide whether a non-existent `resolved` path (Req 5.6, Issue #88)
* should open as a brand-new, empty in-memory document rather than
* degrade to "no workspace" — called from {@link resolveStartupTarget}'s
* catch block only when the initial `stat` failed with `ENOENT`. Two
* guards gate this, both chosen over the alternative of opening
* unconditionally and deferring to a save-time error (which is what
* `buffer/documentManager.ts`'s `openDocument` does instead, for its own,
* different reasons — see that function's TSDoc):
*
* - **Directory-shaped positional**: `raw` (the ORIGINAL argv token, not
* `resolved`) ending in `/` or `\` unambiguously means the user meant a
* directory — `path.resolve` normalizes away a trailing separator, so
* by the time `resolved` exists that signal is already gone, which is
* why this checks `raw`. A file can never be opened at a path spelled
* with a trailing separator, so `tecode newdir/` on a non-existent
* `newdir` degrades to the ordinary "does not exist" warning rather
* than silently opening `newdir` (no trailing slash) as a file.
* - **Missing parent directory** (`a/b/c.txt` where `a/b` doesn't exist):
* opening this as a new file would let a typo'd deep path silently
* open an empty editor with nothing on screen to say anything is
* wrong — discoverable only later, when a save fails for a reason
* (`ENOENT` on the temp-file write, inside a directory that was never
* the one the user actually mistyped) that no longer even names the
* original mistake. A startup warning that names the exact path right
* away is a far better first signal for a CLI entry point, so this
* only treats `resolved` as a new file when `dirname(resolved)` both
* exists and is itself a directory.
*
* Returns `undefined` (caller falls through to its existing warning) when
* either guard fails, or the parent's own `stat` call fails for any
* reason.
*/
async function tryResolveAsNewFile(
fs: ArgvResolutionFs,
raw: string,
resolved: string,
): Promise<StartupTarget | undefined> {
// `/` is a separator everywhere. `\` is one only on Windows: on POSIX it
// is an ordinary filename character, so `tecode 'draft\'` names a
// perfectly valid file that does not exist yet, and rejecting it here
// would warn-and-fall-back on a path the user could legitimately create
// (CodeRabbit finding on PR #89 — `path.resolve("/tmp", "draft\\")` gives
// `/tmp/draft\`, whose `dirname` is `/tmp`, not a directory named
// `draft`).
if (raw.endsWith("/")) return undefined;
if (process.platform === "win32" && raw.endsWith("\\")) return undefined;
const parent = dirname(resolved);
try {
const parentStats = await fs.stat(parent);
if (!parentStats.isDirectory()) return undefined;
} catch {
return undefined;
}
return { workspaceRoot: parent, initialFilePath: resolved };
}

/** 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
Expand DownExpand Up@@ -121,11 +194,18 @@ export function resolveConfigDirOverride(argv: readonly string[]): string | unde
* 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
* 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.
* path that can't be `stat`-ed for a reason other than "missing" is
* reported to `log` as a warning and treated as if no argument had been
* given (`cwd`) — a bad 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.
*
* **A path that does not exist (`ENOENT`) opens as a new, empty document
* instead** (Req 5.6, Issue #88), UNLESS {@link tryResolveAsNewFile}'s two
* guards say otherwise (a directory-shaped positional, or a missing
* parent directory) — in either of those cases this still falls through
* to the warning-and-`cwd` degradation above, exactly as before Issue
* #88. See {@link tryResolveAsNewFile}'s TSDoc for the full reasoning.
*/
export async function resolveStartupTarget(
argv: readonly string[],
Expand All@@ -145,6 +225,10 @@ export async function resolveStartupTarget(
if (stats.isDirectory()) return { workspaceRoot: resolved };
return { workspaceRoot: dirname(resolved), initialFilePath: resolved };
} catch (cause) {
if (errorCode(cause) === "ENOENT") {
const asNewFile = await tryResolveAsNewFile(fs, positional, resolved);
if (asNewFile) return asNewFile;
}
log.append("warning", {
message: `Startup path "${resolved}" does not exist or could not be read (${describeError(cause)}); starting with no workspace.`,
path: resolved,
Expand Down
85 changes: 84 additions & 1 deletion packages/cli/src/main.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
import { expect, test } from "bun:test";
import { mkdir, mkdtemp, readdir as nodeReaddir, rm, stat as nodeStat, writeFile } from "node:fs/promises";
import {
mkdir,
mkdtemp,
readdir as nodeReaddir,
readFile,
rm,
stat as nodeStat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
BASE_THEME_ID,
createHostLog,
EXTENSIONS_RELOAD_COMMAND_ID,
getUserExtensionsDir,
KEYBINDINGS_ENSURE_FILE_COMMAND_ID,
Expand All@@ -22,6 +31,7 @@ import {
type DiscoveryFs,
} from "@tecode/core";
import pkg from "../package.json";
import { resolveStartupTarget } from "./argv";
import { buildAssemblyRoot, runDeferredPhase } from "./main";

/** A {@link DiscoveryFs} backed by the real filesystem, except the real
Expand DownExpand Up@@ -347,6 +357,79 @@ test("runDeferredPhase loads a workspace extension, activates it on startup, wir
}
}, 15_000);

test("Issue #88 end to end: `tecode README2.md` on a non-existent path opens an editable empty buffer, and saving it creates the real file on disk — through argv resolution, not just DocumentManager in isolation", async () => {
const homeDir = await mkdtemp(join(tmpdir(), "tecode-cli-home-"));
const workspaceDir = await mkdtemp(join(tmpdir(), "tecode-cli-ws-"));
const savedHome = process.env["HOME"];
const savedAppData = process.env["APPDATA"];
process.env["HOME"] = homeDir;
process.env["APPDATA"] = homeDir;

const targetFile = join(workspaceDir, "README2.md");
// The load-bearing assumption under test: this file does NOT exist yet.
await expect(nodeStat(targetFile)).rejects.toBeDefined();

let root: ReturnType<typeof buildAssemblyRoot>;
try {
// Real argv resolution (Req 5.6, Req 12.4, Issue #88) — the whole
// point of this test is exercising `resolveStartupTarget` too, not
// just handing `documentManager` a pre-resolved path.
const log = createHostLog();
const target = await resolveStartupTarget([targetFile], workspaceDir, log);
expect(target).toEqual({ workspaceRoot: workspaceDir, initialFilePath: targetFile });
expect(log.entries()).toEqual([]);

root = buildAssemblyRoot(target.workspaceRoot);
try {
await root.config.ready;

const { extensionHost, loadResult } = await runDeferredPhase(root, {
initialFilePath: target.initialFilePath,
fs: createHermeticDiscoveryFs(),
builtins: [],
});
expect(loadResult.skipped).toEqual([]);

// Opened as a new, empty, non-dirty document — not "No editor open."
const uri = pathToUri(targetFile);
const doc = root.documents.documents.find((d) => d.uri === uri);
expect(doc).toBeDefined();
expect(doc!.getText()).toBe("");
expect(doc!.readonly).toBe(false);
expect(doc!.dirty).toBe(false);

// Opening alone must not have touched the real filesystem.
await expect(nodeStat(targetFile)).rejects.toBeDefined();

// Edit, then save through the real DocumentManager/real fs.
doc!.applyEdits([
{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
newText: "# README2\n",
},
]);
const saved = await root.documents.save(uri);
expect(saved).toBe(true);
expect(doc!.dirty).toBe(false);

// The real file now exists on disk with the expected content.
const onDisk = await readFile(targetFile, "utf8");
expect(onDisk).toBe("# README2\n");

await extensionHost.disposeAll();
} finally {
root.config.dispose();
}
} 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;
await rm(homeDir, { recursive: true, force: true });
await rm(workspaceDir, { recursive: true, force: true });
}
}, 15_000);

test("runDeferredPhase reports a bad extension without failing startup (Req 2.4)", async () => {
const homeDir = await mkdtemp(join(tmpdir(), "tecode-cli-home-"));
const workspaceDir = await mkdtemp(join(tmpdir(), "tecode-cli-ws-"));
Expand Down
Loading
Loading