@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
@@ -14,9 +14,12 @@ export function publish(buddy) {
14
14
  middleware: "Publish a default middleware from storage/framework/defaults/app/Middleware/ to app/Middleware/",
15
15
  action: "Publish a default action from storage/framework/defaults/app/Actions/ to app/Actions/",
16
16
  core: "Publish a framework package source from node_modules/@stacksjs/<pkg>/ into storage/framework/core/<pkg>/ for editing",
17
+ unpublishCore: "Drop a vendored storage/framework/core/<pkg>/ and go back to the installed @stacksjs/<pkg>",
18
+ all: "Unvendor the whole framework: remove storage/framework/core and resolve every @stacksjs package from the `stacks` dependency in package.json",
17
19
  name: "The name of the resource to publish (e.g. Cart, User)",
18
20
  pkg: "The name of the framework package (e.g. router, orm, faker \u2014 without @stacksjs/ prefix)",
19
21
  force: "Overwrite an existing userland file",
22
+ forceUnpublish: "Delete the vendored source even when it has uncommitted changes",
20
23
  verbose: "Enable verbose output"
21
24
  };
22
25
  buddy.command("publish:model <name>", descriptions.model).option("--force", descriptions.force, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (name, options) => {
@@ -58,6 +61,17 @@ export function publish(buddy) {
58
61
  buddy.command("publish:core <pkg>", descriptions.core).option("--force", descriptions.force, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (pkg, options) => {
59
62
  await publishCorePackage(pkg, !!options.force);
60
63
  });
64
+ buddy.command("unpublish:core [pkg]", descriptions.unpublishCore).option("--all", descriptions.all, { default: !1 }).option("--force", descriptions.forceUnpublish, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (pkg, options) => {
65
+ if (options.all) {
66
+ await unvendorFramework(!!options.force);
67
+ return;
68
+ }
69
+ if (!pkg) {
70
+ log.error("Usage: buddy unpublish:core <pkg> (or --all to move the whole framework to node_modules)");
71
+ process.exit(ExitCode.FatalError);
72
+ }
73
+ await unpublishCorePackage(pkg, !!options.force);
74
+ });
61
75
  buddy.command("publish [resource] [name]", descriptions.command).option("--force", descriptions.force, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (resource, name, options) => {
62
76
  if (!resource || !name) {
63
77
  log.error("Usage: buddy publish:<resource> <Name> (e.g. buddy publish:model Cart)");
@@ -170,3 +184,138 @@ async function publishResource(ctx) {
170
184
  await fs.promises.copyFile(sourcePath, targetPath);
171
185
  log.success(`Published ${kind} ${italic(name)} \u2192 ${italic(targetPath.replace(`${process.cwd()}/`, ""))}`);
172
186
  }
187
+ async function unpublishCorePackage(pkg, force) {
188
+ const shortName = normalizeCoreName(pkg), targetDir = path.frameworkPath(`core/${shortName}`), rel = (p) => p.replace(`${process.cwd()}/`, "");
189
+ if (!existsSync(targetDir)) {
190
+ log.info(`Not vendored: ${italic(rel(targetDir))} \u2014 nothing to do.`);
191
+ return;
192
+ }
193
+ const installed = resolve(process.cwd(), "node_modules", "@stacksjs", shortName);
194
+ if (!existsSync(installed) && !force) {
195
+ log.error(`@stacksjs/${shortName} is not installed, so removing the vendored copy would leave nothing to resolve.`);
196
+ log.info(`Run \`bun add @stacksjs/${shortName}\` first, or pass --force to remove it anyway.`);
197
+ process.exit(ExitCode.FatalError);
198
+ }
199
+ await assertNoUncommittedChanges(targetDir, force);
200
+ await fs.promises.rm(targetDir, { recursive: !0, force: !0 });
201
+ log.success(`Unpublished ${italic(rel(targetDir))} \u2014 @stacksjs/${shortName} now resolves from node_modules.`);
202
+ }
203
+ async function unvendorFramework(force) {
204
+ const coreDir = path.frameworkPath("core"), rel = (p) => p.replace(`${process.cwd()}/`, "");
205
+ if (!existsSync(coreDir)) {
206
+ log.info("No storage/framework/core in this project \u2014 already on the installed packages.");
207
+ return;
208
+ }
209
+ const corePkgPath = resolve(coreDir, "package.json");
210
+ if (!existsSync(corePkgPath)) {
211
+ log.error(`${rel(coreDir)} has no package.json, so its version cannot be determined.`);
212
+ log.info("Unvendor the packages individually with `buddy unpublish:core <pkg>` instead.");
213
+ process.exit(ExitCode.FatalError);
214
+ }
215
+ const corePkg = JSON.parse(await fs.promises.readFile(corePkgPath, "utf-8")), version = corePkg.version;
216
+ if (!version) {
217
+ log.error(`${rel(corePkgPath)} has no version field.`);
218
+ process.exit(ExitCode.FatalError);
219
+ }
220
+ await assertNoUncommittedChanges(coreDir, force);
221
+ const rootPkgPath = resolve(process.cwd(), "package.json"), rootPkgRaw = await fs.promises.readFile(rootPkgPath, "utf-8"), rootPkg = JSON.parse(rootPkgRaw), depName = corePkg.name ?? "stacks", range = `^${version}`, provided = new Set([depName, ...Object.keys(corePkg.dependencies ?? {}).filter((name) => name.startsWith("@stacksjs/"))]);
222
+ for (const entry of await readdir(coreDir, { withFileTypes: !0 }))
223
+ if (entry.isDirectory())
224
+ provided.add(`@stacksjs/${entry.name}`);
225
+ let repointed = 0;
226
+ const repointWorkspaceRanges = (pkg) => {
227
+ let touched = !1;
228
+ for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
229
+ const deps = pkg[field];
230
+ if (!deps)
231
+ continue;
232
+ for (const [name, spec] of Object.entries(deps)) {
233
+ if (!spec.startsWith("workspace:") || !provided.has(name))
234
+ continue;
235
+ deps[name] = range;
236
+ touched = !0;
237
+ repointed++;
238
+ }
239
+ }
240
+ return touched;
241
+ };
242
+ repointWorkspaceRanges(rootPkg);
243
+ if (!rootPkg.dependencies?.[depName] && !rootPkg.devDependencies?.[depName])
244
+ rootPkg.dependencies = { ...rootPkg.dependencies, [depName]: range };
245
+ if (Array.isArray(rootPkg.workspaces)) {
246
+ rootPkg.workspaces = rootPkg.workspaces.filter((glob) => !isCoreWorkspaceGlob(glob));
247
+ if (rootPkg.workspaces.length === 0)
248
+ delete rootPkg.workspaces;
249
+ }
250
+ await fs.promises.writeFile(rootPkgPath, `${JSON.stringify(rootPkg, null, 2)}
251
+ `);
252
+ for (const glob of rootPkg.workspaces ?? [])
253
+ for (const memberPkgPath of globSync(`${glob.replace(/\/$/, "")}/package.json`, { cwd: process.cwd(), absolute: !0 })) {
254
+ const raw = await fs.promises.readFile(memberPkgPath, "utf-8"), memberPkg = JSON.parse(raw);
255
+ if (repointWorkspaceRanges(memberPkg))
256
+ await fs.promises.writeFile(memberPkgPath, `${JSON.stringify(memberPkg, null, 2)}
257
+ `);
258
+ }
259
+ const bunfigPath = resolve(process.cwd(), "bunfig.toml");
260
+ let rewrittenPreloads = 0;
261
+ if (existsSync(bunfigPath)) {
262
+ const bunfig = await fs.promises.readFile(bunfigPath, "utf-8"), next = bunfig.replace(/(["'])\.?\/?storage\/framework\/core\/([\w-]+)\/([^"']+?)\.ts\1/g, (_match, quote, pkgName, subpath) => {
263
+ rewrittenPreloads++;
264
+ return `${quote}@stacksjs/${pkgName}/${subpath}.js${quote}`;
265
+ });
266
+ if (next !== bunfig)
267
+ await fs.promises.writeFile(bunfigPath, next);
268
+ }
269
+ await fs.promises.rm(coreDir, { recursive: !0, force: !0 });
270
+ log.success(`Removed ${italic(rel(coreDir))}`);
271
+ log.info(`package.json now depends on ${depName}@${range}`);
272
+ if (repointed > 0)
273
+ log.info(`Repointed ${repointed} workspace: range${repointed === 1 ? "" : "s"} to ${range}`);
274
+ if (rewrittenPreloads > 0)
275
+ log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads === 1 ? "" : "s"} to package specifiers`);
276
+ log.info("Installing the published packages...");
277
+ if (await Bun.spawn(["bun", "install"], { cwd: process.cwd(), stdout: "inherit", stderr: "inherit" }).exited !== 0) {
278
+ log.error("`bun install` failed. package.json and bunfig.toml were updated; re-run the install once the failure is resolved.");
279
+ process.exit(ExitCode.FatalError);
280
+ }
281
+ log.success("This project now runs on the published Stacks packages.");
282
+ log.info("Vendor an individual package again any time with `buddy publish:core <pkg>`.");
283
+ }
284
+ function normalizeCoreName(pkg) {
285
+ const shortName = pkg.replace(/^@stacksjs\//, "").replace(/^core\//, "");
286
+ if (!shortName || shortName.includes("/") || shortName.includes("..")) {
287
+ process.stderr.write(`Invalid package name: ${pkg}
288
+ `);
289
+ process.stderr.write(" Use a short name like `router` or the fully qualified `@stacksjs/router`.\n");
290
+ process.exit(ExitCode.FatalError);
291
+ }
292
+ return shortName;
293
+ }
294
+ function isCoreWorkspaceGlob(glob) {
295
+ const normalized = glob.replace(/^\.\//, "").replace(/\/$/, "");
296
+ return normalized === "storage/framework/core" || normalized.startsWith("storage/framework/core/");
297
+ }
298
+ async function assertNoUncommittedChanges(dir, force) {
299
+ if (force)
300
+ return;
301
+ try {
302
+ const proc = Bun.spawn(["git", "status", "--porcelain", "--", dir], {
303
+ cwd: process.cwd(),
304
+ stdout: "pipe",
305
+ stderr: "ignore"
306
+ }), output = await new Response(proc.stdout).text();
307
+ if (await proc.exited !== 0)
308
+ return;
309
+ const changed = output.split(`
310
+ `).filter(Boolean);
311
+ if (changed.length === 0)
312
+ return;
313
+ log.error(`${changed.length} uncommitted change${changed.length === 1 ? "" : "s"} under ${italic(dir.replace(`${process.cwd()}/`, ""))}:`);
314
+ for (const line of changed.slice(0, 10))
315
+ log.info(` ${line}`);
316
+ if (changed.length > 10)
317
+ log.info(` ... and ${changed.length - 10} more`);
318
+ log.info("Commit or stash them first, or pass --force to delete them anyway.");
319
+ process.exit(ExitCode.FatalError);
320
+ } catch {}
321
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Invoke a migrated tooling module's `run()` with a synthetic argv so its
3
+ * existing `process.argv` flag parsing (`--check` / `--write` / `--revision` …)
4
+ * sees the flags the buddy command wants, then restore the real argv. Buddy runs
5
+ * one command per process and the action is terminal, so this swap is safe.
6
+ */
7
+ export declare function runTool(run: () => void | Promise<void>, ...flags: string[]): Promise<void>;
@@ -0,0 +1,9 @@
1
+ export async function runTool(run, ...flags) {
2
+ const saved = process.argv;
3
+ process.argv = [saved[0] ?? "bun", saved[1] ?? "buddy", ...flags];
4
+ try {
5
+ await run();
6
+ } finally {
7
+ process.argv = saved;
8
+ }
9
+ }
@@ -7,49 +7,24 @@ export function seed(buddy) {
7
7
  project: "Target a specific project",
8
8
  verbose: "Enable verbose output"
9
9
  };
10
- buddy.command("seed", descriptions.seed).alias("db:seed").option("-p, --project [project]", descriptions.project, { default: !1 }).option("-c, --class [class]", "Run a specific seeder class from database/seeders/", { default: "" }).option("--allow-protected", "Seed auth/oauth models even on a non-fresh DB (will invalidate live tokens)", { default: !1 }).option("--fresh", "Truncate tables before seeding", { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
10
+ buddy.command("seed", descriptions.seed).alias("db:seed").option("-p, --project [project]", descriptions.project, { default: !1 }).option("--only [models]", "Comma-separated list of models to seed", { default: "" }).option("--except [models]", "Comma-separated list of models to skip", { default: "" }).option("--include-defaults", "Also seed the framework's built-in models", { default: !1 }).option("--allow-protected", "Seed auth/oauth models even on a non-fresh DB (will invalidate live tokens)", { default: !1 }).option("--fresh", "Truncate tables before seeding", { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
11
11
  log.debug("Running `buddy seed` ...", options);
12
- const perf = await intro("buddy seed");
13
- if (options.class) {
14
- const { injectGlobalAutoImports } = await import("@stacksjs/server");
15
- await injectGlobalAutoImports();
16
- const { runClassSeeders } = await import("@stacksjs/database"), result = await runClassSeeders({ class: options.class });
17
- await outro(`Class seeders: ran=${result.ran.length}, skipped=${result.skipped.length}`, { startTime: perf, useSeconds: !0 });
18
- process.exit(result.ran.length > 0 ? ExitCode.Success : ExitCode.FatalError);
19
- }
20
- const { injectGlobalAutoImports } = await import("@stacksjs/server");
12
+ const perf = await intro("buddy seed"), { injectGlobalAutoImports } = await import("@stacksjs/server");
21
13
  await injectGlobalAutoImports();
22
- const { runClassSeeders, listSeedableModels } = await import("@stacksjs/database"), { path } = await import("@stacksjs/path"), { fs } = await import("@stacksjs/storage"), seedableModels = await listSeedableModels(), seedersDir = path.projectPath("database/seeders"), existingSeederFiles = fs.existsSync(seedersDir) ? new Set(fs.readdirSync(seedersDir).filter((f) => f.endsWith(".ts"))) : new Set, unmigratedModels = seedableModels.filter((m) => !existingSeederFiles.has(`${m.name}Seeder.ts`));
23
- if (unmigratedModels.length > 0)
24
- log.warn(`[seed] ${unmigratedModels.length} model(s) declare the deprecated \`useSeeder\` trait but have no class seeder file: ${unmigratedModels.map((m) => m.name).join(", ")}. ` + "The auto-walker has been removed (stacksjs/stacks#1919) \u2014 these models will NOT seed. " + "Run `./buddy seed:scaffold` to codemod a class seeder per model and strip the trait (stacksjs/stacks#1929), then re-run `./buddy seed`.");
25
- const classResult = await runClassSeeders(), APP_ENV = process.env.APP_ENV || "local", ran = classResult.ran.length;
26
- await outro(`Seeded your ${APP_ENV} database. Class seeders: ran=${ran}, skipped=${classResult.skipped.length}.`, { startTime: perf, useSeconds: !0 });
27
- process.exit(ExitCode.Success);
28
- });
29
- buddy.command("seed:scaffold", "Generate class seeders for every model with a useSeeder trait (codemod for stacksjs/stacks#1919)").option("--force", "Overwrite existing seeder files", { default: !1 }).option("--dry-run", "Print what would be generated without writing files", { default: !1 }).action(async (options) => {
30
- const perf = await intro("buddy seed:scaffold");
31
- try {
32
- const { scaffoldClassSeedersFromModels } = await import("@stacksjs/database"), result = await scaffoldClassSeedersFromModels({
33
- force: options.force,
34
- dryRun: options.dryRun
35
- }), generated = result.generated.length, alreadyThere = result.skipped.filter((s) => s.reason === "already-exists").length, stripped = result.strippedTrait.length, errors = result.errors.length;
36
- for (const g of result.generated)
37
- console.log(` + ${g.model} \u2192 ${g.file}`);
38
- for (const s of result.skipped.filter((s) => s.reason === "already-exists"))
39
- console.log(` \xB7 ${s.model}: seeder exists (pass --force to overwrite)`);
40
- for (const t of result.strippedTrait)
41
- console.log(` - ${t.model}: removed useSeeder trait from model`);
42
- for (const t of result.traitStripSkipped)
43
- log.warn(` ! ${t.model}: useSeeder value couldn't be auto-removed \u2014 strip it manually (${t.file})`);
44
- for (const e of result.errors)
45
- log.warn(` ! ${e.model}: ${e.error}`);
46
- const verb = options.dryRun ? "would generate" : "generated", strippedVerb = options.dryRun ? "would strip" : "stripped";
47
- await outro(`Seeder scaffold: ${verb} ${generated}, skipped ${alreadyThere} existing, ${strippedVerb} ${stripped} trait(s), ${errors} error(s).`, { startTime: perf, useSeconds: !0 });
48
- process.exit(errors > 0 && generated === 0 ? ExitCode.FatalError : ExitCode.Success);
49
- } catch (err) {
50
- await outro("seed:scaffold failed", { startTime: perf, useSeconds: !0 }, err);
51
- process.exit(ExitCode.FatalError);
14
+ const { seed: seedDatabase } = await import("@stacksjs/database"), list = (value) => value ? value.split(",").map((entry) => entry.trim()).filter(Boolean) : void 0, summary = await seedDatabase({
15
+ verbose: options.verbose,
16
+ fresh: options.fresh,
17
+ only: list(options.only),
18
+ except: list(options.except),
19
+ includeDefaults: options.includeDefaults,
20
+ allowProtected: options.allowProtected
21
+ }), APP_ENV = process.env.APP_ENV || "local";
22
+ if (summary.total === 0) {
23
+ await outro("No models declare a `useSeeder` trait \u2014 nothing to seed.", { startTime: perf, useSeconds: !0 });
24
+ process.exit(ExitCode.Success);
52
25
  }
26
+ await outro(`Seeded your ${APP_ENV} database. ${summary.successful}/${summary.total} model(s) seeded${summary.failed > 0 ? `, ${summary.failed} failed` : ""}.`, { startTime: perf, useSeconds: !0 });
27
+ process.exit(summary.failed > 0 ? ExitCode.FatalError : ExitCode.Success);
53
28
  });
54
29
  buddy.command("seed:roles", "Seed default RBAC role packs (admin, dev, client)").alias("roles:seed").action(async () => {
55
30
  const perf = await intro("buddy seed:roles");
@@ -1,11 +1,24 @@
1
1
  import type { CLI } from '@stacksjs/types';
2
2
  /**
3
- * Resolve the project's includes directory using Stacks conventions. Modern
4
- * Stacks apps keep reusable `.stx` fragments in `resources/components` and
5
- * commonly point `config/stx.ts#partialsDir` there; older apps may still use
6
- * one of the dedicated partials directories below.
3
+ * Resolve the project's includes directory.
4
+ *
5
+ * A configured `config/stx.ts#partialsDir` wins outright. The convention list
6
+ * below is only a fallback for apps that never set one, and it cannot stand in
7
+ * for the config: the candidates are probed for existence in order, so an app
8
+ * that keeps its includes in `resources/components` but also has an unrelated
9
+ * `resources/partials` directory silently resolved to the wrong one and every
10
+ * `@include` failed with ENOENT at runtime.
11
+ *
12
+ * The configured value is relative to the stx root (`resources`), matching how
13
+ * stx itself reads it, but an app-root-relative path is accepted too so either
14
+ * spelling resolves.
15
+ */
16
+ export declare function resolveUserPartialsPath(cwd?: unknown, configuredDir?: string): string | undefined;
17
+ /**
18
+ * Read `partialsDir` off the app's stx config. Failure is non-fatal: without
19
+ * it {@link resolveUserPartialsPath} falls back to the conventions.
7
20
  */
8
- export declare function resolveUserPartialsPath(cwd?: unknown): string | undefined;
21
+ export declare function loadStxPartialsDir(cwd?: unknown): Promise<string | undefined>;
9
22
  /**` requests (and any non-GET/HEAD verb) are
10
23
  * reverse-proxied to the API process — mirroring the dev views server — so
11
24
  * scaffolded `fetch('/api/...')` calls behave identically in production
@@ -30,7 +30,15 @@ function parseCookies(req) {
30
30
  }
31
31
  return out;
32
32
  }
33
- export function resolveUserPartialsPath(cwd = process.cwd()) {
33
+ export function resolveUserPartialsPath(cwd = process.cwd(), configuredDir) {
34
+ if (configuredDir) {
35
+ const configured = [
36
+ join(cwd, "resources", configuredDir),
37
+ join(cwd, configuredDir)
38
+ ].find((candidate) => existsSync(candidate));
39
+ if (configured)
40
+ return configured;
41
+ }
34
42
  const relative = [
35
43
  "resources/partials",
36
44
  "resources/views/partials",
@@ -39,6 +47,17 @@ export function resolveUserPartialsPath(cwd = process.cwd()) {
39
47
  ].find((candidate) => existsSync(join(cwd, candidate)));
40
48
  return relative ? join(cwd, relative) : void 0;
41
49
  }
50
+ export async function loadStxPartialsDir(cwd = process.cwd()) {
51
+ const configPath = join(cwd, "config/stx.ts");
52
+ if (!existsSync(configPath))
53
+ return;
54
+ try {
55
+ const dir = (await import(configPath)).default?.partialsDir;
56
+ return typeof dir === "string" && dir.length > 0 ? dir : void 0;
57
+ } catch {
58
+ return;
59
+ }
60
+ }
42
61
  export function serve(buddy) {
43
62
  buddy.command("serve", "Start the production HTTP server (STX views + /api proxy + coming-soon/maintenance gate)").option("-p, --port <port>", "Port to listen on (defaults to PORT env or 3000)").option("--verbose", "Enable verbose output", { default: !1 }).action(async (options) => {
44
63
  if (options?.port)
@@ -62,7 +81,7 @@ export function serve(buddy) {
62
81
  } catch {}
63
82
  if (!stxServe)
64
83
  ({ serve: stxServe } = await import("bun-plugin-stx/serve"));
65
- const stxModule = await resolveVendoredStxModule(), { site: siteConfig, i18n: i18nConfig } = await loadStxSiteConfig(), userViewsPath = "resources/views", defaultsResources = resolveDefaultsResources(), defaultViewsPath = join(defaultsResources, "views"), userLayoutsPath = existsSync("resources/views/layouts") ? "resources/views/layouts" : "resources/layouts", userPartialsPath = resolveUserPartialsPath(), apiBase = process.env.API_URL || `http://127.0.0.1:${Number(process.env.PORT_API) || config.ports?.api || 3008}`;
84
+ const stxModule = await resolveVendoredStxModule(), { site: siteConfig, i18n: i18nConfig } = await loadStxSiteConfig(), userViewsPath = "resources/views", defaultsResources = resolveDefaultsResources(), defaultViewsPath = join(defaultsResources, "views"), userLayoutsPath = existsSync("resources/views/layouts") ? "resources/views/layouts" : "resources/layouts", userPartialsPath = resolveUserPartialsPath(process.cwd(), await loadStxPartialsDir()), apiBase = process.env.API_URL || `http://127.0.0.1:${Number(process.env.PORT_API) || config.ports?.api || 3008}`;
66
85
  log.info(`Starting production server on port ${port}...`);
67
86
  await stxServe({
68
87
  patterns: [userViewsPath, defaultViewsPath],
@@ -0,0 +1,39 @@
1
+ export declare function isAiProvider(value: string): value is AiProvider;
2
+ /**
3
+ * Sets a project up for one AI coding agent. Idempotent: re-running it after a
4
+ * framework upgrade refreshes what it owns and leaves everything else alone.
5
+ */
6
+ export declare function setupAiProvider(provider: AiProvider, options?: SetupAiOptions): SetupAiResult;
7
+ /**
8
+ * Renders the result of a setup run.
9
+ */
10
+ export declare function reportAiSetup(provider: AiProviderDefinition, result: SetupAiResult): void;
11
+ /*.mdc`, Copilot reads `.github/copilot-instructions.md`,
12
+ * and Gemini CLI reads `GEMINI.md`.
13
+ */
14
+ export declare const AI_PROVIDERS: readonly AiProviderDefinition[];
15
+ export declare interface AiProviderDefinition {
16
+ id: AiProvider
17
+ label: string
18
+ reads: string[]
19
+ }
20
+ export declare interface SetupAiOptions {
21
+ copy?: boolean
22
+ force?: boolean
23
+ }
24
+ export declare interface SetupAiResult {
25
+ created: string[]
26
+ skipped: string[]
27
+ }
28
+ /**
29
+ * `buddy setup:ai` - wires a project up for an AI coding agent.
30
+ *
31
+ * The framework keeps its agent material in `storage/framework/defaults/ai`.
32
+ * Nothing there is read at runtime; this command materializes it into whatever
33
+ * files and directories the chosen agent actually looks for.
34
+ *
35
+ * Only `AGENTS.md` is committed - it is shared guidance every agent reads, and
36
+ * it belongs in review. The generated per-agent directories are gitignored,
37
+ * because which agent a developer uses is a personal choice.
38
+ */
39
+ export type AiProvider = 'claude' | 'codex' | 'cursor' | 'copilot' | 'gemini';
@@ -0,0 +1,121 @@
1
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import process from "node:process";
3
+ import { dim, green, log, yellow } from "@stacksjs/cli";
4
+ import { frameworkPath, join, projectPath, relative } from "@stacksjs/path";
5
+ import { listSkills, resolveSkillPath } from "@stacksjs/skills";
6
+ export const AI_PROVIDERS = [
7
+ { id: "claude", label: "Claude Code", reads: ["AGENTS.md", "CLAUDE.md", ".claude/skills", ".claude/launch.json"] },
8
+ { id: "codex", label: "OpenAI Codex CLI", reads: ["AGENTS.md"] },
9
+ { id: "cursor", label: "Cursor", reads: ["AGENTS.md", ".cursor/rules"] },
10
+ { id: "copilot", label: "GitHub Copilot", reads: ["AGENTS.md", ".github/copilot-instructions.md"] },
11
+ { id: "gemini", label: "Gemini CLI", reads: ["AGENTS.md", "GEMINI.md"] }
12
+ ];
13
+ export function isAiProvider(value) {
14
+ return AI_PROVIDERS.some((provider) => provider.id === value);
15
+ }
16
+ function materialize(target, path, options) {
17
+ const existing = lstatSync(path, { throwIfNoEntry: !1 });
18
+ if (existing) {
19
+ if (!existing.isSymbolicLink() && !options.force)
20
+ return !1;
21
+ rmSync(path, { recursive: !0, force: !0 });
22
+ }
23
+ mkdirSync(join(path, ".."), { recursive: !0 });
24
+ if (options.copy)
25
+ cpSync(target, path, { recursive: !0 });
26
+ else
27
+ symlinkSync(relative(join(path, ".."), target), path, process.platform === "win32" ? "junction" : "dir");
28
+ return !0;
29
+ }
30
+ function materializeFile(source, path, options) {
31
+ if (existsSync(path) && !options.force)
32
+ return !1;
33
+ mkdirSync(join(path, ".."), { recursive: !0 });
34
+ cpSync(source, path, { recursive: !0, force: !0 });
35
+ return !0;
36
+ }
37
+ function installSkills(destination, options) {
38
+ const names = listSkills();
39
+ let installed = 0, skipped = 0;
40
+ mkdirSync(destination, { recursive: !0 });
41
+ for (const entry of readdirSync(destination)) {
42
+ const path = join(destination, entry);
43
+ if (lstatSync(path, { throwIfNoEntry: !1 })?.isSymbolicLink() && !existsSync(path))
44
+ rmSync(path, { force: !0 });
45
+ }
46
+ for (const name of names) {
47
+ const source = resolveSkillPath(name);
48
+ if (!source)
49
+ continue;
50
+ if (materialize(source, join(destination, name), options))
51
+ installed++;
52
+ else
53
+ skipped++;
54
+ }
55
+ return { installed, skipped };
56
+ }
57
+ export function setupAiProvider(provider, options = {}) {
58
+ const created = [], skipped = [], defaults = frameworkPath("defaults/ai"), rel = (path) => relative(projectPath(), path), record = (path, didCreate) => {
59
+ (didCreate ? created : skipped).push(rel(path));
60
+ }, agents = projectPath("AGENTS.md");
61
+ record(agents, materializeFile(join(defaults, "AGENTS.md"), agents, { ...options, force: !1 }));
62
+ switch (provider) {
63
+ case "claude": {
64
+ const claudeMd = projectPath("CLAUDE.md");
65
+ record(claudeMd, materialize(agents, claudeMd, { ...options, copy: !1 }));
66
+ const launch = projectPath(".claude/launch.json");
67
+ record(launch, materializeFile(join(defaults, "claude/launch.json"), launch, options));
68
+ const skillsDir = projectPath(".claude/skills"), { installed, skipped: untouched } = installSkills(skillsDir, options);
69
+ if (installed > 0)
70
+ created.push(`${rel(skillsDir)} (${installed} skills)`);
71
+ if (untouched > 0)
72
+ skipped.push(`${rel(skillsDir)} (${untouched} skills)`);
73
+ break;
74
+ }
75
+ case "codex":
76
+ break;
77
+ case "cursor": {
78
+ const rules = projectPath(".cursor/rules");
79
+ record(rules, materialize(frameworkPath("defaults/ide/cursor/rules"), rules, options));
80
+ break;
81
+ }
82
+ case "copilot": {
83
+ const instructions = projectPath(".github/copilot-instructions.md");
84
+ if (existsSync(instructions) && !options.force)
85
+ skipped.push(rel(instructions));
86
+ else {
87
+ mkdirSync(projectPath(".github"), { recursive: !0 });
88
+ writeFileSync(instructions, pointerFile("AGENTS.md"), "utf8");
89
+ created.push(rel(instructions));
90
+ }
91
+ break;
92
+ }
93
+ case "gemini": {
94
+ const geminiMd = projectPath("GEMINI.md");
95
+ record(geminiMd, materialize(agents, geminiMd, { ...options, copy: !1 }));
96
+ break;
97
+ }
98
+ }
99
+ return { created, skipped };
100
+ }
101
+ function pointerFile(target) {
102
+ return [
103
+ "# Project instructions",
104
+ "",
105
+ `This project keeps its agent guidance in [\`${target}\`](../${target}), so every`,
106
+ "agent works from the same rules. Read that file.",
107
+ "",
108
+ "Regenerate this pointer with `buddy setup:ai copilot --force`.",
109
+ ""
110
+ ].join(`
111
+ `);
112
+ }
113
+ export function reportAiSetup(provider, result) {
114
+ for (const path of result.created)
115
+ log.info(` ${green("+")} ${path}`);
116
+ for (const path of result.skipped)
117
+ log.info(` ${yellow("\xB7")} ${path} ${dim("(already present, left alone)")}`);
118
+ if (result.created.length === 0)
119
+ log.info(dim(" Nothing to do - re-run with --force to overwrite."));
120
+ log.success(`${provider.label} is set up. It reads: ${provider.reads.join(", ")}`);
121
+ }
@@ -21,6 +21,9 @@ export function setup(buddy) {
21
21
  const descriptions = {
22
22
  setup: "This command ensures your project is setup correctly",
23
23
  ssl: "Setup SSL certificates and hosts file for HTTPS development",
24
+ ai: "Set the project up for an AI coding agent (Claude Code, Codex, Cursor, Copilot, Gemini)",
25
+ copy: "Copy the agent files instead of symlinking them, so they can be edited per project",
26
+ force: "Overwrite files that already exist",
24
27
  ohMyZsh: "Enable Oh My Zsh",
25
28
  aws: "Ensures AWS is connected to the project",
26
29
  project: "Target a specific project",
@@ -49,6 +52,25 @@ export function setup(buddy) {
49
52
  log.info("You may need to manually trust certificates or update hosts file");
50
53
  }
51
54
  });
55
+ buddy.command("setup:ai [provider]", descriptions.ai).alias("ai:setup").option("--copy", descriptions.copy, { default: !1 }).option("--force", descriptions.force, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (provider, options) => {
56
+ log.debug("Running `buddy setup:ai` ...", options);
57
+ const { AI_PROVIDERS, isAiProvider, reportAiSetup, setupAiProvider } = await import("./setup-ai");
58
+ let id = provider;
59
+ if (!id) {
60
+ const { select } = await import("@stacksjs/cli");
61
+ id = await select({
62
+ message: "Which AI coding agent do you use?",
63
+ choices: AI_PROVIDERS.map((entry) => ({ value: entry.id, label: entry.label })),
64
+ initial: 0
65
+ });
66
+ }
67
+ if (!id || !isAiProvider(id)) {
68
+ log.error(`Unknown AI provider: ${id}. Expected one of: ${AI_PROVIDERS.map((entry) => entry.id).join(", ")}`);
69
+ process.exit(ExitCode.InvalidArgument);
70
+ }
71
+ const definition = AI_PROVIDERS.find((entry) => entry.id === id);
72
+ reportAiSetup(definition, setupAiProvider(id, { copy: options.copy, force: options.force }));
73
+ });
52
74
  buddy.command("setup:oh-my-zsh", descriptions.ohMyZsh).alias("upgrade:oh-my-zsh").option("--verbose", descriptions.verbose, { default: !1 }).action(async (_options) => {
53
75
  log.debug("Running `buddy setup:oh-my-zsh` ...", _options);
54
76
  const result = await runAction(Action.UpgradeShell);
@@ -1,6 +1,7 @@
1
1
  const commandRegistry = {
2
2
  about: { path: "./commands/about.js", exportName: "about" },
3
3
  add: { path: "./commands/add.js", exportName: "add" },
4
+ "ai:context": { path: "./commands/ai-context.js", exportName: "aiContext" },
4
5
  auth: { path: "./commands/auth.js", exportName: "auth" },
5
6
  build: { path: "./commands/build.js", exportName: "build" },
6
7
  cd: { path: "./commands/cd.js", exportName: "cd" },
@@ -99,6 +100,10 @@ const commandRegistry = {
99
100
  serve: { path: "./commands/serve.js", exportName: "serve" },
100
101
  "serve:api": { path: "./commands/serve.js", exportName: "serveApi" },
101
102
  setup: { path: "./commands/setup.js", exportName: "setup" },
103
+ "setup:ai": { path: "./commands/setup.js", exportName: "setup" },
104
+ "setup:ssl": { path: "./commands/setup.js", exportName: "setup" },
105
+ "setup:oh-my-zsh": { path: "./commands/setup.js", exportName: "setup" },
106
+ "ai:setup": { path: "./commands/setup.js", exportName: "setup" },
102
107
  share: { path: "./commands/share.js", exportName: "share" },
103
108
  stack: { path: "./commands/stacks.js", exportName: "stacks" },
104
109
  sms: { path: "./commands/sms.js", exportName: "sms" },
@@ -108,7 +113,29 @@ const commandRegistry = {
108
113
  types: { path: "./commands/types.js", exportName: "types" },
109
114
  undeploy: { path: "./commands/cloud.js", exportName: "cloud" },
110
115
  upgrade: { path: "./commands/upgrade.js", exportName: "upgrade" },
111
- version: { path: "./commands/version.js", exportName: "version" }
116
+ version: { path: "./commands/version.js", exportName: "version" },
117
+ "protocol:conformance": { path: "./commands/protocol.js", exportName: "protocol" },
118
+ "protocol:sync": { path: "./commands/protocol.js", exportName: "protocol" },
119
+ "protocol:check": { path: "./commands/protocol.js", exportName: "protocol" },
120
+ "protocol:manifest": { path: "./commands/protocol.js", exportName: "protocol" },
121
+ "protocol:manifest:check": { path: "./commands/protocol.js", exportName: "protocol" },
122
+ "protocol:release": { path: "./commands/protocol.js", exportName: "protocol" },
123
+ "protocol:release:check": { path: "./commands/protocol.js", exportName: "protocol" },
124
+ "protocol:drivers": { path: "./commands/protocol.js", exportName: "protocol" },
125
+ "protocol:drivers:check": { path: "./commands/protocol.js", exportName: "protocol" },
126
+ "protocol:drivers:test": { path: "./commands/protocol.js", exportName: "protocol" },
127
+ "protocol:desktop": { path: "./commands/protocol.js", exportName: "protocol" },
128
+ "protocol:desktop:check": { path: "./commands/protocol.js", exportName: "protocol" },
129
+ "protocol:pantry": { path: "./commands/protocol.js", exportName: "protocol" },
130
+ "protocol:pantry:check": { path: "./commands/protocol.js", exportName: "protocol" },
131
+ "protocol:craft": { path: "./commands/protocol.js", exportName: "protocol" },
132
+ "protocol:craft:check": { path: "./commands/protocol.js", exportName: "protocol" },
133
+ "docs:buddy": { path: "./commands/docs.js", exportName: "docs" },
134
+ "docs:buddy:check": { path: "./commands/docs.js", exportName: "docs" },
135
+ "docs:artifacts": { path: "./commands/docs.js", exportName: "docs" },
136
+ "docs:artifacts:check": { path: "./commands/docs.js", exportName: "docs" },
137
+ "docs:links": { path: "./commands/docs.js", exportName: "docs" },
138
+ "docs:links:check": { path: "./commands/docs.js", exportName: "docs" }
112
139
  }, commandGroups = {
113
140
  minimal: ["version", "help"],
114
141
  development: ["dev", "build", "test", "lint"],
@@ -12,6 +12,7 @@ const APP_KEY_OPTIONAL_COMMANDS = [
12
12
  "clean",
13
13
  "fresh",
14
14
  "about",
15
+ "ai:context",
15
16
  "doctor",
16
17
  "list",
17
18
  "setup",