@stacksjs/buddy 0.70.163 → 0.70.164

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 (78) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +1 -0
  3. package/dist/commands/ai-context.js +1 -1
  4. package/dist/commands/create.js +1 -1
  5. package/dist/commands/deploy.d.ts +11 -1
  6. package/dist/commands/deploy.js +22 -4
  7. package/dist/commands/dev.js +44 -14
  8. package/dist/commands/docs/buddy-commands.d.ts +8 -0
  9. package/dist/commands/docs/buddy-commands.js +116 -0
  10. package/dist/commands/docs/buddy-commands.test.d.ts +1 -0
  11. package/dist/commands/docs/buddy-commands.test.js +38 -0
  12. package/dist/commands/docs/generated-artifacts.d.ts +3 -0
  13. package/dist/commands/docs/generated-artifacts.js +69 -0
  14. package/dist/commands/docs/generated-artifacts.test.d.ts +1 -0
  15. package/dist/commands/docs/generated-artifacts.test.js +19 -0
  16. package/dist/commands/docs/links.d.ts +18 -0
  17. package/dist/commands/docs/links.js +83 -0
  18. package/dist/commands/docs/links.test.d.ts +1 -0
  19. package/dist/commands/docs/links.test.js +73 -0
  20. package/dist/commands/docs.d.ts +7 -0
  21. package/dist/commands/docs.js +24 -0
  22. package/dist/commands/doctor.js +8 -0
  23. package/dist/commands/env.d.ts +14 -0
  24. package/dist/commands/env.js +73 -7
  25. package/dist/commands/generate.js +1 -1
  26. package/dist/commands/index.d.ts +2 -0
  27. package/dist/commands/index.js +2 -0
  28. package/dist/commands/mail.js +1 -1
  29. package/dist/commands/migrate.js +4 -4
  30. package/dist/commands/protocol/craft-evidence.d.ts +76 -0
  31. package/dist/commands/protocol/craft-evidence.js +98 -0
  32. package/dist/commands/protocol/craft-evidence.test.d.ts +1 -0
  33. package/dist/commands/protocol/craft-evidence.test.js +20 -0
  34. package/dist/commands/protocol/desktop-lifecycle-report.d.ts +16 -0
  35. package/dist/commands/protocol/desktop-lifecycle-report.js +57 -0
  36. package/dist/commands/protocol/desktop-lifecycle-report.test.d.ts +1 -0
  37. package/dist/commands/protocol/desktop-lifecycle-report.test.js +32 -0
  38. package/dist/commands/protocol/desktop-support.d.ts +1 -0
  39. package/dist/commands/protocol/desktop-support.js +32 -0
  40. package/dist/commands/protocol/desktop-workflow.test.d.ts +1 -0
  41. package/dist/commands/protocol/desktop-workflow.test.js +33 -0
  42. package/dist/commands/protocol/driver-registry.d.ts +1 -0
  43. package/dist/commands/protocol/driver-registry.js +71 -0
  44. package/dist/commands/protocol/pantry-evidence.d.ts +67 -0
  45. package/dist/commands/protocol/pantry-evidence.js +144 -0
  46. package/dist/commands/protocol/pantry-evidence.test.d.ts +1 -0
  47. package/dist/commands/protocol/pantry-evidence.test.js +29 -0
  48. package/dist/commands/protocol/release-manifest.d.ts +30 -0
  49. package/dist/commands/protocol/release-manifest.js +102 -0
  50. package/dist/commands/protocol/release-manifest.test.d.ts +1 -0
  51. package/dist/commands/protocol/release-manifest.test.js +34 -0
  52. package/dist/commands/protocol/run-conformance.d.ts +21 -0
  53. package/dist/commands/protocol/run-conformance.js +358 -0
  54. package/dist/commands/protocol/run-conformance.test.d.ts +1 -0
  55. package/dist/commands/protocol/run-conformance.test.js +52 -0
  56. package/dist/commands/protocol/run-driver-contracts.d.ts +7 -0
  57. package/dist/commands/protocol/run-driver-contracts.js +94 -0
  58. package/dist/commands/protocol/run-driver-contracts.test.d.ts +1 -0
  59. package/dist/commands/protocol/run-driver-contracts.test.js +18 -0
  60. package/dist/commands/protocol/source-manifest.d.ts +25 -0
  61. package/dist/commands/protocol/source-manifest.js +113 -0
  62. package/dist/commands/protocol/sync-suite.d.ts +8 -0
  63. package/dist/commands/protocol/sync-suite.js +137 -0
  64. package/dist/commands/protocol/sync-suite.test.d.ts +1 -0
  65. package/dist/commands/protocol/sync-suite.test.js +44 -0
  66. package/dist/commands/protocol.d.ts +8 -0
  67. package/dist/commands/protocol.js +60 -0
  68. package/dist/commands/publish.js +149 -0
  69. package/dist/commands/run-tool.d.ts +7 -0
  70. package/dist/commands/run-tool.js +9 -0
  71. package/dist/commands/seed.js +15 -40
  72. package/dist/commands/serve.d.ts +18 -5
  73. package/dist/commands/serve.js +21 -2
  74. package/dist/commands/setup-ai.d.ts +39 -0
  75. package/dist/commands/setup-ai.js +121 -0
  76. package/dist/commands/setup.js +22 -0
  77. package/dist/lazy-commands.js +27 -1
  78. package/package.json +43 -42
package/README.md CHANGED
@@ -344,7 +344,7 @@ For help, discussion about best practices, or any other conversation that would
344
344
 
345
345
  For casual chit-chat with others using this package:
346
346
 
347
- [Join the Stacks Discord Server](https://discord.gg/stacksjs)
347
+ [Join the Stacks Discord Server](https://stacksjs.com/discord)
348
348
 
349
349
  ## Postcardware
350
350
 
package/dist/cli.js CHANGED
@@ -38,6 +38,7 @@ async function main() {
38
38
  await applyBuddyConfig(buddy);
39
39
  } catch {}
40
40
  if (needsFullSetup) {
41
+ p.ensureRuntimeDirectories();
41
42
  const { loadCommands, getCommandsToLoad, markLoaded } = await import("./lazy-commands.js"), { setup } = await import("./commands/setup.js");
42
43
  setup(buddy);
43
44
  markLoaded(buddy, "setup");
@@ -18,7 +18,7 @@ ${JSON.stringify(result.metrics, null, 2)}
18
18
  return { output, outputPath };
19
19
  }
20
20
  export function aiContext(buddy) {
21
- buddy.command("ai:context", "Generate compact deterministic project context for coding models").option("-J, --json", "Emit the versioned machine-readable context contract", { default: !1 }).option("-o, --output [path]", "Write output to a file instead of stdout").option("--max-chars [characters]", "Maximum characters in the prompt context payload", { default: 4000 }).option("--model [model]", "Model family used for the heuristic token estimate", { default: "gpt-4o" }).example("buddy ai:context").example("buddy ai:context --json").example("buddy ai:context --json --output .stacks/ai-context.json").action((options) => {
21
+ buddy.command("ai:context", "Generate compact deterministic project context for coding models").option("-J, --json", "Emit the versioned machine-readable context contract", { default: !1 }).option("-o, --output [path]", "Write output to a file instead of stdout").option("--max-chars [characters]", "Maximum characters in the prompt context payload", { default: 4000 }).option("--model [model]", "Model family used for the heuristic token estimate", { default: "gpt-4o" }).example("buddy ai:context").example("buddy ai:context --json").example("buddy ai:context --json --output storage/framework/runtime/ai-context.json").action((options) => {
22
22
  const generated = generateAiContextOutput(options);
23
23
  if (generated.outputPath)
24
24
  console.log(`Wrote AI project context to ${generated.outputPath}`);
@@ -38,8 +38,8 @@ export function create(buddy) {
38
38
  log.error(result.error);
39
39
  process.exit(ExitCode.FatalError);
40
40
  }
41
- await ensureEnv(path, options);
42
41
  ensureExecutableScripts(path);
42
+ await ensureEnv(path, options);
43
43
  await install(path, options);
44
44
  if (options.minimal)
45
45
  await stripFeatures(path);
@@ -18,11 +18,21 @@ import type { CLI } from '@stacksjs/types';
18
18
  * ciphertext it never had a chance to decrypt (no DOTENV_PRIVATE_KEY_* in
19
19
  * that 2-key set).
20
20
  *
21
+ * Keys namespaced to another tenant on this box (`cloud.tenants` in
22
+ * `config/cloud.ts`) are dropped here, before anything is shipped. Those
23
+ * projects deploy from their own repositories with their own env files and
24
+ * never need the owner's copy — and because ts-cloud treats `site.env` as the
25
+ * complete `.env`, leaving them in writes one tenant's secrets into an
26
+ * unrelated site's `.env` on disk.
27
+ *
21
28
  * Returns `{}` (not an error) when the file doesn't exist or fails to
22
29
  * parse — an app with no `.env.production` yet shouldn't block deploying
23
30
  * with whatever `site.env` overrides it does have.
31
+ *
32
+ * @param environment - Which `.env.<environment>` to read.
33
+ * @param tsCloudConfig - The deploy target's ts-cloud config, read for `project.slug`.
24
34
  */
25
- export declare function resolveDeployEnvValues(environment: 'production' | 'staging' | 'development'): Promise<Record<string, string>>;
35
+ export declare function resolveDeployEnvValues(environment: 'production' | 'staging' | 'development', tsCloudConfig?: { project?: { slug?: string } }): Promise<Record<string, string>>;
26
36
  /**
27
37
  * Merge the deploy-target's resolved env values underneath each site's own
28
38
  * explicit `env` overrides, stripping a general `PORT` when the site
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { join, relative } from "node:path";
4
4
  import process from "node:process";
5
5
  import { runAction } from "@stacksjs/actions";
6
6
  import { italic, onUnknownSubcommand, outro, prompts, runCommand } from "@stacksjs/cli";
@@ -432,7 +432,15 @@ async function waitForRemoteReady(ip) {
432
432
  });
433
433
  log.success("Server is ready (bun installed)");
434
434
  }
435
- export async function resolveDeployEnvValues(environment) {
435
+ async function resolveDeclaredTenants() {
436
+ try {
437
+ const { config } = await import("@stacksjs/config"), tenants = config.cloud?.tenants;
438
+ return Array.isArray(tenants) ? tenants.filter((slug) => typeof slug === "string") : [];
439
+ } catch {
440
+ return [];
441
+ }
442
+ }
443
+ export async function resolveDeployEnvValues(environment, tsCloudConfig) {
436
444
  const fileName = environment === "production" ? ".env.production" : environment === "staging" ? ".env.staging" : existsSync(p.projectPath(".env.development")) ? ".env.development" : ".env", filePath = p.projectPath(fileName);
437
445
  if (!existsSync(filePath))
438
446
  return {};
@@ -448,7 +456,13 @@ export async function resolveDeployEnvValues(environment) {
448
456
  continue;
449
457
  values[key] = String(value);
450
458
  }
451
- return values;
459
+ const { foreignTenantKeys, partitionTenantEnv } = await import("@stacksjs/env"), partition = partitionTenantEnv(values, {
460
+ self: tsCloudConfig?.project?.slug,
461
+ tenants: await resolveDeclaredTenants()
462
+ });
463
+ for (const { tenant, keys } of foreignTenantKeys(partition))
464
+ log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} \u2014 they belong to that tenant's own ` + `repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);
465
+ return partition.own;
452
466
  } catch (error) {
453
467
  log.debug(`[deploy] Failed to resolve ${fileName} for site env merging:`, error);
454
468
  return {};
@@ -715,7 +729,11 @@ async function runHetznerDeploy(args) {
715
729
  ".cache",
716
730
  "bin",
717
731
  "dist",
732
+ relative(p.projectPath(), p.stxPath()).replace(/\/$/, ""),
718
733
  ".stx",
734
+ relative(p.projectPath(), p.cloudStatePath()).replace(/\/$/, ""),
735
+ ".ts-cloud",
736
+ relative(p.projectPath(), p.frameworkRuntimePath()).replace(/\/$/, ""),
719
737
  "tmp",
720
738
  "temp",
721
739
  ".DS_Store",
@@ -754,7 +772,7 @@ async function runHetznerDeploy(args) {
754
772
  }
755
773
  if (docker)
756
774
  await buildContainerImageWithPantry({ slug, sites, verbose });
757
- const resolvedDeployEnv = await resolveDeployEnvValues(environment), sitesWithResolvedEnv = mergeSiteDeployEnv(sites, resolvedDeployEnv);
775
+ const resolvedDeployEnv = await resolveDeployEnvValues(environment, tsCloudConfig), sitesWithResolvedEnv = mergeSiteDeployEnv(sites, resolvedDeployEnv);
758
776
  for (const [envKey, envValue] of Object.entries(resolvedDeployEnv))
759
777
  if (process.env[envKey] === void 0)
760
778
  process.env[envKey] = envValue;
@@ -4,10 +4,10 @@ import readline from "node:readline";
4
4
  import process from "node:process";
5
5
  import { bold, cyan, dim, green, intro, log, onUnknownSubcommand, outro, prompts, runCommand, yellow } from "@stacksjs/cli";
6
6
  import { homedir } from "node:os";
7
- import { dirname, join } from "node:path";
7
+ import { dirname, join, relative } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { Action } from "@stacksjs/enums";
10
- import { libsPath, projectPath } from "@stacksjs/path";
10
+ import { libsPath, projectPath, stxPath } from "@stacksjs/path";
11
11
  import { ExitCode } from "@stacksjs/types";
12
12
  import { version } from "../../package.json";
13
13
  const DEV_BOOT_STARTING_LINE_COUNT = 3;
@@ -215,17 +215,32 @@ export function dev(buddy) {
215
215
  onUnknownSubcommand(buddy, "dev");
216
216
  }
217
217
  export async function startDevelopmentServer(_options, _startTime) {
218
- const options = _options, startedAt = _startTime, appUrl = process.env.APP_URL ?? "stacks.localhost", nativeMode = options.native === !0, proxyManagedExternally = process.env.STACKS_PROXY_MANAGED === "1", frontendPort = Number(process.env.PORT) || 3000, apiPort = Number(process.env.PORT_API) || 3008, docsPort = Number(process.env.PORT_DOCS) || 3006, dashboardPort = Number(process.env.PORT_ADMIN) || 3002, includeDashboard = process.env.STACKS_DEV_DASHBOARD === "1", domain = resolvePrettyDevDomain(appUrl, nativeMode), appLooksCustom = domain !== null, localhostOnly = process.env.STACKS_DEV_LOCALHOST === "1", prettyUrlsRequested = appLooksCustom && !localhostOnly, systemAuthorized = !prettyUrlsRequested || proxyManagedExternally ? !0 : await canStartPrettyDevProxy(), usePrettyUrls = shouldUsePrettyDevUrls({
218
+ const options = _options, startedAt = _startTime, appUrl = process.env.APP_URL ?? "stacks.localhost", nativeMode = options.native === !0, proxyManagedExternally = process.env.STACKS_PROXY_MANAGED === "1", preferredFrontendPort = Number(process.env.PORT) || 3000, preferredApiPort = Number(process.env.PORT_API) || 3008, preferredDocsPort = Number(process.env.PORT_DOCS) || 3006, preferredDashboardPort = Number(process.env.PORT_ADMIN) || 3002;
219
+ if (process.env.STACKS_DEV_NO_KILL !== "1")
220
+ await cleanupStaleDevProcesses([
221
+ preferredFrontendPort,
222
+ preferredApiPort,
223
+ preferredDocsPort,
224
+ preferredDashboardPort
225
+ ]);
226
+ const portShifts = [], claimPort = async (label, preferred) => {
227
+ if (proxyManagedExternally)
228
+ return preferred;
229
+ const port = await findAvailablePort(preferred);
230
+ if (port !== preferred)
231
+ portShifts.push(`${label} :${preferred} \u2192 :${port}`);
232
+ return port;
233
+ }, frontendPort = await claimPort("Frontend", preferredFrontendPort), apiPort = await claimPort("API", preferredApiPort), docsPort = await claimPort("Docs", preferredDocsPort), dashboardPort = await claimPort("Dashboard", preferredDashboardPort);
234
+ process.env.PORT = String(frontendPort);
235
+ process.env.PORT_API = String(apiPort);
236
+ process.env.PORT_DOCS = String(docsPort);
237
+ process.env.PORT_ADMIN = String(dashboardPort);
238
+ const includeDashboard = process.env.STACKS_DEV_DASHBOARD === "1", domain = resolvePrettyDevDomain(appUrl, nativeMode), appLooksCustom = domain !== null, localhostOnly = process.env.STACKS_DEV_LOCALHOST === "1", prettyUrlsRequested = appLooksCustom && !localhostOnly, systemAuthorized = !prettyUrlsRequested || proxyManagedExternally ? !0 : await canStartPrettyDevProxy(), usePrettyUrls = shouldUsePrettyDevUrls({
219
239
  domain,
220
240
  localhostOnly,
221
241
  proxyManagedExternally,
222
242
  systemAuthorized
223
- }), prettySetupRequired = prettyUrlsRequested && !usePrettyUrls, hasCustomDomain = usePrettyUrls && !proxyManagedExternally, displayedDomain = usePrettyUrls ? domain : null, dashboardDomain = displayedDomain ? `dashboard.${displayedDomain}` : null, frontendUrl = displayedDomain ? `https://${displayedDomain}` : `http://localhost:${frontendPort}`, apiUrl = displayedDomain ? `https://${displayedDomain}/api` : `http://localhost:${apiPort}`, docsUrl = displayedDomain ? `https://${displayedDomain}/docs` : `http://localhost:${docsPort}`, dashboardUrl = dashboardDomain ? `https://${dashboardDomain}` : `http://localhost:${dashboardPort}`, managedPorts = [
224
- frontendPort,
225
- apiPort,
226
- docsPort,
227
- ...includeDashboard ? [dashboardPort] : []
228
- ];
243
+ }), prettySetupRequired = prettyUrlsRequested && !usePrettyUrls, hasCustomDomain = usePrettyUrls && !proxyManagedExternally, displayedDomain = usePrettyUrls ? domain : null, dashboardDomain = displayedDomain ? `dashboard.${displayedDomain}` : null, frontendUrl = displayedDomain ? `https://${displayedDomain}` : `http://localhost:${frontendPort}`, apiUrl = displayedDomain ? `https://${displayedDomain}/api` : `http://localhost:${apiPort}`, docsUrl = displayedDomain ? `https://${displayedDomain}/docs` : `http://localhost:${docsPort}`, dashboardUrl = dashboardDomain ? `https://${dashboardDomain}` : `http://localhost:${dashboardPort}`;
229
244
  process.env.STACKS_PROXY_MANAGED = "1";
230
245
  process.env.STACKS_DEV_QUIET = "1";
231
246
  console.log();
@@ -234,10 +249,12 @@ export async function startDevelopmentServer(_options, _startTime) {
234
249
  console.log(` ${dim(" ")}${dim("Run `./buddy setup:ssl` once, then restart `./buddy dev`.")}`);
235
250
  console.log();
236
251
  }
252
+ if (portShifts.length > 0) {
253
+ console.log(` ${yellow("\u26A0")} ${yellow("Ports in use by another process")} ${dim(`- ${portShifts.join(", ")}`)}`);
254
+ console.log();
255
+ }
237
256
  console.log(` ${bold(cyan("stacks"))} ${dim(`v${version}`)} ${dim("starting\u2026")}`);
238
257
  console.log();
239
- if (process.env.STACKS_DEV_NO_KILL !== "1")
240
- await cleanupStaleDevProcesses(managedPorts);
241
258
  const rpxTlsPreflight = hasCustomDomain && domain ? prepareRpxTlsForDev({ domain, includeDashboard, options }) : Promise.resolve();
242
259
  if (localhostOnly && domain && !proxyManagedExternally)
243
260
  removeStalePublicDomainOverrides(domain, includeDashboard, options.verbose ?? !1).catch(() => {});
@@ -432,16 +449,16 @@ function printDevReadyBanner(input) {
432
449
  console.log();
433
450
  }
434
451
  function printDevEngineNotes() {
435
- const routesFile = join(projectPath(), ".stx/routes.ts"), crosswindConfig = join(projectPath(), "config/crosswind.ts");
452
+ const routesFile = stxPath("routes.ts"), routesLabel = relative(projectPath(), routesFile), crosswindConfig = join(projectPath(), "config/crosswind.ts");
436
453
  if (existsSync(crosswindConfig) || existsSync(join(projectPath(), "crosswind.config.ts")))
437
454
  console.log(` ${green("[Crosswind]")} ${dim("CSS engine loaded")}`);
438
455
  if (existsSync(routesFile))
439
456
  try {
440
457
  const routeCount = (readFileSync(routesFile, "utf8").match(/pattern:/g) ?? []).length;
441
458
  if (routeCount > 0)
442
- console.log(` ${green("[stx]")} ${dim(`Generated ${routeCount} routes \u2192 .stx/routes.ts`)}`);
459
+ console.log(` ${green("[stx]")} ${dim(`Generated ${routeCount} routes \u2192 ${routesLabel}`)}`);
443
460
  } catch {
444
- console.log(` ${green("[stx]")} ${dim("Routes manifest \u2192 .stx/routes.ts")}`);
461
+ console.log(` ${green("[stx]")} ${dim(`Routes manifest \u2192 ${routesLabel}`)}`);
445
462
  }
446
463
  }
447
464
  async function launchNativeAppWindow(url, options) {
@@ -617,6 +634,19 @@ async function probeDevServerHttp(port) {
617
634
  return !0;
618
635
  return !1;
619
636
  }
637
+ async function findAvailablePort(preferred, attempts = 20) {
638
+ for (let port = preferred;port < preferred + attempts; port++) {
639
+ let server;
640
+ try {
641
+ server = Bun.serve({ port, hostname: "0.0.0.0", reusePort: !1, fetch: () => new Response });
642
+ } catch {
643
+ continue;
644
+ }
645
+ server.stop(!0);
646
+ return port;
647
+ }
648
+ return preferred;
649
+ }
620
650
  async function waitForPort(port, timeoutMs) {
621
651
  const deadline = Date.now() + timeoutMs;
622
652
  while (Date.now() < deadline) {
@@ -0,0 +1,8 @@
1
+ import type { BuddyCommandInventoryEntry } from '../list';
2
+ export declare function renderBuddyCommandReference(inventory: BuddyInventory): string;
3
+ export declare function loadBuddyInventory(): BuddyInventory;
4
+ export declare function run(): Promise<void>;
5
+ declare interface BuddyInventory {
6
+ commands: BuddyCommandInventoryEntry[]
7
+ total: number
8
+ }
@@ -0,0 +1,116 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ const root = resolve(import.meta.dir, "../../../../../../.."), outputPath = resolve(root, "docs/guide/buddy/commands.md"), cliPath = resolve(root, "storage/framework/core/buddy/src/cli.ts");
4
+ function markdown(value) {
5
+ return String(value).replaceAll("\\", "\\\\").replaceAll("|", "\\|").replaceAll(`
6
+ `, "<br>");
7
+ }
8
+ function code(value) {
9
+ return `\`${String(value).replaceAll("`", "\\`")}\``;
10
+ }
11
+ function optionFlags(option) {
12
+ return option.flags.map((flag) => code(`${flag.length === 1 ? "-" : "--"}${flag}`)).join(", ");
13
+ }
14
+ function optionKind(option) {
15
+ return [
16
+ option.boolean ? "boolean" : "value",
17
+ option.required ? "required" : "optional",
18
+ ...option.negated ? ["negated"] : []
19
+ ].join(", ");
20
+ }
21
+ function argumentSyntax(argument) {
22
+ const value = `${argument.name}${argument.variadic ? "..." : ""}`;
23
+ return code(argument.required ? `<${value}>` : `[${value}]`);
24
+ }
25
+ function commandSection(command) {
26
+ const lines = [
27
+ `### ${code(command.name)}`,
28
+ "",
29
+ command.description || "No description provided.",
30
+ "",
31
+ `- Usage: ${code(command.usage)}`,
32
+ `- Namespace: ${command.namespace ? code(command.namespace) : "none"}`,
33
+ `- Aliases: ${command.aliases.length ? command.aliases.map(code).join(", ") : "none"}`,
34
+ `- Arguments: ${command.arguments.length ? command.arguments.map(argumentSyntax).join(", ") : "none"}`
35
+ ];
36
+ if (command.options.length)
37
+ lines.push("", "| Option | Description | Contract | Default |", "| --- | --- | --- | --- |", ...command.options.map((option) => `| ${optionFlags(option)} | ${markdown(option.description || "\u2014")} | ${optionKind(option)} | ${option.default === void 0 ? "\u2014" : code(JSON.stringify(option.default))} |`));
38
+ if (command.examples.length)
39
+ lines.push("", "Examples:", "", "```bash", ...command.examples, "```");
40
+ return lines.join(`
41
+ `);
42
+ }
43
+ export function renderBuddyCommandReference(inventory) {
44
+ const commands = [...inventory.commands].sort((a, b) => a.name.localeCompare(b.name));
45
+ if (inventory.total !== commands.length)
46
+ throw Error(`Buddy inventory total ${inventory.total} does not match ${commands.length} commands`);
47
+ if (new Set(commands.map((command) => command.name)).size !== commands.length)
48
+ throw Error("Buddy inventory contains duplicate canonical command names");
49
+ const groups = new Map;
50
+ for (const command of commands) {
51
+ const group = command.namespace || (command.name.includes(":") ? command.name.split(":")[0] : "general"), entries = groups.get(group) || [];
52
+ entries.push(command);
53
+ groups.set(group, entries);
54
+ }
55
+ const groupSummary = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([group, entries]) => `| ${code(group)} | ${entries.length} |`);
56
+ return `${[
57
+ "---",
58
+ "title: Buddy Command Reference",
59
+ "description: Generated reference for every Buddy command, argument, option, alias, default, and example.",
60
+ "---",
61
+ "<!-- Generated by storage/framework/core/buddy/src/commands/docs/buddy-commands.ts. Do not edit by hand. -->",
62
+ "",
63
+ "# Buddy Command Reference",
64
+ "",
65
+ `This reference is generated from Buddy's runtime command registry and currently contains **${commands.length} commands**. Run \`bun run docs:buddy\` after changing the registry; CI rejects stale output.`,
66
+ "",
67
+ "## Command groups",
68
+ "",
69
+ "| Group | Commands |",
70
+ "| --- | ---: |",
71
+ ...groupSummary,
72
+ "",
73
+ "## Commands",
74
+ "",
75
+ ...commands.flatMap((command) => [commandSection(command), ""])
76
+ ].join(`
77
+ `).split(`
78
+ `).map((line) => line.trimEnd()).join(`
79
+ `).trimEnd()}
80
+ `;
81
+ }
82
+ export function loadBuddyInventory() {
83
+ const result = Bun.spawnSync([process.execPath, cliPath, "list", "--json"], {
84
+ cwd: root,
85
+ env: { ...process.env, NO_COLOR: "1" },
86
+ stdout: "pipe",
87
+ stderr: "pipe"
88
+ });
89
+ if (result.exitCode !== 0)
90
+ throw Error(`Buddy command inventory failed (${result.exitCode}): ${result.stderr.toString().trim()}`);
91
+ const inventory = JSON.parse(result.stdout.toString());
92
+ if (!Array.isArray(inventory.commands) || !Number.isInteger(inventory.total))
93
+ throw Error("Buddy command inventory returned an invalid payload");
94
+ return inventory;
95
+ }
96
+ export async function run() {
97
+ const mode = process.argv.includes("--write") ? "write" : process.argv.includes("--check") ? "check" : null;
98
+ if (!mode) {
99
+ console.error("usage: bun storage/framework/core/buddy/src/commands/docs/buddy-commands.ts --write | --check");
100
+ process.exit(2);
101
+ }
102
+ const expected = renderBuddyCommandReference(loadBuddyInventory());
103
+ if (mode === "write") {
104
+ mkdirSync(dirname(outputPath), { recursive: !0 });
105
+ writeFileSync(outputPath, expected);
106
+ console.log(`Wrote ${outputPath}`);
107
+ } else {
108
+ if (!existsSync(outputPath))
109
+ throw Error("Buddy command reference is missing; run bun run docs:buddy");
110
+ if (readFileSync(outputPath, "utf8") !== expected)
111
+ throw Error("Buddy command reference is stale; run bun run docs:buddy");
112
+ console.log("Buddy command reference matches the runtime registry");
113
+ }
114
+ }
115
+ if (import.meta.main)
116
+ await run();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { renderBuddyCommandReference } from "./buddy-commands";
3
+ const command = {
4
+ name: "deploy",
5
+ description: "Deploy the application",
6
+ aliases: ["ship"],
7
+ usage: "$ buddy deploy [environment]",
8
+ arguments: [{ name: "environment", required: !1, variadic: !1 }],
9
+ options: [{
10
+ name: "region",
11
+ flags: ["r", "region"],
12
+ description: "Cloud region",
13
+ required: !0,
14
+ boolean: !1,
15
+ negated: !1,
16
+ default: "us-east-1"
17
+ }],
18
+ examples: ["buddy deploy production"]
19
+ };
20
+ describe("Buddy command reference", () => {
21
+ it("renders every command contract field", () => {
22
+ const result = renderBuddyCommandReference({ commands: [command], total: 1 });
23
+ expect(result).toContain("**1 commands**");
24
+ expect(result).toContain("### `deploy`");
25
+ expect(result).toContain("Aliases: `ship`");
26
+ expect(result).toContain("Arguments: `[environment]`");
27
+ expect(result).toContain('| `-r`, `--region` | Cloud region | value, required | `"us-east-1"` |');
28
+ expect(result).toContain("buddy deploy production");
29
+ });
30
+ it("sorts commands and rejects incomplete inventories", () => {
31
+ const about = { ...command, name: "about" }, result = renderBuddyCommandReference({ commands: [command, about], total: 2 });
32
+ expect(result.indexOf("### `about`")).toBeLessThan(result.indexOf("### `deploy`"));
33
+ expect(() => renderBuddyCommandReference({ commands: [command], total: 2 })).toThrow("does not match");
34
+ });
35
+ it("rejects duplicate canonical names", () => {
36
+ expect(() => renderBuddyCommandReference({ commands: [command, command], total: 2 })).toThrow("duplicate");
37
+ });
38
+ });
@@ -0,0 +1,3 @@
1
+ import { type OpenApiDocument } from '../../../../api/src/generate-types';
2
+ export declare function validateOpenApi(document: OpenApiDocument): string[];
3
+ export declare function run(): Promise<void>;
@@ -0,0 +1,69 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { generateOpenApi } from "../../../../api/src/generate-openapi";
4
+ import { renderOpenApiTypes } from "../../../../api/src/generate-types";
5
+ const root = resolve(import.meta.dir, "../../../../../../.."), openApiPath = resolve(root, "storage/framework/api/openapi.json"), apiTypesPath = resolve(root, "storage/framework/api/api-types.ts");
6
+ export function validateOpenApi(document) {
7
+ const errors = [], paths = Object.entries(document.paths || {});
8
+ if (paths.length < 10)
9
+ errors.push("OpenAPI document has fewer than 10 registered paths");
10
+ const operationIds = new Set;
11
+ for (const [route, item] of paths)
12
+ for (const [method, operation] of Object.entries(item)) {
13
+ if (!operation.operationId)
14
+ errors.push(`${method.toUpperCase()} ${route}: operationId is missing`);
15
+ else if (operationIds.has(operation.operationId))
16
+ errors.push(`${method.toUpperCase()} ${route}: duplicate operationId ${operation.operationId}`);
17
+ else
18
+ operationIds.add(operation.operationId);
19
+ for (const parameter of operation.parameters || [])
20
+ if (parameter.in === "path" && !parameter.required)
21
+ errors.push(`${method.toUpperCase()} ${route}: path parameter ${parameter.name} must be required`);
22
+ }
23
+ return errors;
24
+ }
25
+ async function expectedArtifacts() {
26
+ const document = await generateOpenApi({ write: !1 }), errors = validateOpenApi(document);
27
+ if (errors.length)
28
+ throw Error(errors.join(`
29
+ `));
30
+ return {
31
+ openApi: JSON.stringify(document, null, 2),
32
+ apiTypes: renderOpenApiTypes(document)
33
+ };
34
+ }
35
+ async function write() {
36
+ const expected = await expectedArtifacts();
37
+ writeFileSync(openApiPath, expected.openApi);
38
+ writeFileSync(apiTypesPath, expected.apiTypes);
39
+ console.log("Generated OpenAPI and API type artifacts");
40
+ }
41
+ async function check() {
42
+ const expected = await expectedArtifacts(), errors = [];
43
+ if (readFileSync(openApiPath, "utf8") !== expected.openApi)
44
+ errors.push("storage/framework/api/openapi.json is stale");
45
+ if (readFileSync(apiTypesPath, "utf8") !== expected.apiTypes)
46
+ errors.push("storage/framework/api/api-types.ts is stale");
47
+ if (errors.length)
48
+ throw Error(`${errors.join(`
49
+ `)}
50
+ Run bun run docs:artifacts and review the generated diff.`);
51
+ console.log(`Generated API artifacts are current (${Object.keys(JSON.parse(expected.openApi).paths).length} paths)`);
52
+ }
53
+ export async function run() {
54
+ try {
55
+ if (process.argv.includes("--write"))
56
+ await write();
57
+ else if (process.argv.includes("--check"))
58
+ await check();
59
+ else {
60
+ console.error("usage: bun storage/framework/core/buddy/src/commands/docs/generated-artifacts.ts --write | --check");
61
+ process.exit(2);
62
+ }
63
+ } catch (error) {
64
+ console.error(error instanceof Error ? error.message : String(error));
65
+ process.exit(1);
66
+ }
67
+ }
68
+ if (import.meta.main)
69
+ await run();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { validateOpenApi } from "./generated-artifacts";
3
+ describe("generated API artifact validation", () => {
4
+ it("rejects empty, duplicate, and optional path evidence", () => {
5
+ expect(validateOpenApi({ paths: {} })).toContain("OpenAPI document has fewer than 10 registered paths");
6
+ const paths = {};
7
+ for (let index = 0;index < 10; index++)
8
+ paths[`/items/${index}/{id}`] = {
9
+ get: {
10
+ operationId: index < 2 ? "duplicate" : `get_${index}`,
11
+ parameters: [{ name: "id", in: "path", required: index !== 2, schema: { type: "string" } }],
12
+ responses: {}
13
+ }
14
+ };
15
+ const errors = validateOpenApi({ paths });
16
+ expect(errors).toContain("GET /items/1/{id}: duplicate operationId duplicate");
17
+ expect(errors).toContain("GET /items/2/{id}: path parameter id must be required");
18
+ });
19
+ });
@@ -0,0 +1,18 @@
1
+ /** True for links this checker deliberately does not resolve on disk. */
2
+ export declare function isSkippableLink(target: string): boolean;
3
+ /** Extract inline-link targets with 1-based line numbers, skipping code. */
4
+ export declare function extractLinks(content: string): Array<{ target: string, line: number }>;
5
+ /**
6
+ * On-disk paths a link could legitimately resolve to. Absolute (`/x`) links are
7
+ * rooted at `docsRoot`; relative links at the file's directory. Extensionless
8
+ * links also try `.md` and `index.md` (VitePress clean URLs), and `.html` links
9
+ * try their `.md` source.
10
+ */
11
+ export declare function resolveCandidates(target: string, fileDir: string, docsRoot: string): string[];
12
+ export declare function checkDocsLinks(docsRoot?: unknown): BrokenDocLink[];
13
+ export declare function run(): Promise<void>;
14
+ export declare interface BrokenDocLink {
15
+ file: string
16
+ line: number
17
+ target: string
18
+ }
@@ -0,0 +1,83 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ const root = resolve(import.meta.dir, "../../../../../../.."), docsDir = resolve(root, "docs"), INLINE_LINK = /\[[^\]]*\]\(([^)]+)\)/g;
4
+ export function isSkippableLink(target) {
5
+ return target === "" || target.startsWith("#") || /^[a-z][\w+.-]*:/i.test(target) || target.startsWith("//") || target.startsWith("{{") || target.includes("<");
6
+ }
7
+ export function extractLinks(content) {
8
+ const out = [], lines = content.replace(/<!--[\s\S]*?-->/g, (match) => match.replace(/[^\n]/g, " ")).split(`
9
+ `);
10
+ let inFence = !1;
11
+ for (let index = 0;index < lines.length; index++) {
12
+ const raw = lines[index];
13
+ if (/^\s*(```|~~~)/.test(raw)) {
14
+ inFence = !inFence;
15
+ continue;
16
+ }
17
+ if (inFence)
18
+ continue;
19
+ const line = raw.replace(/`[^`]*`/g, "");
20
+ for (const match of line.matchAll(INLINE_LINK)) {
21
+ let target = match[1].trim();
22
+ const space = target.search(/\s/);
23
+ if (space !== -1)
24
+ target = target.slice(0, space);
25
+ out.push({ target, line: index + 1 });
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+ export function resolveCandidates(target, fileDir, docsRoot) {
31
+ const clean = target.split("#")[0].split("?")[0];
32
+ if (!clean)
33
+ return [];
34
+ const base = clean.startsWith("/") ? join(docsRoot, clean.slice(1)) : resolve(fileDir, clean), candidates = [base];
35
+ if (!/\.\w+$/.test(clean))
36
+ candidates.push(`${base}.md`, join(base, "index.md"));
37
+ else if (clean.endsWith(".html"))
38
+ candidates.push(base.replace(/\.html$/, ".md"), join(base.replace(/\.html$/, ""), "index.md"));
39
+ return candidates;
40
+ }
41
+ function walkMarkdown(dir) {
42
+ const files = [];
43
+ for (const entry of readdirSync(dir, { withFileTypes: !0 })) {
44
+ const full = join(dir, entry.name);
45
+ if (entry.isDirectory()) {
46
+ if (entry.name === "node_modules" || entry.name.startsWith("."))
47
+ continue;
48
+ files.push(...walkMarkdown(full));
49
+ } else if (entry.name.endsWith(".md"))
50
+ files.push(full);
51
+ }
52
+ return files;
53
+ }
54
+ export function checkDocsLinks(docsRoot = docsDir) {
55
+ const broken = [];
56
+ for (const file of walkMarkdown(docsRoot)) {
57
+ const content = readFileSync(file, "utf8");
58
+ for (const { target, line } of extractLinks(content)) {
59
+ if (isSkippableLink(target))
60
+ continue;
61
+ const candidates = resolveCandidates(target, dirname(file), docsRoot);
62
+ if (candidates.length === 0)
63
+ continue;
64
+ if (!candidates.some((candidate) => existsSync(candidate) && statSync(candidate).isFile()))
65
+ broken.push({ file: relative(docsRoot, file), line, target });
66
+ }
67
+ }
68
+ return broken;
69
+ }
70
+ export async function run() {
71
+ const broken = checkDocsLinks();
72
+ if (broken.length === 0)
73
+ console.log("\u2713 All internal documentation links resolve.");
74
+ else {
75
+ console.error(`\u2717 ${broken.length} broken internal documentation link(s):`);
76
+ for (const link of broken)
77
+ console.error(` ${link.file}:${link.line} -> ${link.target}`);
78
+ if (process.argv.includes("--check"))
79
+ process.exit(1);
80
+ }
81
+ }
82
+ if (import.meta.main)
83
+ await run();
@@ -0,0 +1 @@
1
+ export {};