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: 4 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ Session lifecycle:

Other:
cred-refresh Start the credential refresh service
validate-policy Validate a sandbox policy YAML file
login Authenticate with the gateway
logout Remove stored provider credentials
providers List configured providers
Expand All @@ -30,6 +29,7 @@ Other:
update-base Rewrite .openlock/Containerfile FROM to current base hash
prune-images Remove stale openlock images (use --legacy for pre-M5)
refs Inspect and promote sandbox commits to real branches
validate Validate .openlock/ config + policy
report Collect diagnostic bundle for bug reports
complete <shell> Print shell completion script (bash|zsh|fish)

Expand Down Expand Up @@ -96,11 +96,6 @@ function main(): void {
case "cred-refresh":
import("./cli/cred-refresh").then(({ credRefreshCmd }) => credRefreshCmd(args.slice(1)));
return;
case "validate-policy":
import("./cli/validate-policy").then(({ validatePolicyCmd }) =>
validatePolicyCmd(args.slice(1)),
);
return;
case "echo-server":
console.error("echo-server not yet implemented");
process.exit(1);
Expand Down Expand Up @@ -156,6 +151,9 @@ function main(): void {
completeCmd(args.slice(1)).then(processExit),
);
return;
case "validate":
import("./cli/validate").then(({ validateCmd }) => validateCmd(args.slice(1)));
return;
case "__list-sessions":
import("./sandbox/session-store").then(({ listAllSessions, sessionsDir }) => {
for (const m of listAllSessions(sessionsDir())) console.log(m.name);
Expand Down
2 changes: 1 addition & 1 deletion src/cli/_commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ describe("COMMAND_FLAGS", () => {
"shell",
"exec",
"cred-refresh",
"validate-policy",
"login",
"logout",
"providers",
Expand All @@ -23,6 +22,7 @@ describe("COMMAND_FLAGS", () => {
"complete",
"refs",
"report",
"validate",
].sort();
expect(Object.keys(COMMAND_FLAGS).sort()).toEqual(expected);
});
Expand Down
4 changes: 2 additions & 2 deletions src/cli/_commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { flagSchema as shellFlags } from "./shell";
import { flagSchema as statusFlags } from "./status";
import { flagSchema as stopFlags } from "./stop";
import { flagSchema as updateImagesFlags } from "./update-images";
import { flagSchema as validatePolicyFlags } from "./validate-policy";
import { flagSchema as validateFlags } from "./validate";

export const COMMAND_FLAGS = {
sandbox: sandboxFlags,
Expand All @@ -29,7 +29,6 @@ export const COMMAND_FLAGS = {
shell: shellFlags,
exec: execFlags,
"cred-refresh": credRefreshFlags,
"validate-policy": validatePolicyFlags,
login: loginFlags,
logout: logoutFlags,
providers: providersFlags,
Expand All @@ -39,6 +38,7 @@ export const COMMAND_FLAGS = {
complete: completeFlags,
refs: refsFlags,
report: reportFlags,
validate: validateFlags,
} as const satisfies Record<string, ParseArgsOptionsConfig>;

export type CommandName = keyof typeof COMMAND_FLAGS;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/_descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export const COMMAND_DESCRIPTIONS = {
shell: "Open bash inside the session container",
exec: "Run a command inside the session container",
"cred-refresh": "Start the credential refresh service",
"validate-policy": "Validate a sandbox policy YAML file",
login: "Authenticate with the gateway",
logout: "Remove stored provider credentials",
providers: "List configured providers",
Expand All @@ -27,6 +26,7 @@ export const COMMAND_DESCRIPTIONS = {
complete: "Print shell completion script",
refs: "Inspect and promote sandbox commits to real branches",
report: "Collect diagnostic bundle for bug reports",
validate: "Validate .openlock/ config + policy",
} as const;

export type CommandName = keyof typeof COMMAND_DESCRIPTIONS;
8 changes: 0 additions & 8 deletions src/cli/validate-policy.test.ts

This file was deleted.

35 changes: 0 additions & 35 deletions src/cli/validate-policy.ts

This file was deleted.

54 changes: 54 additions & 0 deletions src/cli/validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "bun:test";
import type { Issue } from "../config-core";
import { flagSchema, renderIssues, summaryLine } from "./validate";

describe("validate flagSchema", () => {
it("declares --offline and --help", () => {
expect(Object.keys(flagSchema).sort()).toEqual(["help", "offline"]);
});
});

describe("renderIssues", () => {
it("prints ok per file when there are no issues", () => {
expect(renderIssues([])).toEqual([" config.yaml: ok", " policy.yaml: ok"]);
});

it("groups issues by file and tier with fix lines", () => {
const issues: Issue[] = [
{
file: "config.yaml",
severity: "error",
path: "caps",
message: 'unknown key "caps"',
fix: 'remove "caps"',
},
{
file: "config.yaml",
severity: "filesystem",
path: "mounts[0].source",
message: "source /x does not exist",
},
];
const lines = renderIssues(issues);
expect(lines).toContain(" config.yaml:");
expect(lines).toContain(' caps: unknown key "caps"');
expect(lines).toContain(' fix: remove "caps"');
expect(lines.some((l) => l.includes("[fs] mounts[0].source"))).toBe(true);
expect(lines).toContain(" policy.yaml: ok");
});
});

describe("summaryLine", () => {
it("reports ok per file when clean", () => {
expect(summaryLine([])).toBe("config.yaml: ok · policy.yaml: ok");
});

it("counts issues per file", () => {
const issues: Issue[] = [
{ file: "config.yaml", severity: "error", path: "a", message: "x" },
{ file: "config.yaml", severity: "filesystem", path: "b", message: "y" },
{ file: "policy.yaml", severity: "error", path: "c", message: "z" },
];
expect(summaryLine(issues)).toBe("config.yaml: 2 issues · policy.yaml: 1 issue");
});
});
62 changes: 62 additions & 0 deletions src/cli/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { ParseArgsOptionsConfig } from "node:util";
import { parseArgs } from "node:util";
import type { ConfigFile, Issue, Severity } from "../config-core";
import { lintFolder } from "../config-core";
import { printCmdHelp } from "./_help";

export const flagSchema = {
offline: { type: "boolean" },
help: { type: "boolean", short: "h" },
} as const satisfies ParseArgsOptionsConfig;

const FILE_ORDER: ConfigFile[] = ["config.yaml", "policy.yaml"];
const SEVERITY_ORDER: Severity[] = ["error", "filesystem"];

function renderFile(file: ConfigFile, issues: Issue[]): string[] {
const lines: string[] = [];
if (issues.length === 0) {
lines.push(` ${file}: ok`);
return lines;
}
lines.push(` ${file}:`);
for (const sev of SEVERITY_ORDER) {
for (const issue of issues.filter((i) => i.severity === sev)) {
const loc = issue.path ? `${issue.path}: ` : "";
const tag = sev === "filesystem" ? "[fs] " : "";
lines.push(` ${tag}${loc}${issue.message}`);
if (issue.fix) lines.push(` fix: ${issue.fix}`);
}
}
return lines;
}

export function renderIssues(issues: Issue[]): string[] {
const lines: string[] = [];
for (const file of FILE_ORDER) {
const forFile = issues.filter((i) => i.file === file);
lines.push(...renderFile(file, forFile));
}
return lines;
}

export function summaryLine(issues: Issue[]): string {
const parts = FILE_ORDER.map((file) => {
const n = issues.filter((i) => i.file === file).length;
return n === 0 ? `${file}: ok` : `${file}: ${n} issue${n === 1 ? "" : "s"}`;
});
return parts.join(" · ");
}

export function validateCmd(args: string[]): void {
const { values, positionals } = parseArgs({ args, options: flagSchema, allowPositionals: true });
if (values.help === true) {
printCmdHelp("validate", flagSchema, "[path]");
return;
}
const projectDir = positionals[0] ?? process.cwd();
const issues = lintFolder(projectDir, { offline: values.offline === true });
for (const line of renderIssues(issues)) console.log(line);
console.log(summaryLine(issues));
const blocking = issues.some((i) => i.severity === "error" || i.severity === "filesystem");
process.exit(blocking ? 1 : 0);
}
70 changes: 70 additions & 0 deletions src/config-core/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { lintFolder } from "./index";

let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "openlock-folder-lint-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

function writeFolder(config: string, policy: string): void {
mkdirSync(join(root, ".openlock"), { recursive: true });
writeFileSync(join(root, ".openlock/config.yaml"), config);
writeFileSync(join(root, ".openlock/policy.yaml"), policy);
}

describe("lintFolder", () => {
it("errors for both files (with init hint) when .openlock/ is missing", () => {
const issues = lintFolder(root, { offline: false });
expect(issues).toHaveLength(2);
expect(issues.map((i) => i.file).sort()).toEqual(["config.yaml", "policy.yaml"]);
expect(issues[0]?.message).toMatch(/no \.openlock\/ directory/);
expect(issues.every((i) => /openlock init/.test(i.fix ?? ""))).toBe(true);
});

it("flags a missing policy.yaml while still linting config.yaml", () => {
mkdirSync(join(root, ".openlock"), { recursive: true });
writeFileSync(join(root, ".openlock/config.yaml"), "args: []\n");
const issues = lintFolder(root, { offline: false });
expect(issues).toHaveLength(1);
expect(issues[0]?.file).toBe("policy.yaml");
expect(issues[0]?.message).toMatch(/policy\.yaml not found/);
});

it("flags a missing config.yaml while still linting policy.yaml", () => {
mkdirSync(join(root, ".openlock"), { recursive: true });
writeFileSync(join(root, ".openlock/policy.yaml"), "version: 1\n");
const issues = lintFolder(root, { offline: false });
expect(issues).toHaveLength(1);
expect(issues[0]?.file).toBe("config.yaml");
expect(issues[0]?.message).toMatch(/config\.yaml not found/);
});

it("returns [] for a valid folder", () => {
writeFolder("args: []\n", "version: 1\n");
expect(lintFolder(root, { offline: false })).toEqual([]);
});

it("reports config and policy issues together, tagged by file", () => {
writeFolder("caps: [js]\n", "filesystem_policy: {}\n");
const issues = lintFolder(root, { offline: false });
expect(issues.some((i) => i.file === "config.yaml")).toBe(true);
expect(issues.some((i) => i.file === "policy.yaml")).toBe(true);
});

it("offline:true suppresses a missing-source filesystem issue", () => {
writeFolder(
"mounts:\n - source: nope\n target: /sandbox/.openlock/x\n type: copy-once\n",
"version: 1\n",
);
expect(lintFolder(root, { offline: true })).toEqual([]);
expect(lintFolder(root, { offline: false }).some((i) => i.severity === "filesystem")).toBe(
true,
);
});
});
49 changes: 49 additions & 0 deletions src/config-core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { lintManifest } from "./manifest/index";
import { lintPolicy } from "./policy/index";
import type { Issue } from "./types";

export { parseManifest } from "./manifest/index";
export type { ConfigFile, Issue, ManifestConfig, Mount, Severity } from "./types";
export { SANDBOX_OPENLOCK_PREFIX } from "./types";

/** Validate the whole .openlock/ folder (manifest + policy). Collect-all,
* never throws. Each issue is tagged with its source file. */
export function lintFolder(projectDir: string, opts: { offline: boolean }): Issue[] {
const folder = join(projectDir, ".openlock");
const fix = "run `openlock init` to scaffold it";
if (!existsSync(folder)) {
const message = `no .openlock/ directory found in ${projectDir}`;
return [
{ file: "config.yaml", severity: "error", path: "", message, fix },
{ file: "policy.yaml", severity: "error", path: "", message, fix },
];
}
const issues: Issue[] = [];
const configPath = join(folder, "config.yaml");
if (existsSync(configPath)) {
issues.push(...lintManifest(readFileSync(configPath, "utf-8"), projectDir, opts));
} else {
issues.push({
file: "config.yaml",
severity: "error",
path: "",
message: "config.yaml not found",
fix,
});
}
const policyPath = join(folder, "policy.yaml");
if (existsSync(policyPath)) {
issues.push(...lintPolicy(readFileSync(policyPath, "utf-8")));
} else {
issues.push({
file: "policy.yaml",
severity: "error",
path: "",
message: "policy.yaml not found",
fix,
});
}
return issues;
}
Loading