@stacksjs/buddy 0.72.76 → 0.72.77
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/dist/commands/deploy.d.ts +25 -0
- package/dist/commands/deploy.js +1 -1
- package/dist/commands/publish.js +2 -2
- package/dist/commands/setup-ai.d.ts +8 -0
- package/dist/commands/setup-ai.js +1 -1
- package/dist/unvendor-rewrite.d.ts +19 -0
- package/dist/unvendor-rewrite.js +2 -0
- package/package.json +51 -51
|
@@ -88,6 +88,23 @@ export declare function pollUntil(opts: {
|
|
|
88
88
|
* @param environment - Which `.env.<environment>` to read.
|
|
89
89
|
* @param tsCloudConfig - The deploy target's ts-cloud config, read for `project.slug`.
|
|
90
90
|
*/
|
|
91
|
+
/** Trim, lowercase and drop the empties from a list of hostnames. */
|
|
92
|
+
export declare function normalizeDomains(domains: readonly unknown[]): string[];
|
|
93
|
+
/**
|
|
94
|
+
* The domains an existing gateway fragment serves that this project has not
|
|
95
|
+
* accounted for - neither declared as a site nor listed as retired.
|
|
96
|
+
*
|
|
97
|
+
* Pure, because the interesting part is the decision and the rest of
|
|
98
|
+
* `assertFragmentIsOurs` is an ssh call. `www.` is matched loosely in both
|
|
99
|
+
* directions: the gateway adds a `www` route for an apex on its own, so a
|
|
100
|
+
* project that declares (or retires) `example.com` has accounted for
|
|
101
|
+
* `www.example.com` too.
|
|
102
|
+
*
|
|
103
|
+
* @param fragment - Raw contents of `/etc/rpx/sites.d/<slug>.json`.
|
|
104
|
+
* @param ours - Domains this project declares as sites.
|
|
105
|
+
* @param retired - Domains this project used to serve and deliberately no longer does.
|
|
106
|
+
*/
|
|
107
|
+
export declare function orphanedFragmentDomains(fragment: string, ours: Iterable<string>, retired?: Iterable<string>): string[];
|
|
91
108
|
/**
|
|
92
109
|
* Refuse to overwrite a gateway fragment that is serving somebody else.
|
|
93
110
|
*
|
|
@@ -98,6 +115,14 @@ export declare function pollUntil(opts: {
|
|
|
98
115
|
* Best-effort on the read (an unreachable box or an absent fragment is the
|
|
99
116
|
* normal first-deploy case and must not block it) but hard on the answer: a
|
|
100
117
|
* fragment that clearly belongs to someone else stops the deploy.
|
|
118
|
+
*
|
|
119
|
+
* `cloud.retiredDomains` is how a project says "this host WAS ours and
|
|
120
|
+
* deliberately is not any more". Without it the guard has no false branch:
|
|
121
|
+
* declaring the domain keeps serving it and not declaring it is refused, so
|
|
122
|
+
* retiring a hostname is unexpressible and the only ways through are a hand
|
|
123
|
+
* edit of the fragment on the box or renaming the slug - which strands the old
|
|
124
|
+
* fragment still serving a dead release. It is config rather than a flag so
|
|
125
|
+
* the decision stays in git, next to the sites it used to sit among.
|
|
101
126
|
*/
|
|
102
127
|
export declare function assertFragmentIsOurs(ip: string, tsCloudConfig: any, log: { error: (m: string) => void, info: (m: string) => void }): Promise<void>;
|
|
103
128
|
/**
|
package/dist/commands/deploy.js
CHANGED
|
@@ -3,7 +3,7 @@ import{existsSync,mkdirSync,readFileSync,readdirSync,statSync,writeFileSync}from
|
|
|
3
3
|
Last attempt: ${detail}`:"")+`
|
|
4
4
|
A connection timeout means the box is probably still booting, so raise TS_CLOUD_SSH_WAIT_SECS and retry. "Permission denied" means the key is not authorized, and a refused or reset connection (especially after earlier attempts got further) usually means fail2ban banned this IP. Waiting longer fixes neither.`}export function bunRuntimeMissingMessage(opts){const detail=pollFailureDetail(opts.lastError);return`bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
|
|
5
5
|
Last attempt: ${detail}`:"")+`
|
|
6
|
-
cloud-init may have failed: SSH in and check /var/log/cloud-init-output.log. Raise TS_CLOUD_BOOT_WAIT_SECS for slow regions.`}export async function pollUntil(opts){log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`);const started=Date.now(),deadline=started+opts.timeoutSecs*1000;let lastHeartbeat=0,lastError;for(;;)try{await opts.check();return}catch(err){lastError=err;const elapsedSecs=Math.floor((Date.now()-started)/1000);if(Date.now()>deadline)throw Error(opts.timeoutMessage(elapsedSecs,lastError));if(elapsedSecs-lastHeartbeat>=30){log.info(` \u2026 still waiting (${elapsedSecs}s elapsed)`);lastHeartbeat=elapsedSecs}await new Promise((r)=>setTimeout(r,opts.intervalMs??5000))}}async function waitForRemoteReady(ip){const{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),run=(remote)=>sshExecOrThrow(ip,remote,{user:"root",connectTimeoutSec:10}),sshWaitSecs=readWaitSecs("TS_CLOUD_SSH_WAIT_SECS",480);await pollUntil({label:"Waiting for SSH to come up",timeoutSecs:sshWaitSecs,check:()=>run("true"),timeoutMessage:(elapsed,lastError)=>sshUnreachableMessage({ip,waitSecs:sshWaitSecs,elapsedSecs:elapsed,lastError})});log.success("SSH is up");log.info("Waiting for cloud-init (installing bun + caddy)...");try{await run("cloud-init status --wait || true")}catch(err){log.debug("cloud-init status --wait returned non-zero (continuing):",err)}const bootWaitSecs=readWaitSecs("TS_CLOUD_BOOT_WAIT_SECS",720);await pollUntil({label:"Waiting for the bun runtime",timeoutSecs:bootWaitSecs,check:()=>run("test -x /usr/local/bin/bun"),timeoutMessage:(elapsed,lastError)=>bunRuntimeMissingMessage({waitSecs:bootWaitSecs,elapsedSecs:elapsed,lastError})});log.success("Server is ready (bun installed)")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),tenants=config.cloud?.tenants;return Array.isArray(tenants)?tenants.filter((slug)=>typeof slug==="string"):[]}catch{return[]}}export async function assertFragmentIsOurs(ip,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",ours=new Set(Object.values(tsCloudConfig.sites??{}).map((site)=>String(site?.domain??"").toLowerCase()).filter(Boolean));let remote="";try{const{execSync}=await import("node:child_process"),args=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=15",`root@${ip}`];remote=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`cat /etc/rpx/sites.d/${slug}.json 2>/dev/null || true`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}if(!remote.trim())return;const
|
|
6
|
+
cloud-init may have failed: SSH in and check /var/log/cloud-init-output.log. Raise TS_CLOUD_BOOT_WAIT_SECS for slow regions.`}export async function pollUntil(opts){log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`);const started=Date.now(),deadline=started+opts.timeoutSecs*1000;let lastHeartbeat=0,lastError;for(;;)try{await opts.check();return}catch(err){lastError=err;const elapsedSecs=Math.floor((Date.now()-started)/1000);if(Date.now()>deadline)throw Error(opts.timeoutMessage(elapsedSecs,lastError));if(elapsedSecs-lastHeartbeat>=30){log.info(` \u2026 still waiting (${elapsedSecs}s elapsed)`);lastHeartbeat=elapsedSecs}await new Promise((r)=>setTimeout(r,opts.intervalMs??5000))}}async function waitForRemoteReady(ip){const{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),run=(remote)=>sshExecOrThrow(ip,remote,{user:"root",connectTimeoutSec:10}),sshWaitSecs=readWaitSecs("TS_CLOUD_SSH_WAIT_SECS",480);await pollUntil({label:"Waiting for SSH to come up",timeoutSecs:sshWaitSecs,check:()=>run("true"),timeoutMessage:(elapsed,lastError)=>sshUnreachableMessage({ip,waitSecs:sshWaitSecs,elapsedSecs:elapsed,lastError})});log.success("SSH is up");log.info("Waiting for cloud-init (installing bun + caddy)...");try{await run("cloud-init status --wait || true")}catch(err){log.debug("cloud-init status --wait returned non-zero (continuing):",err)}const bootWaitSecs=readWaitSecs("TS_CLOUD_BOOT_WAIT_SECS",720);await pollUntil({label:"Waiting for the bun runtime",timeoutSecs:bootWaitSecs,check:()=>run("test -x /usr/local/bin/bun"),timeoutMessage:(elapsed,lastError)=>bunRuntimeMissingMessage({waitSecs:bootWaitSecs,elapsedSecs:elapsed,lastError})});log.success("Server is ready (bun installed)")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),tenants=config.cloud?.tenants;return Array.isArray(tenants)?tenants.filter((slug)=>typeof slug==="string"):[]}catch{return[]}}export function normalizeDomains(domains){return domains.map((domain)=>String(domain??"").trim().toLowerCase()).filter(Boolean)}export function orphanedFragmentDomains(fragment,ours,retired=[]){const declared=new Set(normalizeDomains([...ours])),givenUp=new Set(normalizeDomains([...retired])),accountedFor=(domain,set)=>set.has(domain)||set.has(domain.replace(/^www\./,""));return[...new Set([...fragment.matchAll(/"(?:domain|to|from)"\s*:\s*"([a-z0-9.*-]+\.[a-z]{2,})"/gi)].map((match)=>String(match[1]).toLowerCase()).filter(Boolean))].filter((domain)=>!accountedFor(domain,declared)&&!accountedFor(domain,givenUp))}export async function assertFragmentIsOurs(ip,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",ours=new Set(Object.values(tsCloudConfig.sites??{}).map((site)=>String(site?.domain??"").toLowerCase()).filter(Boolean));let remote="";try{const{execSync}=await import("node:child_process"),args=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=15",`root@${ip}`];remote=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`cat /etc/rpx/sites.d/${slug}.json 2>/dev/null || true`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}if(!remote.trim())return;const retired=normalizeDomains(Array.isArray(tsCloudConfig.cloud?.retiredDomains)?tsCloudConfig.cloud.retiredDomains:[]),orphaned=orphanedFragmentDomains(remote,ours,retired);if(orphaned.length===0){if(retired.length>0)log.info(`Retiring ${retired.length} domain(s) this project no longer serves: ${retired.join(", ")}`);return}log.error(`/etc/rpx/sites.d/${slug}.json on the box already serves ${orphaned.length} domain(s) this project does not declare:`);for(const domain of orphaned.slice(0,8))log.error(` ${domain}`);log.error("Deploying would replace that fragment and take those domains down.");log.info(`Either the slug '${slug}' belongs to another project (pick a different project.slug), or those domains belong here and should be in config/cloud.ts sites.`);log.info("If you mean to stop serving them, list them in `cloud.retiredDomains` in config/cloud.ts.");process.exit(ExitCode.FatalError)}export async function assertPortsAreFree(ip,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",wanted=new Map;for(const[name,site]of Object.entries(tsCloudConfig.sites??{})){const port=Number(site?.port);if(Number.isFinite(port)&&port>0)wanted.set(port,name)}if(wanted.size===0)return;let listing="";try{const{execSync}=await import("node:child_process"),args=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=15",`root@${ip}`];listing=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`for p in ${[...wanted.keys()].join(" ")}; do
|
|
7
7
|
pid=$(ss -lntpH "sport = :$p" 2>/dev/null | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)
|
|
8
8
|
[ -n "$pid" ] || continue
|
|
9
9
|
unit=$(systemctl status "$pid" 2>/dev/null | head -1 | grep -oE '[a-zA-Z0-9_.@-]+\\.service' | head -1)
|
package/dist/commands/publish.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import{existsSync,mkdirSync,realpathSync}from"node:fs";import{cp,readdir,stat}from"node:fs/promises";import{homedir}from"node:os";import{dirname,join,resolve}from"node:path";import process from"node:process";import{italic,log,onUnknownSubcommand}from"@stacksjs/cli";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{pruneVendoredCoreFromWorkflows,splitFrameworkTypecheckScript}from"../workflow-prune";import{ExitCode}from"@stacksjs/types";export function publish(buddy){const descriptions={command:"Publish a Stacks default into your userland (app/) directory so you can customize it",model:"Publish a default model from storage/framework/defaults/app/Models/ to app/Models/",controller:"Publish a default controller from storage/framework/defaults/app/Controllers/ to app/Controllers/",middleware:"Publish a default middleware from storage/framework/defaults/app/Middleware/ to app/Middleware/",action:"Publish a default action from storage/framework/defaults/app/Actions/ to app/Actions/",core:"Publish a framework package source from node_modules/@stacksjs/<pkg>/ into storage/framework/core/<pkg>/ for editing",unpublishCore:"Drop a vendored storage/framework/core/<pkg>/ and go back to the installed @stacksjs/<pkg>",all:"Unvendor the whole framework: remove storage/framework/core and resolve every @stacksjs package from the `stacks` dependency in package.json",publishAll:"Vendor the whole framework: copy storage/framework/core out of a local Stacks checkout and wire it up as a Bun workspace",frameworkPath:"The Stacks checkout to vendor from (defaults to $STACKS_FRAMEWORK_PATH, ../stacks, then ~/Code/stacks)",coreStatus:"Report whether this project runs on a vendored storage/framework/core or on the published packages",name:"The name of the resource to publish (e.g. Cart, User)",pkg:"The name of the framework package (e.g. router, orm, faker - without @stacksjs/ prefix)",force:"Overwrite an existing userland file",forceUnpublish:"Delete the vendored source even when it has uncommitted changes",verbose:"Enable verbose output"};buddy.command("publish:model <name>",descriptions.model).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force})});buddy.command("publish:controller <name>",descriptions.controller).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force})});buddy.command("publish:middleware <name>",descriptions.middleware).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force})});buddy.command("publish:action <name>",descriptions.action).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force})});buddy.command("publish:core [pkg]",descriptions.core).option("--all",descriptions.publishAll,{default:!1}).option("--path <path>",descriptions.frameworkPath).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy publish:core router").example("buddy publish:core --all").example("buddy publish:core --all --path ../stacks").action(async(pkg,options)=>{if(options.all){await vendorFramework(options.path,!!options.force);return}if(!pkg){log.error("Usage: buddy publish:core <pkg> (or --all to vendor the whole framework as a workspace)");process.exit(ExitCode.FatalError)}await publishCorePackage(pkg,!!options.force)});buddy.command("core:status",descriptions.coreStatus).alias("publish:core:status").option("--verbose",descriptions.verbose,{default:!1}).example("buddy core:status").action(async()=>{await reportCoreStatus()});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)=>{if(options.all){await unvendorFramework(!!options.force);return}if(!pkg){log.error("Usage: buddy unpublish:core <pkg> (or --all to move the whole framework to node_modules)");process.exit(ExitCode.FatalError)}await unpublishCorePackage(pkg,!!options.force)});buddy.command("publish [resource] [name]",descriptions.command).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(resource,name,options)=>{if(!resource||!name){log.error("Usage: buddy publish:<resource> <Name> (e.g. buddy publish:model Cart)");process.exit(ExitCode.FatalError)}const handler={model:()=>publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force}),controller:()=>publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force}),middleware:()=>publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force}),action:()=>publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force}),core:()=>publishCorePackage(name,!!options.force)}[resource.toLowerCase()];if(!handler){log.error(`Unknown publishable resource: ${italic(resource)}`);log.info("Available: model, controller, middleware, action, core");process.exit(ExitCode.FatalError)}await handler()});onUnknownSubcommand(buddy,"publish")}async function publishCorePackage(pkg,force){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,""),fail=(msg,hint)=>{process.stderr.write(`${msg}
|
|
1
|
+
import{existsSync,mkdirSync,realpathSync}from"node:fs";import{cp,readdir,stat}from"node:fs/promises";import{homedir}from"node:os";import{dirname,join,resolve}from"node:path";import process from"node:process";import{italic,log,onUnknownSubcommand}from"@stacksjs/cli";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{pruneVendoredCoreFromWorkflows,splitFrameworkTypecheckScript}from"../workflow-prune";import{detectInstaller,findCoreReferences,isDanglingLink,rewriteCoreCommandPaths,rewriteCoreSourceImports}from"../unvendor-rewrite";import{ExitCode}from"@stacksjs/types";export function publish(buddy){const descriptions={command:"Publish a Stacks default into your userland (app/) directory so you can customize it",model:"Publish a default model from storage/framework/defaults/app/Models/ to app/Models/",controller:"Publish a default controller from storage/framework/defaults/app/Controllers/ to app/Controllers/",middleware:"Publish a default middleware from storage/framework/defaults/app/Middleware/ to app/Middleware/",action:"Publish a default action from storage/framework/defaults/app/Actions/ to app/Actions/",core:"Publish a framework package source from node_modules/@stacksjs/<pkg>/ into storage/framework/core/<pkg>/ for editing",unpublishCore:"Drop a vendored storage/framework/core/<pkg>/ and go back to the installed @stacksjs/<pkg>",all:"Unvendor the whole framework: remove storage/framework/core and resolve every @stacksjs package from the `stacks` dependency in package.json",publishAll:"Vendor the whole framework: copy storage/framework/core out of a local Stacks checkout and wire it up as a Bun workspace",frameworkPath:"The Stacks checkout to vendor from (defaults to $STACKS_FRAMEWORK_PATH, ../stacks, then ~/Code/stacks)",coreStatus:"Report whether this project runs on a vendored storage/framework/core or on the published packages",name:"The name of the resource to publish (e.g. Cart, User)",pkg:"The name of the framework package (e.g. router, orm, faker - without @stacksjs/ prefix)",force:"Overwrite an existing userland file",forceUnpublish:"Delete the vendored source even when it has uncommitted changes",verbose:"Enable verbose output"};buddy.command("publish:model <name>",descriptions.model).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force})});buddy.command("publish:controller <name>",descriptions.controller).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force})});buddy.command("publish:middleware <name>",descriptions.middleware).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force})});buddy.command("publish:action <name>",descriptions.action).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force})});buddy.command("publish:core [pkg]",descriptions.core).option("--all",descriptions.publishAll,{default:!1}).option("--path <path>",descriptions.frameworkPath).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy publish:core router").example("buddy publish:core --all").example("buddy publish:core --all --path ../stacks").action(async(pkg,options)=>{if(options.all){await vendorFramework(options.path,!!options.force);return}if(!pkg){log.error("Usage: buddy publish:core <pkg> (or --all to vendor the whole framework as a workspace)");process.exit(ExitCode.FatalError)}await publishCorePackage(pkg,!!options.force)});buddy.command("core:status",descriptions.coreStatus).alias("publish:core:status").option("--verbose",descriptions.verbose,{default:!1}).example("buddy core:status").action(async()=>{await reportCoreStatus()});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)=>{if(options.all){await unvendorFramework(!!options.force);return}if(!pkg){log.error("Usage: buddy unpublish:core <pkg> (or --all to move the whole framework to node_modules)");process.exit(ExitCode.FatalError)}await unpublishCorePackage(pkg,!!options.force)});buddy.command("publish [resource] [name]",descriptions.command).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(resource,name,options)=>{if(!resource||!name){log.error("Usage: buddy publish:<resource> <Name> (e.g. buddy publish:model Cart)");process.exit(ExitCode.FatalError)}const handler={model:()=>publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force}),controller:()=>publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force}),middleware:()=>publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force}),action:()=>publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force}),core:()=>publishCorePackage(name,!!options.force)}[resource.toLowerCase()];if(!handler){log.error(`Unknown publishable resource: ${italic(resource)}`);log.info("Available: model, controller, middleware, action, core");process.exit(ExitCode.FatalError)}await handler()});onUnknownSubcommand(buddy,"publish")}async function publishCorePackage(pkg,force){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,""),fail=(msg,hint)=>{process.stderr.write(`${msg}
|
|
2
2
|
`);if(hint)process.stderr.write(` ${hint}
|
|
3
3
|
`);process.exit(ExitCode.FatalError)};if(!shortName||shortName.includes("/")||shortName.includes(".."))fail(`Invalid package name: ${pkg}`,"Use a short name like `router` or the fully qualified `@stacksjs/router`.");const sourceDir=resolve(process.cwd(),"node_modules","@stacksjs",shortName),targetDir=path.frameworkPath(`core/${shortName}`);try{if(!(await stat(sourceDir)).isDirectory())fail(`${sourceDir} is not a directory.`)}catch{fail(`Could not find @stacksjs/${shortName} in node_modules.`,"Run `bun install` first, or check the package name.")}if(existsSync(targetDir)&&!force)fail(`Already published: ${targetDir.replace(`${process.cwd()}/`,"")}`,"Pass --force to overwrite.");mkdirSync(dirname(targetDir),{recursive:!0});const SKIP=new Set(["node_modules","dist",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceDir,targetDir,SKIP,()=>copied++);log.success(`Published @stacksjs/${shortName} \u2192 ${italic(targetDir.replace(`${process.cwd()}/`,""))} (${copied} files)`);log.info("Edit freely - local changes win over the installed package.")}async function copyTreeFiltered(sourceDir,targetDir,skip,onFile){mkdirSync(targetDir,{recursive:!0});const entries=await readdir(sourceDir,{withFileTypes:!0});for(const entry of entries){if(skip.has(entry.name))continue;const src=`${sourceDir}/${entry.name}`,dst=`${targetDir}/${entry.name}`;if(entry.isDirectory()){await copyTreeFiltered(src,dst,skip,onFile);continue}await cp(src,dst,{force:!0,dereference:!0});onFile()}}async function publishResource(ctx){const{kind,name,defaultsDir,userDir,force}=ctx,fileName=name.endsWith(".ts")?name:`${name}.ts`,matches=globSync([`${defaultsDir}/**/${fileName}`],{absolute:!0});if(!matches.length){log.error(`Could not find default ${kind}: ${italic(fileName)}`);log.info(`Looked under: ${italic(defaultsDir)}`);process.exit(ExitCode.FatalError)}if(matches.length>1){log.warn(`Multiple defaults match ${italic(fileName)}; using the first:`);for(const m of matches)log.info(` ${m}`)}const sourcePath=matches[0];if(!sourcePath)throw Error(`Could not resolve default ${kind}: ${fileName}`);const targetPath=`${userDir.replace(/\/$/,"")}/${fileName}`;if(existsSync(targetPath)&&!force){log.error(`Already exists: ${italic(targetPath)}`);log.info("Pass --force to overwrite.");process.exit(ExitCode.FatalError)}mkdirSync(dirname(targetPath),{recursive:!0});await fs.promises.copyFile(sourcePath,targetPath);const carried=await carryRelativeImports(sourcePath,targetPath);log.success(`Published ${kind} ${italic(name)} \u2192 ${italic(targetPath.replace(`${process.cwd()}/`,""))}`);for(const file of carried)log.info(` + ${italic(file.replace(`${process.cwd()}/`,""))} (imported by it)`)}export async function carryRelativeImports(sourcePath,targetPath,seen=new Set){if(seen.has(sourcePath))return[];seen.add(sourcePath);const source=await fs.promises.readFile(sourcePath,"utf-8"),written=[],root=realpathSync(process.cwd()),targetDir=realpathSync(dirname(targetPath)),specifiers=[...source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)].map((match)=>match[1]);for(const specifier of new Set(specifiers)){if(!specifier)continue;const candidates=specifier.endsWith(".ts")?[specifier]:[`${specifier}.ts`,`${specifier}/index.ts`];for(const candidate of candidates){const from=resolve(dirname(sourcePath),candidate),to=resolve(targetDir,candidate);if(!existsSync(from))continue;if(!to.startsWith(`${root}/`))break;if(!existsSync(to)){mkdirSync(dirname(to),{recursive:!0});await fs.promises.copyFile(from,to);written.push(to)}written.push(...await carryRelativeImports(from,to,seen));break}}return written}async function vendorFramework(explicitPath,force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(existsSync(coreDir)&&!force){log.info(`Already vendored: ${italic(rel(coreDir))}`);log.info("Pass --force to replace it with a fresh copy of the checkout.");return}const framework=resolveFrameworkCheckout(explicitPath);if(!framework){log.error("No Stacks checkout found to vendor from.");log.info("Pass one with `--path <dir>`, or set STACKS_FRAMEWORK_PATH.");log.info("A checkout is required: the published packages ship `dist` only, so a copy of them would not be editable.");process.exit(ExitCode.FatalError)}const sourceCore=join(framework,"storage/framework/core"),corePkgPath=resolve(sourceCore,"package.json");if(!existsSync(corePkgPath)){log.error(`${sourceCore} has no package.json - that does not look like a Stacks checkout.`);process.exit(ExitCode.FatalError)}const depName=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")).name??"stacks";log.info(`Vendoring the framework from ${italic(framework)}...`);if(existsSync(coreDir))await fs.promises.rm(coreDir,{recursive:!0,force:!0});const SKIP=new Set(["node_modules",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceCore,coreDir,SKIP,()=>copied++);const rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")),provided=new Set([depName]);for(const entry of await readdir(coreDir,{withFileTypes:!0})){if(!entry.isDirectory())continue;if(existsSync(resolve(coreDir,entry.name,"package.json")))provided.add(`@stacksjs/${entry.name}`)}let repointed=0;for(const field of["dependencies","devDependencies"]){const deps=rootPkg[field];if(!deps)continue;for(const name of Object.keys(deps)){if(!provided.has(name)||deps[name].startsWith("workspace:"))continue;deps[name]="workspace:*";repointed++}}if(!rootPkg.dependencies?.[depName]&&!rootPkg.devDependencies?.[depName])rootPkg.dependencies={...rootPkg.dependencies,[depName]:"workspace:*"};const workspaces=rootPkg.workspaces??[];let addedGlobs=0;for(const glob of["storage/framework/core","storage/framework/core/*"]){if(workspaces.some((existing)=>existing.replace(/^\.\//,"").replace(/\/$/,"")===glob))continue;workspaces.push(glob);addedGlobs++}rootPkg.workspaces=workspaces;await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
|
|
4
4
|
`);const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])@stacksjs\/([\w-]+)\/([^"']+?)\.js\1/g,(match,quote,pkgName,subpath)=>{if(!provided.has(`@stacksjs/${pkgName}`))return match;rewrittenPreloads++;return`${quote}./storage/framework/core/${pkgName}/${subpath}.ts${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/tsconfig\.app\.json"/,'"extends": "./storage/framework/core/tsconfig.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}log.success(`Vendored ${copied} files into ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@workspace:*`);if(repointed>0)log.info(`Repointed ${repointed} version range${repointed===1?"":"s"} to workspace:*`);if(addedGlobs>0)log.info(`Added ${addedGlobs} workspace glob${addedGlobs===1?"":"s"} for the vendored packages`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to the vendored source`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/core/tsconfig.json");log.info("Linking the workspace...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. The files are in place; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the vendored framework - edits under storage/framework/core are live.");log.info("Go back to the published packages any time with `buddy unpublish:core --all`.")}function resolveFrameworkCheckout(explicit){const candidates=[explicit,process.env.STACKS_FRAMEWORK_PATH,resolve(process.cwd(),"../stacks"),join(homedir(),"Code/stacks")].filter(Boolean);for(const candidate of candidates){const full=resolve(candidate);if(full===resolve(process.cwd()))continue;if(existsSync(join(full,"storage/framework/core/package.json")))return full}return null}async function reportCoreStatus(){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,""),vendored=existsSync(resolve(coreDir,"package.json")),rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=existsSync(rootPkgPath)?JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")):{},declared=rootPkg.dependencies?.stacks??rootPkg.devDependencies?.stacks;if(vendored){const corePkg=JSON.parse(await fs.promises.readFile(resolve(coreDir,"package.json"),"utf-8")),packages=(await readdir(coreDir,{withFileTypes:!0})).filter((entry)=>entry.isDirectory()&&existsSync(resolve(coreDir,entry.name,"package.json")));log.info(`Layout: vendored - ${italic(rel(coreDir))} (${packages.length} packages, v${corePkg.version??"unknown"})`);log.info(`Declared: stacks@${declared??"(not declared)"}`);if(declared&&!declared.startsWith("workspace:"))log.warn(`The vendored copy is not linked: stacks is declared as ${declared}, not workspace:*. Run \`buddy publish:core --all --force\` to relink, or \`buddy unpublish:core --all\` to remove it.`);log.info("Move to the published packages with `buddy unpublish:core --all`.");return}const installed=resolve(process.cwd(),"node_modules/@stacksjs/buddy/package.json"),installedVersion=existsSync(installed)?JSON.parse(await fs.promises.readFile(installed,"utf-8")).version:void 0;log.info("Layout: published packages - no storage/framework/core in this project");log.info(`Declared: stacks@${declared??"(not declared)"}`);log.info(`Installed: ${installedVersion?`v${installedVersion}`:"(run bun install)"}`);log.info("Vendor the framework for local development with `buddy publish:core --all`.")}async function unpublishCorePackage(pkg,force){const shortName=normalizeCoreName(pkg),targetDir=path.frameworkPath(`core/${shortName}`),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(targetDir)){log.info(`Not vendored: ${italic(rel(targetDir))} - nothing to do.`);return}const installed=resolve(process.cwd(),"node_modules","@stacksjs",shortName);if(!existsSync(installed)&&!force){log.error(`@stacksjs/${shortName} is not installed, so removing the vendored copy would leave nothing to resolve.`);log.info(`Run \`bun add @stacksjs/${shortName}\` first, or pass --force to remove it anyway.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(targetDir,force);await fs.promises.rm(targetDir,{recursive:!0,force:!0});log.success(`Unpublished ${italic(rel(targetDir))} - @stacksjs/${shortName} now resolves from node_modules.`)}async function unvendorFramework(force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(coreDir)){log.info("No storage/framework/core in this project - already on the installed packages.");return}const corePkgPath=resolve(coreDir,"package.json");if(!existsSync(corePkgPath)){log.error(`${rel(coreDir)} has no package.json, so its version cannot be determined.`);log.info("Unvendor the packages individually with `buddy unpublish:core <pkg>` instead.");process.exit(ExitCode.FatalError)}const corePkg=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")),version=corePkg.version;if(!version){log.error(`${rel(corePkgPath)} has no version field.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(coreDir,force);const depName=corePkg.name??"stacks",range=`^${await resolvePublishedVersion(depName,version)}`,rootPkgPath=resolve(process.cwd(),"package.json"),rootPkgRaw=await fs.promises.readFile(rootPkgPath,"utf-8"),rootPkg=JSON.parse(rootPkgRaw),provided=new Set([depName,...Object.keys(corePkg.dependencies??{}).filter((name)=>name.startsWith("@stacksjs/"))]);for(const entry of await readdir(coreDir,{withFileTypes:!0}))if(entry.isDirectory())provided.add(`@stacksjs/${entry.name}`);let repointed=0;const repointWorkspaceRanges=(pkg)=>{let touched=!1;for(const field of["dependencies","devDependencies","peerDependencies"]){const deps=pkg[field];if(!deps)continue;for(const[name,spec]of Object.entries(deps)){if(!spec.startsWith("workspace:")||!provided.has(name))continue;deps[name]=range;touched=!0;repointed++}}return touched};repointWorkspaceRanges(rootPkg);if(!rootPkg.dependencies?.[depName]&&!rootPkg.devDependencies?.[depName])rootPkg.dependencies={...rootPkg.dependencies,[depName]:range};let rewrittenScripts=0;for(const[name,script]of Object.entries(rootPkg.scripts??{})){if(!/storage\/framework\/core\/buddy\/src\/cli\.ts/.test(script))continue;rootPkg.scripts[name]=script.replace(/\bbunx?\s+(?:--bun\s+)?\.?\/?storage\/framework\/core\/buddy\/src\/cli\.ts/g,"./buddy");rewrittenScripts++}if(Array.isArray(rootPkg.workspaces)){rootPkg.workspaces=rootPkg.workspaces.filter((glob)=>!isCoreWorkspaceGlob(glob));if(rootPkg.workspaces.length===0)delete rootPkg.workspaces}await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
|
|
5
5
|
`);for(const glob of rootPkg.workspaces??[])for(const memberPkgPath of globSync(`${glob.replace(/\/$/,"")}/package.json`,{cwd:process.cwd(),absolute:!0})){const raw=await fs.promises.readFile(memberPkgPath,"utf-8"),memberPkg=JSON.parse(raw);if(repointWorkspaceRanges(memberPkg))await fs.promises.writeFile(memberPkgPath,`${JSON.stringify(memberPkg,null,2)}
|
|
6
6
|
`)}const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])\.?\/?storage\/framework\/core\/([\w-]+)\/([^"']+?)\.ts\1/g,(_match,quote,pkgName,subpath)=>{rewrittenPreloads++;return`${quote}@stacksjs/${pkgName}/${subpath}.js${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1,rewroteTypecheck=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/core\/tsconfig\.json"/,'"extends": "./storage/framework/tsconfig.app.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}const splitTypecheck=splitFrameworkTypecheckScript(rootPkg.scripts??{});if(splitTypecheck){rootPkg.scripts=splitTypecheck;rewroteTypecheck=!0;await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
|
|
7
|
-
`)}const prunedWorkflows=await pruneVendoredCoreFromWorkflows(process.cwd());await fs.promises.rm(coreDir,{recursive:!0,force:!0});const scopedDir=resolve(process.cwd(),"
|
|
7
|
+
`)}const rewrittenCommands=await rewriteCoreCommandPaths(process.cwd()),prunedWorkflows=await pruneVendoredCoreFromWorkflows(process.cwd()),rewrittenImports=await rewriteCoreSourceImports(process.cwd());await fs.promises.rm(coreDir,{recursive:!0,force:!0});let danglingRemoved=0;for(const depsDir of["node_modules","pantry"]){const scopedDir=resolve(process.cwd(),depsDir,"@stacksjs");if(!existsSync(scopedDir))continue;for(const entry of await readdir(scopedDir,{withFileTypes:!0})){if(!entry.isSymbolicLink())continue;const link=resolve(scopedDir,entry.name);if(existsSync(link))continue;await fs.promises.rm(link,{force:!0});danglingRemoved++}const rootLink=resolve(process.cwd(),depsDir,depName);if(existsSync(dirname(rootLink))&&isDanglingLink(rootLink)){await fs.promises.rm(rootLink,{force:!0});danglingRemoved++}}log.success(`Removed ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@${range}`);if(repointed>0)log.info(`Repointed ${repointed} workspace: range${repointed===1?"":"s"} to ${range}`);if(danglingRemoved>0)log.info(`Removed ${danglingRemoved} node_modules symlink${danglingRemoved===1?"":"s"} left pointing into it`);if(rewrittenScripts>0)log.info(`Repointed ${rewrittenScripts} package.json script${rewrittenScripts===1?"":"s"} to ./buddy`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to package specifiers`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/tsconfig.app.json");if(rewroteTypecheck)log.info("`typecheck` now checks this app as well as the framework files it still ships");for(const pruned of prunedWorkflows){const parts=[pruned.removedJobs.length>0?`${pruned.removedJobs.length} job${pruned.removedJobs.length===1?"":"s"} (${pruned.removedJobs.join(", ")})`:"",pruned.removedSteps>0?`${pruned.removedSteps} step${pruned.removedSteps===1?"":"s"}`:""].filter(Boolean);log.info(`${pruned.file}: removed ${parts.join(" and ")} that ran against the vendored core`)}if(rewrittenCommands.length>0){log.info(`Repointed vendored-CLI commands to ./buddy in ${rewrittenCommands.length} file${rewrittenCommands.length===1?"":"s"}:`);for(const file of rewrittenCommands)log.info(` ${file}`)}if(rewrittenImports.length>0){log.info(`Repointed vendored-source imports to package specifiers in ${rewrittenImports.length} file${rewrittenImports.length===1?"":"s"}:`);for(const file of rewrittenImports)log.info(` ${file}`)}const stragglers=await findCoreReferences(process.cwd());if(stragglers.length>0){log.warn(`${stragglers.length} file${stragglers.length===1?"":"s"} still reference storage/framework/core, which no longer exists:`);for(const{file,line,text}of stragglers)log.warn(` ${file}:${line} ${text}`);log.info("Run those through `./buddy <command>` or a package specifier before deploying.")}const installer=detectInstaller(process.cwd());log.info(`Installing the published packages with \`${installer.join(" ")}\`...`);if(await Bun.spawn(installer,{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error(`\`${installer.join(" ")}\` failed. package.json and bunfig.toml were updated; re-run the install once the failure is resolved.`);process.exit(ExitCode.FatalError)}log.success("This project now runs on the published Stacks packages.");log.info("Vendor an individual package again any time with `buddy publish:core <pkg>`.")}async function resolvePublishedVersion(depName,vendored){let published;try{published=await fetchPublishedVersions(depName)}catch(error){log.warn(`Could not reach the npm registry to check ${depName} versions (${error instanceof Error?error.message:String(error)}).`);log.info(`Pinning the vendored version, ${depName}@^${vendored}.`);return vendored}if(published.versions.has(vendored))return vendored;if(!published.latest){log.warn(`${depName}@${vendored} is not published and the registry reports no latest version.`);return vendored}log.warn(`${depName}@${vendored} is not published yet - the vendored copy is ahead of npm.`);log.info(`Pinning the newest published version instead, ${depName}@^${published.latest}.`);return published.latest}async function fetchPublishedVersions(depName){const response=await fetch(`https://registry.npmjs.org/${depName.replace("/","%2F")}`,{headers:{accept:"application/vnd.npm.install-v1+json"}});if(!response.ok)throw Error(`registry responded ${response.status}`);const packument=await response.json();return{latest:packument["dist-tags"]?.latest,versions:new Set(Object.keys(packument.versions??{}))}}function normalizeCoreName(pkg){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,"");if(!shortName||shortName.includes("/")||shortName.includes("..")){process.stderr.write(`Invalid package name: ${pkg}
|
|
8
8
|
`);process.stderr.write(" Use a short name like `router` or the fully qualified `@stacksjs/router`.\n");process.exit(ExitCode.FatalError)}return shortName}function isCoreWorkspaceGlob(glob){const normalized=glob.replace(/^\.\//,"").replace(/\/$/,"");return normalized==="storage/framework/core"||normalized.startsWith("storage/framework/core/")}async function assertNoUncommittedChanges(dir,force){if(force)return;try{const proc=Bun.spawn(["git","status","--porcelain","--",dir],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"}),output=await new Response(proc.stdout).text();if(await proc.exited!==0)return;const changed=output.split(`
|
|
9
9
|
`).filter(Boolean);if(changed.length===0)return;log.error(`${changed.length} uncommitted change${changed.length===1?"":"s"} under ${italic(dir.replace(`${process.cwd()}/`,""))}:`);for(const line of changed.slice(0,10))log.info(` ${line}`);if(changed.length>10)log.info(` ... and ${changed.length-10} more`);log.info("Commit or stash them first, or pass --force to delete them anyway.");process.exit(ExitCode.FatalError)}catch{}}
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
export declare function isAiProvider(value: string): value is AiProvider;
|
|
2
|
+
/**
|
|
3
|
+
* Writes `path` as a symlink to `target`, or as a copy when `copy` is set.
|
|
4
|
+
*
|
|
5
|
+
* Returns false when something is already there and `force` was not passed -
|
|
6
|
+
* a hand-edited `CLAUDE.md` or a customized skill should never be clobbered by
|
|
7
|
+
* a setup command.
|
|
8
|
+
*/
|
|
9
|
+
export declare function materialize(target: string, path: string, options: SetupAiOptions): boolean;
|
|
2
10
|
/**
|
|
3
11
|
* Sets a project up for one AI coding agent. Idempotent: re-running it after a
|
|
4
12
|
* framework upgrade refreshes what it owns and leaves everything else alone.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{cpSync,existsSync,lstatSync,mkdirSync,readdirSync,rmSync,symlinkSync,writeFileSync}from"node:fs";import process from"node:process";import{dim,green,log,yellow}from"@stacksjs/cli";import{frameworkPath,join,projectPath,relative}from"@stacksjs/path";import{listSkills,resolveSkillPath}from"@stacksjs/skills";export const AI_PROVIDERS=[{id:"claude",label:"Claude Code",reads:["AGENTS.md","CLAUDE.md",".claude/skills",".claude/launch.json"]},{id:"codex",label:"OpenAI Codex CLI",reads:["AGENTS.md"]},{id:"cursor",label:"Cursor",reads:["AGENTS.md",".cursor/rules"]},{id:"copilot",label:"GitHub Copilot",reads:["AGENTS.md",".github/copilot-instructions.md"]},{id:"gemini",label:"Gemini CLI",reads:["AGENTS.md","GEMINI.md"]}];export function isAiProvider(value){return AI_PROVIDERS.some((provider)=>provider.id===value)}function materialize(target,path,options){const existing=lstatSync(path,{throwIfNoEntry:!1});if(existing){if(!existing.isSymbolicLink()&&!options.force)return!1;rmSync(path,{recursive:!0,force:!0})}mkdirSync(join(path,".."),{recursive:!0});if(options.copy)cpSync(target,path,{recursive:!0});else symlinkSync(relative(join(path,".."),target),path,process.platform==="win32"?"junction":"dir");return!0}function materializeFile(source,path,options){if(existsSync(path)&&!options.force)return!1;mkdirSync(join(path,".."),{recursive:!0});cpSync(source,path,{recursive:!0,force:!0});return!0}function installSkills(destination,options){const names=listSkills();let installed=0,skipped=0;mkdirSync(destination,{recursive:!0});for(const entry of readdirSync(destination)){const path=join(destination,entry);if(lstatSync(path,{throwIfNoEntry:!1})?.isSymbolicLink()&&!existsSync(path))rmSync(path,{force:!0})}for(const name of names){const source=resolveSkillPath(name);if(!source)continue;if(materialize(source,join(destination,name),options))installed++;else skipped++}return{installed,skipped}}export function setupAiProvider(provider,options={}){const created=[],skipped=[],defaults=frameworkPath("defaults/ai"),rel=(path)=>relative(projectPath(),path),record=(path,didCreate)=>{(didCreate?created:skipped).push(rel(path))},agents=projectPath("AGENTS.md");record(agents,materializeFile(join(defaults,"AGENTS.md"),agents,{...options,force:!1}));switch(provider){case"claude":{const claudeMd=projectPath("CLAUDE.md")
|
|
1
|
+
import{cpSync,existsSync,lstatSync,mkdirSync,readdirSync,rmSync,symlinkSync,writeFileSync}from"node:fs";import process from"node:process";import{dim,green,log,yellow}from"@stacksjs/cli";import{frameworkPath,join,projectPath,relative}from"@stacksjs/path";import{listSkills,resolveSkillPath}from"@stacksjs/skills";export const AI_PROVIDERS=[{id:"claude",label:"Claude Code",reads:["AGENTS.md","CLAUDE.md",".claude/skills",".claude/launch.json"]},{id:"codex",label:"OpenAI Codex CLI",reads:["AGENTS.md"]},{id:"cursor",label:"Cursor",reads:["AGENTS.md",".cursor/rules"]},{id:"copilot",label:"GitHub Copilot",reads:["AGENTS.md",".github/copilot-instructions.md"]},{id:"gemini",label:"Gemini CLI",reads:["AGENTS.md","GEMINI.md"]}];export function isAiProvider(value){return AI_PROVIDERS.some((provider)=>provider.id===value)}export function materialize(target,path,options){const existing=lstatSync(path,{throwIfNoEntry:!1});if(existing){if(!existing.isSymbolicLink()&&!options.force)return!1;rmSync(path,{recursive:!0,force:!0})}mkdirSync(join(path,".."),{recursive:!0});if(options.copy)cpSync(target,path,{recursive:!0});else symlinkSync(relative(join(path,".."),target),path,process.platform==="win32"?"junction":"dir");return!0}function materializeFile(source,path,options){if(existsSync(path)&&!options.force)return!1;mkdirSync(join(path,".."),{recursive:!0});cpSync(source,path,{recursive:!0,force:!0});return!0}function installSkills(destination,options){const names=listSkills();let installed=0,skipped=0;mkdirSync(destination,{recursive:!0});for(const entry of readdirSync(destination)){const path=join(destination,entry);if(lstatSync(path,{throwIfNoEntry:!1})?.isSymbolicLink()&&!existsSync(path))rmSync(path,{force:!0})}for(const name of names){const source=resolveSkillPath(name);if(!source)continue;if(materialize(source,join(destination,name),options))installed++;else skipped++}return{installed,skipped}}export function setupAiProvider(provider,options={}){const created=[],skipped=[],defaults=frameworkPath("defaults/ai"),rel=(path)=>relative(projectPath(),path),record=(path,didCreate)=>{(didCreate?created:skipped).push(rel(path))},agents=projectPath("AGENTS.md");record(agents,materializeFile(join(defaults,"AGENTS.md"),agents,{...options,force:!1}));switch(provider){case"claude":{const claudeMd=projectPath("CLAUDE.md"),linked=materialize(agents,claudeMd,{...options,copy:!1,force:!1});record(claudeMd,linked);if(!linked&&existsSync(claudeMd)&&!lstatSync(claudeMd,{throwIfNoEntry:!1})?.isSymbolicLink())log.info(` \xB7 ${rel(claudeMd)} is a real file, so it is left as-is. Move its content into AGENTS.md and delete it to have both agents read one file.`);const launch=projectPath(".claude/launch.json");record(launch,materializeFile(join(defaults,"claude/launch.json"),launch,options));const skillsDir=projectPath(".claude/skills"),{installed,skipped:untouched}=installSkills(skillsDir,options);if(installed>0)created.push(`${rel(skillsDir)} (${installed} skills)`);if(untouched>0)skipped.push(`${rel(skillsDir)} (${untouched} skills)`);break}case"codex":break;case"cursor":{const rules=projectPath(".cursor/rules");record(rules,materialize(frameworkPath("defaults/ide/cursor/rules"),rules,options));break}case"copilot":{const instructions=projectPath(".github/copilot-instructions.md");if(existsSync(instructions)&&!options.force)skipped.push(rel(instructions));else{mkdirSync(projectPath(".github"),{recursive:!0});writeFileSync(instructions,pointerFile("AGENTS.md"),"utf8");created.push(rel(instructions))}break}case"gemini":{const geminiMd=projectPath("GEMINI.md");record(geminiMd,materialize(agents,geminiMd,{...options,copy:!1}));break}}return{created,skipped}}function pointerFile(target){return["# Project instructions","",`This project keeps its agent guidance in [\`${target}\`](../${target}), so every`,"agent works from the same rules. Read that file.","","Regenerate this pointer with `buddy setup:ai copilot --force`.",""].join(`
|
|
2
2
|
`)}export function reportAiSetup(provider,result){for(const path of result.created)log.info(` ${green("+")} ${path}`);for(const path of result.skipped)log.info(` ${yellow("\xB7")} ${path} ${dim("(already present, left alone)")}`);if(result.created.length===0)log.info(dim(" Nothing to do - re-run with --force to overwrite."));log.success(`${provider.label} is set up. It reads: ${provider.reads.join(", ")}`)}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does this path exist as a symlink whose target does not?
|
|
3
|
+
*
|
|
4
|
+
* `existsSync` follows the link, so it answers "no" for both a missing link and
|
|
5
|
+
* a dangling one; only the second is ours to clean up.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isDanglingLink(target: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* The package manager this project actually installs with.
|
|
10
|
+
*
|
|
11
|
+
* An app installed with pantry has no node_modules at all, so running `bun
|
|
12
|
+
* install` at the end of an unvendor writes a second, competing dependency
|
|
13
|
+
* tree that the `./buddy` shim does not read — the install "succeeds" and the
|
|
14
|
+
* project still cannot resolve a single @stacksjs package.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectInstaller(cwd: string): string[];
|
|
17
|
+
export declare function rewriteCoreCommandPaths(cwd: string): Promise<string[]>;
|
|
18
|
+
export declare function rewriteCoreSourceImports(cwd: string): Promise<string[]>;
|
|
19
|
+
export declare function findCoreReferences(cwd: string): Promise<{ file: string, line: number, text: string }[]>;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{existsSync,lstatSync}from"node:fs";import{readdir}from"node:fs/promises";import{join,resolve}from"node:path";import{log}from"@stacksjs/cli";import{fs}from"@stacksjs/storage";export function isDanglingLink(target){try{return lstatSync(target).isSymbolicLink()&&!existsSync(target)}catch{return!1}}export function detectInstaller(cwd){const has=(rel)=>existsSync(resolve(cwd,rel)),usesPantry=has("pantry")||has("pantry.lock")&&!has("node_modules");if(usesPantry&&Bun.which("pantry"))return["pantry","install"];if(usesPantry)log.warn("This project installs with pantry, but no `pantry` binary is on PATH \u2014 falling back to `bun install`.");return["bun","install"]}const SCANNED_EXTENSIONS=new Set([".ts",".tsx",".js",".mjs",".cjs",".json",".sh",".yml",".yaml",".toml"]),SKIPPED_DIRECTORIES=new Set(["node_modules","pantry","dist","storage","public","coverage","temp","tmp",".git",".cache",".claude",".idea",".stx",".vscode"]);async function*projectFiles(cwd){const walk=async function*(dir){let entries;try{entries=await readdir(dir,{withFileTypes:!0})}catch{return}for(const entry of entries){const full=join(dir,entry.name);if(entry.isDirectory()){if(SKIPPED_DIRECTORIES.has(entry.name))continue;yield*walk(full);continue}if(!entry.isFile())continue;const dot=entry.name.lastIndexOf("."),ext=dot===-1?"":entry.name.slice(dot);if(SCANNED_EXTENSIONS.has(ext)||entry.name==="Dockerfile"||entry.name.startsWith("Dockerfile."))yield full}};yield*walk(cwd)}const CORE_SOURCE_IMPORT=/(\bfrom\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])(?:\.{1,2}\/)*storage\/framework\/core\/([\w.-]+)\/src\/([^'"]+?)(?:\.ts)?\2/g;function toPackageSpecifier(pkg,rest){const subpath=rest.replace(/\/index$/,"");return subpath==="index"?`@stacksjs/${pkg}`:`@stacksjs/${pkg}/${subpath}`}const CORE_ACTION_COMMANDS={"actions/src/migrate/database":"migrate","actions/src/migrate/fresh":"migrate:fresh","actions/src/database/seed":"seed","actions/src/auth/setup":"auth:setup","actions/src/dev/api":"dev:api","actions/src/key-generate":"key:generate"},CORE_COMMAND_PATH=/\bbunx?\s+(?:-{1,2}[\w=.-]+\s+)*(?:\.\/)?storage\/framework\/core\/([\w/.-]+?)\.ts\b/g;export async function rewriteCoreCommandPaths(cwd){const touched=[];for await(const file of projectFiles(cwd)){const raw=await fs.promises.readFile(file,"utf-8");if(!raw.includes("storage/framework/core/"))continue;const next=raw.replace(CORE_COMMAND_PATH,(match,entry)=>{if(entry==="buddy/src/cli")return"./buddy";const command=CORE_ACTION_COMMANDS[entry];return command?`./buddy ${command}`:match});if(next!==raw){await fs.promises.writeFile(file,next);touched.push(file.replace(`${cwd}/`,""))}}return touched}export async function rewriteCoreSourceImports(cwd){const touched=[];for await(const file of projectFiles(cwd)){const raw=await fs.promises.readFile(file,"utf-8");if(!raw.includes("storage/framework/core/"))continue;const next=raw.replace(CORE_SOURCE_IMPORT,(_match,lead,quote,pkg,rest)=>`${lead}${quote}${toPackageSpecifier(pkg,rest)}${quote}`);if(next!==raw){await fs.promises.writeFile(file,next);touched.push(file.replace(`${cwd}/`,""))}}return touched}export async function findCoreReferences(cwd){const hits=[];for await(const file of projectFiles(cwd)){const raw=await fs.promises.readFile(file,"utf-8");if(!raw.includes("storage/framework/core"))continue;raw.split(`
|
|
2
|
+
`).forEach((text,index)=>{if(!text.includes("storage/framework/core"))return;const trimmed=text.trim();if(/^(?:\/\/|\*|#|<!--)/.test(trimmed))return;hits.push({file:file.replace(`${cwd}/`,""),line:index+1,text:trimmed.slice(0,120)})})}return hits}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.72.
|
|
5
|
+
"version": "0.72.77",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,62 +95,62 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "^0.72.
|
|
99
|
-
"@stacksjs/ai": "^0.72.
|
|
100
|
-
"@stacksjs/alias": "^0.72.
|
|
101
|
-
"@stacksjs/analytics": "^0.72.
|
|
102
|
-
"@stacksjs/arrays": "^0.72.
|
|
103
|
-
"@stacksjs/auth": "^0.72.
|
|
104
|
-
"@stacksjs/browser-extension": "^0.72.
|
|
105
|
-
"@stacksjs/build": "^0.72.
|
|
106
|
-
"@stacksjs/cache": "^0.72.
|
|
107
|
-
"@stacksjs/chat": "^0.72.
|
|
98
|
+
"@stacksjs/actions": "^0.72.77",
|
|
99
|
+
"@stacksjs/ai": "^0.72.77",
|
|
100
|
+
"@stacksjs/alias": "^0.72.77",
|
|
101
|
+
"@stacksjs/analytics": "^0.72.77",
|
|
102
|
+
"@stacksjs/arrays": "^0.72.77",
|
|
103
|
+
"@stacksjs/auth": "^0.72.77",
|
|
104
|
+
"@stacksjs/browser-extension": "^0.72.77",
|
|
105
|
+
"@stacksjs/build": "^0.72.77",
|
|
106
|
+
"@stacksjs/cache": "^0.72.77",
|
|
107
|
+
"@stacksjs/chat": "^0.72.77",
|
|
108
108
|
"@stacksjs/clapp": "^0.2.12",
|
|
109
|
-
"@stacksjs/cli": "^0.72.
|
|
110
|
-
"@stacksjs/cloud": "^0.72.
|
|
111
|
-
"@stacksjs/cms": "^0.72.
|
|
112
|
-
"@stacksjs/collections": "^0.72.
|
|
113
|
-
"@stacksjs/config": "^0.72.
|
|
114
|
-
"@stacksjs/database": "^0.72.
|
|
115
|
-
"@stacksjs/desktop-build": "^0.72.
|
|
116
|
-
"@stacksjs/dns": "^0.72.
|
|
109
|
+
"@stacksjs/cli": "^0.72.77",
|
|
110
|
+
"@stacksjs/cloud": "^0.72.77",
|
|
111
|
+
"@stacksjs/cms": "^0.72.77",
|
|
112
|
+
"@stacksjs/collections": "^0.72.77",
|
|
113
|
+
"@stacksjs/config": "^0.72.77",
|
|
114
|
+
"@stacksjs/database": "^0.72.77",
|
|
115
|
+
"@stacksjs/desktop-build": "^0.72.77",
|
|
116
|
+
"@stacksjs/dns": "^0.72.77",
|
|
117
117
|
"@stacksjs/dnsx": "^0.2.3",
|
|
118
|
-
"@stacksjs/email": "^0.72.
|
|
119
|
-
"@stacksjs/enums": "^0.72.
|
|
120
|
-
"@stacksjs/env": "^0.72.
|
|
121
|
-
"@stacksjs/error-handling": "^0.72.
|
|
122
|
-
"@stacksjs/events": "^0.72.
|
|
123
|
-
"@stacksjs/git": "^0.72.
|
|
118
|
+
"@stacksjs/email": "^0.72.77",
|
|
119
|
+
"@stacksjs/enums": "^0.72.77",
|
|
120
|
+
"@stacksjs/env": "^0.72.77",
|
|
121
|
+
"@stacksjs/error-handling": "^0.72.77",
|
|
122
|
+
"@stacksjs/events": "^0.72.77",
|
|
123
|
+
"@stacksjs/git": "^0.72.77",
|
|
124
124
|
"@stacksjs/gitit": "^0.2.5",
|
|
125
|
-
"@stacksjs/health": "^0.72.
|
|
125
|
+
"@stacksjs/health": "^0.72.77",
|
|
126
126
|
"@stacksjs/httx": "^0.1.10",
|
|
127
|
-
"@stacksjs/image": "^0.72.
|
|
128
|
-
"@stacksjs/lint": "^0.72.
|
|
129
|
-
"@stacksjs/logging": "^0.72.
|
|
130
|
-
"@stacksjs/notifications": "^0.72.
|
|
131
|
-
"@stacksjs/objects": "^0.72.
|
|
132
|
-
"@stacksjs/orm": "^0.72.
|
|
133
|
-
"@stacksjs/path": "^0.72.
|
|
134
|
-
"@stacksjs/payments": "^0.72.
|
|
135
|
-
"@stacksjs/realtime": "^0.72.
|
|
136
|
-
"@stacksjs/router": "^0.72.
|
|
127
|
+
"@stacksjs/image": "^0.72.77",
|
|
128
|
+
"@stacksjs/lint": "^0.72.77",
|
|
129
|
+
"@stacksjs/logging": "^0.72.77",
|
|
130
|
+
"@stacksjs/notifications": "^0.72.77",
|
|
131
|
+
"@stacksjs/objects": "^0.72.77",
|
|
132
|
+
"@stacksjs/orm": "^0.72.77",
|
|
133
|
+
"@stacksjs/path": "^0.72.77",
|
|
134
|
+
"@stacksjs/payments": "^0.72.77",
|
|
135
|
+
"@stacksjs/realtime": "^0.72.77",
|
|
136
|
+
"@stacksjs/router": "^0.72.77",
|
|
137
137
|
"@stacksjs/rpx": "^0.11.42",
|
|
138
|
-
"@stacksjs/scheduler": "^0.72.
|
|
139
|
-
"@stacksjs/search-engine": "^0.72.
|
|
140
|
-
"@stacksjs/security": "^0.72.
|
|
141
|
-
"@stacksjs/server": "^0.72.
|
|
142
|
-
"@stacksjs/sites": "^0.72.
|
|
143
|
-
"@stacksjs/skills": "^0.72.
|
|
144
|
-
"@stacksjs/storage": "^0.72.
|
|
145
|
-
"@stacksjs/strings": "^0.72.
|
|
146
|
-
"@stacksjs/testing": "^0.72.
|
|
147
|
-
"@stacksjs/tinker": "^0.72.
|
|
138
|
+
"@stacksjs/scheduler": "^0.72.77",
|
|
139
|
+
"@stacksjs/search-engine": "^0.72.77",
|
|
140
|
+
"@stacksjs/security": "^0.72.77",
|
|
141
|
+
"@stacksjs/server": "^0.72.77",
|
|
142
|
+
"@stacksjs/sites": "^0.72.77",
|
|
143
|
+
"@stacksjs/skills": "^0.72.77",
|
|
144
|
+
"@stacksjs/storage": "^0.72.77",
|
|
145
|
+
"@stacksjs/strings": "^0.72.77",
|
|
146
|
+
"@stacksjs/testing": "^0.72.77",
|
|
147
|
+
"@stacksjs/tinker": "^0.72.77",
|
|
148
148
|
"@stacksjs/ts-cloud": "^0.12.4",
|
|
149
|
-
"@stacksjs/tunnel": "^0.72.
|
|
150
|
-
"@stacksjs/types": "^0.72.
|
|
151
|
-
"@stacksjs/ui": "^0.72.
|
|
152
|
-
"@stacksjs/utils": "^0.72.
|
|
153
|
-
"@stacksjs/validation": "^0.72.
|
|
149
|
+
"@stacksjs/tunnel": "^0.72.77",
|
|
150
|
+
"@stacksjs/types": "^0.72.77",
|
|
151
|
+
"@stacksjs/ui": "^0.72.77",
|
|
152
|
+
"@stacksjs/utils": "^0.72.77",
|
|
153
|
+
"@stacksjs/validation": "^0.72.77",
|
|
154
154
|
"ajv": "^8.20.0",
|
|
155
155
|
"ajv-formats": "^3.0.1",
|
|
156
156
|
"ts-pantry": "^0.11.35"
|