@cruxy/cli 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/approval/classify.js +25 -3
- package/dist/approval/policy.d.ts +6 -0
- package/dist/approval/policy.js +15 -3
- package/dist/approval/prompt.js +11 -0
- package/dist/approval/types.d.ts +8 -1
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +3 -0
- package/dist/checkpoint/index.js +3 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -0
- package/dist/checkpoint/set.d.ts +44 -0
- package/dist/checkpoint/set.js +142 -0
- package/dist/checkpoint/types.d.ts +47 -0
- package/dist/cli/commands/rollback.d.ts +11 -6
- package/dist/cli/commands/rollback.js +93 -33
- package/dist/cli/commands/run.js +59 -10
- package/dist/cli/onboard.js +4 -1
- package/dist/cli/repl.d.ts +2 -2
- package/dist/cli/session-factory.d.ts +4 -3
- package/dist/cli/session-factory.js +98 -12
- package/dist/errors/constructors.d.ts +65 -0
- package/dist/errors/constructors.js +168 -0
- package/dist/errors/types.d.ts +46 -0
- package/dist/errors/types.js +64 -0
- package/dist/indexing/retriever.d.ts +29 -0
- package/dist/indexing/retriever.js +26 -0
- package/dist/indexing/service.js +3 -1
- package/dist/indexing/types.d.ts +7 -0
- package/dist/lsp/tools/common.d.ts +34 -7
- package/dist/lsp/tools/common.js +33 -11
- package/dist/lsp/tools/find-definition.js +2 -2
- package/dist/lsp/tools/find-references.js +10 -4
- package/dist/lsp/tools/get-diagnostics.js +6 -4
- package/dist/render/diff.js +42 -5
- package/dist/sandbox/docker-runtime.js +4 -1
- package/dist/sandbox/policy.d.ts +12 -3
- package/dist/sandbox/policy.js +17 -3
- package/dist/sandbox/types.d.ts +10 -1
- package/dist/subagent/orchestrator.d.ts +15 -0
- package/dist/subagent/orchestrator.js +2 -0
- package/dist/testing/run-tests-tool.js +3 -0
- package/dist/tools/create-pull-request.d.ts +3 -0
- package/dist/tools/create-pull-request.js +50 -4
- package/dist/tools/file/apply-patch.js +2 -2
- package/dist/tools/file/edit-file.js +2 -2
- package/dist/tools/file/glob.d.ts +9 -2
- package/dist/tools/file/glob.js +73 -19
- package/dist/tools/file/grep-files.d.ts +12 -2
- package/dist/tools/file/grep-files.js +113 -38
- package/dist/tools/file/paths.d.ts +123 -17
- package/dist/tools/file/paths.js +158 -50
- package/dist/tools/file/read-file.js +2 -2
- package/dist/tools/file/write-file.js +2 -2
- package/dist/tools/git-status.d.ts +8 -1
- package/dist/tools/git-status.js +43 -11
- package/dist/tools/list-files.d.ts +9 -3
- package/dist/tools/list-files.js +48 -13
- package/dist/tools/search-codebase.d.ts +10 -0
- package/dist/tools/search-codebase.js +117 -14
- package/dist/tools/shell/exec.js +8 -1
- package/dist/tools/types.d.ts +63 -1
- package/dist/vcs/git.d.ts +8 -0
- package/dist/vcs/git.js +14 -0
- package/dist/vcs/github.d.ts +7 -1
- package/dist/vcs/github.js +10 -1
- package/dist/vcs/service.d.ts +8 -0
- package/dist/vcs/service.js +33 -1
- package/dist/vcs/types.d.ts +18 -2
- package/dist/workspace/index.d.ts +5 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/resolve.d.ts +54 -0
- package/dist/workspace/resolve.js +96 -0
- package/dist/workspace/select.d.ts +41 -0
- package/dist/workspace/select.js +44 -0
- package/dist/workspace/types.d.ts +30 -0
- package/dist/workspace/types.js +15 -0
- package/dist/workspace/workspace.d.ts +56 -0
- package/dist/workspace/workspace.js +180 -0
- package/package.json +1 -1
package/dist/vcs/types.d.ts
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
* interface is the swap seam — GitHub ships first, GitLab/Bitbucket slot in later
|
|
4
4
|
* without touching call sites (same discipline as `VectorStore`/`Embedder`).
|
|
5
5
|
*/
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The forge-target identity parsed from the `origin` remote — just host/owner/repo,
|
|
8
|
+
* no network lookup. This is the value the C.26 Step-4 wrong-repo guard compares
|
|
9
|
+
* across the preview→API-call window (see {@link ForgeProvider.resolveRepoIdentity}).
|
|
10
|
+
*/
|
|
11
|
+
export interface RepoIdentity {
|
|
8
12
|
/** Forge host, e.g. `github.com`. */
|
|
9
13
|
readonly host: string;
|
|
10
14
|
/** Repository owner (user or org). */
|
|
11
15
|
readonly owner: string;
|
|
12
16
|
/** Repository name (no `.git` suffix). */
|
|
13
17
|
readonly repo: string;
|
|
18
|
+
}
|
|
19
|
+
/** Where a repository lives and who owns it, parsed from the `origin` remote. */
|
|
20
|
+
export interface RepoInfo extends RepoIdentity {
|
|
14
21
|
/** Default branch, when the provider can resolve it (PR base fallback). */
|
|
15
22
|
readonly defaultBranch?: string;
|
|
16
23
|
}
|
|
@@ -40,6 +47,15 @@ export interface ForgeProvider {
|
|
|
40
47
|
readonly id: string;
|
|
41
48
|
/** Parse the repository's `origin` remote into structured {@link RepoInfo}. */
|
|
42
49
|
getRepoInfo(cwd: string): Promise<RepoInfo>;
|
|
50
|
+
/**
|
|
51
|
+
* Re-parse just the `origin` remote's {@link RepoIdentity} — LOCAL only, no
|
|
52
|
+
* network. The C.26 Step-4 wrong-repo guard calls this immediately before the
|
|
53
|
+
* pull-request API call and compares it against the identity shown at approval;
|
|
54
|
+
* a mismatch (a mid-run `git remote set-url`) refuses the PR. It stays local so
|
|
55
|
+
* the re-check is cheap and cannot fail on a network hiccup. Throws the same
|
|
56
|
+
* coded usage errors as {@link getRepoInfo} when there is no parseable `origin`.
|
|
57
|
+
*/
|
|
58
|
+
resolveRepoIdentity(cwd: string): RepoIdentity;
|
|
43
59
|
/** Open a pull request and return its URL (idempotent on already-exists). */
|
|
44
60
|
createPullRequest(repo: RepoInfo, spec: PullRequestSpec): Promise<PullRequestResult>;
|
|
45
61
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { DeclaredRoot, RootSpec } from "./types.js";
|
|
2
|
+
export { Workspace, buildWorkspace, singleRootWorkspace } from "./workspace.js";
|
|
3
|
+
export { PathEscapeError, confineToRoot, isInside, resolveInWorkspace, } from "./resolve.js";
|
|
4
|
+
export { selectRoot } from "./select.js";
|
|
5
|
+
export type { RootRef, SelectedRoot } from "./select.js";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CruxyError } from "../errors/index.js";
|
|
2
|
+
import type { Workspace } from "./workspace.js";
|
|
3
|
+
/**
|
|
4
|
+
* The single path-confinement funnel for the whole CLI (C.26). Two things live
|
|
5
|
+
* here so there is exactly ONE confinement implementation, not several:
|
|
6
|
+
* • {@link confineToRoot} — the pure 2-layer check (lexical + symlink) against
|
|
7
|
+
* ONE root. It never sees any other root, which is the structural argument
|
|
8
|
+
* that a validated target can't cross into a sibling root.
|
|
9
|
+
* • {@link resolveInWorkspace} — select the one named root, then confine to it.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Thrown when a tool argument resolves to a path outside the root it is acting in
|
|
13
|
+
* — via `../` traversal, an absolute path, an outward symlink, OR a path that
|
|
14
|
+
* lands in a *different* declared root. A cross-root path is deliberately the
|
|
15
|
+
* SAME error as any other escape (R2): a distinct code would wrongly imply
|
|
16
|
+
* "less bad". A {@link CruxyError} (code CRUXY_E_PATH_ESCAPE) so it carries a code
|
|
17
|
+
* if it reaches the boundary; tools still catch it and surface `{ ok:false }`.
|
|
18
|
+
*/
|
|
19
|
+
export declare class PathEscapeError extends CruxyError {
|
|
20
|
+
constructor(message: string);
|
|
21
|
+
}
|
|
22
|
+
/** Is `target` the root itself or a descendant of it? */
|
|
23
|
+
export declare function isInside(root: string, target: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve `p` against a SINGLE root and prove it stays inside — the pure kernel
|
|
26
|
+
* every file tool ultimately funnels through.
|
|
27
|
+
*
|
|
28
|
+
* Two layers: (1) a lexical check that the resolved absolute path is within root
|
|
29
|
+
* (rejects `../` and absolute-outside before touching the FS); (2) a symlink
|
|
30
|
+
* check that the real target — or, for a new path, its nearest existing parent —
|
|
31
|
+
* resolves inside the *real* root. The root is realpath'd too, so this is correct
|
|
32
|
+
* even when the root itself sits under a symlink (macOS `/var → /private/var`).
|
|
33
|
+
*
|
|
34
|
+
* Crucially, this function is a pure function of `(one root, the path)`. The rest
|
|
35
|
+
* of the declared root set is NOT in scope here, so there is no code path by
|
|
36
|
+
* which a target validated against this root can be accepted into another root.
|
|
37
|
+
*
|
|
38
|
+
* @returns the resolved absolute path (lexical, not realpath'd — so callers
|
|
39
|
+
* operate on the intended location).
|
|
40
|
+
* @throws {PathEscapeError} if the path escapes the root.
|
|
41
|
+
*/
|
|
42
|
+
export declare function confineToRoot(rootAbsPath: string, p: string): Promise<string>;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a tool-supplied path against ONE named root of the workspace and prove
|
|
45
|
+
* it stays inside that root. Selection happens first (by exact name — see
|
|
46
|
+
* {@link Workspace.rootByName}, which fail-loud refuses an unknown name), and
|
|
47
|
+
* confinement runs against only that one root's path. A `../otherRoot/x` that
|
|
48
|
+
* would land in a sibling declared root is refused exactly as any escape is (R2),
|
|
49
|
+
* because {@link confineToRoot} is only ever handed this one root.
|
|
50
|
+
*
|
|
51
|
+
* @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN if `rootName` is not declared.
|
|
52
|
+
* @throws {PathEscapeError} if `p` escapes the selected root.
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveInWorkspace(ws: Workspace, rootName: string, p: string): Promise<string>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CruxyError, ErrorCode } from "../errors/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* The single path-confinement funnel for the whole CLI (C.26). Two things live
|
|
6
|
+
* here so there is exactly ONE confinement implementation, not several:
|
|
7
|
+
* • {@link confineToRoot} — the pure 2-layer check (lexical + symlink) against
|
|
8
|
+
* ONE root. It never sees any other root, which is the structural argument
|
|
9
|
+
* that a validated target can't cross into a sibling root.
|
|
10
|
+
* • {@link resolveInWorkspace} — select the one named root, then confine to it.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Thrown when a tool argument resolves to a path outside the root it is acting in
|
|
14
|
+
* — via `../` traversal, an absolute path, an outward symlink, OR a path that
|
|
15
|
+
* lands in a *different* declared root. A cross-root path is deliberately the
|
|
16
|
+
* SAME error as any other escape (R2): a distinct code would wrongly imply
|
|
17
|
+
* "less bad". A {@link CruxyError} (code CRUXY_E_PATH_ESCAPE) so it carries a code
|
|
18
|
+
* if it reaches the boundary; tools still catch it and surface `{ ok:false }`.
|
|
19
|
+
*/
|
|
20
|
+
export class PathEscapeError extends CruxyError {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super({ code: ErrorCode.PathEscape, title: message });
|
|
23
|
+
this.name = "PathEscapeError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Is `target` the root itself or a descendant of it? */
|
|
27
|
+
export function isInside(root, target) {
|
|
28
|
+
return target === root || target.startsWith(root + path.sep);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* realpath `p`, or — if it doesn't exist yet — the realpath of its nearest
|
|
32
|
+
* existing ancestor directory. Lets us validate a not-yet-created path by the
|
|
33
|
+
* directory it would be created in (catching outward symlinked parents).
|
|
34
|
+
*/
|
|
35
|
+
async function realpathOfNearestExisting(p) {
|
|
36
|
+
let cur = p;
|
|
37
|
+
for (;;) {
|
|
38
|
+
try {
|
|
39
|
+
return await fs.realpath(cur);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
if (err.code !== "ENOENT")
|
|
43
|
+
throw err;
|
|
44
|
+
const parent = path.dirname(cur);
|
|
45
|
+
if (parent === cur)
|
|
46
|
+
return cur; // reached the filesystem root
|
|
47
|
+
cur = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve `p` against a SINGLE root and prove it stays inside — the pure kernel
|
|
53
|
+
* every file tool ultimately funnels through.
|
|
54
|
+
*
|
|
55
|
+
* Two layers: (1) a lexical check that the resolved absolute path is within root
|
|
56
|
+
* (rejects `../` and absolute-outside before touching the FS); (2) a symlink
|
|
57
|
+
* check that the real target — or, for a new path, its nearest existing parent —
|
|
58
|
+
* resolves inside the *real* root. The root is realpath'd too, so this is correct
|
|
59
|
+
* even when the root itself sits under a symlink (macOS `/var → /private/var`).
|
|
60
|
+
*
|
|
61
|
+
* Crucially, this function is a pure function of `(one root, the path)`. The rest
|
|
62
|
+
* of the declared root set is NOT in scope here, so there is no code path by
|
|
63
|
+
* which a target validated against this root can be accepted into another root.
|
|
64
|
+
*
|
|
65
|
+
* @returns the resolved absolute path (lexical, not realpath'd — so callers
|
|
66
|
+
* operate on the intended location).
|
|
67
|
+
* @throws {PathEscapeError} if the path escapes the root.
|
|
68
|
+
*/
|
|
69
|
+
export async function confineToRoot(rootAbsPath, p) {
|
|
70
|
+
const root = path.resolve(rootAbsPath);
|
|
71
|
+
const resolved = path.resolve(root, p);
|
|
72
|
+
if (!isInside(root, resolved)) {
|
|
73
|
+
throw new PathEscapeError(`path "${p}" resolves outside the project root`);
|
|
74
|
+
}
|
|
75
|
+
const realRoot = await fs.realpath(root);
|
|
76
|
+
const realTarget = await realpathOfNearestExisting(resolved);
|
|
77
|
+
if (!isInside(realRoot, realTarget)) {
|
|
78
|
+
throw new PathEscapeError(`path "${p}" resolves outside the project root (via a symlink)`);
|
|
79
|
+
}
|
|
80
|
+
return resolved;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a tool-supplied path against ONE named root of the workspace and prove
|
|
84
|
+
* it stays inside that root. Selection happens first (by exact name — see
|
|
85
|
+
* {@link Workspace.rootByName}, which fail-loud refuses an unknown name), and
|
|
86
|
+
* confinement runs against only that one root's path. A `../otherRoot/x` that
|
|
87
|
+
* would land in a sibling declared root is refused exactly as any escape is (R2),
|
|
88
|
+
* because {@link confineToRoot} is only ever handed this one root.
|
|
89
|
+
*
|
|
90
|
+
* @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN if `rootName` is not declared.
|
|
91
|
+
* @throws {PathEscapeError} if `p` escapes the selected root.
|
|
92
|
+
*/
|
|
93
|
+
export async function resolveInWorkspace(ws, rootName, p) {
|
|
94
|
+
const root = ws.rootByName(rootName); // fail-loud on unknown name (R1)
|
|
95
|
+
return confineToRoot(root.absPath, p);
|
|
96
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { DeclaredRoot } from "./types.js";
|
|
2
|
+
import type { Workspace } from "./workspace.js";
|
|
3
|
+
/**
|
|
4
|
+
* How a tool call addresses a workspace root (C.26, R1). Either an explicit `root`
|
|
5
|
+
* name plus a root-relative `path`, or just a `path` that may carry a leading
|
|
6
|
+
* root-name segment. Bare relative paths fall back to the primary root; absolute
|
|
7
|
+
* paths select the unique root that contains them.
|
|
8
|
+
*/
|
|
9
|
+
export interface RootRef {
|
|
10
|
+
/** Explicit root name (`root: "service"`). Wins over any name in `path`. */
|
|
11
|
+
readonly root?: string;
|
|
12
|
+
/** The path argument (root-relative, name-prefixed, or absolute). */
|
|
13
|
+
readonly path: string;
|
|
14
|
+
}
|
|
15
|
+
/** The outcome of resolving a {@link RootRef}: the chosen root + a path within it. */
|
|
16
|
+
export interface SelectedRoot {
|
|
17
|
+
readonly root: DeclaredRoot;
|
|
18
|
+
/** The path to hand to `confineToRoot` (root-relative or absolute-inside). */
|
|
19
|
+
readonly relPath: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a {@link RootRef} to exactly one declared root and a path within it —
|
|
23
|
+
* the selection half of confinement, run BEFORE any resolution so a call always
|
|
24
|
+
* commits to a single root first (§1.1). Precedence:
|
|
25
|
+
*
|
|
26
|
+
* 1. explicit `root` name → that root, exactly (fail-loud on unknown name);
|
|
27
|
+
* 2. absolute `path` → the unique declared root containing it (fail-loud on
|
|
28
|
+
* unknown / — defensively — ambiguous);
|
|
29
|
+
* 3. `path` whose first segment exactly matches a declared root name → that
|
|
30
|
+
* root, with the segment stripped (only when the name is unambiguous);
|
|
31
|
+
* 4. bare relative `path` → the primary root.
|
|
32
|
+
*
|
|
33
|
+
* `requireExplicit` (set by mutating tools per ⚖︎#3) refuses the case-4 default
|
|
34
|
+
* in a multi-root session: a write must NAME its root rather than silently land
|
|
35
|
+
* in the primary. Read/enumeration tools leave it false.
|
|
36
|
+
*
|
|
37
|
+
* @throws CRUXY_E_ROOT_UNKNOWN / CRUXY_E_ROOT_AMBIGUOUS per the rules above.
|
|
38
|
+
*/
|
|
39
|
+
export declare function selectRoot(ws: Workspace, ref: RootRef, opts?: {
|
|
40
|
+
requireExplicit?: boolean;
|
|
41
|
+
}): SelectedRoot;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { rootAmbiguous } from "../errors/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a {@link RootRef} to exactly one declared root and a path within it —
|
|
5
|
+
* the selection half of confinement, run BEFORE any resolution so a call always
|
|
6
|
+
* commits to a single root first (§1.1). Precedence:
|
|
7
|
+
*
|
|
8
|
+
* 1. explicit `root` name → that root, exactly (fail-loud on unknown name);
|
|
9
|
+
* 2. absolute `path` → the unique declared root containing it (fail-loud on
|
|
10
|
+
* unknown / — defensively — ambiguous);
|
|
11
|
+
* 3. `path` whose first segment exactly matches a declared root name → that
|
|
12
|
+
* root, with the segment stripped (only when the name is unambiguous);
|
|
13
|
+
* 4. bare relative `path` → the primary root.
|
|
14
|
+
*
|
|
15
|
+
* `requireExplicit` (set by mutating tools per ⚖︎#3) refuses the case-4 default
|
|
16
|
+
* in a multi-root session: a write must NAME its root rather than silently land
|
|
17
|
+
* in the primary. Read/enumeration tools leave it false.
|
|
18
|
+
*
|
|
19
|
+
* @throws CRUXY_E_ROOT_UNKNOWN / CRUXY_E_ROOT_AMBIGUOUS per the rules above.
|
|
20
|
+
*/
|
|
21
|
+
export function selectRoot(ws, ref, opts = {}) {
|
|
22
|
+
// 1. Explicit root name wins.
|
|
23
|
+
if (ref.root !== undefined) {
|
|
24
|
+
return { root: ws.rootByName(ref.root), relPath: ref.path };
|
|
25
|
+
}
|
|
26
|
+
// 2. Absolute path → the unique containing root.
|
|
27
|
+
if (path.isAbsolute(ref.path)) {
|
|
28
|
+
return { root: ws.rootContaining(ref.path), relPath: ref.path };
|
|
29
|
+
}
|
|
30
|
+
// 3. `name/rest` where `name` is a declared root (only meaningful multi-root).
|
|
31
|
+
const firstSeg = ref.path.split(/[/\\]/, 1)[0];
|
|
32
|
+
if (firstSeg && ref.path !== firstSeg) {
|
|
33
|
+
const named = ws.tryRootByName(firstSeg);
|
|
34
|
+
if (named) {
|
|
35
|
+
const rest = ref.path.slice(firstSeg.length).replace(/^[/\\]+/, "");
|
|
36
|
+
return { root: named, relPath: rest };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// 4. Bare relative path → primary root, unless a mutation must be explicit.
|
|
40
|
+
if (opts.requireExplicit && ws.isMultiRoot) {
|
|
41
|
+
throw rootAmbiguous(ref.path, ws.roots().map((r) => r.name));
|
|
42
|
+
}
|
|
43
|
+
return { root: ws.primary(), relPath: ref.path };
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-repo workspace model (C.26). A session may operate across N repo/package
|
|
3
|
+
* roots. The {@link Workspace} is the single value that carries the declared root
|
|
4
|
+
* set; every path-taking tool resolves *through* it rather than through a bare
|
|
5
|
+
* `cwd` string.
|
|
6
|
+
*
|
|
7
|
+
* Two invariants the type system helps enforce:
|
|
8
|
+
* • The root set is **immutable for the session** — a {@link Workspace} exposes
|
|
9
|
+
* no mutator, so nothing the model can call adds a root. The set only grows by
|
|
10
|
+
* the CLI constructing a *new* Workspace from an explicit human act (argv, or
|
|
11
|
+
* an interactive add-root that prompts + trusts first).
|
|
12
|
+
* • Roots are addressed by a stable **name** (R1), matched **exactly** — never
|
|
13
|
+
* by prefix or nearest-match. An unknown name is a fail-loud refusal.
|
|
14
|
+
*/
|
|
15
|
+
/** One declared workspace root: a stable name + its resolved absolute path. */
|
|
16
|
+
export interface DeclaredRoot {
|
|
17
|
+
/** Stable, user-facing identifier (assigned at declaration, unique per session). */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/** Absolute, lexically-resolved path (realpath is applied at confinement time). */
|
|
20
|
+
readonly absPath: string;
|
|
21
|
+
/** Exactly one root is primary — the default for bare relative paths + git info. */
|
|
22
|
+
readonly primary: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** A root as declared on the CLI (or interactively), before resolution/validation. */
|
|
25
|
+
export interface RootSpec {
|
|
26
|
+
/** Explicit name (`--root name=path`); defaults to a deduped basename of `path`. */
|
|
27
|
+
readonly name?: string;
|
|
28
|
+
/** Path as supplied (resolved against `process.cwd()` by `buildWorkspace`). */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-repo workspace model (C.26). A session may operate across N repo/package
|
|
3
|
+
* roots. The {@link Workspace} is the single value that carries the declared root
|
|
4
|
+
* set; every path-taking tool resolves *through* it rather than through a bare
|
|
5
|
+
* `cwd` string.
|
|
6
|
+
*
|
|
7
|
+
* Two invariants the type system helps enforce:
|
|
8
|
+
* • The root set is **immutable for the session** — a {@link Workspace} exposes
|
|
9
|
+
* no mutator, so nothing the model can call adds a root. The set only grows by
|
|
10
|
+
* the CLI constructing a *new* Workspace from an explicit human act (argv, or
|
|
11
|
+
* an interactive add-root that prompts + trusts first).
|
|
12
|
+
* • Roots are addressed by a stable **name** (R1), matched **exactly** — never
|
|
13
|
+
* by prefix or nearest-match. An unknown name is a fail-loud refusal.
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { DeclaredRoot, RootSpec } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The declared workspace root set for a session (C.26). Immutable by design: it
|
|
4
|
+
* exposes readers only, no mutator. The root set grows solely by the CLI building
|
|
5
|
+
* a *new* Workspace from an explicit human act — so no tool, and nothing the model
|
|
6
|
+
* emits, can add a root. Roots are addressed by exact name (R1).
|
|
7
|
+
*/
|
|
8
|
+
export declare class Workspace {
|
|
9
|
+
private readonly rootsByName;
|
|
10
|
+
private readonly ordered;
|
|
11
|
+
private readonly primaryRoot;
|
|
12
|
+
constructor(roots: readonly DeclaredRoot[]);
|
|
13
|
+
/** All declared roots, in declaration order. */
|
|
14
|
+
roots(): readonly DeclaredRoot[];
|
|
15
|
+
/** The primary root — the default for bare relative paths and git/instructions. */
|
|
16
|
+
primary(): DeclaredRoot;
|
|
17
|
+
/** True when more than one root is declared (i.e. a genuine multi-repo session). */
|
|
18
|
+
get isMultiRoot(): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Look up a root by EXACT name (R1). Never fuzzy-, prefix-, or nearest-matched
|
|
21
|
+
* — a silent near-match would be a cross-root misfire.
|
|
22
|
+
* @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN if no root has that exact name.
|
|
23
|
+
*/
|
|
24
|
+
rootByName(name: string): DeclaredRoot;
|
|
25
|
+
/** Like {@link rootByName} but returns undefined instead of throwing. */
|
|
26
|
+
tryRootByName(name: string): DeclaredRoot | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* The single declared root that contains `absPath`. Roots never overlap (that's
|
|
29
|
+
* refused at declaration), so at most one can match.
|
|
30
|
+
* @throws CRUXY_E_ROOT_UNKNOWN if the path is inside no declared root.
|
|
31
|
+
* @throws CRUXY_E_ROOT_AMBIGUOUS if — defensively — it matches more than one.
|
|
32
|
+
*/
|
|
33
|
+
rootContaining(absPath: string): DeclaredRoot;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build a {@link Workspace} from declared specs (R1, ⚖︎#5). Each path is resolved
|
|
37
|
+
* against `process.cwd()`, must exist and be a directory, and the set must not
|
|
38
|
+
* overlap (a root nested in / equal to another is refused — declare the monorepo
|
|
39
|
+
* root OR its packages, never both). The first spec is primary unless one is
|
|
40
|
+
* marked. Names are explicit-or-basename, validated, and deduped.
|
|
41
|
+
*
|
|
42
|
+
* This is the ONLY constructor of a Workspace from user input; it is called by the
|
|
43
|
+
* CLI from argv and by the interactive add-root path — never from a tool.
|
|
44
|
+
*
|
|
45
|
+
* @throws CRUXY_E_ROOT_OVERLAP on a nested/overlapping/duplicate root.
|
|
46
|
+
* @throws CRUXY_E_USAGE on a missing path, non-directory, or bad/duplicate name.
|
|
47
|
+
*/
|
|
48
|
+
export declare function buildWorkspace(specs: readonly RootSpec[], opts?: {
|
|
49
|
+
cwd?: string;
|
|
50
|
+
}): Promise<Workspace>;
|
|
51
|
+
/**
|
|
52
|
+
* Build a trivial single-root workspace from one absolute path — the back-compat
|
|
53
|
+
* bridge for the many call sites that still pass a single `cwd`. The one root is
|
|
54
|
+
* primary and named by its basename.
|
|
55
|
+
*/
|
|
56
|
+
export declare function singleRootWorkspace(absPath: string): Workspace;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { rootAmbiguous, rootOverlap, rootUnknown, usageError, } from "../errors/index.js";
|
|
4
|
+
import { isInside } from "./resolve.js";
|
|
5
|
+
/**
|
|
6
|
+
* The declared workspace root set for a session (C.26). Immutable by design: it
|
|
7
|
+
* exposes readers only, no mutator. The root set grows solely by the CLI building
|
|
8
|
+
* a *new* Workspace from an explicit human act — so no tool, and nothing the model
|
|
9
|
+
* emits, can add a root. Roots are addressed by exact name (R1).
|
|
10
|
+
*/
|
|
11
|
+
export class Workspace {
|
|
12
|
+
rootsByName;
|
|
13
|
+
ordered;
|
|
14
|
+
primaryRoot;
|
|
15
|
+
constructor(roots) {
|
|
16
|
+
if (roots.length === 0) {
|
|
17
|
+
throw usageError("a workspace needs at least one root");
|
|
18
|
+
}
|
|
19
|
+
const byName = new Map();
|
|
20
|
+
for (const r of roots)
|
|
21
|
+
byName.set(r.name, Object.freeze({ ...r }));
|
|
22
|
+
const primary = roots.filter((r) => r.primary);
|
|
23
|
+
if (primary.length !== 1) {
|
|
24
|
+
throw usageError(`a workspace must have exactly one primary root (found ${primary.length})`);
|
|
25
|
+
}
|
|
26
|
+
this.ordered = Object.freeze([...roots]);
|
|
27
|
+
this.rootsByName = byName;
|
|
28
|
+
this.primaryRoot = primary[0];
|
|
29
|
+
}
|
|
30
|
+
/** All declared roots, in declaration order. */
|
|
31
|
+
roots() {
|
|
32
|
+
return this.ordered;
|
|
33
|
+
}
|
|
34
|
+
/** The primary root — the default for bare relative paths and git/instructions. */
|
|
35
|
+
primary() {
|
|
36
|
+
return this.primaryRoot;
|
|
37
|
+
}
|
|
38
|
+
/** True when more than one root is declared (i.e. a genuine multi-repo session). */
|
|
39
|
+
get isMultiRoot() {
|
|
40
|
+
return this.ordered.length > 1;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Look up a root by EXACT name (R1). Never fuzzy-, prefix-, or nearest-matched
|
|
44
|
+
* — a silent near-match would be a cross-root misfire.
|
|
45
|
+
* @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN if no root has that exact name.
|
|
46
|
+
*/
|
|
47
|
+
rootByName(name) {
|
|
48
|
+
const found = this.rootsByName.get(name);
|
|
49
|
+
if (!found) {
|
|
50
|
+
throw rootUnknown(name, this.ordered.map((r) => r.name));
|
|
51
|
+
}
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
/** Like {@link rootByName} but returns undefined instead of throwing. */
|
|
55
|
+
tryRootByName(name) {
|
|
56
|
+
return this.rootsByName.get(name);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The single declared root that contains `absPath`. Roots never overlap (that's
|
|
60
|
+
* refused at declaration), so at most one can match.
|
|
61
|
+
* @throws CRUXY_E_ROOT_UNKNOWN if the path is inside no declared root.
|
|
62
|
+
* @throws CRUXY_E_ROOT_AMBIGUOUS if — defensively — it matches more than one.
|
|
63
|
+
*/
|
|
64
|
+
rootContaining(absPath) {
|
|
65
|
+
const target = path.resolve(absPath);
|
|
66
|
+
const hits = this.ordered.filter((r) => isInside(r.absPath, target));
|
|
67
|
+
if (hits.length === 0) {
|
|
68
|
+
throw rootUnknown(absPath, this.ordered.map((r) => r.name));
|
|
69
|
+
}
|
|
70
|
+
if (hits.length > 1) {
|
|
71
|
+
// Unreachable while overlap is refused at declaration — belt-and-suspenders.
|
|
72
|
+
throw rootAmbiguous(absPath, hits.map((r) => r.name));
|
|
73
|
+
}
|
|
74
|
+
return hits[0];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** A valid root name: non-empty, no path separators, not a traversal token. */
|
|
78
|
+
function assertValidName(name) {
|
|
79
|
+
if (name.length === 0 ||
|
|
80
|
+
name.includes("/") ||
|
|
81
|
+
name.includes("\\") ||
|
|
82
|
+
name === "." ||
|
|
83
|
+
name === "..") {
|
|
84
|
+
throw usageError(`invalid workspace root name "${name}"`, [
|
|
85
|
+
"root names must be non-empty and contain no path separators",
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Derive a stable, unique name from a path basename, deduping with -2, -3, … */
|
|
90
|
+
function uniqueBasename(absPath, taken) {
|
|
91
|
+
const base = path.basename(absPath) || "root";
|
|
92
|
+
if (!taken.has(base))
|
|
93
|
+
return base;
|
|
94
|
+
for (let i = 2;; i++) {
|
|
95
|
+
const candidate = `${base}-${i}`;
|
|
96
|
+
if (!taken.has(candidate))
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build a {@link Workspace} from declared specs (R1, ⚖︎#5). Each path is resolved
|
|
102
|
+
* against `process.cwd()`, must exist and be a directory, and the set must not
|
|
103
|
+
* overlap (a root nested in / equal to another is refused — declare the monorepo
|
|
104
|
+
* root OR its packages, never both). The first spec is primary unless one is
|
|
105
|
+
* marked. Names are explicit-or-basename, validated, and deduped.
|
|
106
|
+
*
|
|
107
|
+
* This is the ONLY constructor of a Workspace from user input; it is called by the
|
|
108
|
+
* CLI from argv and by the interactive add-root path — never from a tool.
|
|
109
|
+
*
|
|
110
|
+
* @throws CRUXY_E_ROOT_OVERLAP on a nested/overlapping/duplicate root.
|
|
111
|
+
* @throws CRUXY_E_USAGE on a missing path, non-directory, or bad/duplicate name.
|
|
112
|
+
*/
|
|
113
|
+
export async function buildWorkspace(specs, opts = {}) {
|
|
114
|
+
if (specs.length === 0) {
|
|
115
|
+
throw usageError("no workspace roots declared");
|
|
116
|
+
}
|
|
117
|
+
const baseCwd = opts.cwd ?? process.cwd();
|
|
118
|
+
const taken = new Set();
|
|
119
|
+
const resolved = [];
|
|
120
|
+
for (const spec of specs) {
|
|
121
|
+
const absPath = path.resolve(baseCwd, spec.path);
|
|
122
|
+
let stat;
|
|
123
|
+
try {
|
|
124
|
+
stat = await fs.stat(absPath);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw usageError(`workspace root does not exist: ${spec.path}`, [
|
|
128
|
+
`resolved to ${absPath}`,
|
|
129
|
+
]);
|
|
130
|
+
}
|
|
131
|
+
if (!stat.isDirectory()) {
|
|
132
|
+
throw usageError(`workspace root is not a directory: ${spec.path}`, [
|
|
133
|
+
`resolved to ${absPath}`,
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
if (spec.name !== undefined) {
|
|
137
|
+
assertValidName(spec.name);
|
|
138
|
+
if (taken.has(spec.name)) {
|
|
139
|
+
throw usageError(`duplicate workspace root name "${spec.name}"`);
|
|
140
|
+
}
|
|
141
|
+
taken.add(spec.name);
|
|
142
|
+
}
|
|
143
|
+
resolved.push({ absPath, name: spec.name });
|
|
144
|
+
}
|
|
145
|
+
// Overlap/nesting refusal (⚖︎#5): any pair where one contains or equals the
|
|
146
|
+
// other is rejected at declaration.
|
|
147
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
148
|
+
for (let j = i + 1; j < resolved.length; j++) {
|
|
149
|
+
const a = resolved[i].absPath;
|
|
150
|
+
const b = resolved[j].absPath;
|
|
151
|
+
if (a === b || isInside(a, b) || isInside(b, a)) {
|
|
152
|
+
throw rootOverlap(a, b);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Assign names (explicit first so basenames dedupe around them), then declare.
|
|
157
|
+
for (const r of resolved) {
|
|
158
|
+
if (r.name === undefined) {
|
|
159
|
+
r.name = uniqueBasename(r.absPath, taken);
|
|
160
|
+
taken.add(r.name);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const declared = resolved.map((r, i) => ({
|
|
164
|
+
name: r.name,
|
|
165
|
+
absPath: r.absPath,
|
|
166
|
+
primary: i === 0,
|
|
167
|
+
}));
|
|
168
|
+
return new Workspace(declared);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build a trivial single-root workspace from one absolute path — the back-compat
|
|
172
|
+
* bridge for the many call sites that still pass a single `cwd`. The one root is
|
|
173
|
+
* primary and named by its basename.
|
|
174
|
+
*/
|
|
175
|
+
export function singleRootWorkspace(absPath) {
|
|
176
|
+
const abs = path.resolve(absPath);
|
|
177
|
+
return new Workspace([
|
|
178
|
+
{ name: path.basename(abs) || "root", absPath: abs, primary: true },
|
|
179
|
+
]);
|
|
180
|
+
}
|