@gojsop/cli 0.0.0-stage → 0.2.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/README.md CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # @gojsop/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The [gojsop](https://github.com/efuturetoday/gojsop) CLI. It runs hooks and
4
+ policies on your machine, in the engine the operator uses, against a cluster
5
+ held in memory.
6
+
7
+ ```bash
8
+ npm install --save-dev @gojsop/cli
9
+ npx gojsop run policy.yaml --request request.yaml --trace
10
+ ```
11
+
12
+ npm installs the binary for your platform as an optional dependency
13
+ (`@gojsop/cli-<os>-<arch>`): macOS and Linux on x64 and arm64, Windows on
14
+ x64. Set `GOJSOP_BIN` to use another binary.
15
+
16
+ To test hooks and policies with vitest, use
17
+ [`@gojsop/testing`](https://www.npmjs.com/package/@gojsop/testing); it
18
+ depends on this package.
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { parseArgs } from "node:util";
7
+ import { build } from "./build.js";
8
+ import { bundle, isModule, scriptBeside } from "./bundle.js";
9
+ import { binaryPath, notFoundHint } from "./index.js";
10
+ import { create, remove, rename } from "./scaffold.js";
11
+ const usage = `usage:
12
+ gojsop new <policy|hook> <name> add a policy or hook with a passing test
13
+ gojsop rn <name> <new-name> rename one
14
+ gojsop rm <name> [--yes] remove one
15
+ gojsop build write dist/ for kubectl apply -f dist/
16
+ gojsop run <manifest> (--request <file> | --event <file>) [--cluster <file>] [--trace]
17
+ gojsop serve --stdio
18
+ `;
19
+ const args = process.argv.slice(2);
20
+ // These commands run here, in Node: they edit the workspace or bundle with
21
+ // the project's Vite. run and serve are the gojsop binary's.
22
+ const OWN = new Set([undefined, "help", "-h", "--help", "new", "rm", "remove", "rn", "rename", "build"]);
23
+ async function workspaceCommand() {
24
+ const [cmd, ...rest] = args;
25
+ if (!OWN.has(cmd))
26
+ return false;
27
+ const { values, positionals } = parseArgs({
28
+ args: rest,
29
+ allowPositionals: true,
30
+ options: { yes: { type: "boolean", short: "y" } },
31
+ });
32
+ const want = (n) => {
33
+ if (positionals.length !== n)
34
+ throw new Error(usage);
35
+ };
36
+ switch (cmd) {
37
+ case undefined:
38
+ case "help":
39
+ case "-h":
40
+ case "--help":
41
+ console.log(usage);
42
+ return true;
43
+ case "new":
44
+ want(2);
45
+ create(positionals[0], positionals[1]);
46
+ return true;
47
+ case "rm":
48
+ case "remove":
49
+ want(1);
50
+ await remove(positionals[0], values.yes ?? false);
51
+ return true;
52
+ case "rn":
53
+ case "rename":
54
+ want(2);
55
+ rename(positionals[0], positionals[1]);
56
+ return true;
57
+ case "build":
58
+ want(0);
59
+ await build();
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+ try {
65
+ if (await workspaceCommand())
66
+ process.exit(0);
67
+ // gojsop run on a TypeScript script: bundle it here and hand the result
68
+ // to the binary with --source, as @gojsop/testing does for every call.
69
+ if (args[0] === "run" && args[1] && !args.includes("--source")) {
70
+ const st = fs.statSync(args[1], { throwIfNoEntry: false });
71
+ const entry = st && scriptBeside(st.isDirectory() ? args[1] : path.dirname(args[1]));
72
+ if (entry && isModule(entry)) {
73
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "gojsop-")), "script.js");
74
+ fs.writeFileSync(file, (await bundle(entry)).code);
75
+ args.push("--source", file);
76
+ }
77
+ }
78
+ }
79
+ catch (e) {
80
+ console.error(e instanceof Error ? e.message : e);
81
+ process.exit(1);
82
+ }
83
+ const bin = binaryPath();
84
+ const r = spawnSync(bin, args, { stdio: "inherit" });
85
+ if (r.error) {
86
+ const code = r.error.code;
87
+ console.error(code === "ENOENT" ? notFoundHint(bin) : `cannot start "${bin}": ${r.error.message}`);
88
+ process.exit(1);
89
+ }
90
+ process.exit(r.status ?? 1);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * gojsop build: writes every hook and policy of the workspace to
3
+ * dist/<policy|hook>-<name>.yaml, with its script bundled into
4
+ * spec.source.inline, ready for kubectl apply -f dist/. Files it wrote
5
+ * before for hooks and policies that are gone are removed.
6
+ */
7
+ export declare function build(out?: string): Promise<number>;
package/dist/build.js ADDED
@@ -0,0 +1,46 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parseDocument, Scalar } from "yaml";
4
+ import { bundle, isModule, scriptBeside } from "./bundle.js";
5
+ import { entries } from "./workspace.js";
6
+ /** The most spec.source.inline may hold. */
7
+ const INLINE_LIMIT = 512 * 1024;
8
+ const OWN = /^(policy|hook)-.+\.yaml$/;
9
+ /**
10
+ * gojsop build: writes every hook and policy of the workspace to
11
+ * dist/<policy|hook>-<name>.yaml, with its script bundled into
12
+ * spec.source.inline, ready for kubectl apply -f dist/. Files it wrote
13
+ * before for hooks and policies that are gone are removed.
14
+ */
15
+ export async function build(out = "dist") {
16
+ const all = entries();
17
+ fs.mkdirSync(out, { recursive: true });
18
+ for (const f of fs.readdirSync(out))
19
+ if (OWN.test(f))
20
+ fs.rmSync(path.join(out, f));
21
+ const seen = new Set();
22
+ for (const e of all) {
23
+ const file = `${e.kind}-${e.name}.yaml`;
24
+ if (seen.has(file))
25
+ throw new Error(`two ${e.kind === "hook" ? "hooks" : "policies"} are named ${e.name}`);
26
+ seen.add(file);
27
+ const doc = parseDocument(fs.readFileSync(e.manifest, "utf8"));
28
+ if (!doc.hasIn(["spec", "source", "inline"]) && !doc.hasIn(["spec", "source", "configMapRef"])) {
29
+ const entry = scriptBeside(e.dir);
30
+ if (!entry)
31
+ throw new Error(`${e.dir}: no script next to ${path.basename(e.manifest)}`);
32
+ const code = isModule(entry) ? (await bundle(entry)).code : fs.readFileSync(entry, "utf8");
33
+ if (Buffer.byteLength(code) > INLINE_LIMIT) {
34
+ throw new Error(`${entry}: the bundled script has ${Buffer.byteLength(code)} bytes, more than 512 KiB`);
35
+ }
36
+ const inline = new Scalar(code);
37
+ inline.type = Scalar.BLOCK_LITERAL;
38
+ doc.setIn(["spec", "source"], doc.createNode({ inline }));
39
+ }
40
+ fs.writeFileSync(path.join(out, file), doc.toString({ lineWidth: 0, flowCollectionPadding: false }));
41
+ console.log(`built ${path.join(out, file)}`);
42
+ }
43
+ if (all.length === 0)
44
+ console.log("no hooks or policies found");
45
+ return all.length;
46
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The script next to a manifest: policy.ts or hook.ts (or .mts, .mjs, .js)
3
+ * named like the manifest, else the one script file of the directory that
4
+ * is not a test. Other files are modules the script may import.
5
+ * Undefined when there is none.
6
+ */
7
+ export declare function scriptBeside(manifestDir: string): string | undefined;
8
+ /** Whether a script is a module that bundle() must turn into a plain script. */
9
+ export declare function isModule(file: string): boolean;
10
+ /** A bundled script, the files it was built from, and its source map. */
11
+ export interface Bundle {
12
+ code: string;
13
+ files: string[];
14
+ /** Source map (v3) with absolute paths in sources. */
15
+ map?: {
16
+ version: number;
17
+ sources: string[];
18
+ mappings: string;
19
+ names: string[];
20
+ };
21
+ }
22
+ /**
23
+ * Bundles a TypeScript or ES-module script, with its imports, into the one
24
+ * plain script gojsop runs: ES2023, no modules, the exports as globals.
25
+ * Uses the project's Vite.
26
+ */
27
+ export declare function bundle(file: string): Promise<Bundle>;
package/dist/bundle.js ADDED
@@ -0,0 +1,94 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ // Names the IIFE that holds the module's exports while it runs.
4
+ const EXPORTS = "__gojsop_exports";
5
+ /** Script files gojsop bundles; a .js file is a plain script and runs as it is. */
6
+ const MODULE = /\.(ts|mts|mjs)$/;
7
+ const NOT_A_SCRIPT = /\.(test|spec)\.[cm]?[jt]s$|\.d\.ts$/;
8
+ /**
9
+ * The script next to a manifest: policy.ts or hook.ts (or .mts, .mjs, .js)
10
+ * named like the manifest, else the one script file of the directory that
11
+ * is not a test. Other files are modules the script may import.
12
+ * Undefined when there is none.
13
+ */
14
+ export function scriptBeside(manifestDir) {
15
+ for (const base of ["policy", "hook"]) {
16
+ if (!fs.existsSync(path.join(manifestDir, `${base}.yaml`)))
17
+ continue;
18
+ for (const ext of [".ts", ".mts", ".mjs", ".js"]) {
19
+ const f = path.join(manifestDir, base + ext);
20
+ if (fs.existsSync(f))
21
+ return f;
22
+ }
23
+ }
24
+ const found = fs
25
+ .readdirSync(manifestDir)
26
+ .filter((f) => /\.([cm]?[jt]s)$/.test(f) && !NOT_A_SCRIPT.test(f))
27
+ .sort();
28
+ if (found.length > 1) {
29
+ throw new Error(`${manifestDir}: several scripts (${found.join(", ")}); name the entry policy.ts or hook.ts`);
30
+ }
31
+ return found[0] && path.join(manifestDir, found[0]);
32
+ }
33
+ /** Whether a script is a module that bundle() must turn into a plain script. */
34
+ export function isModule(file) {
35
+ return MODULE.test(file);
36
+ }
37
+ // After the bundle runs, its exports become globals: gojsop calls
38
+ // validate, mutate and handle as properties of globalThis.
39
+ function exportsToGlobals() {
40
+ return {
41
+ name: "gojsop:exports-to-globals",
42
+ generateBundle(_options, bundle) {
43
+ for (const chunk of Object.values(bundle)) {
44
+ if (chunk.type !== "chunk" || !chunk.isEntry)
45
+ continue;
46
+ // Region markers name local paths, and the script ends up in the
47
+ // cluster. Blank them but keep the lines, so the source map holds.
48
+ chunk.code = chunk.code.replace(/^[ \t]*\/\/#(end)?region.*$/gm, "");
49
+ chunk.code += `\n${chunk.exports.map((n) => `globalThis.${n} = ${EXPORTS}.${n};\n`).join("")}`;
50
+ }
51
+ },
52
+ };
53
+ }
54
+ // Vite writes sources relative to its output directory, dist/ of root.
55
+ function absoluteSources(map, root) {
56
+ return { ...map, sources: map.sources.map((s) => path.resolve(root, "dist", s)) };
57
+ }
58
+ /**
59
+ * Bundles a TypeScript or ES-module script, with its imports, into the one
60
+ * plain script gojsop runs: ES2023, no modules, the exports as globals.
61
+ * Uses the project's Vite.
62
+ */
63
+ export async function bundle(file) {
64
+ const { build } = await import("vite");
65
+ const entry = path.resolve(file); // vite resolves a relative entry against root
66
+ const out = await build({
67
+ configFile: false,
68
+ logLevel: "silent",
69
+ root: path.dirname(entry),
70
+ plugins: [exportsToGlobals()],
71
+ build: {
72
+ write: false,
73
+ target: "es2023",
74
+ minify: false,
75
+ sourcemap: "hidden",
76
+ emptyOutDir: false,
77
+ lib: { entry, formats: ["iife"], name: EXPORTS, fileName: "script" },
78
+ },
79
+ });
80
+ const outputs = Array.isArray(out) ? out : [out];
81
+ for (const o of outputs) {
82
+ if ("output" in o) {
83
+ const chunk = o.output.find((c) => c.type === "chunk" && c.isEntry);
84
+ if (chunk && chunk.type === "chunk") {
85
+ return {
86
+ code: chunk.code,
87
+ files: chunk.moduleIds.filter((id) => path.isAbsolute(id)),
88
+ map: chunk.map ? absoluteSources(JSON.parse(chunk.map.toString()), path.dirname(entry)) : undefined,
89
+ };
90
+ }
91
+ }
92
+ }
93
+ throw new Error(`${entry}: vite produced no script`);
94
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The path of the gojsop binary: GOJSOP_BIN if set, else the binary of the
3
+ * platform package npm installed, else "gojsop" from PATH.
4
+ */
5
+ export declare function binaryPath(): string;
6
+ /** Explains how to get the binary when binaryPath() points nowhere. */
7
+ export declare function notFoundHint(bin: string): string;
8
+ export { type Bundle, bundle, isModule, scriptBeside } from "./bundle.js";
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ import { createRequire } from "node:module";
2
+ import { platforms } from "./platforms.js";
3
+ const require = createRequire(import.meta.url);
4
+ /**
5
+ * The path of the gojsop binary: GOJSOP_BIN if set, else the binary of the
6
+ * platform package npm installed, else "gojsop" from PATH.
7
+ */
8
+ export function binaryPath() {
9
+ if (process.env.GOJSOP_BIN)
10
+ return process.env.GOJSOP_BIN;
11
+ const p = platforms[`${process.platform}-${process.arch}`];
12
+ if (p) {
13
+ const exe = process.platform === "win32" ? "gojsop.exe" : "gojsop";
14
+ try {
15
+ return require.resolve(`${p.pkg}/bin/${exe}`);
16
+ }
17
+ catch {
18
+ // not installed (unsupported platform, --omit=optional); fall back to PATH
19
+ }
20
+ }
21
+ return "gojsop";
22
+ }
23
+ /** Explains how to get the binary when binaryPath() points nowhere. */
24
+ export function notFoundHint(bin) {
25
+ return (`cannot start the gojsop binary "${bin}": not found. ` +
26
+ `Reinstall @gojsop/cli without --omit=optional, put gojsop on PATH, or set GOJSOP_BIN to its path.`);
27
+ }
28
+ export { bundle, isModule, scriptBeside } from "./bundle.js";
@@ -0,0 +1,7 @@
1
+ export interface Platform {
2
+ /** npm package that holds the binary */
3
+ pkg: string;
4
+ goos: string;
5
+ goarch: string;
6
+ }
7
+ export declare const platforms: Record<string, Platform>;
@@ -0,0 +1,7 @@
1
+ export const platforms = {
2
+ "darwin-arm64": { pkg: "@gojsop/cli-darwin-arm64", goos: "darwin", goarch: "arm64" },
3
+ "darwin-x64": { pkg: "@gojsop/cli-darwin-x64", goos: "darwin", goarch: "amd64" },
4
+ "linux-arm64": { pkg: "@gojsop/cli-linux-arm64", goos: "linux", goarch: "arm64" },
5
+ "linux-x64": { pkg: "@gojsop/cli-linux-x64", goos: "linux", goarch: "amd64" },
6
+ "win32-x64": { pkg: "@gojsop/cli-win32-x64", goos: "windows", goarch: "amd64" },
7
+ };
@@ -0,0 +1,6 @@
1
+ /** gojsop new <policy|hook> <name>: a new directory with a passing test. */
2
+ export declare function create(kind: string, name: string): void;
3
+ /** gojsop rm <name>: removes the directory of a hook or policy. */
4
+ export declare function remove(nameOrDir: string, yes: boolean): Promise<void>;
5
+ /** gojsop rn <name> <new-name>: renames a hook or policy and its directory. */
6
+ export declare function rename(nameOrDir: string, to: string): void;
@@ -0,0 +1,127 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { parseDocument } from "yaml";
5
+ import { checkName, find, KINDS } from "./workspace.js";
6
+ // What gojsop new writes: a manifest, a script and a test that passes.
7
+ const FILES = {
8
+ policy: (name) => ({
9
+ "policy.yaml": `apiVersion: core.gojsop.io/v1alpha1
10
+ kind: JSAdmission
11
+ metadata:
12
+ name: ${name}
13
+ spec:
14
+ type: validating
15
+ rules:
16
+ - apiGroups: [""]
17
+ apiVersions: ["v1"]
18
+ resources: ["pods"]
19
+ operations: ["CREATE", "UPDATE"]
20
+ # Audit records what the policy would deny and blocks nothing; switch to
21
+ # Deny once kubectl describe jsadmission ${name} shows no surprises.
22
+ enforcement: Audit
23
+ `,
24
+ "policy.ts": `import type { Request, Response } from "@gojsop/types";
25
+
26
+ export function validate(req: Request): Response {
27
+ return { allowed: true };
28
+ }
29
+ `,
30
+ "policy.test.ts": `import { expect, test } from "vitest";
31
+ import { pod, policy } from "@gojsop/testing";
32
+
33
+ const ${camel(name)} = policy("./policy.yaml");
34
+
35
+ test("allows a pod", async () => {
36
+ const result = await ${camel(name)}.review({ object: pod() });
37
+ expect(result.allowed).toBe(true);
38
+ });
39
+ `,
40
+ }),
41
+ hook: (name) => ({
42
+ "hook.yaml": `apiVersion: core.gojsop.io/v1alpha1
43
+ kind: JSHook
44
+ metadata:
45
+ name: ${name}
46
+ spec:
47
+ bindings:
48
+ - name: pods
49
+ apiGroups: [""]
50
+ apiVersions: ["v1"]
51
+ resources: ["pods"]
52
+ # What kube.* may do besides reading the bindings, for example:
53
+ # permissions:
54
+ # - apiGroups: [""]
55
+ # resources: ["configmaps"]
56
+ # verbs: ["get", "create", "patch"]
57
+ `,
58
+ "hook.ts": `import type { Event } from "@gojsop/types";
59
+
60
+ export function handle(event: Event) {
61
+ console.log(event.type, event.object.metadata?.name);
62
+ }
63
+ `,
64
+ "hook.test.ts": `import { expect, test } from "vitest";
65
+ import { hook, pod } from "@gojsop/testing";
66
+
67
+ const ${camel(name)} = hook("./hook.yaml");
68
+
69
+ test("logs the pod", async () => {
70
+ const result = await ${camel(name)}.handle({ object: pod({ name: "web" }) });
71
+ expect(result.console.map((l) => l.text)).toEqual(["Added web"]);
72
+ });
73
+ `,
74
+ }),
75
+ };
76
+ function camel(name) {
77
+ return name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
78
+ }
79
+ /** gojsop new <policy|hook> <name>: a new directory with a passing test. */
80
+ export function create(kind, name) {
81
+ if (kind !== "policy" && kind !== "hook")
82
+ throw new Error(`gojsop new: give policy or hook, not "${kind}"`);
83
+ checkName(name);
84
+ const dir = path.join(KINDS[kind].dir, name);
85
+ if (fs.existsSync(dir))
86
+ throw new Error(`${dir} exists already`);
87
+ fs.mkdirSync(dir, { recursive: true });
88
+ for (const [file, text] of Object.entries(FILES[kind](name)))
89
+ fs.writeFileSync(path.join(dir, file), text);
90
+ console.log(`created ${dir}: ${Object.keys(FILES[kind](name)).join(", ")}`);
91
+ }
92
+ async function confirm(question) {
93
+ if (!process.stdin.isTTY)
94
+ return false;
95
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
96
+ const answer = await rl.question(`${question} [y/N] `);
97
+ rl.close();
98
+ return /^y(es)?$/i.test(answer.trim());
99
+ }
100
+ function stillDeployed(kind, name) {
101
+ return `If it is deployed, it stays in the cluster until: kubectl delete ${KINDS[kind].resource} ${name}`;
102
+ }
103
+ /** gojsop rm <name>: removes the directory of a hook or policy. */
104
+ export async function remove(nameOrDir, yes) {
105
+ const e = find(nameOrDir);
106
+ if (!yes && !(await confirm(`Remove ${e.dir}?`))) {
107
+ throw new Error(`not removed; confirm, or pass --yes`);
108
+ }
109
+ fs.rmSync(e.dir, { recursive: true });
110
+ console.log(`removed ${e.dir}\n${stillDeployed(e.kind, e.name)}`);
111
+ }
112
+ /** gojsop rn <name> <new-name>: renames a hook or policy and its directory. */
113
+ export function rename(nameOrDir, to) {
114
+ checkName(to);
115
+ const e = find(nameOrDir);
116
+ const doc = parseDocument(fs.readFileSync(e.manifest, "utf8"));
117
+ doc.setIn(["metadata", "name"], to);
118
+ fs.writeFileSync(e.manifest, doc.toString({ lineWidth: 0, flowCollectionPadding: false }));
119
+ let dir = e.dir;
120
+ if (path.basename(e.dir) === e.name) {
121
+ dir = path.join(path.dirname(e.dir), to);
122
+ if (fs.existsSync(dir))
123
+ throw new Error(`${dir} exists already`);
124
+ fs.renameSync(e.dir, dir);
125
+ }
126
+ console.log(`renamed ${e.name} to ${to} (${dir})\n${stillDeployed(e.kind, e.name)}`);
127
+ }
@@ -0,0 +1,30 @@
1
+ export declare const KINDS: {
2
+ readonly policy: {
3
+ readonly file: "policy.yaml";
4
+ readonly kind: "JSAdmission";
5
+ readonly dir: "policies";
6
+ readonly resource: "jsadmission";
7
+ };
8
+ readonly hook: {
9
+ readonly file: "hook.yaml";
10
+ readonly kind: "JSHook";
11
+ readonly dir: "hooks";
12
+ readonly resource: "jshook";
13
+ };
14
+ };
15
+ export type Kind = keyof typeof KINDS;
16
+ /** A hook or policy of the workspace. */
17
+ export interface Entry {
18
+ kind: Kind;
19
+ /** metadata.name */
20
+ name: string;
21
+ dir: string;
22
+ manifest: string;
23
+ }
24
+ /** Reads the hook or policy of a directory; undefined when it has none. */
25
+ export declare function entryIn(dir: string): Entry | undefined;
26
+ /** Every hook and policy below root, in a stable order. */
27
+ export declare function entries(root?: string): Entry[];
28
+ /** The one hook or policy a name or directory names; throws otherwise. */
29
+ export declare function find(nameOrDir: string): Entry;
30
+ export declare function checkName(name: string): void;
@@ -0,0 +1,59 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parseDocument } from "yaml";
4
+ // The layout of a workspace: one directory per hook or policy, holding
5
+ // hook.yaml or policy.yaml, the script and its tests.
6
+ export const KINDS = {
7
+ policy: { file: "policy.yaml", kind: "JSAdmission", dir: "policies", resource: "jsadmission" },
8
+ hook: { file: "hook.yaml", kind: "JSHook", dir: "hooks", resource: "jshook" },
9
+ };
10
+ const SKIP = new Set(["node_modules", "dist"]);
11
+ /** Reads the hook or policy of a directory; undefined when it has none. */
12
+ export function entryIn(dir) {
13
+ for (const kind of Object.keys(KINDS)) {
14
+ const manifest = path.join(dir, KINDS[kind].file);
15
+ if (!fs.existsSync(manifest))
16
+ continue;
17
+ const doc = parseDocument(fs.readFileSync(manifest, "utf8"));
18
+ const name = doc.getIn(["metadata", "name"]);
19
+ return { kind, name: typeof name === "string" && name ? name : path.basename(dir), dir, manifest };
20
+ }
21
+ return undefined;
22
+ }
23
+ /** Every hook and policy below root, in a stable order. */
24
+ export function entries(root = ".") {
25
+ const found = [];
26
+ const walk = (dir) => {
27
+ const e = entryIn(dir);
28
+ if (e)
29
+ found.push(e);
30
+ for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
31
+ if (d.isDirectory() && !SKIP.has(d.name) && !d.name.startsWith("."))
32
+ walk(path.join(dir, d.name));
33
+ }
34
+ };
35
+ walk(root);
36
+ return found.sort((a, b) => a.dir.localeCompare(b.dir));
37
+ }
38
+ /** The one hook or policy a name or directory names; throws otherwise. */
39
+ export function find(nameOrDir) {
40
+ if (fs.existsSync(nameOrDir) && fs.statSync(nameOrDir).isDirectory()) {
41
+ const e = entryIn(nameOrDir);
42
+ if (e)
43
+ return e;
44
+ }
45
+ const hits = entries().filter((e) => e.name === nameOrDir || path.basename(e.dir) === nameOrDir);
46
+ if (hits.length === 1)
47
+ return hits[0];
48
+ if (hits.length === 0)
49
+ throw new Error(`no hook or policy named ${nameOrDir}`);
50
+ throw new Error(`${nameOrDir} names ${hits.map((h) => h.dir).join(" and ")}; give the directory instead`);
51
+ }
52
+ // metadata.name becomes part of ServiceAccount and webhook names, so a
53
+ // DNS label keeps every one of them valid.
54
+ const NAME = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
55
+ export function checkName(name) {
56
+ if (!NAME.test(name)) {
57
+ throw new Error(`"${name}" is not a valid name: use lowercase letters, digits and "-", at most 63 characters`);
58
+ }
59
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@gojsop/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "The gojsop CLI: run and serve gojsop hooks and policies locally, in the engine the operator uses.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/efuturetoday/gojsop.git",
9
+ "directory": "sdk/cli"
10
+ },
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "bin": {
21
+ "gojsop": "dist/bin.js"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22.12"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json",
32
+ "test": "vitest run"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.0.0",
36
+ "typescript": "^7.0.2",
37
+ "vite": "^8.0.0"
38
+ },
39
+ "peerDependencies": {
40
+ "vite": ">=7"
41
+ },
42
+ "dependencies": {
43
+ "yaml": "^2.9.1"
44
+ },
45
+ "optionalDependencies": {
46
+ "@gojsop/cli-darwin-arm64": "0.2.0",
47
+ "@gojsop/cli-darwin-x64": "0.2.0",
48
+ "@gojsop/cli-linux-arm64": "0.2.0",
49
+ "@gojsop/cli-linux-x64": "0.2.0",
50
+ "@gojsop/cli-win32-x64": "0.2.0"
51
+ }
52
+ }