@stacksjs/buddy 0.74.3 → 0.74.4

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.
@@ -1,31 +1,41 @@
1
+ import { FEATURE_FILES, FEATURE_NAMES } from '@stacksjs/features';
1
2
  import type { CLI } from '@stacksjs/types';
3
+ import type { FeatureName } from '@stacksjs/features';
2
4
  /**
3
- * Parse a migration filename like `0000000045-create-posts-table.sql` or
4
- * `0000000085-alter-posts-author_id.sql` to extract the table name it
5
- * acts on. Returns `null` for filenames that don't match the
6
- * recognised `create-<table>-table` / `alter-<table>-` shapes — those
7
- * pass through the gate unchanged.
5
+ * Feature install / uninstall commands.
8
6
  *
9
- * Index migrations (`create-<table>_<col>_unique-index-in-<table>.sql`)
10
- * use the trailing `-in-<table>.sql` segment as the source of truth
11
- * since the leading segment includes the index name. The other
12
- * forms read the table from the segment immediately after the verb.
13
- */
14
- export declare function migrationTable(filename: string): string | null;
15
- /**
16
- * Returns the feature that owns the given migration filename, or `null`
17
- * if the migration isn't claimed by any feature (in which case it
18
- * always runs). Used by the migration runner's gating pass.
7
+ * Each framework feature bundle (dashboard, commerce, cms, marketing,
8
+ * monitoring, realtime, queue) lives in its own `config/<feature>.ts`
9
+ * file. Running `./buddy <feature>:install` flips that file's top-level
10
+ * `enabled` to `true` (scaffolding the file from a starter template if it's
11
+ * missing). `./buddy <feature>:uninstall` flips the flag back to `false`
12
+ * AND removes the feature's stamped action/model/view files from the
13
+ * project (pass `--keep-files` to preserve them); the config file itself
14
+ * is preserved so any custom driver/credential settings survive a future
15
+ * reinstall.
16
+ *
17
+ * The framework loaders (`orm/index.ts` eager-load, `defaults/bootstrap.ts`
18
+ * route registration, action prefetch) consult `feature(name)` at boot and
19
+ * skip anything whose flag is off — so an app with only `auth` activated
20
+ * never pays the cost of importing 70+ Commerce models or registering
21
+ * hundreds of dashboard routes it doesn't use.
22
+ *
23
+ * Auth is intentionally not in this list: it has its own scaffolding
24
+ * pipeline (`buddy auth:setup`) which handles migrations + personal-access
25
+ * client setup beyond a simple `enabled` flip.
26
+ *
27
+ * Mirrors Laravel's `php artisan passport:install` / `horizon:install`
28
+ * pattern: features are inert dead code on disk until installed.
19
29
  */
20
- export declare function migrationFeature(filename: string): FeatureName | null;
21
- /**
22
- * True when an application-owned, top-level model explicitly declares a table.
23
- * This lets apps intentionally use generic names such as `payments` without
24
- * their migrations being mistaken for disabled framework-feature scaffolding.
25
- * Root models listed in FEATURE_FILES remain feature-owned and do not override
26
- * the gate.
30
+ /*
31
+ * The manifest moved to `@stacksjs/features`.
32
+ *
33
+ * The migration runner needs the table half of it to hide a disabled
34
+ * feature's migrations, and it was reaching in here through a dynamic import
35
+ * to get it - which made `@stacksjs/database` depend on the CLI. Re-exported
36
+ * so everything that reads these from `@stacksjs/buddy` still can.
27
37
  */
28
- export declare function appModelClaimsTable(table: string, root?: string): boolean;
38
+ export type { FeatureName } from '@stacksjs/features';
29
39
  /**
30
40
  * Returns the subset of a feature's manifest paths that currently exist
31
41
  * on disk under `root` (defaults to `projectPath()`). Used by both the
@@ -84,104 +94,6 @@ export declare function setFeatureEnabled(feature: FeatureName, enabled: boolean
84
94
  */
85
95
  export declare function uninstallAllFeatures(options?: { root?: string }): Promise<UninstallAllFeaturesResult[]>;
86
96
  export declare function features(buddy: CLI): void;
87
- /**
88
- * Feature install / uninstall commands.
89
- *
90
- * Each framework feature bundle (dashboard, commerce, cms, marketing,
91
- * monitoring, realtime, queue) lives in its own `config/<feature>.ts`
92
- * file. Running `./buddy <feature>:install` flips that file's top-level
93
- * `enabled` to `true` (scaffolding the file from a starter template if it's
94
- * missing). `./buddy <feature>:uninstall` flips the flag back to `false`
95
- * AND removes the feature's stamped action/model/view files from the
96
- * project (pass `--keep-files` to preserve them); the config file itself
97
- * is preserved so any custom driver/credential settings survive a future
98
- * reinstall.
99
- *
100
- * The framework loaders (`orm/index.ts` eager-load, `defaults/bootstrap.ts`
101
- * route registration, action prefetch) consult `feature(name)` at boot and
102
- * skip anything whose flag is off — so an app with only `auth` activated
103
- * never pays the cost of importing 70+ Commerce models or registering
104
- * hundreds of dashboard routes it doesn't use.
105
- *
106
- * Auth is intentionally not in this list: it has its own scaffolding
107
- * pipeline (`buddy auth:setup`) which handles migrations + personal-access
108
- * client setup beyond a simple `enabled` flip.
109
- *
110
- * Mirrors Laravel's `php artisan passport:install` / `horizon:install`
111
- * pattern: features are inert dead code on disk until installed.
112
- */
113
- export declare const FEATURE_NAMES: readonly ['dashboard', 'commerce', 'cms', 'forms', 'marketing', 'monitoring', 'realtime', 'queue'];
114
- /**
115
- * Per-feature stamped file/directory manifest. Paths are relative to the
116
- * project root and mirror the layout that `./buddy new` lays down. Entries
117
- * ending in `/` are directory trees (recursive remove on uninstall); bare
118
- * paths are single files.
119
- *
120
- * A feature must also claim the shared files its own actions IMPORT.
121
- * `app/Actions/Dashboard/dashboard-response.ts` is the case that bit: five
122
- * features publish a subdirectory of `app/Actions/Dashboard/`, and the actions
123
- * in each import `../dashboard-response`. Without it, `<feature>:install`
124
- * copied 22 actions whose very first import did not resolve.
125
- *
126
- * Manifests intentionally overlap where features share scaffolding —
127
- * `dashboard` claims the umbrella `app/Actions/Dashboard/` even though
128
- * `app/Actions/Dashboard/Content/` is also claimed by `cms`. Both the
129
- * uninstall delete and the doctor orphan check are idempotent
130
- * (already-gone paths are skipped silently), so the overlap is safe.
131
- *
132
- * Adding a new file to one of these directories does **not** require a
133
- * manifest update — directory entries are recursive. Only add an entry
134
- * when a feature introduces a new top-level path the framework didn't
135
- * already claim.
136
- * @defaultValue
137
- * ```ts
138
- * {
139
- * forms: [ 'app/Models/Forms/', ],
140
- * cms: [ 'app/Actions/Cms/', 'app/Actions/Dashboard/Content/', 'app/Actions/Dashboard/dashboard-response.ts', 'app/Models/Content/', 'app/Models/Tag.ts', 'app/Models/Comment.ts', 'resources/views/dashboard/content/', ],
141
- * commerce: [ 'app/Actions/Commerce/', 'app/Actions/Dashboard/Commerce/', 'app/Actions/Dashboard/dashboard-response.ts', 'app/Models/commerce/', 'resources/components/Dashboard/Commerce/', 'resources/views/dashboard/commerce/', ],
142
- * dashboard: [ 'app/Actions/Dashboard/', 'resources/components/Dashboard/', 'resources/views/dashboard/', 'routes/dashboard.ts', 'routes/dashboard-api.ts', ],
143
- * marketing: [ 'app/Actions/Dashboard/Marketing/', 'app/Actions/Dashboard/dashboard-response.ts', 'app/Models/Campaign.ts', 'app/Models/CampaignSend.ts', 'app/Models/EmailList.ts', 'app/Models/EmailListSubscriber.ts', 'app/Models/SocialPost.ts', 'resources/components/Marketing/', 'resources/views/dashboard/marketing/', ],
144
- * monitoring: [ 'app/Actions/Monitoring/', 'app/Actions/TestErrorAction.ts', 'app/Models/Error.ts', 'functions/monitoring/', 'resources/views/dashboard/monitoring/', 'resources/views/dashboard/errors/', ],
145
- * realtime: [ 'app/Actions/Realtime/', 'app/Actions/Dashboard/Realtime/', 'app/Actions/Dashboard/dashboard-response.ts', 'app/Models/realtime/', 'app/Broadcasts/', 'functions/realtime/', 'resources/views/dashboard/realtime/', ],
146
- * queue: [ 'app/Actions/Queue/', 'app/Actions/Dashboard/Jobs/', 'app/Actions/Dashboard/dashboard-response.ts', 'app/Jobs/', 'app/Models/Job.ts', 'app/Models/FailedJob.ts', 'functions/jobs.ts', 'resources/views/dashboard/queue/', 'resources/views/dashboard/jobs/', ]
147
- * }
148
- * ```
149
- */
150
- export declare const FEATURE_FILES: Record<FeatureName, readonly string[]>;
151
- /**
152
- * Per-feature database table ownership (stacksjs/stacks#1854).
153
- *
154
- * Stacks generates SQL migrations from model files, so each feature's
155
- * tables map 1:1 with the models in its `FEATURE_FILES.app/Models/...`
156
- * entries. Listed here explicitly rather than derived at runtime so
157
- * additions are visible in a single grep-able place and the migration
158
- * gate doesn't depend on filesystem scanning at boot.
159
- *
160
- * The migration runner consults this when `config.<feature>.enabled =
161
- * false`: matching `*-create-<table>-table.sql` (and `*-alter-<table>-*.sql`)
162
- * files get hidden for the duration of the run, so a project that
163
- * never installed CMS doesn't materialize `posts`, `pages`,
164
- * `comments`, etc. on `./buddy migrate`.
165
- *
166
- * Tables on this list are scoped to a single feature. Tables shared
167
- * across features (none today, but `categories` could end up here)
168
- * should stay out of the manifest until that's resolved — the runner
169
- * defaults to "run unless owned by a disabled feature".
170
- * @defaultValue
171
- * ```ts
172
- * {
173
- * forms: ['forms', 'form_fields', 'form_submissions'],
174
- * cms: [ 'posts', 'pages', 'comments', 'tags', 'authors', 'categories', 'taggable_models', 'categorizable_models', 'commentables', 'page_revisions', 'redirects', 'menus', 'menu_items', ],
175
- * commerce: [ 'products', 'product_variants', 'product_units', 'manufacturers', 'orders', 'order_items', 'order_idempotency', 'carts', 'cart_items', 'payments', 'payment_methods', 'payment_products', 'payment_transactions', 'customers', 'subscribers', 'subscriber_emails', 'subscriptions', 'gift_cards', 'coupons', 'transactions', 'reviews', 'couriers', 'courier_pings', 'delivery_routes', 'delivery_stops', 'digital_deliveries', 'shipping_methods', 'shipping_rates', 'shipping_zones', 'license_keys', 'loyalty_points', 'loyalty_rewards', 'print_devices', 'receipts', 'tax_rates', 'waitlist_products', 'waitlist_restaurants', 'auctions', 'auction_items', 'bids', 'pledges', ],
176
- * dashboard: [ 'boards', 'board_columns', 'cards', 'card_labels', 'card_assignees', 'card_comments', 'labels', 'ci_run_states', 'ci_runner_samples', 'ci_runner_alert_states', 'requests', 'logs', ],
177
- * marketing: [ 'campaigns', 'campaign_sends', 'email_lists', 'email_list_subscribers', 'social_posts', 'mail_preferences', ],
178
- * monitoring: ['errors'],
179
- * realtime: ['websockets'],
180
- * queue: ['jobs', 'failed_jobs']
181
- * }
182
- * ```
183
- */
184
- export declare const FEATURE_TABLES: Record<FeatureName, readonly string[]>;
185
97
  export declare interface CopyFeatureFilesOptions {
186
98
  force?: boolean
187
99
  source?: string
@@ -192,5 +104,12 @@ export declare interface UninstallAllFeaturesResult {
192
104
  configOutcome: SetFeatureEnabledOutcome
193
105
  filesRemoved: string[]
194
106
  }
195
- export type FeatureName = (typeof FEATURE_NAMES)[number];
196
107
  export type SetFeatureEnabledOutcome = 'created' | 'flipped' | 'unchanged' | 'missing';
108
+ export {
109
+ appModelClaimsTable,
110
+ FEATURE_FILES,
111
+ FEATURE_NAMES,
112
+ FEATURE_TABLES,
113
+ migrationFeature,
114
+ migrationTable,
115
+ } from '@stacksjs/features';
@@ -1,4 +1,4 @@
1
- import{existsSync,readdirSync,readFileSync}from"node:fs";import{cp,rm}from"node:fs/promises";import{join}from"node:path";import process from"node:process";import{frameworkPath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";export const FEATURE_NAMES=["dashboard","commerce","cms","forms","marketing","monitoring","realtime","queue"],FEATURE_FILES={forms:["app/Models/Forms/"],cms:["app/Actions/Cms/","app/Actions/Dashboard/Content/","app/Actions/Dashboard/dashboard-response.ts","app/Models/Content/","app/Models/Tag.ts","app/Models/Comment.ts","resources/views/dashboard/content/"],commerce:["app/Actions/Commerce/","app/Actions/Dashboard/Commerce/","app/Actions/Dashboard/dashboard-response.ts","app/Models/commerce/","resources/components/Dashboard/Commerce/","resources/views/dashboard/commerce/"],dashboard:["app/Actions/Dashboard/","resources/components/Dashboard/","resources/views/dashboard/","routes/dashboard.ts","routes/dashboard-api.ts"],marketing:["app/Actions/Dashboard/Marketing/","app/Actions/Dashboard/dashboard-response.ts","app/Models/Campaign.ts","app/Models/CampaignSend.ts","app/Models/EmailList.ts","app/Models/EmailListSubscriber.ts","app/Models/SocialPost.ts","resources/components/Marketing/","resources/views/dashboard/marketing/"],monitoring:["app/Actions/Monitoring/","app/Actions/TestErrorAction.ts","app/Models/Error.ts","functions/monitoring/","resources/views/dashboard/monitoring/","resources/views/dashboard/errors/"],realtime:["app/Actions/Realtime/","app/Actions/Dashboard/Realtime/","app/Actions/Dashboard/dashboard-response.ts","app/Models/realtime/","app/Broadcasts/","functions/realtime/","resources/views/dashboard/realtime/"],queue:["app/Actions/Queue/","app/Actions/Dashboard/Jobs/","app/Actions/Dashboard/dashboard-response.ts","app/Jobs/","app/Models/Job.ts","app/Models/FailedJob.ts","functions/jobs.ts","resources/views/dashboard/queue/","resources/views/dashboard/jobs/"]},FEATURE_TABLES={forms:["forms","form_fields","form_submissions"],cms:["posts","pages","comments","tags","authors","categories","taggable_models","categorizable_models","commentables","page_revisions","redirects","menus","menu_items"],commerce:["products","product_variants","product_units","manufacturers","orders","order_items","order_idempotency","carts","cart_items","payments","payment_methods","payment_products","payment_transactions","customers","subscribers","subscriber_emails","subscriptions","gift_cards","coupons","transactions","reviews","couriers","courier_pings","delivery_routes","delivery_stops","digital_deliveries","shipping_methods","shipping_rates","shipping_zones","license_keys","loyalty_points","loyalty_rewards","print_devices","receipts","tax_rates","waitlist_products","waitlist_restaurants","auctions","auction_items","bids","pledges"],dashboard:["boards","board_columns","cards","card_labels","card_assignees","card_comments","labels","ci_run_states","ci_runner_samples","ci_runner_alert_states","requests","logs"],marketing:["campaigns","campaign_sends","email_lists","email_list_subscribers","social_posts","mail_preferences"],monitoring:["errors"],realtime:["websockets"],queue:["jobs","failed_jobs"]};export function migrationTable(filename){const inMatch=filename.match(/-in-([a-z0-9_]+)\.sql$/i);if(inMatch)return inMatch[1]??null;const createMatch=filename.match(/-create-([a-z0-9_]+)-table\.sql$/i);if(createMatch)return createMatch[1]??null;const alterMatch=filename.match(/-alter-([a-z0-9_]+)-/i);if(alterMatch)return alterMatch[1]??null;return null}export function migrationFeature(filename){const table=migrationTable(filename);if(!table)return null;for(const f of FEATURE_NAMES)if(FEATURE_TABLES[f].includes(table))return f;return null}export function appModelClaimsTable(table,root=projectPath()){const modelsDir=join(root,"app/Models");if(!existsSync(modelsDir))return!1;const featureModelFiles=new Set(FEATURE_NAMES.flatMap((feature)=>FEATURE_FILES[feature]).filter((path)=>path.startsWith("app/Models/")&&!path.endsWith("/"))),escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),declaration=new RegExp(`\\btable\\s*:\\s*['"]${escaped}['"]`);for(const entry of readdirSync(modelsDir,{withFileTypes:!0})){if(!entry.isFile()||!/\.[cm]?[jt]s$/.test(entry.name))continue;if(featureModelFiles.has(`app/Models/${entry.name}`))continue;if(declaration.test(readFileSync(join(modelsDir,entry.name),"utf8")))return!0}return!1}export function featurePathsPresent(feature,root=projectPath()){return FEATURE_FILES[feature].filter((rel)=>existsSync(`${root}/${rel}`))}export async function deleteFeatureFiles(feature,root=projectPath()){const removed=[];for(const rel of FEATURE_FILES[feature]){const full=`${root}/${rel}`;if(!existsSync(full))continue;await rm(full,{recursive:!0,force:!0});removed.push(rel)}return removed}export async function copyFeatureFiles(feature,options={}){const source=options.source??frameworkPath("defaults"),target=options.target??projectPath(),force=options.force===!0,copied=[],skipped=[];for(const rel of FEATURE_FILES[feature]){const sourceFull=join(source,rel);if(!existsSync(sourceFull)){skipped.push(rel);continue}const targetFull=join(target,rel);if(existsSync(targetFull)&&!force){skipped.push(rel);continue}await cp(sourceFull,targetFull,{recursive:!0,force});copied.push(rel)}return{copied,skipped}}const FEATURE_DESCRIPTIONS={dashboard:"Admin SPA shell + Activity/Log/Request/Deployment/Notification dashboards.",commerce:"Order/Cart/Product/Customer/Coupon/GiftCard/Shipping + storefront API.",cms:"Post/Page/Author/Comment/Tag models + content edit dashboards.",forms:"User-defined forms: builder models, conditional fields, public submit + CSV export.",marketing:"/api/email/subscribe, /api/contact, Campaign/EmailList/SocialPost.",monitoring:"Error model + error-tracking views and actions.",realtime:"WebSocket broadcaster + Websocket model + realtime-stats actions.",queue:"Job + FailedJob models + queue dashboard pages."},STARTER_TEMPLATES={dashboard:`import type { DashboardConfig } from '@stacksjs/types'
1
+ import{existsSync}from"node:fs";import{cp,rm}from"node:fs/promises";import{join}from"node:path";import process from"node:process";import{frameworkPath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";export{appModelClaimsTable,FEATURE_FILES,FEATURE_NAMES,FEATURE_TABLES,migrationFeature,migrationTable}from"@stacksjs/features";import{FEATURE_FILES,FEATURE_NAMES}from"@stacksjs/features";export function featurePathsPresent(feature,root=projectPath()){return FEATURE_FILES[feature].filter((rel)=>existsSync(`${root}/${rel}`))}export async function deleteFeatureFiles(feature,root=projectPath()){const removed=[];for(const rel of FEATURE_FILES[feature]){const full=`${root}/${rel}`;if(!existsSync(full))continue;await rm(full,{recursive:!0,force:!0});removed.push(rel)}return removed}export async function copyFeatureFiles(feature,options={}){const source=options.source??frameworkPath("defaults"),target=options.target??projectPath(),force=options.force===!0,copied=[],skipped=[];for(const rel of FEATURE_FILES[feature]){const sourceFull=join(source,rel);if(!existsSync(sourceFull)){skipped.push(rel);continue}const targetFull=join(target,rel);if(existsSync(targetFull)&&!force){skipped.push(rel);continue}await cp(sourceFull,targetFull,{recursive:!0,force});copied.push(rel)}return{copied,skipped}}const FEATURE_DESCRIPTIONS={dashboard:"Admin SPA shell + Activity/Log/Request/Deployment/Notification dashboards.",commerce:"Order/Cart/Product/Customer/Coupon/GiftCard/Shipping + storefront API.",cms:"Post/Page/Author/Comment/Tag models + content edit dashboards.",forms:"User-defined forms: builder models, conditional fields, public submit + CSV export.",marketing:"/api/email/subscribe, /api/contact, Campaign/EmailList/SocialPost.",monitoring:"Error model + error-tracking views and actions.",realtime:"WebSocket broadcaster + Websocket model + realtime-stats actions.",queue:"Job + FailedJob models + queue dashboard pages."},STARTER_TEMPLATES={dashboard:`import type { DashboardConfig } from '@stacksjs/types'
2
2
 
3
3
  /**
4
4
  * **Dashboard Configuration**
@@ -1,3 +1,3 @@
1
1
  import process from"node:process";import{generateComponentMeta,generateCoreSymlink,generateIdeHelpers,generateLibEntries,generateOpenApiSpec,generatePantryConfig,generateProjectImages,generateTypes,generateVsCodeCustomData,generateWebTypes,invoke as startGenerationProcess,watchTypes}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{frameworkPath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{reportFailure,resultFailed}from"../result";export function generate(buddy){const descriptions={command:"Automagically build any of your libraries/packages for production use. Select any of the following packages",types:"Generate your TypeScript types",entries:"Generate your function & Component Library Entry Points",webTypes:"Generate web-types.json for IDEs",customData:"Generate VS Code custom data (custom-elements.json) for IDEs",ideHelpers:"Generate IDE helpers",componentMeta:"Generate component meta information",coreSymlink:"Generate symlink of the core framework to the project root",pantry:"Generate the pantry configuration file",openApi:"Generate the OpenAPI specification",images:"Generate every image declared in config/images.ts",og:"Generate the social cards used by link previews",appStore:"Generate the App Store screenshot set",appIcons:"Generate the app icon and favicon sets",select:"What are you trying to generate?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("generate",descriptions.command).option("-t, --types",descriptions.types).option("-e, --entries",descriptions.entries).option("-w, --web-types",descriptions.webTypes).option("-c, --custom-data",descriptions.customData).option("-i, --ide-helpers",descriptions.ideHelpers).option("-c, --component-meta",descriptions.componentMeta).option("-p, --pantry",descriptions.pantry).option("-o, --openapi",descriptions.openApi).option("--images",descriptions.images).option("-p, --project [project]",descriptions.project,{default:!1}).option("--core-symlink",descriptions.coreSymlink).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate` ...",options);await startGenerationProcess(options);process.exit(ExitCode.Success)});buddy.command("generate:types",descriptions.types).option("-p, --project [project]",descriptions.project,{default:!1}).option("-w, --watch","Re-run on changes to models/ and config/",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).alias("types:generate").action(async(options)=>{log.debug("Running `buddy generate:types` ...",options);await generateTypes(options);try{const{buildDatabaseSchema}=await import("@stacksjs/orm");await buildDatabaseSchema()}catch(err){log.warn(`[generate:db-types] skipped: ${err.message}`)}if(options.watch)await watchTypes(options)});buddy.command("generate:db-types","Refresh database/types.d.ts for db.selectFrom autocomplete (stacksjs/stacks#1923)").option("--dry-run","Print the would-be file content without writing",{default:!1}).option("--framework","Write the framework's own FrameworkSchema instead of the app's DatabaseSchema",{default:!1}).action(async(options)=>{const{buildDatabaseSchema}=await import("@stacksjs/orm"),result=await buildDatabaseSchema(options.framework?{dryRun:options.dryRun,target:"framework",outFile:frameworkPath("core/database/src/framework-schema.ts"),migrationsDir:projectPath("database/migrations")}:{dryRun:options.dryRun});if(options.dryRun)console.log(result.content);for(const e of result.errors)log.warn(`[generate:db-types] ${e.file}: ${e.error}`);log.info(`[generate:db-types] resolved ${result.tables.length} table(s)`)});buddy.command("generate:vschema","Derive a Vitess VSchema from your models (writes database/vschema.json)").option("--dry-run","Print the VSchema without writing it",{default:!1}).option("--out [path]","Where to write the VSchema",{default:"database/vschema.json"}).action(async(options)=>{const{generateVSchema}=await import("@stacksjs/actions"),result=await generateVSchema({dryRun:options.dryRun,out:options.out});if(!result.ok){console.error(`
2
2
  \u274C ${result.error}
3
- `);process.exit(ExitCode.FatalError)}console.log(result.report);if(options.dryRun)console.log(JSON.stringify(result.vschema,null,2));else log.success(`Wrote ${result.path} (${result.tableCount} tables)`)});buddy.command("generate:entries",descriptions.entries).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:entries` ...",options);await generateLibEntries(options)});buddy.command("generate:web-types",descriptions.webTypes).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:web-types` ...",options);await generateWebTypes()});buddy.command("generate:vscode-custom-data",descriptions.customData).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:vscode-custom-data` ...",options);await generateVsCodeCustomData()});buddy.command("generate:ide-helpers",descriptions.ideHelpers).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:ide-helpers` ...",options);await generateIdeHelpers()});buddy.command("generate:component-meta",descriptions.componentMeta).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:component-meta` ...",options);await generateComponentMeta()});buddy.command("generate:pantry-config",descriptions.pantry).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:pantry-config` ...",options);await generatePantryConfig()});buddy.command("generate:openapi-spec",descriptions.openApi).alias("generate:openapi").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:openapi-spec` ...",options);const perf=await intro("buddy generate:openapi-spec");await generateOpenApiSpec();await outro("Generated OpenAPI specification",{startTime:perf,useSeconds:!0})});buddy.command("generate:migrations","Generate Migrations").action(async(options)=>{log.debug("Running `buddy generate:migrations` ...",options);const{generateMigrations}=await import("@stacksjs/database"),result=await generateMigrations();if(resultFailed(result))reportFailure(result,"generateMigrations failed")});buddy.command("generate:core-symlink","Symlink `.framework` -> storage/framework. A shortcut for core developers.").action(async(options)=>{log.debug("Running `buddy core-symlink` ...",options);await generateCoreSymlink()});buddy.command("generate:images",descriptions.images).alias("images:generate").option("--social","Only build the social cards").option("--app-store","Only build the App Store screenshots").option("--app-icons","Only build the app icons and favicons").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:images` ...",options);const perf=await intro("buddy generate:images"),only=[];if(options.social)only.push("social");if(options.appStore)only.push("app-store");if(options.appIcons)only.push("app-icons");await generateProjectImages({only,verbose:options.verbose});await outro("Generated images",{startTime:perf,useSeconds:!0})});buddy.command("generate:og",descriptions.og).alias("generate:social").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:og` ...",options);const perf=await intro("buddy generate:og");await generateProjectImages({only:["social"],verbose:options.verbose});await outro("Generated social cards",{startTime:perf,useSeconds:!0})});buddy.command("generate:app-store",descriptions.appStore).alias("generate:screenshots").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:app-store` ...",options);const perf=await intro("buddy generate:app-store");await generateProjectImages({only:["app-store"],verbose:options.verbose});await outro("Generated App Store screenshots",{startTime:perf,useSeconds:!0})});buddy.command("generate:app-icons",descriptions.appIcons).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:app-icons` ...",options);const perf=await intro("buddy generate:app-icons");await generateProjectImages({only:["app-icons"],verbose:options.verbose});await outro("Generated app icons",{startTime:perf,useSeconds:!0})});onUnknownSubcommand(buddy,"generate")}
3
+ `);process.exit(ExitCode.FatalError)}console.log(result.report);if(options.dryRun)console.log(JSON.stringify(result.vschema,null,2));else log.success(`Wrote ${result.path} (${result.tableCount} tables)`)});buddy.command("generate:entries",descriptions.entries).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:entries` ...",options);await generateLibEntries(options)});buddy.command("generate:web-types",descriptions.webTypes).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:web-types` ...",options);await generateWebTypes()});buddy.command("generate:vscode-custom-data",descriptions.customData).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:vscode-custom-data` ...",options);await generateVsCodeCustomData()});buddy.command("generate:ide-helpers",descriptions.ideHelpers).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:ide-helpers` ...",options);await generateIdeHelpers()});buddy.command("generate:component-meta",descriptions.componentMeta).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:component-meta` ...",options);await generateComponentMeta()});buddy.command("generate:pantry-config",descriptions.pantry).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:pantry-config` ...",options);await generatePantryConfig()});buddy.command("generate:openapi-spec",descriptions.openApi).alias("generate:openapi").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:openapi-spec` ...",options);const perf=await intro("buddy generate:openapi-spec");await generateOpenApiSpec();await outro("Generated OpenAPI specification",{startTime:perf,useSeconds:!0})});buddy.command("generate:migrations","Generate Migrations").action(async(options)=>{log.debug("Running `buddy generate:migrations` ...",options);const{generateMigrations}=await import("@stacksjs/database"),result=await generateMigrations();if(resultFailed(result))reportFailure(result,"generateMigrations failed")});buddy.command("generate:core-symlink","Symlink `.framework` -> storage/framework. A shortcut for core developers.").action(async(options)=>{log.debug("Running `buddy generate:core-symlink` ...",options);await generateCoreSymlink()});buddy.command("generate:images",descriptions.images).alias("images:generate").option("--social","Only build the social cards").option("--app-store","Only build the App Store screenshots").option("--app-icons","Only build the app icons and favicons").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:images` ...",options);const perf=await intro("buddy generate:images"),only=[];if(options.social)only.push("social");if(options.appStore)only.push("app-store");if(options.appIcons)only.push("app-icons");await generateProjectImages({only,verbose:options.verbose});await outro("Generated images",{startTime:perf,useSeconds:!0})});buddy.command("generate:og",descriptions.og).alias("generate:social").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:og` ...",options);const perf=await intro("buddy generate:og");await generateProjectImages({only:["social"],verbose:options.verbose});await outro("Generated social cards",{startTime:perf,useSeconds:!0})});buddy.command("generate:app-store",descriptions.appStore).alias("generate:screenshots").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:app-store` ...",options);const perf=await intro("buddy generate:app-store");await generateProjectImages({only:["app-store"],verbose:options.verbose});await outro("Generated App Store screenshots",{startTime:perf,useSeconds:!0})});buddy.command("generate:app-icons",descriptions.appIcons).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:app-icons` ...",options);const perf=await intro("buddy generate:app-icons");await generateProjectImages({only:["app-icons"],verbose:options.verbose});await outro("Generated app icons",{startTime:perf,useSeconds:!0})});onUnknownSubcommand(buddy,"generate")}
@@ -36,6 +36,7 @@ export * from './migrate-project';
36
36
  export * from './outdated';
37
37
  export * from './phone';
38
38
  export * from './ports';
39
+ export * from './server';
39
40
  export * from './link';
40
41
  export * from './user';
41
42
  export * from './prepublish';
@@ -1 +1 @@
1
- export*from"./about";export*from"./auth";export*from"./build";export*from"./cd";export*from"./changelog";export*from"./clean";export*from"./cloud";export*from"./commit";export*from"./completion";export*from"./config-migrate";export*from"./configure";export*from"./create";export*from"./db";export*from"./deploy";export*from"./deploy-preview";export*from"./dev";export*from"./dns";export*from"./docs";export*from"./doctor";export*from"./domains";export*from"./email";export*from"./env";export*from"./features";export*from"./fresh";export*from"./generate";export*from"./http";export*from"./install";export*from"./key";export*from"./libs";export*from"./lint";export*from"./list";export*from"./mail";export*from"./make";export*from"./migrate";export*from"./migrate-project";export*from"./outdated";export*from"./phone";export*from"./ports";export*from"./link";export*from"./user";export*from"./prepublish";export*from"./projects";export*from"./publish";export*from"./queue";export*from"./release";export*from"./route";export*from"./saas";export*from"./schedule";export*from"./search";export*from"./seed";export*from"./serve";export*from"./setup";export*from"./sms";export*from"./telemetry";export*from"./test";export*from"./tinker";export*from"./types";export*from"./upgrade";export*from"./version";
1
+ export*from"./about";export*from"./auth";export*from"./build";export*from"./cd";export*from"./changelog";export*from"./clean";export*from"./cloud";export*from"./commit";export*from"./completion";export*from"./config-migrate";export*from"./configure";export*from"./create";export*from"./db";export*from"./deploy";export*from"./deploy-preview";export*from"./dev";export*from"./dns";export*from"./docs";export*from"./doctor";export*from"./domains";export*from"./email";export*from"./env";export*from"./features";export*from"./fresh";export*from"./generate";export*from"./http";export*from"./install";export*from"./key";export*from"./libs";export*from"./lint";export*from"./list";export*from"./mail";export*from"./make";export*from"./migrate";export*from"./migrate-project";export*from"./outdated";export*from"./phone";export*from"./ports";export*from"./server";export*from"./link";export*from"./user";export*from"./prepublish";export*from"./projects";export*from"./publish";export*from"./queue";export*from"./release";export*from"./route";export*from"./saas";export*from"./schedule";export*from"./search";export*from"./seed";export*from"./serve";export*from"./setup";export*from"./sms";export*from"./telemetry";export*from"./test";export*from"./tinker";export*from"./types";export*from"./upgrade";export*from"./version";
@@ -17,7 +17,7 @@ import{getErrorMessage}from"@stacksjs/utils";import{readFileSync,existsSync}from
17
17
  \uD83D\uDCDE Searching for ${phoneType} numbers in ${countryCode}...
18
18
  `);loadAwsCredentials();try{const{ConnectClient}=await import("@stacksjs/ts-cloud"),connect=new ConnectClient(process.env.AWS_REGION||"us-east-1"),instanceAlias=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-phone`,instance=(await withTimeout(connect.listInstances({MaxResults:100}))).InstanceSummaryList?.find((i)=>i.InstanceAlias===instanceAlias);if(!instance?.Arn){console.log("Phone service not deployed. Run `buddy phone:setup` first.");process.exit(0);return}const available=await withTimeout(connect.searchAvailablePhoneNumbers({TargetArn:instance.Arn,PhoneNumberCountryCode:countryCode,PhoneNumberType:phoneType,MaxResults:10}));if(!available.AvailableNumbersList||available.AvailableNumbersList.length===0){console.log(`No ${phoneType} numbers available in ${countryCode}.`);console.log(`
19
19
  \uD83D\uDCA1 Try a different country or number type.`);process.exit(0);return}console.log(`Available Phone Numbers:
20
- `);for(const num of available.AvailableNumbersList)console.log(` \uD83D\uDCF1 ${num.PhoneNumber}`);console.log("\n\uD83D\uDCA1 To claim a number, use `buddy phone:claim <number>`")}catch(error){if(getErrorMessage(error).includes("not authorized")){console.log("Not authorized to search phone numbers.");console.log("Make sure your AWS account has Amazon Connect permissions.")}else console.error("Error searching numbers:",getErrorMessage(error))}process.exit(0)});buddy.command("phone:setup",descriptions.setup).action(async()=>{console.log(`
20
+ `);for(const num of available.AvailableNumbersList)console.log(` \uD83D\uDCF1 ${num.PhoneNumber}`);console.log("\n\uD83D\uDCA1 Claiming a number is not available from the CLI yet - provision it with your provider, then run `buddy phone:numbers` to confirm it is visible here.")}catch(error){if(getErrorMessage(error).includes("not authorized")){console.log("Not authorized to search phone numbers.");console.log("Make sure your AWS account has Amazon Connect permissions.")}else console.error("Error searching numbers:",getErrorMessage(error))}process.exit(0)});buddy.command("phone:setup",descriptions.setup).action(async()=>{console.log(`
21
21
  \uD83D\uDCDE Phone Service Setup
22
22
  `);console.log("Amazon Connect setup requires manual configuration.");console.log(`
23
23
  Steps to set up phone service:
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The catalogue Raspberry Pi publishes for its own imager.
3
+ *
4
+ * Read rather than hardcoded because image URLs carry a build date, so a
5
+ * pinned link goes stale within months and a stale Pi OS image predates
6
+ * cloud-init entirely. `init_format` is the field that says which first-boot
7
+ * system the image runs, and it is the only thing that decides whether the
8
+ * files we generate will be read at all.
9
+ */
10
+ export declare function parseOsCatalogue(raw: unknown): ServerImage[];
11
+ /** Pick one image out of the catalogue, or say what is on offer. */
12
+ export declare function selectImage(images: ServerImage[], id: ServerOsId): ServerImage;
13
+ /**
14
+ * Whether a disk may be written to, and if not, why not.
15
+ *
16
+ * Every condition is a refusal rather than a permission: a disk qualifies only
17
+ * by being a whole device, not internal, not the running system, and removable
18
+ * or external by at least one of the three ways macOS reports that. A partition
19
+ * is refused outright, because writing an image to one leaves an unbootable
20
+ * card and may well have been a mistyped whole-disk name.
21
+ */
22
+ export declare function flashRefusalReason(info: DiskInfo): string | null;
23
+ /** A one-line description of a disk, for the confirmation prompt. */
24
+ export declare function describeDisk(info: DiskInfo): string;
25
+ /**
26
+ * Where the boot partition of a freshly written card is mounted.
27
+ *
28
+ * macOS mounts it by volume name, and the two OS families use different ones.
29
+ * A card that was just written is often not mounted yet, so a miss here is
30
+ * normal and the caller waits or takes an explicit path.
31
+ */
32
+ export declare function resolveBootVolume(image: Pick<ServerImage, 'bootVolume'>, exists: (path: string) => boolean): string | null;
33
+ /**
34
+ * Hosts from `dns-sd -B _ssh._tcp local.` output.
35
+ *
36
+ * The tool prints a running log rather than a list, one line per event, and
37
+ * emits `Rmv` when a host goes away. Removals have to be honoured or a board
38
+ * that just rebooted is offered as if it were still answering.
39
+ */
40
+ export declare function parseDnsSdBrowse(output: string): DiscoveredHost[];
41
+ /** One image as the official index describes it. */
42
+ export declare interface ServerImage {
43
+ id: ServerOsId
44
+ name: string
45
+ url: string
46
+ extractSha256?: string
47
+ downloadSha256?: string
48
+ downloadSize?: number
49
+ extractSize?: number
50
+ releaseDate?: string
51
+ bootVolume: string
52
+ firstBoot: FirstBootFormat
53
+ supportsPi5: boolean
54
+ }
55
+ /** What `diskutil info -plist` says about a candidate device. */
56
+ export declare interface DiskInfo {
57
+ DeviceIdentifier?: string
58
+ DeviceNode?: string
59
+ MediaName?: string
60
+ Size?: number
61
+ WholeDisk?: boolean
62
+ Internal?: boolean
63
+ Ejectable?: boolean
64
+ Removable?: boolean
65
+ RemovableMediaOrExternalDevice?: boolean
66
+ SystemImage?: boolean
67
+ BusProtocol?: string
68
+ }
69
+ /** One host advertising SSH over mDNS. */
70
+ export declare interface DiscoveredHost {
71
+ name: string
72
+ hostname: string
73
+ }
74
+ /**
75
+ * Choosing an operating system image, and choosing a disk to write it to.
76
+ *
77
+ * Everything here is pure so the dangerous part can be tested. `server:flash`
78
+ * writes a raw image to a block device, and the difference between the right
79
+ * device and the wrong one is the difference between a prepared SD card and an
80
+ * erased laptop. The disk allowlist below is therefore written to refuse by
81
+ * default and to say why, rather than to permit whatever looks plausible.
82
+ */
83
+ /** The OS images `buddy server:flash` knows how to fetch. */
84
+ export type ServerOsId = 'raspberry-pi-os-lite' | 'raspberry-pi-os' | 'ubuntu-24.04' | 'ubuntu-26.04';
85
+ /** Which first-boot files an image reads, which decides what we generate. */
86
+ export type FirstBootFormat = 'cloudinit' | 'unsupported';
@@ -0,0 +1,2 @@
1
+ const CATALOGUE_NAMES={"raspberry-pi-os-lite":{match:/^Raspberry Pi OS Lite \(64-bit\)$/,bootVolume:"bootfs"},"raspberry-pi-os":{match:/^Raspberry Pi OS \(64-bit\)$/,bootVolume:"bootfs"},"ubuntu-24.04":{match:/^Ubuntu Server 24\.04.*\(64-bit\)$/,bootVolume:"system-boot"},"ubuntu-26.04":{match:/^Ubuntu Server 26\.04.*\(64-bit\)$/,bootVolume:"system-boot"}};export function parseOsCatalogue(raw){const found=[],seen=new Set,visit=(node)=>{if(Array.isArray(node)){for(const item of node)visit(item);return}if(!node||typeof node!=="object")return;const entry=node,name=typeof entry.name==="string"?entry.name:"",url=typeof entry.url==="string"?entry.url:"";if(name&&url)for(const[id,spec]of Object.entries(CATALOGUE_NAMES)){if(seen.has(id)||!spec.match.test(name))continue;const devices=Array.isArray(entry.devices)?entry.devices.map(String):[],init=typeof entry.init_format==="string"?entry.init_format:"";seen.add(id);found.push({id,name,url,extractSha256:typeof entry.extract_sha256==="string"?entry.extract_sha256:void 0,downloadSha256:typeof entry.image_download_sha256==="string"?entry.image_download_sha256:void 0,downloadSize:typeof entry.image_download_size==="number"?entry.image_download_size:void 0,extractSize:typeof entry.extract_size==="number"?entry.extract_size:void 0,releaseDate:typeof entry.release_date==="string"?entry.release_date:void 0,bootVolume:spec.bootVolume,firstBoot:init.startsWith("cloudinit")?"cloudinit":"unsupported",supportsPi5:devices.includes("pi5-64bit")})}for(const value of Object.values(entry))visit(value)};visit(raw);return found}export function selectImage(images,id){const image=images.find((candidate)=>candidate.id===id);if(!image)throw Error(`No image named '${id}' in the catalogue. Found: ${images.map((i)=>i.id).join(", ")||"nothing"}.`);if(image.firstBoot!=="cloudinit")throw Error(`'${image.name}' does not boot with cloud-init, so buddy cannot write its first-boot configuration. Pick a current image, or flash it with Raspberry Pi Imager and run \`buddy server:setup\` afterwards.`);return image}export function flashRefusalReason(info){const id=info.DeviceIdentifier||info.DeviceNode||"that disk";if(info.WholeDisk!==!0)return`${id} is a partition, not a whole disk. Pass the whole device, for example /dev/disk4 rather than /dev/disk4s1.`;if(info.SystemImage===!0)return`${id} holds the running macOS system.`;if(info.Internal===!0)return`${id} is an internal disk (${info.MediaName||"unknown model"}).`;if(!(info.Removable===!0||info.Ejectable===!0||info.RemovableMediaOrExternalDevice===!0))return`${id} is neither removable nor external, so buddy will not write to it.`;return null}export function describeDisk(info){const gb=typeof info.Size==="number"?`${(info.Size/1e9).toFixed(1)} GB`:"unknown size";return`${info.DeviceNode||info.DeviceIdentifier} - ${info.MediaName||"unknown model"}, ${gb}${info.BusProtocol?`, ${info.BusProtocol}`:""}`}export function resolveBootVolume(image,exists){const primary=`/Volumes/${image.bootVolume}`;if(exists(primary))return primary;for(let n=1;n<=5;n++){const candidate=`${primary} ${n}`;if(exists(candidate))return candidate}return null}export function parseDnsSdBrowse(output){const live=new Map;for(const line of output.split(`
2
+ `)){const match=/\b(Add|Rmv)\b.*?\b_ssh\._tcp\.?\s+(.+?)\s*$/.exec(line);if(!match)continue;const[,action,rawName]=match,name=(rawName??"").replace(/\\032/g," ").trim();if(!name)continue;if(action==="Rmv")live.delete(name);else live.set(name,{name,hostname:`${name.replace(/\s+/g,"-")}.local`})}return[...live.values()].sort((a,b)=>a.name.localeCompare(b.name))}
@@ -0,0 +1,71 @@
1
+ /** The certificate path to read on the host, honouring an explicit override. */
2
+ export declare function resolveCaPath(flag?: string | null): string;
3
+ /**
4
+ * A host as it can safely appear in a filename.
5
+ *
6
+ * A host is whatever the config says, so it can be an IPv6 literal, and it is
7
+ * about to be part of a path this command creates. Anything outside the set a
8
+ * hostname or an address needs becomes a hyphen, which keeps `../` and a colon
9
+ * out of the result without silently mapping two different hosts onto one file.
10
+ */
11
+ export declare function caFileSlug(host: string): string;
12
+ /**
13
+ * Where this machine keeps its copy of a host's authority.
14
+ *
15
+ * Under `storage/cloud/` with the rest of the deploy state, keyed by host, so a
16
+ * second run and a second board do not overwrite each other and the user has a
17
+ * file to point a browser, a phone or `curl --cacert` at afterwards.
18
+ */
19
+ export declare function caCopyPath(host: string, projectRoot?: string): string;
20
+ /** A single-quoted argument for a remote `sh`, safe for any byte but NUL. */
21
+ export declare function shellQuote(value: string): string;
22
+ /**
23
+ * The remote script that reads the authority.
24
+ *
25
+ * The file is readable by everyone on most boxes, but the directory it sits in
26
+ * need not be, so an unreadable-but-present file falls back to a non-interactive
27
+ * sudo. `sudo -n` rather than `sudo`: the deploy runs with `BatchMode=yes` and
28
+ * no terminal, and a sudo that prompts would hang rather than fail.
29
+ */
30
+ export declare function caReadScript(caPath: string): string;
31
+ /**
32
+ * The summary, with the profile path present only when one was actually written.
33
+ *
34
+ * A `mobileconfigPath` of null in the JSON would read as "a profile, at nowhere".
35
+ * Omitting the key says the same thing without inviting a consumer to use it.
36
+ */
37
+ export declare function trustSummary(input: {
38
+ host: string
39
+ caPath: string
40
+ savedPath: string
41
+ fingerprint: string
42
+ trusted: boolean
43
+ mobileconfigPath?: string | null
44
+ }): TrustSummary;
45
+ /** The steps that get a profile onto an iPhone, including the one people miss. */
46
+ export declare function mobileconfigInstructions(profilePath: string): string[];
47
+ /**
48
+ * Where rpx writes the authority on a box that issues its own LAN certificate.
49
+ *
50
+ * ts-cloud configures rpx with `localCa.dir` at `/etc/rpx/local-ca`, and rpx
51
+ * names the root `rpx-root-ca.crt` inside it. This is a convention rather than
52
+ * something either side reports, which is why `--ca-path` exists.
53
+ */
54
+ export declare const DEFAULT_LAN_CA_PATH: '/etc/rpx/local-ca/rpx-root-ca.crt';
55
+ /**
56
+ * The exit status the remote read uses for "the file is not there".
57
+ *
58
+ * A missing authority and an unreachable box need different advice, and `cat`
59
+ * cannot tell them apart on its own: both come back as a non-zero ssh. Picking
60
+ * a status no shell assigns on its own keeps the two distinguishable.
61
+ */
62
+ export declare const CA_MISSING_EXIT: 44;
63
+ /** What `--json` prints, and what the human output says in prose. */
64
+ export declare interface TrustSummary {
65
+ host: string
66
+ caPath: string
67
+ savedPath: string
68
+ fingerprint: string
69
+ trusted: boolean
70
+ mobileconfigPath?: string
71
+ }
@@ -0,0 +1,2 @@
1
+ import{join}from"node:path";import process from"node:process";export const DEFAULT_LAN_CA_PATH="/etc/rpx/local-ca/rpx-root-ca.crt",CA_MISSING_EXIT=44;export function resolveCaPath(flag){return(typeof flag==="string"?flag.trim():"")||DEFAULT_LAN_CA_PATH}export function caFileSlug(host){return String(host??"").trim().toLowerCase().replace(/[^a-z0-9._-]+/g,"-").replace(/^[.-]+|[.-]+$/g,"")||"host"}export function caCopyPath(host,projectRoot=process.cwd()){return join(projectRoot,"storage","cloud","ssh",`${caFileSlug(host)}.ca.crt`)}export function shellQuote(value){return`'${String(value).replace(/'/g,"'\\''")}'`}export function caReadScript(caPath){const path=shellQuote(caPath);return[`if [ -r ${path} ]; then cat ${path}; exit 0; fi`,`if [ -e ${path} ]; then sudo -n cat ${path}; exit $?; fi`,`exit ${CA_MISSING_EXIT}`].join(`
2
+ `)}export function trustSummary(input){return{host:input.host,caPath:input.caPath,savedPath:input.savedPath,fingerprint:input.fingerprint,trusted:input.trusted===!0,...input.mobileconfigPath?{mobileconfigPath:input.mobileconfigPath}:{}}}export function mobileconfigInstructions(profilePath){return[`Send ${profilePath} to the device by AirDrop, mail or a link, then open it.`,"Install it under Settings > General > VPN & Device Management.","Turn on full trust under Settings > General > About > Certificate Trust Settings.","The last step is not optional. A profile that is installed but not fully trusted still fails, and the symptom looks like a bad certificate."]}
@@ -0,0 +1,4 @@
1
+ import type { CLI } from '@stacksjs/types';
2
+ export declare function server(buddy: CLI): void;
3
+ /** Why reading the authority off the host did not produce a certificate. */
4
+ declare type CaReadFailure = 'unreachable' | 'missing' | 'unreadable';
@@ -0,0 +1,10 @@
1
+ import{createWriteStream,existsSync,mkdirSync,statSync}from"node:fs";import{homedir}from"node:os";import{dirname,join}from"node:path";import process from"node:process";import{intro,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{loadTsCloudConfig,loadTsCloudDeployApi,resolveProvider}from"./deploy";import{mergeSshStatePin,resolveSshTarget,sshCliArgs,sshStatePin}from"./deploy-ssh-target";import{describeDisk,flashRefusalReason,parseDnsSdBrowse,parseOsCatalogue,resolveBootVolume,selectImage}from"./server-image";import{CA_MISSING_EXIT,caCopyPath,caReadScript,DEFAULT_LAN_CA_PATH,mobileconfigInstructions,resolveCaPath,trustSummary}from"./server-trust";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args)},OS_CATALOGUE_URL="https://downloads.raspberrypi.com/os_list_imagingutility_v4.json";function imageCacheDir(){return join(homedir(),".cache","stacks","images")}async function readDiskInfo(device){const proc=Bun.spawn(["diskutil","info","-plist",device],{stdout:"pipe",stderr:"pipe"}),[out,code]=await Promise.all([new Response(proc.stdout).text(),proc.exited]);if(code!==0||!out.trim())return null;const bool=(key)=>{const match=new RegExp(`<key>${key}</key>\\s*<(true|false)/>`).exec(out);return match?match[1]==="true":void 0},str=(key)=>{return new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(out)?.[1]},num=(key)=>{const match=new RegExp(`<key>${key}</key>\\s*<integer>(\\d+)</integer>`).exec(out);return match?Number(match[1]):void 0};return{DeviceIdentifier:str("DeviceIdentifier"),DeviceNode:str("DeviceNode"),MediaName:str("MediaName"),Size:num("Size"),WholeDisk:bool("WholeDisk"),Internal:bool("Internal"),Ejectable:bool("Ejectable"),Removable:bool("Removable"),RemovableMediaOrExternalDevice:bool("RemovableMediaOrExternalDevice"),SystemImage:bool("SystemImage"),BusProtocol:str("BusProtocol")}}async function listFlashableDisks(){const proc=Bun.spawn(["diskutil","list","-plist"],{stdout:"pipe",stderr:"pipe"}),[out]=await Promise.all([new Response(proc.stdout).text(),proc.exited]),whole=[...out.matchAll(/<string>(disk\d+)<\/string>/g)].map((match)=>match[1]),found=[];for(const id of[...new Set(whole)]){const info=await readDiskInfo(`/dev/${id}`);if(info&&flashRefusalReason(info)===null)found.push(info)}return found}async function resolveImage(os){const response=await fetch(OS_CATALOGUE_URL);if(!response.ok)throw Error(`Could not read the image catalogue (HTTP ${response.status}). Check the network and try again.`);return selectImage(parseOsCatalogue(await response.json()),os)}async function downloadImage(image){const dir=imageCacheDir();mkdirSync(dir,{recursive:!0});const target=join(dir,image.url.split("/").pop()||`${image.id}.img.xz`);if(existsSync(target)&&image.downloadSize&&statSync(target).size===image.downloadSize){log.info(`Using the cached download at ${target}`);return target}const size=image.downloadSize?` (${(image.downloadSize/1e9).toFixed(2)} GB)`:"";log.info(`Downloading ${image.name}${size}...`);const response=await fetch(image.url);if(!response.ok||!response.body)throw Error(`Download failed (HTTP ${response.status}) for ${image.url}`);const file=createWriteStream(`${target}.part`);await Bun.write(Bun.file(`${target}.part`),response);file.close();await Bun.$`mv ${`${target}.part`} ${target}`.quiet();return target}async function resolveDecompressor(){for(const candidate of["xz","unxz"])if(Bun.spawnSync(["which",candidate]).exitCode===0)return[candidate,"-dc"];throw Error("No xz decompressor found, and the images are .img.xz.\n Install one with: brew install xz\n Or flash the card with Raspberry Pi Imager, then run `buddy server:first-boot` against the mounted card.")}async function loadSshApi(){const api=await loadTsCloudDeployApi(),missing=["SshDriver","buildCloudInitFirstBoot","buildSshBootstrapScript","evaluatePreflight","formatPreflightFindings"].filter((name)=>typeof api[name]!=="function");if(missing.length>0){log.error("This @stacksjs/ts-cloud does not support deploying to a host over SSH.");log.error(`Missing: ${missing.join(", ")}.`);log.info("Upgrade with `bun update @stacksjs/ts-cloud`, or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}return api}async function loadSshProject(environment){const config=await loadTsCloudConfig(environment);if(!config){log.error("No ts-cloud configuration found. Expected a `tsCloud` export from config/cloud.ts.");process.exit(ExitCode.FatalError)}const target=resolveSshTarget(config);if(!target){log.error("No SSH host configured.");log.info("Add one to config/cloud.ts: ssh: { hosts: [{ host: 'pi-stacks.local', user: 'pi' }] }");log.info("Or set TS_CLOUD_SSH_HOST (with TS_CLOUD_SSH_USER / TS_CLOUD_SSH_PORT / TS_CLOUD_SSH_KEY).");process.exit(ExitCode.FatalError)}if(resolveProvider(config)!=="ssh")log.warn(`config/cloud.ts sets provider '${resolveProvider(config)}'. Set it to 'ssh' before \`buddy deploy\` will use this host.`);return{config,target}}async function discoverHosts(seconds=4){if(process.platform!=="darwin")return[];const proc=Bun.spawn(["dns-sd","-B","_ssh._tcp","local."],{stdout:"pipe",stderr:"ignore"}),timer=setTimeout(()=>proc.kill(),seconds*1000);try{return parseDnsSdBrowse(await new Response(proc.stdout).text())}catch{return[]}finally{clearTimeout(timer)}}async function reportPreflight(api,target,asJson){const driver=new api.SshDriver({hosts:[{host:target.host,user:target.user,port:target.port,privateKeyPath:target.identityFile}],hostKey:target.hostKey,profile:target.profile});let facts,findings;try{({facts,findings}=await driver.preflight(target.host))}catch(err){const detail=err instanceof Error?err.message:String(err),unreachable={code:"ssh.unreachable",severity:"error",message:`Could not reach ${target.user}@${target.host} over SSH.`,remediation:"Check the board is powered on and on this network, that SSH is enabled, and that your key is authorised. A board that has just booted can take a minute to answer.",detail};if(asJson)console.log(JSON.stringify({host:target.host,facts:null,findings:[unreachable]},null,2));else{log.error(unreachable.message);log.info(unreachable.remediation);log.info(detail.split(`
2
+ `).find((line)=>line.trim()&&!line.startsWith("Remote SSH"))||detail)}return!1}if(asJson)console.log(JSON.stringify({host:target.host,facts,findings},null,2));else{const text=api.formatPreflightFindings(findings);if(text.trim())console.log(text);else log.success("No problems found.")}return!(typeof api.preflightFailed==="function"?api.preflightFailed(findings):findings.some((finding)=>finding.severity==="error"))}function buildBootstrapOrExit(api,config,environment,sudoUser){const profile=config.ssh?.profile==="generic"?"generic":"raspberry-pi";try{return api.buildSshBootstrapScript({config,environment,profile,sudoUser,lan:config.ssh?.lan})}catch(err){log.error(err instanceof Error?err.message:String(err));log.info("Edit config/cloud.ts and run this again.");process.exit(ExitCode.FatalError)}}async function readRemoteCa(target,caPath){const proc=Bun.spawn(["ssh",...sshCliArgs(target,{connectTimeoutSec:20}),"sh","-s"],{stdin:new TextEncoder().encode(caReadScript(caPath)),stdout:"pipe",stderr:"pipe"}),[out,err,code]=await Promise.all([new Response(proc.stdout).text(),new Response(proc.stderr).text(),proc.exited]);if(code===CA_MISSING_EXIT)return{failure:"missing",detail:""};if(code!==0)return{failure:err.toLowerCase().includes("sudo")?"unreadable":"unreachable",detail:err.trim()};if(!out.includes("-----BEGIN CERTIFICATE-----"))return{failure:"unreadable",detail:""};return{pem:out}}function trustPlatform(){if(process.platform==="darwin")return"macos";return process.platform==="win32"?"windows":"debian"}export function server(buddy){const descriptions={flash:"Write a Linux OS image to an SD card or USB disk",os:"Which image to write: raspberry-pi-os-lite, raspberry-pi-os, ubuntu-24.04, ubuntu-26.04",device:"The whole disk to write to, for example /dev/disk4",verbose:"Enable verbose output"};buddy.command("server:flash",descriptions.flash).option("--os <name>",descriptions.os,{default:"raspberry-pi-os-lite"}).option("--device <path>",descriptions.device,{default:void 0}).option("--list","List the disks that could be written to, and exit",{default:!1}).option("--dry-run","Say what would happen without writing anything",{default:!1}).option("--yes","Do not ask for confirmation before writing",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy server:flash");if(process.platform!=="darwin"){log.error("`buddy server:flash` currently supports macOS only.");log.info("On Linux, write the image with `dd`, then run `buddy server:first-boot` against the mounted boot partition.");process.exit(ExitCode.FatalError)}const disks=await listFlashableDisks();if(options.list){if(disks.length===0)log.info("No removable disks are attached.");for(const disk of disks)log.info(` ${describeDisk(disk)}`);await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}let image;try{image=await resolveImage(options.os)}catch(err){log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}log.info(`Image: ${image.name} (${image.releaseDate??"unknown date"})`);if(!image.supportsPi5)log.warn("This image is not listed as supporting the Raspberry Pi 5.");let device=options.device;if(!device){if(disks.length===0){log.error("No removable disk is attached. Insert the card and try again, or pass --device.");process.exit(ExitCode.FatalError)}if(disks.length>1){log.error("Several removable disks are attached, so buddy will not pick one:");for(const disk of disks)log.error(` ${describeDisk(disk)}`);log.info("Re-run with --device /dev/diskN naming the one you mean.");process.exit(ExitCode.FatalError)}device=disks[0]?.DeviceNode}const info=device?await readDiskInfo(device):null;if(!info){log.error(`Could not read ${device}. Check the device path with \`diskutil list\`.`);process.exit(ExitCode.FatalError)}const refusal=flashRefusalReason(info);if(refusal){log.error(refusal);process.exit(ExitCode.FatalError)}let decompressor;try{decompressor=await resolveDecompressor()}catch(err){log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}log.info(`Target: ${describeDisk(info)}`);if(options.dryRun){log.info("Dry run: nothing was downloaded and nothing was written.");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes){if(await prompts.confirm({message:`Erase ${describeDisk(info)} and write ${image.name}?`,initial:!1})!==!0){log.info("Nothing was written.");process.exit(ExitCode.Success)}}let download;try{download=await downloadImage(image)}catch(err){log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}const raw=info.DeviceNode.replace("/dev/disk","/dev/rdisk");log.info(`Unmounting ${info.DeviceNode}...`);await Bun.$`diskutil unmountDisk ${info.DeviceNode}`.nothrow();log.info("Writing the image. This needs your password, and takes a few minutes.");log.info(` ${decompressor.join(" ")} ${download} | sudo dd of=${raw} bs=4m status=progress`);if(await Bun.spawn(["sh","-c",`${decompressor[0]} -dc ${JSON.stringify(download)} | sudo dd of=${JSON.stringify(raw)} bs=4m status=progress`],{stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("Writing the image failed. The card is probably unusable until it is written again.");process.exit(ExitCode.FatalError)}await Bun.$`sync`.nothrow();log.success("Image written.");const boot=resolveBootVolume(image,existsSync);if(boot)log.info(`Boot partition mounted at ${boot}`);else log.info(`Re-insert the card if it does not mount, then look for /Volumes/${image.bootVolume}`);log.info("Next: `buddy server:first-boot --hostname <name> --user <name>` to configure the first boot.");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:first-boot","Write the first-boot configuration onto a freshly flashed card").option("--hostname <name>","The name the board answers to on the network",{default:"pi-stacks"}).option("--user <name>","The login to create, which the deploy then uses",{default:"pi"}).option("--ssh-key <path>","Public key to authorise",{default:void 0}).option("--os <name>",descriptions.os,{default:"raspberry-pi-os-lite"}).option("--out <dir>","Write the files here instead of the mounted boot partition",{default:void 0}).option("--wifi-ssid <ssid>","Join this wireless network on first boot",{default:void 0}).option("--wifi-country <code>","Two-letter regulatory domain, required with wifi",{default:void 0}).option("--timezone <tz>","IANA timezone for the board",{default:void 0}).option("--env <name>","Environment whose configuration to bootstrap",{default:"production"}).option("--force","Overwrite first-boot files already on the card",{default:!1}).action(async(options)=>{const perf=await intro("buddy server:first-boot"),api=await loadSshApi(),{config}=await loadSshProject(options.env),keyPath=options.sshKey||join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(keyPath)){log.error(`No public key at ${keyPath}.`);log.info("Generate one with: ssh-keygen -t ed25519");log.info("Or point at an existing key with --ssh-key.");process.exit(ExitCode.FatalError)}let wifi;if(options.wifiSsid){if(!options.wifiCountry){log.error("--wifi-country is required with --wifi-ssid. It sets the radio regulatory domain.");process.exit(ExitCode.FatalError)}const passphrase=process.env.WIFI_PASSWORD||await prompts.password({message:`Passphrase for ${options.wifiSsid}`});if(typeof passphrase!=="string"||!passphrase){log.error("No wireless passphrase given.");process.exit(ExitCode.FatalError)}wifi={ssid:options.wifiSsid,passphrase,country:String(options.wifiCountry).toUpperCase()}}const bootstrap=buildBootstrapOrExit(api,config,options.env,options.user==="root"?void 0:options.user),os=String(options.os).startsWith("ubuntu")?"ubuntu":"raspberry-pi-os",bundle=api.buildCloudInitFirstBoot({hostname:options.hostname,user:options.user,publicKey:(await Bun.file(keyPath).text()).trim(),timezone:options.timezone,wifi},bootstrap,{os});let destination=options.out;if(!destination){const image=await resolveImage(options.os).catch(()=>null);destination=image?resolveBootVolume(image,existsSync):null;if(!destination){log.error("The card does not appear to be mounted.");log.info("Insert the freshly written card and try again, or pass --out <dir> to write the files elsewhere.");process.exit(ExitCode.FatalError)}}mkdirSync(destination,{recursive:!0});for(const name of Object.keys(bundle.files)){const path=join(destination,name);if(existsSync(path)&&!options.force){log.error(`${path} already exists. Re-run with --force to replace it.`);process.exit(ExitCode.FatalError)}}for(const[name,contents]of Object.entries(bundle.files)){await Bun.write(join(destination,name),contents);log.success(`Wrote ${join(destination,name)}`)}if(bundle.instructions)console.log(`
3
+ ${bundle.instructions}`);log.info(`Next: eject the card, boot the board, then \`buddy server:doctor ${options.hostname}.local\``);await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:doctor [host]","Check that a host can run this application before deploying to it").option("--env <name>","Environment whose configuration to check against",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--json","Print the findings as JSON",{default:!1}).action(async(host,options)=>{const perf=options.json?void 0:await intro("buddy server:doctor"),api=await loadSshApi(),{config,target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();if(found.length===0)log.info("No hosts advertising SSH were found on this network.");for(const entry of found)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const checked=host_?{...target,host:host_}:target;log.info(`Checking ${checked.user}@${checked.host}${checked.port===22?"":`:${checked.port}`}...`);const ok=await reportPreflight(api,checked,options.json===!0);if(perf)await outro(ok?"Ready":"Not ready",{startTime:perf,useSeconds:!0});process.exit(ok?ExitCode.Success:ExitCode.FatalError)});buddy.command("server:setup [host]","Adopt a host: check it, then install what the deploy needs").option("--env <name>","Environment whose configuration to bootstrap",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--dry-run","Run the checks and stop before changing the host",{default:!1}).action(async(host,options)=>{const perf=await intro("buddy server:setup"),api=await loadSshApi(),{config,target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();for(const entry of found)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const adopted=host_?{...target,host:host_}:target;log.info(`Adopting ${adopted.user}@${adopted.host}${adopted.port===22?"":`:${adopted.port}`}`);if(!await reportPreflight(api,adopted,!1)){log.error("The host is not ready. Nothing was changed on it.");process.exit(ExitCode.FatalError)}if(options.dryRun){log.info("Dry run: the host passed its checks and was not modified.");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const driver=api.createCloudDriver({config,provider:"ssh"});if(!driver.provisionComputeInfrastructure){log.error("This ts-cloud cannot bootstrap an SSH host (update @stacksjs/ts-cloud).");process.exit(ExitCode.FatalError)}buildBootstrapOrExit(api,config,options.env,adopted.user==="root"?void 0:adopted.user);log.info("Installing the runtime, gateway and service units if they are missing...");let outputs;try{outputs=await driver.provisionComputeInfrastructure({config,environment:options.env})}catch(err){log.error("Bootstrapping the host failed.");log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}const stackName=config.project?.stackName||`${config.project?.slug||"app"}-${options.env}`,dir=join(process.cwd(),"storage","cloud","state"),statePath=join(dir,`${stackName}.json`);let recorded=null;try{recorded=existsSync(statePath)?JSON.parse(await Bun.file(statePath).text()):null}catch{recorded=null}mkdirSync(dir,{recursive:!0});await Bun.write(statePath,`${JSON.stringify(mergeSshStatePin(recorded,sshStatePin({stackName,target:adopted,deployStoragePath:outputs?.deployStoragePath})),null,2)}
4
+ `);log.success(`Host adopted. Recorded at storage/cloud/state/${stackName}.json`);log.info("Next: `buddy deploy --prod`");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:trust [host]","Trust the host's own certificate authority on this machine").option("--env <name>","Environment whose configuration names the host",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--ca-path <path>","Where the authority lives on the host",{default:DEFAULT_LAN_CA_PATH}).option("--mobileconfig <path>","Also write an Apple configuration profile for an iPhone or iPad",{default:void 0}).option("--export-only","Save the certificate without changing this machine, and say how to trust it by hand",{default:!1}).option("--json","Print the result as JSON",{default:!1}).action(async(host,options)=>{const asJson=options.json===!0,perf=asJson?void 0:await intro("buddy server:trust"),{target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();if(found.length===0&&!asJson)log.info("No hosts advertising SSH were found on this network.");for(const entry of found)if(!asJson)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const checked=host_?{...target,host:host_}:target,caPath=resolveCaPath(options.caPath),fail=(reason,message,remediation,detail)=>{if(asJson)console.log(JSON.stringify({host:checked.host,caPath,error:reason,message,remediation,...detail?{detail}:{}},null,2));else{log.error(message);log.info(remediation);if(detail)log.info(detail)}process.exit(ExitCode.FatalError)};if(!asJson)log.info(`Reading ${caPath} from ${checked.user}@${checked.host}${checked.port===22?"":`:${checked.port}`}...`);const read=await readRemoteCa(checked,caPath);if("failure"in read){if(read.failure==="missing")return fail("ca.absent",`There is no certificate authority at ${caPath} on ${checked.host}.`,"That host is not serving LAN HTTPS from its own authority. Set `ssh: { lan: { tls: 'local-ca' } }` in config/cloud.ts and run `buddy deploy --prod`, which is what creates it. Nothing was created on the host. If the authority lives elsewhere, name it with --ca-path.");if(read.failure==="unreadable")return fail("ca.unreadable",`${caPath} on ${checked.host} exists but could not be read as a certificate.`,`Check it by hand with \`ssh ${checked.user}@${checked.host} sudo cat ${caPath}\`. Reading it falls back to \`sudo -n\`, so a host whose sudo asks for a password cannot serve it to this command.`,read.detail||void 0);return fail("ssh.unreachable",`Could not reach ${checked.user}@${checked.host} over SSH.`,"Check the host is powered on and on this network, that SSH is enabled, and that your key is authorised. `buddy server:doctor` reports on all three.",read.detail.split(`
5
+ `).find((line)=>line.trim())||void 0)}const pem=read.pem,{exportCA,getCertSha256Fingerprint,isCertTrusted,trustInstructions}=await import("@stacksjs/tlsx");let fingerprint;try{fingerprint=getCertSha256Fingerprint(pem)}catch(err){return fail("ca.unparseable",`The file at ${caPath} on ${checked.host} is not a certificate this can read.`,"Point --ca-path at the root certificate rpx writes, which is a PEM.",err instanceof Error?err.message:String(err))}const savedPath=caCopyPath(checked.host);mkdirSync(dirname(savedPath),{recursive:!0});await Bun.write(savedPath,pem.endsWith(`
6
+ `)?pem:`${pem}
7
+ `);if(!asJson)log.success(`Saved the certificate to ${savedPath}`);let mobileconfigPath;if(options.mobileconfig){try{const profile=await exportCA({caCertPath:savedPath,format:"mobileconfig"}),requested=String(options.mobileconfig);mobileconfigPath=requested.endsWith("/")||existsSync(requested)&&statSync(requested).isDirectory()?join(requested,profile.filename):requested;mkdirSync(dirname(mobileconfigPath),{recursive:!0});await Bun.write(mobileconfigPath,profile.data)}catch(err){return fail("profile.failed","Could not write the configuration profile.",`Check that ${options.mobileconfig} is somewhere you can write to.`,err instanceof Error?err.message:String(err))}if(!asJson){log.success(`Wrote the configuration profile to ${mobileconfigPath}`);for(const step of mobileconfigInstructions(mobileconfigPath))log.info(` ${step}`)}}let trusted=!1;try{trusted=await isCertTrusted(pem)}catch{trusted=!1}if(options.exportOnly){if(!asJson){log.info(trusted?"This machine already trusts it. --export-only, so nothing was changed.":"This machine does not trust it yet. --export-only, so nothing was changed.");console.log(trustInstructions(trustPlatform(),savedPath))}}else if(trusted){if(!asJson)log.success("This machine already trusts it. Nothing changed.")}else{if(!asJson){log.info("Installing it into this machine's trust store.");log.info("This needs sudo, so you will be asked for your password. You type it, not buddy.")}try{const{addCertToSystemTrustStore}=await import("@stacksjs/tlsx"),report=await addCertToSystemTrustStore(savedPath);trusted=report.trusted===!0;if(!asJson)for(const store of report.stores??[])log.info(` ${store.store}: ${store.status}${store.detail?` (${store.detail})`:""}`)}catch(err){const detail=err instanceof Error?err.message:String(err);return fail("trust.failed","Installing the certificate into the trust store failed.",/\s/.test(savedPath)?`The path ${savedPath} contains a space, which the underlying \`security\` invocation cannot pass through. Trust it by hand:
8
+ ${trustInstructions(trustPlatform(),savedPath)}`:`Trust it by hand:
9
+ ${trustInstructions(trustPlatform(),savedPath)}`,detail)}if(!trusted)return fail("trust.refused","The certificate was not added to any trust store.",`Trust it by hand:
10
+ ${trustInstructions(trustPlatform(),savedPath)}`);if(!asJson)log.success("Installed. Restart any open browser before trying the LAN address again.")}const summary=trustSummary({host:checked.host,caPath,savedPath,fingerprint,trusted,mobileconfigPath});if(asJson)console.log(JSON.stringify(summary,null,2));else{log.info(`Fingerprint (SHA-256): ${fingerprint}`);if(perf)await outro("Done",{startTime:perf,useSeconds:!0})}process.exit(ExitCode.Success)});buddy.on("server:*",()=>{onUnknownSubcommand(buddy,"server")})}
@@ -46,7 +46,7 @@ export declare function getCommandsToLoad(args: string[]): string[];
46
46
  * 'development': ['dev', 'build', 'test', 'lint'],
47
47
  * 'database': ['migrate', 'seed', 'fresh'],
48
48
  * 'scaffolding': ['make', 'generate'],
49
- * 'deployment': ['deploy', 'release', 'cloud'],
49
+ * 'deployment': ['deploy', 'release', 'cloud', 'server'],
50
50
  * 'info': ['about', 'doctor', 'list']
51
51
  * }
52
52
  * ```