@howells/boundaries 0.1.0 → 0.1.2
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/AGENTS.md +46 -0
- package/README.md +15 -0
- package/package.json +23 -3
- package/scripts/smoke-install.mjs +32 -0
- package/scripts/validate-skill.mjs +31 -0
- package/src/check.js +65 -15
- package/src/cli.js +152 -19
- package/src/init.js +40 -5
- package/src/output.js +51 -0
- package/src/schema.js +63 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @howells/boundaries
|
|
2
|
+
|
|
3
|
+
Package-level boundary conventions for Turborepo workspaces. The npm package is `@howells/boundaries`; the executable is `boundaries`.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm test # Run unit and CLI contract tests
|
|
9
|
+
npm run validate:skill # Validate bundled Codex skill metadata
|
|
10
|
+
npm run smoke:install # Pack, install in a temp project, run the bin
|
|
11
|
+
npm pack --dry-run # Inspect publish contents
|
|
12
|
+
node src/cli.js --schema # Print machine-readable command schema
|
|
13
|
+
node src/cli.js init --dry-run # Preview generated Turbo boundary files
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Key Files
|
|
17
|
+
|
|
18
|
+
- `src/cli.js`: command parsing and output formatting.
|
|
19
|
+
- `src/init.js`: workspace discovery and config generation.
|
|
20
|
+
- `src/check.js`: local config validation and `turbo boundaries` delegation.
|
|
21
|
+
- `src/core.js`: tag inference and dependency rule evaluation.
|
|
22
|
+
- `src/schema.js`: machine-readable CLI schema.
|
|
23
|
+
- `skills/howells-boundaries/SKILL.md`: bundled Codex skill.
|
|
24
|
+
|
|
25
|
+
## Conventions
|
|
26
|
+
|
|
27
|
+
- Keep the tool package-level. Do not add package-internal layer enforcement here.
|
|
28
|
+
- Prefer Turbo `boundaries` as the backend; this package is a convention layer.
|
|
29
|
+
- CLI commands must stay non-interactive.
|
|
30
|
+
- Any new command output should support `--json` when an agent may consume it.
|
|
31
|
+
- Mutating commands should support `--dry-run` before they write files.
|
|
32
|
+
|
|
33
|
+
## Permissions
|
|
34
|
+
|
|
35
|
+
Always:
|
|
36
|
+
- Read and edit files in this package.
|
|
37
|
+
- Run `npm test`, `npm run validate:skill`, and `npm pack --dry-run`.
|
|
38
|
+
|
|
39
|
+
Ask first:
|
|
40
|
+
- Publish a new npm version.
|
|
41
|
+
- Change the default boundary policy.
|
|
42
|
+
- Add runtime dependencies.
|
|
43
|
+
|
|
44
|
+
Never:
|
|
45
|
+
- Weaken generated boundary rules just to silence violations.
|
|
46
|
+
- Edit user repos outside this package unless explicitly asked.
|
package/README.md
CHANGED
|
@@ -12,6 +12,13 @@ pnpm add -D @howells/boundaries
|
|
|
12
12
|
|
|
13
13
|
## Use
|
|
14
14
|
|
|
15
|
+
Try without installing:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx @howells/boundaries --help
|
|
19
|
+
npx @howells/boundaries init --dry-run
|
|
20
|
+
```
|
|
21
|
+
|
|
15
22
|
Initialize boundary config:
|
|
16
23
|
|
|
17
24
|
```sh
|
|
@@ -43,6 +50,14 @@ pnpm exec boundaries explain apps/web packages/ui
|
|
|
43
50
|
pnpm exec boundaries explain apps/web apps/admin
|
|
44
51
|
```
|
|
45
52
|
|
|
53
|
+
Machine-readable output:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
pnpm exec boundaries --schema
|
|
57
|
+
pnpm exec boundaries init --dry-run --json
|
|
58
|
+
pnpm exec boundaries explain apps/web packages/ui --json
|
|
59
|
+
```
|
|
60
|
+
|
|
46
61
|
## Default Policy
|
|
47
62
|
|
|
48
63
|
The default tags are:
|
package/package.json
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@howells/boundaries",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Opinionated Turborepo package boundary conventions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"boundaries": "
|
|
7
|
+
"boundaries": "src/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"license": "MIT",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"turborepo",
|
|
12
|
+
"monorepo",
|
|
13
|
+
"boundaries",
|
|
14
|
+
"architecture",
|
|
15
|
+
"cli"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/howells/boundaries.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/howells/boundaries/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://www.npmjs.com/package/@howells/boundaries",
|
|
10
25
|
"files": [
|
|
26
|
+
"AGENTS.md",
|
|
11
27
|
"README.md",
|
|
28
|
+
"scripts",
|
|
12
29
|
"src",
|
|
13
30
|
"skills"
|
|
14
31
|
],
|
|
15
32
|
"scripts": {
|
|
16
|
-
"
|
|
33
|
+
"prepack": "npm test && npm run validate:skill",
|
|
34
|
+
"smoke:install": "node scripts/smoke-install.mjs",
|
|
35
|
+
"test": "node --test",
|
|
36
|
+
"validate:skill": "node scripts/validate-skill.mjs"
|
|
17
37
|
},
|
|
18
38
|
"engines": {
|
|
19
39
|
"node": ">=20"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, unlink } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const root = new URL("..", import.meta.url);
|
|
9
|
+
const temp = await mkdtemp(join(tmpdir(), "boundaries-smoke-"));
|
|
10
|
+
|
|
11
|
+
const { stdout: packOutput } = await execFileAsync("npm", ["pack", "--pack-destination", temp], {
|
|
12
|
+
cwd: root,
|
|
13
|
+
});
|
|
14
|
+
const filename = packOutput
|
|
15
|
+
.split("\n")
|
|
16
|
+
.map((line) => line.trim())
|
|
17
|
+
.find((line) => line.endsWith(".tgz"));
|
|
18
|
+
if (!filename) {
|
|
19
|
+
throw new Error(`Could not find packed tarball in npm pack output:\n${packOutput}`);
|
|
20
|
+
}
|
|
21
|
+
const tarball = join(temp, filename);
|
|
22
|
+
|
|
23
|
+
await execFileAsync("npm", ["init", "-y"], { cwd: temp });
|
|
24
|
+
await execFileAsync("npm", ["install", tarball, "--ignore-scripts"], { cwd: temp });
|
|
25
|
+
|
|
26
|
+
const { stdout } = await execFileAsync("npx", ["boundaries", "--help"], { cwd: temp });
|
|
27
|
+
if (!stdout.includes("Usage: boundaries <command>")) {
|
|
28
|
+
throw new Error("Installed boundaries binary did not print expected help.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
process.stdout.write(`Smoke install passed in ${temp}\n`);
|
|
32
|
+
await unlink(tarball);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
const skillPath = new URL("../skills/howells-boundaries/SKILL.md", import.meta.url);
|
|
4
|
+
const skill = await readFile(skillPath, "utf8");
|
|
5
|
+
|
|
6
|
+
if (!skill.startsWith("---\n")) {
|
|
7
|
+
fail("SKILL.md must start with YAML frontmatter.");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const end = skill.indexOf("\n---\n", 4);
|
|
11
|
+
if (end === -1) {
|
|
12
|
+
fail("SKILL.md frontmatter is not closed.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const frontmatter = skill.slice(4, end);
|
|
16
|
+
for (const field of ["name:", "description:"]) {
|
|
17
|
+
if (!frontmatter.includes(field)) {
|
|
18
|
+
fail(`SKILL.md frontmatter is missing ${field}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!skill.includes("boundaries init") || !skill.includes("boundaries check")) {
|
|
23
|
+
fail("SKILL.md must document init and check workflows.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
process.stdout.write("Skill is valid.\n");
|
|
27
|
+
|
|
28
|
+
function fail(message) {
|
|
29
|
+
process.stderr.write(`${message}\n`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
package/src/check.js
CHANGED
|
@@ -9,6 +9,7 @@ export async function checkRepository({
|
|
|
9
9
|
runTurbo = true,
|
|
10
10
|
stdout = process.stdout,
|
|
11
11
|
stderr = process.stderr,
|
|
12
|
+
quiet = false,
|
|
12
13
|
} = {}) {
|
|
13
14
|
const rootPackageJson = await readJson(join(root, "package.json"));
|
|
14
15
|
const rootTurboJson = await readJson(join(root, "turbo.json"), {});
|
|
@@ -16,22 +17,29 @@ export async function checkRepository({
|
|
|
16
17
|
const errors = await validateBoundarySetup(root, rootTurboJson, workspaces);
|
|
17
18
|
|
|
18
19
|
if (errors.length > 0) {
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
if (!quiet) {
|
|
21
|
+
for (const error of errors) {
|
|
22
|
+
stderr.write(`boundaries: ${error.message}\n`);
|
|
23
|
+
}
|
|
21
24
|
}
|
|
22
25
|
return { ok: false, exitCode: 1, errors };
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
if (!runTurbo) {
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
if (!quiet) {
|
|
30
|
+
stdout.write(`Boundary configuration is valid for ${workspaces.length} workspace${workspaces.length === 1 ? "" : "s"}.\n`);
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, exitCode: 0, errors: [], workspaces };
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
const
|
|
35
|
+
const turboResult = await runTurboBoundaries({ root, quiet });
|
|
36
|
+
const turboExitCode = turboResult.exitCode;
|
|
31
37
|
return {
|
|
32
38
|
ok: turboExitCode === 0,
|
|
33
39
|
exitCode: turboExitCode,
|
|
34
|
-
errors: [],
|
|
40
|
+
errors: turboExitCode === 0 ? [] : [turboResult.error],
|
|
41
|
+
turbo: turboResult,
|
|
42
|
+
workspaces,
|
|
35
43
|
};
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -39,36 +47,78 @@ async function validateBoundarySetup(root, rootTurboJson, workspaces) {
|
|
|
39
47
|
const errors = [];
|
|
40
48
|
|
|
41
49
|
if (!rootTurboJson.boundaries?.tags) {
|
|
42
|
-
errors.push(
|
|
50
|
+
errors.push({
|
|
51
|
+
code: "ROOT_BOUNDARIES_MISSING",
|
|
52
|
+
message: "root turbo.json is missing boundaries.tags; run `boundaries init`.",
|
|
53
|
+
file: "turbo.json",
|
|
54
|
+
is_retriable: false,
|
|
55
|
+
suggestions: ["Run `boundaries init`."],
|
|
56
|
+
});
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
for (const workspace of workspaces) {
|
|
46
60
|
const packageTurboJson = await readJson(join(root, workspace.path, "turbo.json"), {});
|
|
47
61
|
if (!Array.isArray(packageTurboJson.tags) || packageTurboJson.tags.length === 0) {
|
|
48
|
-
errors.push(
|
|
62
|
+
errors.push({
|
|
63
|
+
code: "PACKAGE_TAGS_MISSING",
|
|
64
|
+
message: `${workspace.path}/turbo.json is missing package boundary tags.`,
|
|
65
|
+
file: `${workspace.path}/turbo.json`,
|
|
66
|
+
is_retriable: false,
|
|
67
|
+
suggestions: ["Run `boundaries init`."],
|
|
68
|
+
});
|
|
49
69
|
}
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
return errors;
|
|
53
73
|
}
|
|
54
74
|
|
|
55
|
-
function runTurboBoundaries({ root }) {
|
|
75
|
+
function runTurboBoundaries({ root, quiet }) {
|
|
56
76
|
return new Promise((resolve) => {
|
|
57
77
|
const child = spawn("turbo", ["boundaries"], {
|
|
58
78
|
cwd: root,
|
|
59
79
|
shell: process.platform === "win32",
|
|
60
|
-
stdio: "inherit",
|
|
80
|
+
stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
61
81
|
});
|
|
82
|
+
let stdout = "";
|
|
83
|
+
let stderr = "";
|
|
84
|
+
|
|
85
|
+
if (quiet) {
|
|
86
|
+
child.stdout?.on("data", (chunk) => {
|
|
87
|
+
stdout += chunk.toString();
|
|
88
|
+
});
|
|
89
|
+
child.stderr?.on("data", (chunk) => {
|
|
90
|
+
stderr += chunk.toString();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
62
93
|
|
|
63
94
|
child.on("error", (error) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
const problem = {
|
|
96
|
+
code: "TURBO_NOT_FOUND",
|
|
97
|
+
message: `could not run \`turbo boundaries\` (${error.message}).`,
|
|
98
|
+
is_retriable: false,
|
|
99
|
+
suggestions: ["Install turbo or run `boundaries check --no-turbo`."],
|
|
100
|
+
};
|
|
101
|
+
if (!quiet) {
|
|
102
|
+
process.stderr.write(`boundaries: ${problem.message} ${problem.suggestions[0]}\n`);
|
|
103
|
+
}
|
|
104
|
+
resolve({ exitCode: 69, stdout, stderr, error: problem });
|
|
68
105
|
});
|
|
69
106
|
|
|
70
107
|
child.on("close", (code) => {
|
|
71
|
-
|
|
108
|
+
const exitCode = code ?? 1;
|
|
109
|
+
resolve({
|
|
110
|
+
exitCode,
|
|
111
|
+
stdout,
|
|
112
|
+
stderr,
|
|
113
|
+
error: exitCode === 0
|
|
114
|
+
? undefined
|
|
115
|
+
: {
|
|
116
|
+
code: "TURBO_BOUNDARIES_FAILED",
|
|
117
|
+
message: "`turbo boundaries` reported violations.",
|
|
118
|
+
is_retriable: false,
|
|
119
|
+
suggestions: ["Read the captured Turbo output and fix package imports or dependency declarations."],
|
|
120
|
+
},
|
|
121
|
+
});
|
|
72
122
|
});
|
|
73
123
|
});
|
|
74
124
|
}
|
package/src/cli.js
CHANGED
|
@@ -5,6 +5,15 @@ import { join } from "node:path";
|
|
|
5
5
|
import { checkRepository } from "./check.js";
|
|
6
6
|
import { evaluateDependency } from "./core.js";
|
|
7
7
|
import { discoverWorkspaces, initRepository } from "./init.js";
|
|
8
|
+
import {
|
|
9
|
+
EXIT_CODES,
|
|
10
|
+
failure,
|
|
11
|
+
hasFlag,
|
|
12
|
+
stripFlags,
|
|
13
|
+
success,
|
|
14
|
+
writeJson,
|
|
15
|
+
} from "./output.js";
|
|
16
|
+
import { commandSchema } from "./schema.js";
|
|
8
17
|
|
|
9
18
|
const HELP = `Usage: boundaries <command>
|
|
10
19
|
|
|
@@ -17,40 +26,101 @@ Commands:
|
|
|
17
26
|
|
|
18
27
|
async function main(argv) {
|
|
19
28
|
const [command, ...args] = argv;
|
|
29
|
+
const json = hasFlag(argv, "--json");
|
|
30
|
+
const schema = hasFlag(argv, "--schema");
|
|
31
|
+
|
|
32
|
+
if (schema) {
|
|
33
|
+
writeJson(process.stdout, success(commandSchema));
|
|
34
|
+
return EXIT_CODES.OK;
|
|
35
|
+
}
|
|
20
36
|
|
|
21
37
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
38
|
+
if (json) {
|
|
39
|
+
writeJson(process.stdout, success(commandSchema));
|
|
40
|
+
return EXIT_CODES.OK;
|
|
41
|
+
}
|
|
22
42
|
process.stdout.write(HELP);
|
|
23
|
-
return
|
|
43
|
+
return EXIT_CODES.OK;
|
|
24
44
|
}
|
|
25
45
|
|
|
26
46
|
if (command === "init") {
|
|
27
|
-
const result = await initRepository();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
const result = await initRepository({ dryRun: hasFlag(args, "--dry-run") });
|
|
48
|
+
if (json) {
|
|
49
|
+
writeJson(process.stdout, success(summarizeInitResult(result)));
|
|
50
|
+
} else {
|
|
51
|
+
process.stdout.write(
|
|
52
|
+
`${result.dryRun ? "Planned" : "Initialized"} boundaries for ${result.workspaces.length} workspace${result.workspaces.length === 1 ? "" : "s"}.\n`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return EXIT_CODES.OK;
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
if (command === "check") {
|
|
35
59
|
const result = await checkRepository({
|
|
36
60
|
runTurbo: !args.includes("--no-turbo"),
|
|
61
|
+
quiet: json,
|
|
37
62
|
});
|
|
63
|
+
if (json) {
|
|
64
|
+
if (result.ok) {
|
|
65
|
+
writeJson(process.stdout, success({
|
|
66
|
+
ok: true,
|
|
67
|
+
workspaceCount: result.workspaces?.length ?? 0,
|
|
68
|
+
ranTurbo: !args.includes("--no-turbo"),
|
|
69
|
+
turbo: result.turbo
|
|
70
|
+
? { exitCode: result.turbo.exitCode, stdout: result.turbo.stdout, stderr: result.turbo.stderr }
|
|
71
|
+
: undefined,
|
|
72
|
+
}));
|
|
73
|
+
} else {
|
|
74
|
+
const primary = result.errors[0] ?? {
|
|
75
|
+
code: "BOUNDARY_CHECK_FAILED",
|
|
76
|
+
message: "Boundary check failed.",
|
|
77
|
+
is_retriable: false,
|
|
78
|
+
suggestions: ["Inspect command output and retry."],
|
|
79
|
+
};
|
|
80
|
+
writeJson(process.stderr, failure(primary, {
|
|
81
|
+
errors: result.errors,
|
|
82
|
+
turbo: result.turbo
|
|
83
|
+
? { exitCode: result.turbo.exitCode, stdout: result.turbo.stdout, stderr: result.turbo.stderr }
|
|
84
|
+
: undefined,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
38
88
|
return result.exitCode;
|
|
39
89
|
}
|
|
40
90
|
|
|
41
91
|
if (command === "explain") {
|
|
42
|
-
return explain(args);
|
|
92
|
+
return explain(stripFlags(args, ["--json"]), { json });
|
|
43
93
|
}
|
|
44
94
|
|
|
45
|
-
|
|
46
|
-
|
|
95
|
+
const error = {
|
|
96
|
+
code: "UNKNOWN_COMMAND",
|
|
97
|
+
message: `Unknown command: ${command}`,
|
|
98
|
+
is_retriable: false,
|
|
99
|
+
suggestions: ["Run `boundaries --help` for available commands."],
|
|
100
|
+
};
|
|
101
|
+
if (json) {
|
|
102
|
+
writeJson(process.stderr, failure(error));
|
|
103
|
+
} else {
|
|
104
|
+
process.stderr.write(`${error.message}\n\n${HELP}`);
|
|
105
|
+
}
|
|
106
|
+
return EXIT_CODES.USAGE;
|
|
47
107
|
}
|
|
48
108
|
|
|
49
|
-
async function explain(args) {
|
|
109
|
+
async function explain(args, { json = false } = {}) {
|
|
50
110
|
const [fromSelector, toSelector] = args;
|
|
51
111
|
if (!fromSelector || !toSelector) {
|
|
52
|
-
|
|
53
|
-
|
|
112
|
+
const error = {
|
|
113
|
+
code: "USAGE_ERROR",
|
|
114
|
+
message: "Usage: boundaries explain <from> <to>",
|
|
115
|
+
is_retriable: false,
|
|
116
|
+
suggestions: ["Provide both source and target workspace selectors."],
|
|
117
|
+
};
|
|
118
|
+
if (json) {
|
|
119
|
+
writeJson(process.stderr, failure(error));
|
|
120
|
+
} else {
|
|
121
|
+
process.stderr.write(`${error.message}\n`);
|
|
122
|
+
}
|
|
123
|
+
return EXIT_CODES.USAGE;
|
|
54
124
|
}
|
|
55
125
|
|
|
56
126
|
const root = process.cwd();
|
|
@@ -61,8 +131,18 @@ async function explain(args) {
|
|
|
61
131
|
const to = findWorkspace(workspaces, toSelector);
|
|
62
132
|
|
|
63
133
|
if (!from || !to) {
|
|
64
|
-
|
|
65
|
-
|
|
134
|
+
const error = {
|
|
135
|
+
code: "WORKSPACE_NOT_FOUND",
|
|
136
|
+
message: "Could not find both workspaces. Use a package name or workspace path.",
|
|
137
|
+
is_retriable: false,
|
|
138
|
+
suggestions: ["Run `boundaries init --dry-run --json` to inspect discovered workspaces."],
|
|
139
|
+
};
|
|
140
|
+
if (json) {
|
|
141
|
+
writeJson(process.stderr, failure(error));
|
|
142
|
+
} else {
|
|
143
|
+
process.stderr.write(`${error.message}\n`);
|
|
144
|
+
}
|
|
145
|
+
return EXIT_CODES.DATA;
|
|
66
146
|
}
|
|
67
147
|
|
|
68
148
|
const decision = evaluateDependency({
|
|
@@ -73,13 +153,55 @@ async function explain(args) {
|
|
|
73
153
|
toTags: await readTags(root, to),
|
|
74
154
|
});
|
|
75
155
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
156
|
+
const data = {
|
|
157
|
+
allowed: decision.allowed,
|
|
158
|
+
reason: decision.reason,
|
|
159
|
+
from: describeWorkspace(from, await readTags(root, from)),
|
|
160
|
+
to: describeWorkspace(to, await readTags(root, to)),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (json) {
|
|
164
|
+
if (decision.allowed) {
|
|
165
|
+
writeJson(process.stdout, success(data));
|
|
166
|
+
} else {
|
|
167
|
+
writeJson(process.stdout, failure({
|
|
168
|
+
code: "BOUNDARY_BLOCKED",
|
|
169
|
+
message: decision.reason,
|
|
170
|
+
is_retriable: false,
|
|
171
|
+
suggestions: ["Move shared code into a package or adjust package boundary tags intentionally."],
|
|
172
|
+
}, data));
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
process.stdout.write(
|
|
176
|
+
`${from.name ?? from.path} -> ${to.name ?? to.path}: ${decision.allowed ? "allowed" : "blocked"}\n${decision.reason}\n`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
79
179
|
|
|
80
180
|
return decision.allowed ? 0 : 1;
|
|
81
181
|
}
|
|
82
182
|
|
|
183
|
+
function summarizeInitResult(result) {
|
|
184
|
+
return {
|
|
185
|
+
dryRun: result.dryRun,
|
|
186
|
+
workspaces: result.workspaces.map((workspace) => ({
|
|
187
|
+
name: workspace.name,
|
|
188
|
+
path: workspace.path,
|
|
189
|
+
})),
|
|
190
|
+
plannedWrites: result.plannedWrites.map((write) => ({
|
|
191
|
+
path: write.path,
|
|
192
|
+
kind: write.kind,
|
|
193
|
+
})),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function describeWorkspace(workspace, tags) {
|
|
198
|
+
return {
|
|
199
|
+
name: workspace.name,
|
|
200
|
+
path: workspace.path,
|
|
201
|
+
tags,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
83
205
|
function findWorkspace(workspaces, selector) {
|
|
84
206
|
return workspaces.find((workspace) => {
|
|
85
207
|
return workspace.name === selector || workspace.path === selector || workspace.path.endsWith(`/${selector}`);
|
|
@@ -107,6 +229,17 @@ main(process.argv.slice(2))
|
|
|
107
229
|
process.exitCode = exitCode;
|
|
108
230
|
})
|
|
109
231
|
.catch((error) => {
|
|
110
|
-
process.
|
|
111
|
-
|
|
232
|
+
const json = process.argv.includes("--json");
|
|
233
|
+
const problem = {
|
|
234
|
+
code: error.code === "ENOENT" ? "FILE_NOT_FOUND" : "UNHANDLED_ERROR",
|
|
235
|
+
message: error.message,
|
|
236
|
+
is_retriable: false,
|
|
237
|
+
suggestions: ["Check that you are running from a workspace root with package.json."],
|
|
238
|
+
};
|
|
239
|
+
if (json) {
|
|
240
|
+
writeJson(process.stderr, failure(problem));
|
|
241
|
+
} else {
|
|
242
|
+
process.stderr.write(`boundaries: ${error.message}\n`);
|
|
243
|
+
}
|
|
244
|
+
process.exitCode = error.code === "ENOENT" ? EXIT_CODES.DATA : EXIT_CODES.SOFTWARE;
|
|
112
245
|
});
|
package/src/init.js
CHANGED
|
@@ -7,29 +7,48 @@ import {
|
|
|
7
7
|
inferTagsForWorkspace,
|
|
8
8
|
} from "./core.js";
|
|
9
9
|
|
|
10
|
-
export async function initRepository({ root = process.cwd() } = {}) {
|
|
10
|
+
export async function initRepository({ root = process.cwd(), dryRun = false } = {}) {
|
|
11
11
|
const rootPackageJsonPath = join(root, "package.json");
|
|
12
12
|
const rootTurboJsonPath = join(root, "turbo.json");
|
|
13
13
|
const rootPackageJson = await readJson(rootPackageJsonPath);
|
|
14
14
|
const rootTurboJson = await readJson(rootTurboJsonPath, {});
|
|
15
15
|
const workspaces = await discoverWorkspaces(root, rootPackageJson);
|
|
16
|
+
const plannedWrites = [];
|
|
16
17
|
|
|
17
18
|
rootPackageJson.scripts = {
|
|
18
19
|
...(rootPackageJson.scripts ?? {}),
|
|
19
20
|
boundaries: rootPackageJson.scripts?.boundaries ?? "boundaries check",
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
await
|
|
23
|
-
|
|
23
|
+
await planJsonWrite({
|
|
24
|
+
root,
|
|
25
|
+
filePath: rootPackageJsonPath,
|
|
26
|
+
value: rootPackageJson,
|
|
27
|
+
dryRun,
|
|
28
|
+
plannedWrites,
|
|
29
|
+
});
|
|
30
|
+
await planJsonWrite({
|
|
31
|
+
root,
|
|
32
|
+
filePath: rootTurboJsonPath,
|
|
33
|
+
value: applyRootBoundaryConfig(rootTurboJson),
|
|
34
|
+
dryRun,
|
|
35
|
+
plannedWrites,
|
|
36
|
+
});
|
|
24
37
|
|
|
25
38
|
for (const workspace of workspaces) {
|
|
26
39
|
const turboJsonPath = join(root, workspace.path, "turbo.json");
|
|
27
40
|
const currentTurboJson = await readJson(turboJsonPath, {});
|
|
28
41
|
const tags = inferTagsForWorkspace(workspace);
|
|
29
|
-
await
|
|
42
|
+
await planJsonWrite({
|
|
43
|
+
root,
|
|
44
|
+
filePath: turboJsonPath,
|
|
45
|
+
value: createPackageTurboConfig(currentTurboJson, tags),
|
|
46
|
+
dryRun,
|
|
47
|
+
plannedWrites,
|
|
48
|
+
});
|
|
30
49
|
}
|
|
31
50
|
|
|
32
|
-
return { workspaces };
|
|
51
|
+
return { dryRun, workspaces, plannedWrites };
|
|
33
52
|
}
|
|
34
53
|
|
|
35
54
|
export async function discoverWorkspaces(root, rootPackageJson = undefined) {
|
|
@@ -162,3 +181,19 @@ async function writeJson(filePath, value) {
|
|
|
162
181
|
await mkdir(dirname(filePath), { recursive: true });
|
|
163
182
|
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
164
183
|
}
|
|
184
|
+
|
|
185
|
+
async function planJsonWrite({ root, filePath, value, dryRun, plannedWrites }) {
|
|
186
|
+
plannedWrites.push({
|
|
187
|
+
path: relativePath(root, filePath),
|
|
188
|
+
kind: "json",
|
|
189
|
+
content: `${JSON.stringify(value, null, 2)}\n`,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (!dryRun) {
|
|
193
|
+
await writeJson(filePath, value);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function relativePath(root, filePath) {
|
|
198
|
+
return filePath.startsWith(`${root}/`) ? filePath.slice(root.length + 1) : filePath;
|
|
199
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const EXIT_CODES = {
|
|
2
|
+
OK: 0,
|
|
3
|
+
USAGE: 64,
|
|
4
|
+
DATA: 65,
|
|
5
|
+
UNAVAILABLE: 69,
|
|
6
|
+
SOFTWARE: 70,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function success(data, metadata = undefined) {
|
|
10
|
+
return {
|
|
11
|
+
success: true,
|
|
12
|
+
data,
|
|
13
|
+
...(metadata ? { metadata } : {}),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function failure(error, data = undefined) {
|
|
18
|
+
return {
|
|
19
|
+
success: false,
|
|
20
|
+
...(data ? { data } : {}),
|
|
21
|
+
error: problem(error),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function problem({
|
|
26
|
+
code,
|
|
27
|
+
message,
|
|
28
|
+
is_retriable = false,
|
|
29
|
+
suggestions = [],
|
|
30
|
+
file = undefined,
|
|
31
|
+
}) {
|
|
32
|
+
return {
|
|
33
|
+
code,
|
|
34
|
+
message,
|
|
35
|
+
is_retriable,
|
|
36
|
+
suggestions,
|
|
37
|
+
...(file ? { file } : {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeJson(stream, value) {
|
|
42
|
+
stream.write(`${JSON.stringify(value)}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function hasFlag(args, flag) {
|
|
46
|
+
return args.includes(flag);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function stripFlags(args, flags) {
|
|
50
|
+
return args.filter((arg) => !flags.includes(arg));
|
|
51
|
+
}
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const commandSchema = {
|
|
2
|
+
name: "boundaries",
|
|
3
|
+
description: "Opinionated Turborepo package boundary conventions.",
|
|
4
|
+
commands: [
|
|
5
|
+
{
|
|
6
|
+
name: "init",
|
|
7
|
+
description: "Add root Turbo boundary rules and per-package tags.",
|
|
8
|
+
options: [
|
|
9
|
+
option("--json", "Print a machine-readable response envelope."),
|
|
10
|
+
option("--dry-run", "Print planned file writes without changing files."),
|
|
11
|
+
],
|
|
12
|
+
output: "InitResult",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "check",
|
|
16
|
+
description: "Validate boundary config and optionally run turbo boundaries.",
|
|
17
|
+
options: [
|
|
18
|
+
option("--json", "Print a machine-readable response envelope."),
|
|
19
|
+
option("--no-turbo", "Validate convention config without running turbo boundaries."),
|
|
20
|
+
],
|
|
21
|
+
output: "CheckResult",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "explain",
|
|
25
|
+
description: "Explain whether one workspace may depend on another.",
|
|
26
|
+
arguments: [
|
|
27
|
+
argument("from", "Package name, workspace path, or workspace basename."),
|
|
28
|
+
argument("to", "Package name, workspace path, or workspace basename."),
|
|
29
|
+
],
|
|
30
|
+
options: [option("--json", "Print a machine-readable response envelope.")],
|
|
31
|
+
output: "ExplainResult",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "help",
|
|
35
|
+
description: "Show command help.",
|
|
36
|
+
options: [
|
|
37
|
+
option("--json", "Print a machine-readable response envelope."),
|
|
38
|
+
option("--schema", "Print this command schema as JSON."),
|
|
39
|
+
],
|
|
40
|
+
output: "CommandSchema",
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
responseEnvelope: {
|
|
44
|
+
success: "boolean",
|
|
45
|
+
data: "object | undefined",
|
|
46
|
+
error: "BoundaryProblem | undefined",
|
|
47
|
+
},
|
|
48
|
+
errorShape: {
|
|
49
|
+
code: "string",
|
|
50
|
+
message: "string",
|
|
51
|
+
is_retriable: "boolean",
|
|
52
|
+
suggestions: "string[]",
|
|
53
|
+
file: "string | undefined",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function option(name, description) {
|
|
58
|
+
return { name, description };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function argument(name, description) {
|
|
62
|
+
return { name, description, required: true };
|
|
63
|
+
}
|