activate-agentmd 2.4.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.
Files changed (44) hide show
  1. package/README.md +158 -0
  2. package/bin/agentmd.js +186 -0
  3. package/package.json +55 -0
  4. package/src/commands/agents.js +44 -0
  5. package/src/commands/analytics.js +128 -0
  6. package/src/commands/auth.js +144 -0
  7. package/src/commands/ci.js +296 -0
  8. package/src/commands/extract.js +254 -0
  9. package/src/commands/info.js +105 -0
  10. package/src/commands/init.js +166 -0
  11. package/src/commands/install.js +216 -0
  12. package/src/commands/link.js +206 -0
  13. package/src/commands/list.js +99 -0
  14. package/src/commands/outdated.js +92 -0
  15. package/src/commands/remove.js +72 -0
  16. package/src/commands/review.js +293 -0
  17. package/src/commands/search.js +110 -0
  18. package/src/commands/sync.js +245 -0
  19. package/src/commands/telemetry.js +82 -0
  20. package/src/commands/test.js +226 -0
  21. package/src/commands/update.js +115 -0
  22. package/src/commands/validate.js +125 -0
  23. package/src/config/license-public-keys.json +17 -0
  24. package/src/detect.json +452 -0
  25. package/src/lib/agents.js +301 -0
  26. package/src/lib/analytics.js +137 -0
  27. package/src/lib/coordinates.js +103 -0
  28. package/src/lib/credentials.js +115 -0
  29. package/src/lib/detect.js +352 -0
  30. package/src/lib/diff.js +142 -0
  31. package/src/lib/enterprise.js +140 -0
  32. package/src/lib/fetcher.js +174 -0
  33. package/src/lib/invocation.js +47 -0
  34. package/src/lib/license.js +234 -0
  35. package/src/lib/manifest.js +191 -0
  36. package/src/lib/patterns.js +135 -0
  37. package/src/lib/pro.js +149 -0
  38. package/src/lib/ranking.js +287 -0
  39. package/src/lib/registry.js +207 -0
  40. package/src/lib/star.js +105 -0
  41. package/src/lib/status.js +75 -0
  42. package/src/lib/telemetry.js +169 -0
  43. package/src/lib/versions.js +190 -0
  44. package/src/registry.json +33907 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * detect.js
3
+ *
4
+ * Inspects a project directory and maps what it finds to packages that
5
+ * actually exist in the registry.
6
+ *
7
+ * Two rules govern this file:
8
+ *
9
+ * 1. Never recommend a package that isn't in the registry. Every id below is
10
+ * asserted against the registry by the test suite, so a renamed preset
11
+ * breaks CI instead of producing a dead recommendation.
12
+ * 2. Detecting a technology is not the same as having packages for it. The
13
+ * registry has no Python, Go or Rust packages today; those stacks are
14
+ * reported as detected with zero recommendations rather than being
15
+ * mapped to something vaguely adjacent.
16
+ */
17
+
18
+ "use strict";
19
+
20
+ const fs = require("fs");
21
+ const path = require("path");
22
+
23
+ /** Files we look for, keyed by the signal they represent. */
24
+ const FILE_SIGNALS = [
25
+ { file: "Dockerfile", signal: "docker" },
26
+ { file: "docker-compose.yml", signal: "docker-compose" },
27
+ { file: "docker-compose.yaml", signal: "docker-compose" },
28
+ { file: "vercel.json", signal: "vercel" },
29
+ { file: "requirements.txt", signal: "python" },
30
+ { file: "pyproject.toml", signal: "python" },
31
+ { file: "go.mod", signal: "go" },
32
+ { file: "Cargo.toml", signal: "rust" },
33
+ { file: "pom.xml", signal: "java" },
34
+ { file: "build.gradle", signal: "java" },
35
+ { file: "tsconfig.json", signal: "typescript" },
36
+ { file: "tailwind.config.js", signal: "tailwind" },
37
+ { file: "tailwind.config.ts", signal: "tailwind" },
38
+ { file: "next.config.js", signal: "nextjs" },
39
+ { file: "next.config.ts", signal: "nextjs" },
40
+ { file: "next.config.mjs", signal: "nextjs" },
41
+ ];
42
+
43
+ const DIR_SIGNALS = [
44
+ { dir: path.join(".github", "workflows"), signal: "github-actions" },
45
+ { dir: "prisma", signal: "prisma" },
46
+ { dir: "k8s", signal: "kubernetes" },
47
+ { dir: "kubernetes", signal: "kubernetes" },
48
+ ];
49
+
50
+ /** npm dependency name (or prefix, with a trailing *) -> signal. */
51
+ const DEPENDENCY_SIGNALS = {
52
+ next: "nextjs",
53
+ react: "react",
54
+ vue: "vue",
55
+ "@angular/core": "angular",
56
+ svelte: "svelte",
57
+ typescript: "typescript",
58
+ tailwindcss: "tailwind",
59
+ express: "express",
60
+ fastify: "fastify",
61
+ "@nestjs/core": "nestjs",
62
+ prisma: "prisma",
63
+ "@prisma/client": "prisma",
64
+ "drizzle-orm": "orm",
65
+ typeorm: "orm",
66
+ sequelize: "orm",
67
+ mongoose: "mongodb",
68
+ mongodb: "mongodb",
69
+ pg: "postgres",
70
+ postgres: "postgres",
71
+ mysql2: "mysql",
72
+ redis: "redis",
73
+ ioredis: "redis",
74
+ graphql: "graphql",
75
+ vitest: "unit-tests",
76
+ jest: "unit-tests",
77
+ mocha: "unit-tests",
78
+ "@playwright/test": "e2e-tests",
79
+ playwright: "e2e-tests",
80
+ cypress: "e2e-tests",
81
+ "next-auth": "auth",
82
+ "@auth/core": "auth",
83
+ passport: "auth",
84
+ jsonwebtoken: "jwt",
85
+ jose: "jwt",
86
+ zustand: "state-management",
87
+ redux: "state-management",
88
+ "@reduxjs/toolkit": "state-management",
89
+ "@tanstack/react-query": "state-management",
90
+ };
91
+
92
+ /**
93
+ * signal -> packages, as `<category>/<preset>` (the model is chosen by the
94
+ * caller). Signals with an empty list are reported but produce nothing —
95
+ * that's deliberate, see the header.
96
+ */
97
+ const RECOMMENDATIONS = {
98
+ nextjs: ["Frontend/nextjs", "Backend/nextjs", "Frontend/server-components", "Frontend/routing", "Frontend/metadata"],
99
+ react: ["Frontend/react", "Frontend/hooks", "Frontend/client-components"],
100
+ typescript: ["Frontend/typescript"],
101
+ tailwind: ["Frontend/tailwind"],
102
+ "state-management": ["Frontend/state-management"],
103
+ express: ["Backend/express", "Backend/middlewares", "Backend/error-handling"],
104
+ prisma: ["Database/prisma", "Database/orm", "Database/migration", "Database/schema-design"],
105
+ orm: ["Database/orm", "Database/migration", "Database/schema-design"],
106
+ postgres: ["Database/postgres", "Database/indexes", "Database/query-optimization"],
107
+ mysql: ["Database/mysql", "Database/indexes"],
108
+ mongodb: ["Database/mongodb", "Database/schema-design"],
109
+ redis: ["Database/redis", "Performance/caching"],
110
+ graphql: ["API/graphql", "API/pagination"],
111
+ auth: ["Security/authentication", "Security/authorization", "Security/oauth"],
112
+ jwt: ["Security/jwt", "Security/authentication"],
113
+ "unit-tests": ["Testing/unit", "Testing/test-strategy"],
114
+ "e2e-tests": ["Testing/e2e", "Testing/accessibility"],
115
+ docker: ["DevOps/docker", "DevOps/environments"],
116
+ "docker-compose": ["DevOps/docker-compose"],
117
+ kubernetes: ["DevOps/kubernetes", "DevOps/rollback"],
118
+ "github-actions": ["DevOps/github-actions", "DevOps/cicd"],
119
+ vercel: ["DevOps/vercel", "DevOps/deployment"],
120
+
121
+ // Detected, but the registry has nothing for them yet. Listing them with an
122
+ // empty array is how `init` stays honest about coverage.
123
+ vue: [],
124
+ angular: [],
125
+ svelte: [],
126
+ fastify: [],
127
+ nestjs: [],
128
+ python: ["Backend/python-conventions", "Testing/pytest", "Backend/python-async"],
129
+ fastapi: ["Backend/fastapi", "Backend/pydantic", "Backend/python-async"],
130
+ django: ["Backend/django", "Testing/pytest"],
131
+ flask: ["Backend/flask", "Testing/pytest"],
132
+ pydantic: ["Backend/pydantic"],
133
+ sqlalchemy: ["Database/sqlalchemy"],
134
+ pytest: ["Testing/pytest"],
135
+ go: ["Backend/go-conventions", "Backend/go-errors", "Backend/go-http", "Backend/go-concurrency", "Testing/go-testing", "Database/go-database", "Performance/go-performance"],
136
+ rust: [],
137
+ java: [],
138
+ };
139
+
140
+ /** Recommended for every project regardless of stack. */
141
+ const BASELINE = ["Security/owasp", "Review/code-review", "AI/agent-rules"];
142
+
143
+ /** Human labels for the signals `init` prints. */
144
+ const LABELS = {
145
+ nextjs: "Next.js",
146
+ react: "React",
147
+ vue: "Vue",
148
+ angular: "Angular",
149
+ svelte: "Svelte",
150
+ typescript: "TypeScript",
151
+ tailwind: "Tailwind CSS",
152
+ "state-management": "Client state management",
153
+ express: "Express",
154
+ fastify: "Fastify",
155
+ nestjs: "NestJS",
156
+ prisma: "Prisma",
157
+ orm: "SQL ORM",
158
+ postgres: "PostgreSQL",
159
+ mysql: "MySQL",
160
+ mongodb: "MongoDB",
161
+ redis: "Redis",
162
+ graphql: "GraphQL",
163
+ auth: "Authentication",
164
+ jwt: "JWT",
165
+ "unit-tests": "Unit testing",
166
+ "e2e-tests": "End-to-end testing",
167
+ docker: "Docker",
168
+ "docker-compose": "Docker Compose",
169
+ kubernetes: "Kubernetes",
170
+ "github-actions": "GitHub Actions",
171
+ vercel: "Vercel",
172
+ python: "Python",
173
+ fastapi: "FastAPI",
174
+ django: "Django",
175
+ flask: "Flask",
176
+ pydantic: "Pydantic",
177
+ sqlalchemy: "SQLAlchemy",
178
+ pytest: "pytest",
179
+ go: "Go",
180
+ rust: "Rust",
181
+ java: "Java",
182
+ };
183
+
184
+ /**
185
+ * A directory holding one of these is treated as a workspace worth scanning
186
+ * on its own.
187
+ *
188
+ * Deliberately not a workspace-config parser. Reading `workspaces` globs from
189
+ * package.json would miss pnpm-workspace.yaml (needs a YAML parser), Cargo
190
+ * `[workspace] members`, Go multi-module layouts and Nx project graphs — four
191
+ * formats, four parsers, four things to keep current. Looking for the manifest
192
+ * files themselves covers every one of those layouts, and any layout nobody
193
+ * has invented yet, in one bounded walk.
194
+ */
195
+ const WORKSPACE_MARKERS = [
196
+ "package.json",
197
+ "pyproject.toml",
198
+ "requirements.txt",
199
+ "go.mod",
200
+ "Cargo.toml",
201
+ "pom.xml",
202
+ "build.gradle",
203
+ "Dockerfile",
204
+ ];
205
+
206
+ /** Never descended into: build output, dependencies, and virtualenvs. */
207
+ const SKIP_DIRS = new Set([
208
+ "node_modules", ".git", ".next", ".nuxt", ".svelte-kit", ".turbo", ".venv",
209
+ "venv", "__pycache__", "dist", "build", "out", "target", "vendor", "coverage",
210
+ ".cache", ".terraform", "bin", "obj",
211
+ ]);
212
+
213
+ /**
214
+ * How deep to look for workspaces. `apps/web`, `services/api` and
215
+ * `packages/ui/core` are all within three segments of the root, which covers
216
+ * every monorepo layout in common use without walking a whole source tree.
217
+ */
218
+ const MAX_DEPTH = 3;
219
+
220
+ function exists(cwd, relative) {
221
+ return fs.existsSync(path.join(cwd, relative));
222
+ }
223
+
224
+ /**
225
+ * Directories to scan: the root, plus every nested directory holding a
226
+ * manifest. Returns paths relative to `cwd`, root first.
227
+ */
228
+ function findWorkspaces(cwd, maxDepth = MAX_DEPTH) {
229
+ const found = ["."];
230
+ const walk = (relative, depth) => {
231
+ if (depth > maxDepth) return;
232
+ let entries;
233
+ try {
234
+ entries = fs.readdirSync(path.join(cwd, relative), { withFileTypes: true });
235
+ } catch {
236
+ return; // unreadable directory shouldn't abort the scan
237
+ }
238
+ for (const entry of entries) {
239
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
240
+ const child = relative === "." ? entry.name : path.join(relative, entry.name);
241
+ if (WORKSPACE_MARKERS.some((m) => exists(cwd, path.join(child, m)))) found.push(child);
242
+ walk(child, depth + 1);
243
+ }
244
+ };
245
+ walk(".", 1);
246
+ return found;
247
+ }
248
+
249
+ /** Scan exactly one directory. Returns Map of signal -> evidence. */
250
+ function scanOne(dir) {
251
+ const found = new Map();
252
+ const note = (signal, via) => {
253
+ if (!found.has(signal)) found.set(signal, via);
254
+ };
255
+
256
+ for (const { file, signal } of FILE_SIGNALS) {
257
+ if (exists(dir, file)) note(signal, file);
258
+ }
259
+ for (const { dir: sub, signal } of DIR_SIGNALS) {
260
+ if (exists(dir, sub)) note(signal, sub + "/");
261
+ }
262
+
263
+ const pkgPath = path.join(dir, "package.json");
264
+ if (fs.existsSync(pkgPath)) {
265
+ let pkg;
266
+ try {
267
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
268
+ } catch {
269
+ pkg = null; // malformed package.json shouldn't abort the whole scan
270
+ }
271
+ if (pkg) {
272
+ const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
273
+ for (const dep of deps) {
274
+ const signal = Object.prototype.hasOwnProperty.call(DEPENDENCY_SIGNALS, dep)
275
+ ? DEPENDENCY_SIGNALS[dep]
276
+ : null;
277
+ if (signal) note(signal, `package.json (${dep})`);
278
+ }
279
+ }
280
+ }
281
+
282
+ // Python dependency files are plain text, so a name match is enough. Only
283
+ // libraries with a package behind them are listed.
284
+ for (const file of ["requirements.txt", "pyproject.toml"]) {
285
+ const p = path.join(dir, file);
286
+ if (!fs.existsSync(p)) continue;
287
+ let text = "";
288
+ try { text = fs.readFileSync(p, "utf-8").toLowerCase(); } catch { continue; }
289
+ for (const lib of ["fastapi", "django", "flask", "pydantic", "sqlalchemy", "pytest"]) {
290
+ if (new RegExp(`(^|[\\s"'\\[])${lib}([\\s"'\\]=<>~!;,]|$)`, "m").test(text)) note(lib, `${file} (${lib})`);
291
+ }
292
+ }
293
+
294
+ return found;
295
+ }
296
+
297
+ /**
298
+ * Scan a project, including every workspace in a monorepo.
299
+ *
300
+ * Returns { signals, packages, workspaces }. `signals` and `packages` are the
301
+ * union across the whole repo — one `agentmd link` writes one config, so the
302
+ * agent needs the union regardless of where each signal came from. The
303
+ * per-workspace breakdown is kept so `init` can show which package produced
304
+ * what rather than presenting a merged list as if it were one stack.
305
+ */
306
+ function detect(cwd) {
307
+ const dirs = findWorkspaces(cwd);
308
+ const evidence = new Map(); // signal -> "apps/web/package.json (next)"
309
+ const workspaces = [];
310
+
311
+ for (const relative of dirs) {
312
+ const found = scanOne(path.join(cwd, relative));
313
+ if (found.size === 0) continue;
314
+
315
+ for (const [signal, via] of found) {
316
+ if (!evidence.has(signal)) {
317
+ evidence.set(signal, relative === "." ? via : `${relative}/${via}`);
318
+ }
319
+ }
320
+
321
+ const ids = [...found.keys()];
322
+ workspaces.push({
323
+ path: relative,
324
+ signals: ids.map((signal) => ({ signal, label: LABELS[signal] || signal, via: found.get(signal) })),
325
+ packages: [...new Set(ids.flatMap((s) => RECOMMENDATIONS[s] || []))],
326
+ });
327
+ }
328
+
329
+ const signals = [...evidence.entries()].map(([signal, via]) => ({
330
+ signal,
331
+ label: LABELS[signal] || signal,
332
+ via,
333
+ packages: RECOMMENDATIONS[signal] || [],
334
+ }));
335
+
336
+ const packages = [...new Set(signals.flatMap((s) => s.packages).concat(BASELINE))];
337
+
338
+ return { signals, packages, workspaces };
339
+ }
340
+
341
+ module.exports = {
342
+ detect,
343
+ findWorkspaces,
344
+ scanOne,
345
+ RECOMMENDATIONS,
346
+ BASELINE,
347
+ LABELS,
348
+ DEPENDENCY_SIGNALS,
349
+ FILE_SIGNALS,
350
+ DIR_SIGNALS,
351
+ WORKSPACE_MARKERS,
352
+ };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * diff.js
3
+ *
4
+ * Line diff for `update --dry`, so you can see what an update would do to a
5
+ * package before it touches the file.
6
+ *
7
+ * "Trust us, run it and find out" is not a reasonable thing to ask of a tool
8
+ * that rewrites instructions an agent will act on. The preview is the point.
9
+ *
10
+ * Hand-rolled rather than pulled from npm: the CLI's zero-runtime-dependency
11
+ * property is worth more than the 40 lines below, and a package manager for
12
+ * supply-chain-sensitive content adding a transitive tree to print a diff
13
+ * would be its own punchline.
14
+ */
15
+
16
+ "use strict";
17
+
18
+ const pc = require("picocolors");
19
+
20
+ /**
21
+ * Above this many differing lines on either side, the quadratic LCS table
22
+ * stops being worth it (1500² Uint32 is ~9 MB) and a full replace reads no
23
+ * worse than a line-matched diff would.
24
+ */
25
+ const MAX_LCS = 1500;
26
+
27
+ /**
28
+ * Diff two line arrays into [marker, text] pairs where marker is " ", "-" or "+".
29
+ *
30
+ * Common prefix and suffix are trimmed first. For the edits this tool actually
31
+ * makes — a paragraph rewritten inside an otherwise stable document — that
32
+ * collapses the LCS problem to a handful of lines.
33
+ */
34
+ function diffLines(a, b) {
35
+ let start = 0;
36
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
37
+
38
+ let endA = a.length;
39
+ let endB = b.length;
40
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
41
+ endA--;
42
+ endB--;
43
+ }
44
+
45
+ const out = [];
46
+ for (let i = 0; i < start; i++) out.push([" ", a[i]]);
47
+
48
+ const midA = a.slice(start, endA);
49
+ const midB = b.slice(start, endB);
50
+ const n = midA.length;
51
+ const m = midB.length;
52
+
53
+ if (n > MAX_LCS || m > MAX_LCS) {
54
+ for (const line of midA) out.push(["-", line]);
55
+ for (const line of midB) out.push(["+", line]);
56
+ } else {
57
+ // table[i][j] = length of the LCS of midA[i..] and midB[j..]
58
+ const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
59
+ for (let i = n - 1; i >= 0; i--) {
60
+ for (let j = m - 1; j >= 0; j--) {
61
+ table[i][j] =
62
+ midA[i] === midB[j]
63
+ ? table[i + 1][j + 1] + 1
64
+ : Math.max(table[i + 1][j], table[i][j + 1]);
65
+ }
66
+ }
67
+
68
+ let i = 0;
69
+ let j = 0;
70
+ while (i < n && j < m) {
71
+ if (midA[i] === midB[j]) {
72
+ out.push([" ", midA[i]]);
73
+ i++;
74
+ j++;
75
+ } else if (table[i + 1][j] >= table[i][j + 1]) {
76
+ out.push(["-", midA[i++]]);
77
+ } else {
78
+ out.push(["+", midB[j++]]);
79
+ }
80
+ }
81
+ while (i < n) out.push(["-", midA[i++]]);
82
+ while (j < m) out.push(["+", midB[j++]]);
83
+ }
84
+
85
+ for (let i = endA; i < a.length; i++) out.push([" ", a[i]]);
86
+ return out;
87
+ }
88
+
89
+ /** Added and removed line counts, for a one-line summary. */
90
+ function stat(before, after) {
91
+ const ops = diffLines(before.split("\n"), after.split("\n"));
92
+ return {
93
+ added: ops.filter(([m]) => m === "+").length,
94
+ removed: ops.filter(([m]) => m === "-").length,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Drop runs of unchanged lines, keeping `context` on each side of every edit.
100
+ * Elided runs are marked "@" so the output never implies the file is shorter
101
+ * than it is.
102
+ */
103
+ function toHunks(ops, context) {
104
+ const keep = new Set();
105
+ for (let i = 0; i < ops.length; i++) {
106
+ if (ops[i][0] === " ") continue;
107
+ for (let k = Math.max(0, i - context); k <= Math.min(ops.length - 1, i + context); k++) {
108
+ keep.add(k);
109
+ }
110
+ }
111
+
112
+ const out = [];
113
+ let previous = -1;
114
+ for (let i = 0; i < ops.length; i++) {
115
+ if (!keep.has(i)) continue;
116
+ if (previous !== -1 && i > previous + 1) out.push(["@", ""]);
117
+ out.push(ops[i]);
118
+ previous = i;
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** Render a diff for the terminal. Returns "" when the two sides are identical. */
124
+ function formatDiff(before, after, options) {
125
+ const { context = 2, maxLines = 24 } = options || {};
126
+ const hunks = toHunks(diffLines(before.split("\n"), after.split("\n")), context);
127
+ if (hunks.length === 0) return "";
128
+
129
+ const lines = hunks.slice(0, maxLines).map(([marker, text]) => {
130
+ if (marker === "+") return pc.green(` + ${text}`);
131
+ if (marker === "-") return pc.red(` - ${text}`);
132
+ if (marker === "@") return pc.dim(" ⋮");
133
+ return pc.dim(` ${text}`);
134
+ });
135
+
136
+ const hidden = hunks.length - maxLines;
137
+ if (hidden > 0) lines.push(pc.dim(` … ${hidden} more diff line${hidden === 1 ? "" : "s"}`));
138
+
139
+ return lines.join("\n");
140
+ }
141
+
142
+ module.exports = { diffLines, formatDiff, stat };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * enterprise.js
3
+ *
4
+ * `.agentmd/enterprise.json` — the file a company commits so every developer
5
+ * and every CI run in that repo is pointed at the same place, without anyone
6
+ * having to remember a flag.
7
+ *
8
+ * {
9
+ * "privateRegistry": "https://standards.acme.internal/agentmd",
10
+ * "cloudUrl": "https://ci.acme.internal/agentmd",
11
+ * "licenseKey": "agmd_…",
12
+ * "requireSso": true,
13
+ * "ssoProvider": "okta",
14
+ * "allowPublicRegistry": true,
15
+ * "pinnedStandards": ["Security/owasp", "Review/code-review"]
16
+ * }
17
+ *
18
+ * It is committed, so it holds no secrets beyond the license key — which is a
19
+ * per-company entitlement, not a credential that can spend money. Anything
20
+ * that could (an API key, an SSO client secret) stays in the environment, and
21
+ * this file only ever names it.
22
+ *
23
+ * Environment variables win over the file, so a developer can point at a
24
+ * staging registry without editing a shared, committed file.
25
+ */
26
+
27
+ "use strict";
28
+
29
+ const fs = require("fs");
30
+ const path = require("path");
31
+
32
+ const FILE = path.join(".agentmd", "enterprise.json");
33
+
34
+ /** Keys we understand. An unknown key is surfaced rather than ignored. */
35
+ const KNOWN = [
36
+ "privateRegistry",
37
+ "cloudUrl",
38
+ "licenseKey",
39
+ "requireSso",
40
+ "ssoProvider",
41
+ "allowPublicRegistry",
42
+ "pinnedStandards",
43
+ "$schema",
44
+ ];
45
+
46
+ /**
47
+ * Read the config for this project.
48
+ *
49
+ * Always returns an object: `{ config, problems, path, exists }`. A malformed
50
+ * file is a problem to report, never a reason to abort — the local workflow
51
+ * must keep working when someone commits a stray comma.
52
+ */
53
+ function load(cwd = process.cwd()) {
54
+ const file = path.join(cwd, FILE);
55
+ const problems = [];
56
+
57
+ let config = {};
58
+ let exists = false;
59
+ try {
60
+ const raw = fs.readFileSync(file, "utf-8");
61
+ exists = true;
62
+ config = JSON.parse(raw);
63
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
64
+ problems.push(`${FILE} must contain a JSON object`);
65
+ config = {};
66
+ }
67
+ } catch (err) {
68
+ if (err.code !== "ENOENT") problems.push(`${FILE}: ${err.message}`);
69
+ }
70
+
71
+ for (const key of Object.keys(config)) {
72
+ if (!KNOWN.includes(key)) problems.push(`${FILE}: unknown key \`${key}\``);
73
+ }
74
+ for (const key of ["privateRegistry", "cloudUrl"]) {
75
+ const value = config[key];
76
+ if (value === undefined) continue;
77
+ if (typeof value !== "string") { problems.push(`${FILE}: \`${key}\` must be a string`); continue; }
78
+ // A private registry may be an https endpoint or a path on a shared
79
+ // volume; a plaintext http endpoint is refused for the same reason the
80
+ // fetcher refuses one — this content becomes agent instructions.
81
+ if (/^http:\/\//i.test(value)) problems.push(`${FILE}: \`${key}\` must be https, not http`);
82
+ }
83
+ if (config.pinnedStandards !== undefined && !Array.isArray(config.pinnedStandards)) {
84
+ problems.push(`${FILE}: \`pinnedStandards\` must be an array`);
85
+ }
86
+
87
+ // The environment wins, so a developer can redirect without touching a file
88
+ // their whole team shares.
89
+ const resolved = {
90
+ ...config,
91
+ privateRegistry: process.env.AGENTMD_PRIVATE_REGISTRY || config.privateRegistry || null,
92
+ cloudUrl: process.env.AGENTMD_CLOUD_URL || config.cloudUrl || null,
93
+ requireSso: config.requireSso === true,
94
+ allowPublicRegistry: config.allowPublicRegistry !== false,
95
+ };
96
+
97
+ return { config: resolved, problems, path: file, exists };
98
+ }
99
+
100
+ /** Write a starter config. Refuses to clobber one that already exists. */
101
+ function init(cwd = process.cwd(), values = {}) {
102
+ const file = path.join(cwd, FILE);
103
+ if (fs.existsSync(file)) throw new Error(`${FILE} already exists`);
104
+ const body = {
105
+ $schema: "https://agent-dot-md.vercel.app/schema/enterprise.json",
106
+ privateRegistry: values.privateRegistry || "https://standards.example.internal/agentmd",
107
+ cloudUrl: values.cloudUrl || null,
108
+ licenseKey: values.licenseKey || "",
109
+ requireSso: values.requireSso ?? false,
110
+ allowPublicRegistry: true,
111
+ pinnedStandards: [],
112
+ };
113
+ fs.mkdirSync(path.dirname(file), { recursive: true });
114
+ fs.writeFileSync(file, JSON.stringify(body, null, 2) + "\n", "utf-8");
115
+ return file;
116
+ }
117
+
118
+ /**
119
+ * Whether SSO is satisfied.
120
+ *
121
+ * The CLI cannot perform an SSO handshake by itself — that belongs to the
122
+ * identity provider and the web session. What it can do is refuse to reach a
123
+ * private registry unless the company's chosen proof is present, and say
124
+ * exactly which one is missing. `requireSso` is enforced, not advisory.
125
+ */
126
+ function ssoStatus(config, env = process.env) {
127
+ if (!config.requireSso) return { required: false, satisfied: true, via: null };
128
+ const token = env.AGENTMD_SSO_TOKEN || env.AGENTMD_OIDC_TOKEN;
129
+ if (token) return { required: true, satisfied: true, via: env.AGENTMD_SSO_TOKEN ? "AGENTMD_SSO_TOKEN" : "AGENTMD_OIDC_TOKEN" };
130
+ // GitHub Actions can mint an OIDC token for the job; that is the CI answer.
131
+ if (env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) return { required: true, satisfied: true, via: "GitHub OIDC" };
132
+ return {
133
+ required: true,
134
+ satisfied: false,
135
+ via: null,
136
+ hint: `SSO is required by ${FILE}${config.ssoProvider ? ` (${config.ssoProvider})` : ""}. Set AGENTMD_SSO_TOKEN, or run in a job with GitHub OIDC enabled.`,
137
+ };
138
+ }
139
+
140
+ module.exports = { load, init, ssoStatus, FILE, KNOWN };