@stacksjs/buddy 0.70.162 → 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 (80) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +1 -0
  3. package/dist/commands/ai-context.d.ts +13 -0
  4. package/dist/commands/ai-context.js +29 -0
  5. package/dist/commands/create.js +1 -1
  6. package/dist/commands/deploy.d.ts +11 -1
  7. package/dist/commands/deploy.js +29 -28
  8. package/dist/commands/dev.js +44 -14
  9. package/dist/commands/docs/buddy-commands.d.ts +8 -0
  10. package/dist/commands/docs/buddy-commands.js +116 -0
  11. package/dist/commands/docs/buddy-commands.test.d.ts +1 -0
  12. package/dist/commands/docs/buddy-commands.test.js +38 -0
  13. package/dist/commands/docs/generated-artifacts.d.ts +3 -0
  14. package/dist/commands/docs/generated-artifacts.js +69 -0
  15. package/dist/commands/docs/generated-artifacts.test.d.ts +1 -0
  16. package/dist/commands/docs/generated-artifacts.test.js +19 -0
  17. package/dist/commands/docs/links.d.ts +18 -0
  18. package/dist/commands/docs/links.js +83 -0
  19. package/dist/commands/docs/links.test.d.ts +1 -0
  20. package/dist/commands/docs/links.test.js +73 -0
  21. package/dist/commands/docs.d.ts +7 -0
  22. package/dist/commands/docs.js +24 -0
  23. package/dist/commands/doctor.js +8 -0
  24. package/dist/commands/env.d.ts +14 -0
  25. package/dist/commands/env.js +73 -7
  26. package/dist/commands/generate.js +1 -1
  27. package/dist/commands/index.d.ts +2 -0
  28. package/dist/commands/index.js +2 -0
  29. package/dist/commands/mail.js +1 -1
  30. package/dist/commands/migrate.js +9 -7
  31. package/dist/commands/protocol/craft-evidence.d.ts +76 -0
  32. package/dist/commands/protocol/craft-evidence.js +98 -0
  33. package/dist/commands/protocol/craft-evidence.test.d.ts +1 -0
  34. package/dist/commands/protocol/craft-evidence.test.js +20 -0
  35. package/dist/commands/protocol/desktop-lifecycle-report.d.ts +16 -0
  36. package/dist/commands/protocol/desktop-lifecycle-report.js +57 -0
  37. package/dist/commands/protocol/desktop-lifecycle-report.test.d.ts +1 -0
  38. package/dist/commands/protocol/desktop-lifecycle-report.test.js +32 -0
  39. package/dist/commands/protocol/desktop-support.d.ts +1 -0
  40. package/dist/commands/protocol/desktop-support.js +32 -0
  41. package/dist/commands/protocol/desktop-workflow.test.d.ts +1 -0
  42. package/dist/commands/protocol/desktop-workflow.test.js +33 -0
  43. package/dist/commands/protocol/driver-registry.d.ts +1 -0
  44. package/dist/commands/protocol/driver-registry.js +71 -0
  45. package/dist/commands/protocol/pantry-evidence.d.ts +67 -0
  46. package/dist/commands/protocol/pantry-evidence.js +144 -0
  47. package/dist/commands/protocol/pantry-evidence.test.d.ts +1 -0
  48. package/dist/commands/protocol/pantry-evidence.test.js +29 -0
  49. package/dist/commands/protocol/release-manifest.d.ts +30 -0
  50. package/dist/commands/protocol/release-manifest.js +102 -0
  51. package/dist/commands/protocol/release-manifest.test.d.ts +1 -0
  52. package/dist/commands/protocol/release-manifest.test.js +34 -0
  53. package/dist/commands/protocol/run-conformance.d.ts +21 -0
  54. package/dist/commands/protocol/run-conformance.js +358 -0
  55. package/dist/commands/protocol/run-conformance.test.d.ts +1 -0
  56. package/dist/commands/protocol/run-conformance.test.js +52 -0
  57. package/dist/commands/protocol/run-driver-contracts.d.ts +7 -0
  58. package/dist/commands/protocol/run-driver-contracts.js +94 -0
  59. package/dist/commands/protocol/run-driver-contracts.test.d.ts +1 -0
  60. package/dist/commands/protocol/run-driver-contracts.test.js +18 -0
  61. package/dist/commands/protocol/source-manifest.d.ts +25 -0
  62. package/dist/commands/protocol/source-manifest.js +113 -0
  63. package/dist/commands/protocol/sync-suite.d.ts +8 -0
  64. package/dist/commands/protocol/sync-suite.js +137 -0
  65. package/dist/commands/protocol/sync-suite.test.d.ts +1 -0
  66. package/dist/commands/protocol/sync-suite.test.js +44 -0
  67. package/dist/commands/protocol.d.ts +8 -0
  68. package/dist/commands/protocol.js +60 -0
  69. package/dist/commands/publish.js +149 -0
  70. package/dist/commands/run-tool.d.ts +7 -0
  71. package/dist/commands/run-tool.js +9 -0
  72. package/dist/commands/seed.js +15 -40
  73. package/dist/commands/serve.d.ts +18 -5
  74. package/dist/commands/serve.js +21 -2
  75. package/dist/commands/setup-ai.d.ts +39 -0
  76. package/dist/commands/setup-ai.js +121 -0
  77. package/dist/commands/setup.js +22 -0
  78. package/dist/lazy-commands.js +28 -1
  79. package/dist/project-setup.js +1 -0
  80. package/package.json +43 -43
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");
@@ -0,0 +1,13 @@
1
+ import type { CLI, CliOptions } from '@stacksjs/types';
2
+ export declare function generateAiContextOutput(options?: AiContextCommandOptions, projectRoot?: string): AiContextCommandResult;
3
+ export declare function aiContext(buddy: CLI): void;
4
+ export declare interface AiContextCommandOptions extends CliOptions {
5
+ json?: boolean
6
+ output?: string
7
+ maxChars?: string | number
8
+ model?: string
9
+ }
10
+ export declare interface AiContextCommandResult {
11
+ output: string
12
+ outputPath: string | null
13
+ }
@@ -0,0 +1,29 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import process from "node:process";
4
+ import { buildProjectContext } from "@stacksjs/ai";
5
+ import { onUnknownSubcommand } from "@stacksjs/cli";
6
+ export function generateAiContextOutput(options = {}, projectRoot = process.cwd()) {
7
+ const maxChars = options.maxChars === void 0 ? void 0 : Number(options.maxChars), result = buildProjectContext(projectRoot, { maxChars, model: options.model }), output = options.json ? `${JSON.stringify(result, null, 2)}
8
+ ` : `${result.text}
9
+
10
+ Context metrics:
11
+ ${JSON.stringify(result.metrics, null, 2)}
12
+ `;
13
+ if (!options.output)
14
+ return { output, outputPath: null };
15
+ const outputPath = resolve(projectRoot, String(options.output));
16
+ mkdirSync(dirname(outputPath), { recursive: !0 });
17
+ writeFileSync(outputPath, output);
18
+ return { output, outputPath };
19
+ }
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 storage/framework/runtime/ai-context.json").action((options) => {
22
+ const generated = generateAiContextOutput(options);
23
+ if (generated.outputPath)
24
+ console.log(`Wrote AI project context to ${generated.outputPath}`);
25
+ else
26
+ process.stdout.write(generated.output);
27
+ });
28
+ onUnknownSubcommand(buddy, "ai:context");
29
+ }
@@ -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";
@@ -395,7 +395,7 @@ async function pollUntil(opts) {
395
395
  let lastHeartbeat = 0;
396
396
  for (;; )
397
397
  try {
398
- opts.check();
398
+ await opts.check();
399
399
  return;
400
400
  } catch {
401
401
  const elapsedSecs = Math.floor((Date.now() - started) / 1000);
@@ -408,33 +408,18 @@ async function pollUntil(opts) {
408
408
  await new Promise((r) => setTimeout(r, opts.intervalMs ?? 5000));
409
409
  }
410
410
  }
411
- async function waitForRemoteReady(ip, verbose) {
412
- const { execSync } = await import("node:child_process"), sshArgs = [
413
- "-o",
414
- "StrictHostKeyChecking=no",
415
- "-o",
416
- "UserKnownHostsFile=/dev/null",
417
- "-o",
418
- "BatchMode=yes",
419
- "-o",
420
- "ConnectTimeout=10",
421
- `root@${ip}`
422
- ], run = (remote) => execSync(`ssh ${sshArgs.map((a) => `'${a}'`).join(" ")} '${remote.replace(/'/g, "'\\''")}'`, {
423
- encoding: "utf8",
424
- stdio: ["ignore", "pipe", verbose ? "inherit" : "pipe"]
425
- }), sshWaitSecs = readWaitSecs("TS_CLOUD_SSH_WAIT_SECS", 480);
411
+ async function waitForRemoteReady(ip) {
412
+ const { sshExecOrThrow } = await import("@stacksjs/ts-cloud"), run = (remote) => sshExecOrThrow(ip, remote, { user: "root", connectTimeoutSec: 10 }), sshWaitSecs = readWaitSecs("TS_CLOUD_SSH_WAIT_SECS", 480);
426
413
  await pollUntil({
427
414
  label: "Waiting for SSH to come up",
428
415
  timeoutSecs: sshWaitSecs,
429
- check: () => {
430
- run("true");
431
- },
416
+ check: () => run("true"),
432
417
  timeoutMessage: (elapsed) => `SSH did not become reachable on ${ip} within ${fmtDuration(sshWaitSecs)} (waited ${elapsed}s). ` + "The box may still be booting \u2014 raise TS_CLOUD_SSH_WAIT_SECS and retry."
433
418
  });
434
419
  log.success("SSH is up");
435
420
  log.info("Waiting for cloud-init (installing bun + caddy)...");
436
421
  try {
437
- run("cloud-init status --wait || true");
422
+ await run("cloud-init status --wait || true");
438
423
  } catch (err) {
439
424
  log.debug("cloud-init status --wait returned non-zero (continuing):", err);
440
425
  }
@@ -442,14 +427,20 @@ async function waitForRemoteReady(ip, verbose) {
442
427
  await pollUntil({
443
428
  label: "Waiting for the bun runtime",
444
429
  timeoutSecs: bootWaitSecs,
445
- check: () => {
446
- run("test -x /usr/local/bin/bun");
447
- },
430
+ check: () => run("test -x /usr/local/bin/bun"),
448
431
  timeoutMessage: (elapsed) => `bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(bootWaitSecs)} (waited ${elapsed}s). ` + "cloud-init may have failed \u2014 SSH in and check /var/log/cloud-init-output.log; " + "raise TS_CLOUD_BOOT_WAIT_SECS for slow regions."
449
432
  });
450
433
  log.success("Server is ready (bun installed)");
451
434
  }
452
- 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) {
453
444
  const fileName = environment === "production" ? ".env.production" : environment === "staging" ? ".env.staging" : existsSync(p.projectPath(".env.development")) ? ".env.development" : ".env", filePath = p.projectPath(fileName);
454
445
  if (!existsSync(filePath))
455
446
  return {};
@@ -465,7 +456,13 @@ export async function resolveDeployEnvValues(environment) {
465
456
  continue;
466
457
  values[key] = String(value);
467
458
  }
468
- 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;
469
466
  } catch (error) {
470
467
  log.debug(`[deploy] Failed to resolve ${fileName} for site env merging:`, error);
471
468
  return {};
@@ -716,7 +713,7 @@ async function runHetznerDeploy(args) {
716
713
  log.error("Provisioned server has no public IP \u2014 cannot deploy over SSH.");
717
714
  process.exit(ExitCode.FatalError);
718
715
  }
719
- await waitForRemoteReady(ip, verbose);
716
+ await waitForRemoteReady(ip);
720
717
  const { execSync } = await import("node:child_process"), { tmpdir } = await import("node:os"), sites = applyEnvironmentToSites(tsCloudConfig.sites || {}, environment, tsCloudConfig), slug = tsCloudConfig.project?.slug || "app";
721
718
  let sha;
722
719
  try {
@@ -732,7 +729,11 @@ async function runHetznerDeploy(args) {
732
729
  ".cache",
733
730
  "bin",
734
731
  "dist",
732
+ relative(p.projectPath(), p.stxPath()).replace(/\/$/, ""),
735
733
  ".stx",
734
+ relative(p.projectPath(), p.cloudStatePath()).replace(/\/$/, ""),
735
+ ".ts-cloud",
736
+ relative(p.projectPath(), p.frameworkRuntimePath()).replace(/\/$/, ""),
736
737
  "tmp",
737
738
  "temp",
738
739
  ".DS_Store",
@@ -771,7 +772,7 @@ async function runHetznerDeploy(args) {
771
772
  }
772
773
  if (docker)
773
774
  await buildContainerImageWithPantry({ slug, sites, verbose });
774
- const resolvedDeployEnv = await resolveDeployEnvValues(environment), sitesWithResolvedEnv = mergeSiteDeployEnv(sites, resolvedDeployEnv);
775
+ const resolvedDeployEnv = await resolveDeployEnvValues(environment, tsCloudConfig), sitesWithResolvedEnv = mergeSiteDeployEnv(sites, resolvedDeployEnv);
775
776
  for (const [envKey, envValue] of Object.entries(resolvedDeployEnv))
776
777
  if (process.env[envKey] === void 0)
777
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
+ }