@stacksjs/buddy 0.70.163 → 0.70.165
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.js +1 -0
- package/dist/commands/ai-context.js +1 -1
- package/dist/commands/create.js +1 -1
- package/dist/commands/deploy.d.ts +11 -1
- package/dist/commands/deploy.js +22 -4
- package/dist/commands/dev.js +62 -14
- package/dist/commands/docs/buddy-commands.d.ts +8 -0
- package/dist/commands/docs/buddy-commands.js +116 -0
- package/dist/commands/docs/buddy-commands.test.d.ts +1 -0
- package/dist/commands/docs/buddy-commands.test.js +38 -0
- package/dist/commands/docs/generated-artifacts.d.ts +3 -0
- package/dist/commands/docs/generated-artifacts.js +69 -0
- package/dist/commands/docs/generated-artifacts.test.d.ts +1 -0
- package/dist/commands/docs/generated-artifacts.test.js +19 -0
- package/dist/commands/docs/links.d.ts +18 -0
- package/dist/commands/docs/links.js +83 -0
- package/dist/commands/docs/links.test.d.ts +1 -0
- package/dist/commands/docs/links.test.js +73 -0
- package/dist/commands/docs.d.ts +7 -0
- package/dist/commands/docs.js +24 -0
- package/dist/commands/doctor.js +8 -0
- package/dist/commands/env.d.ts +14 -0
- package/dist/commands/env.js +73 -7
- package/dist/commands/generate.js +1 -1
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/mail.js +1 -1
- package/dist/commands/migrate.js +4 -4
- package/dist/commands/protocol/craft-evidence.d.ts +76 -0
- package/dist/commands/protocol/craft-evidence.js +98 -0
- package/dist/commands/protocol/craft-evidence.test.d.ts +1 -0
- package/dist/commands/protocol/craft-evidence.test.js +20 -0
- package/dist/commands/protocol/desktop-lifecycle-report.d.ts +16 -0
- package/dist/commands/protocol/desktop-lifecycle-report.js +57 -0
- package/dist/commands/protocol/desktop-lifecycle-report.test.d.ts +1 -0
- package/dist/commands/protocol/desktop-lifecycle-report.test.js +32 -0
- package/dist/commands/protocol/desktop-support.d.ts +1 -0
- package/dist/commands/protocol/desktop-support.js +32 -0
- package/dist/commands/protocol/desktop-workflow.test.d.ts +1 -0
- package/dist/commands/protocol/desktop-workflow.test.js +33 -0
- package/dist/commands/protocol/driver-registry.d.ts +1 -0
- package/dist/commands/protocol/driver-registry.js +71 -0
- package/dist/commands/protocol/pantry-evidence.d.ts +67 -0
- package/dist/commands/protocol/pantry-evidence.js +144 -0
- package/dist/commands/protocol/pantry-evidence.test.d.ts +1 -0
- package/dist/commands/protocol/pantry-evidence.test.js +29 -0
- package/dist/commands/protocol/release-manifest.d.ts +30 -0
- package/dist/commands/protocol/release-manifest.js +102 -0
- package/dist/commands/protocol/release-manifest.test.d.ts +1 -0
- package/dist/commands/protocol/release-manifest.test.js +34 -0
- package/dist/commands/protocol/run-conformance.d.ts +21 -0
- package/dist/commands/protocol/run-conformance.js +358 -0
- package/dist/commands/protocol/run-conformance.test.d.ts +1 -0
- package/dist/commands/protocol/run-conformance.test.js +52 -0
- package/dist/commands/protocol/run-driver-contracts.d.ts +7 -0
- package/dist/commands/protocol/run-driver-contracts.js +94 -0
- package/dist/commands/protocol/run-driver-contracts.test.d.ts +1 -0
- package/dist/commands/protocol/run-driver-contracts.test.js +18 -0
- package/dist/commands/protocol/source-manifest.d.ts +25 -0
- package/dist/commands/protocol/source-manifest.js +113 -0
- package/dist/commands/protocol/sync-suite.d.ts +8 -0
- package/dist/commands/protocol/sync-suite.js +137 -0
- package/dist/commands/protocol/sync-suite.test.d.ts +1 -0
- package/dist/commands/protocol/sync-suite.test.js +44 -0
- package/dist/commands/protocol.d.ts +8 -0
- package/dist/commands/protocol.js +60 -0
- package/dist/commands/publish.js +149 -0
- package/dist/commands/run-tool.d.ts +7 -0
- package/dist/commands/run-tool.js +9 -0
- package/dist/commands/seed.js +15 -40
- package/dist/commands/serve.d.ts +18 -5
- package/dist/commands/serve.js +21 -2
- package/dist/commands/setup-ai.d.ts +39 -0
- package/dist/commands/setup-ai.js +121 -0
- package/dist/commands/setup.js +22 -0
- package/dist/lazy-commands.js +27 -1
- 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://
|
|
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
|
|
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}`);
|
package/dist/commands/create.js
CHANGED
|
@@ -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
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/commands/dev.js
CHANGED
|
@@ -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",
|
|
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}
|
|
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 =
|
|
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
|
|
459
|
+
console.log(` ${green("[stx]")} ${dim(`Generated ${routeCount} routes \u2192 ${routesLabel}`)}`);
|
|
443
460
|
} catch {
|
|
444
|
-
console.log(` ${green("[stx]")} ${dim(
|
|
461
|
+
console.log(` ${green("[stx]")} ${dim(`Routes manifest \u2192 ${routesLabel}`)}`);
|
|
445
462
|
}
|
|
446
463
|
}
|
|
447
464
|
async function launchNativeAppWindow(url, options) {
|
|
@@ -617,6 +634,37 @@ 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
|
+
if (await portIsFree(port))
|
|
640
|
+
return port;
|
|
641
|
+
return preferred;
|
|
642
|
+
}
|
|
643
|
+
async function portIsFree(port) {
|
|
644
|
+
for (const host of ["127.0.0.1", "::1"])
|
|
645
|
+
if (await someoneIsListening(port, host))
|
|
646
|
+
return !1;
|
|
647
|
+
let server;
|
|
648
|
+
try {
|
|
649
|
+
server = Bun.serve({ port, hostname: "0.0.0.0", reusePort: !1, fetch: () => new Response });
|
|
650
|
+
} catch {
|
|
651
|
+
return !1;
|
|
652
|
+
}
|
|
653
|
+
server.stop(!0);
|
|
654
|
+
return !0;
|
|
655
|
+
}
|
|
656
|
+
async function someoneIsListening(port, host) {
|
|
657
|
+
const { connect } = await import("node:net");
|
|
658
|
+
return new Promise((resolve) => {
|
|
659
|
+
const socket = connect({ port, host }), finish = (listening) => {
|
|
660
|
+
socket.destroy();
|
|
661
|
+
resolve(listening);
|
|
662
|
+
};
|
|
663
|
+
socket.setTimeout(300, () => finish(!1));
|
|
664
|
+
socket.once("connect", () => finish(!0));
|
|
665
|
+
socket.once("error", () => finish(!1));
|
|
666
|
+
});
|
|
667
|
+
}
|
|
620
668
|
async function waitForPort(port, timeoutMs) {
|
|
621
669
|
const deadline = Date.now() + timeoutMs;
|
|
622
670
|
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,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 {};
|