@stacksjs/defaults 0.75.62 → 0.75.63

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,450 +0,0 @@
1
- /**
2
- * The pure half of the extension: everything that can be decided from text.
3
- *
4
- * Nothing here imports `vscode` or touches the filesystem, so it is unit-tested
5
- * directly. Where Stacks itself makes the same decision (the dev server's URL
6
- * and port in `buddy dev`, the command list in `buddy list`), the rule is
7
- * mirrored from that code and the source is named, so the two can be compared
8
- * when either one changes.
9
- */
10
-
11
- /**
12
- * The env files a development process reads, least to most specific - the
13
- * order `autoLoadEnv()` in `@stacksjs/env` (core/env/src/plugin.ts) loads them
14
- * in when no environment is set, which is what `./buddy dev` runs under.
15
- */
16
- export const DEV_ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local'] as const
17
-
18
- /** The dev server's frontend port when `PORT` is unset (`buddy dev`). */
19
- export const DEFAULT_FRONTEND_PORT = 3000
20
-
21
- /** `buddy dev` falls back to this when `APP_URL` is unset. */
22
- export const DEFAULT_APP_URL = 'stacks.localhost'
23
-
24
- export type Env = Record<string, string | undefined>
25
-
26
- /**
27
- * Parse a dotenv file into key/value pairs.
28
- *
29
- * Covers what Stacks' own `.env` files use: `KEY=value`, an optional `export`
30
- * prefix, single/double/backtick quotes, and `#` comments (whole-line, or
31
- * trailing after whitespace on an unquoted value). Multi-line values are not
32
- * needed for anything this extension reads, so they are not supported.
33
- */
34
- export function parseEnvFile(source: string): Record<string, string> {
35
- const result: Record<string, string> = {}
36
-
37
- for (const rawLine of source.split(/\r?\n/)) {
38
- const line = rawLine.trim()
39
- if (!line || line.startsWith('#'))
40
- continue
41
-
42
- const match = line.match(/^(?:export\s+)?([A-Z_a-z][\w.-]*)\s*=\s*(.*)$/)
43
- if (!match)
44
- continue
45
-
46
- const key = match[1] as string
47
- let value = (match[2] as string).trim()
48
- const quote = value[0]
49
-
50
- if (quote === '"' || quote === '\'' || quote === '`') {
51
- const end = value.indexOf(quote, 1)
52
- value = end === -1 ? value.slice(1) : value.slice(1, end)
53
- }
54
- else {
55
- const comment = value.search(/\s#/)
56
- if (comment !== -1)
57
- value = value.slice(0, comment).trim()
58
- }
59
-
60
- result[key] = value
61
- }
62
-
63
- return result
64
- }
65
-
66
- /**
67
- * Merge the development env files of a project, later files winning.
68
- *
69
- * `read` returns a file's contents, or `undefined` when it does not exist.
70
- * Ciphertext (`encrypted:...`) goes through `decrypt`, which the extension
71
- * builds from `@stacksjs/env`'s own decryption and the project's keys
72
- * (./env.ts), so an encrypted `APP_URL` or `PORT` previews the same URL
73
- * `buddy dev` serves. Without `decrypt`, or when it cannot decrypt a value
74
- * (no key), the value is skipped: a ciphertext `PORT` would only produce a
75
- * nonsense URL. Decrypted values stay in this object, in memory.
76
- */
77
- export function loadProjectEnv(
78
- read: (file: string) => string | undefined,
79
- files: readonly string[] = DEV_ENV_FILES,
80
- decrypt?: (file: string, value: string) => string | undefined,
81
- ): Record<string, string> {
82
- const env: Record<string, string> = {}
83
-
84
- for (const file of files) {
85
- const source = read(file)
86
- if (source === undefined)
87
- continue
88
-
89
- for (const [key, value] of Object.entries(parseEnvFile(source))) {
90
- if (!value.startsWith('encrypted:')) {
91
- env[key] = value
92
- continue
93
- }
94
-
95
- const plaintext = decrypt?.(file, value)
96
- if (plaintext !== undefined)
97
- env[key] = plaintext
98
- }
99
- }
100
-
101
- return env
102
- }
103
-
104
- /**
105
- * The pretty domain `buddy dev` serves the app on, or `null` for plain
106
- * localhost. Mirrors `resolvePrettyDevDomain()` in core/buddy/src/commands/dev.ts.
107
- */
108
- export function resolvePrettyDomain(appUrl: string | undefined): string | null {
109
- if (!appUrl)
110
- return null
111
-
112
- try {
113
- const url = new URL(/^https?:\/\//i.test(appUrl) ? appUrl : `https://${appUrl}`)
114
- const hostname = url.hostname.toLowerCase()
115
- if (isLoopbackHost(hostname))
116
- return null
117
-
118
- return hostname
119
- }
120
- catch {
121
- return null
122
- }
123
- }
124
-
125
- export function isLoopbackHost(hostname: string): boolean {
126
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
127
- return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0'
128
- }
129
-
130
- /** Whether a URL points at this machine, so it may need port forwarding when remote. */
131
- export function isLoopbackUrl(url: string): boolean {
132
- try {
133
- return isLoopbackHost(new URL(url).hostname)
134
- }
135
- catch {
136
- return false
137
- }
138
- }
139
-
140
- /**
141
- * Normalize an entry path to `/path?query#hash`. Mirrors
142
- * `normalizeDevelopmentEntryPath()` in core/buddy/src/commands/dev.ts.
143
- */
144
- export function normalizeEntryPath(path: string | undefined): string {
145
- const value = path?.trim()
146
- if (!value || value === '/')
147
- return '/'
148
-
149
- try {
150
- const parsed = new URL(value, 'http://stacks.localhost')
151
- return `${parsed.pathname}${parsed.search}${parsed.hash}`
152
- }
153
- catch {
154
- return '/'
155
- }
156
- }
157
-
158
- /**
159
- * The application path a project configured explicitly: `APP_PATH`, else
160
- * `appPath` in `config/app.ts` - the same two places, read the same way, as
161
- * `configuredApplicationPath()` in core/buddy/src/commands/dev.ts.
162
- *
163
- * `buddy dev` additionally guesses an entry page from the views it finds. The
164
- * extension does not repeat that guess: when it started the dev server itself,
165
- * it reads the exact URL from the banner instead (`parseDevBannerUrl`).
166
- */
167
- export function configuredAppPath(env: Env, appConfigSource?: string): string {
168
- if (env.APP_PATH)
169
- return normalizeEntryPath(env.APP_PATH)
170
-
171
- const fromConfig = appConfigSource?.match(/^[ \t]*appPath\s*:\s*(['"`])([^'"`]+)\1/m)?.[2]
172
- return normalizeEntryPath(fromConfig)
173
- }
174
-
175
- /** Join a base URL and an entry path. Mirrors `developmentUrl()` in dev.ts. */
176
- export function developmentUrl(baseUrl: string, entryPath: string): string {
177
- const normalized = normalizeEntryPath(entryPath)
178
- if (normalized === '/')
179
- return baseUrl.replace(/\/$/, '')
180
-
181
- return new URL(normalized, `${baseUrl.replace(/\/$/, '')}/`).toString()
182
- }
183
-
184
- export interface Endpoint {
185
- host: string
186
- port: number
187
- }
188
-
189
- export interface PreviewCandidate extends Endpoint {
190
- url: string
191
- }
192
-
193
- export interface PreviewPlan {
194
- /** The frontend dev server itself. If this is not listening, nothing is running. */
195
- server: Endpoint
196
- /** URLs to open, best first. The last one is always the plain localhost URL. */
197
- candidates: PreviewCandidate[]
198
- }
199
-
200
- /**
201
- * Work out where the running dev server can be reached, the way `buddy dev`
202
- * decides it (core/buddy/src/commands/dev.ts, `startDevelopmentServer`):
203
- *
204
- * - the frontend binds `PORT`, else 3000;
205
- * - a non-loopback `APP_URL` (default `stacks.localhost`) is served as
206
- * `https://<domain>` through the rpx proxy on :443, unless
207
- * `STACKS_DEV_LOCALHOST=1` opts out;
208
- * - otherwise the app is at `http://localhost:<port>`.
209
- *
210
- * Whether the proxy actually came up is only known at runtime, so the pretty
211
- * URL is a candidate to probe, with localhost behind it.
212
- */
213
- export function planPreview(input: { env: Env, appConfigSource?: string, preferLocalhost?: boolean }): PreviewPlan {
214
- const { env } = input
215
- const port = Number(env.PORT) || DEFAULT_FRONTEND_PORT
216
- const entryPath = configuredAppPath(env, input.appConfigSource)
217
- const local: PreviewCandidate = {
218
- url: developmentUrl(`http://localhost:${port}`, entryPath),
219
- host: 'localhost',
220
- port,
221
- }
222
-
223
- const localhostOnly = input.preferLocalhost === true || env.STACKS_DEV_LOCALHOST === '1'
224
- const domain = localhostOnly ? null : resolvePrettyDomain(env.APP_URL ?? DEFAULT_APP_URL)
225
- const candidates = domain
226
- ? [{ url: developmentUrl(`https://${domain}`, entryPath), host: domain, port: 443 }, local]
227
- : [local]
228
-
229
- return { server: { host: local.host, port }, candidates }
230
- }
231
-
232
- /** Resolves whether something accepts TCP connections on `host:port`. */
233
- export type Probe = (host: string, port: number) => Promise<boolean>
234
-
235
- /**
236
- * The URL to preview, or `undefined` when the dev server is not listening.
237
- *
238
- * The frontend port is the readiness signal, not the proxy: the rpx daemon on
239
- * :443 is shared between projects, so it can answer while this project's dev
240
- * server is down. Once the frontend answers, the first candidate whose
241
- * endpoint accepts connections wins.
242
- */
243
- export async function choosePreviewUrl(plan: PreviewPlan, probe: Probe): Promise<string | undefined> {
244
- if (!await probe(plan.server.host, plan.server.port))
245
- return undefined
246
-
247
- for (const candidate of plan.candidates) {
248
- const isServer = candidate.host === plan.server.host && candidate.port === plan.server.port
249
- if (isServer || await probe(candidate.host, candidate.port))
250
- return candidate.url
251
- }
252
-
253
- return undefined
254
- }
255
-
256
- // eslint-disable-next-line no-control-regex
257
- const ANSI_PATTERN = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)|[@-Z\\-_])/g
258
-
259
- export function stripAnsi(text: string): string {
260
- return text.replace(ANSI_PATTERN, '')
261
- }
262
-
263
- /**
264
- * The app URL from `buddy dev`'s ready banner, e.g. ` ➜ App : https://stacks.localhost/dashboard`.
265
- *
266
- * This is the exact URL the dev server settled on - after port shifts, the
267
- * proxy check and the entry-page guess - so it beats anything `planPreview`
268
- * can infer from configuration.
269
- */
270
- export function parseDevBannerUrl(output: string): string | undefined {
271
- for (const line of stripAnsi(output).split(/\r?\n/)) {
272
- const match = line.match(/➜\s+(?:App|Site)\s*:\s*(https?:\/\/\S+)/)
273
- if (match)
274
- return match[1]
275
- }
276
-
277
- return undefined
278
- }
279
-
280
- export interface BuddyCommandArgument {
281
- name: string
282
- required: boolean
283
- variadic: boolean
284
- }
285
-
286
- export interface BuddyCommand {
287
- name: string
288
- description: string
289
- usage?: string
290
- arguments: BuddyCommandArgument[]
291
- }
292
-
293
- /** `make:model`, `dev`, `env:get`, `test:types` - the shape of a buddy command name. */
294
- const COMMAND_NAME = /^[a-z][\w-]*(?::[\w-]+)*$/i
295
-
296
- export function isBuddyCommandName(name: string): boolean {
297
- return COMMAND_NAME.test(name)
298
- }
299
-
300
- /**
301
- * Parse `./buddy list --json`. Returns `undefined` when the output is not the
302
- * JSON inventory (an older buddy without `--json`, or a crash), so the caller
303
- * can fall back to the text listing.
304
- *
305
- * Anything printed before the JSON (the env loader's status line, say) is skipped.
306
- */
307
- export function parseBuddyListJson(output: string): BuddyCommand[] | undefined {
308
- const start = output.indexOf('{')
309
- const end = output.lastIndexOf('}')
310
- if (start === -1 || end <= start)
311
- return undefined
312
-
313
- let data: unknown
314
- try {
315
- data = JSON.parse(output.slice(start, end + 1))
316
- }
317
- catch {
318
- return undefined
319
- }
320
-
321
- const commands = (data as { commands?: unknown })?.commands
322
- if (!Array.isArray(commands))
323
- return undefined
324
-
325
- const result: BuddyCommand[] = []
326
- for (const entry of commands as Array<Record<string, unknown>>) {
327
- const name = entry?.name
328
- if (typeof name !== 'string' || !isBuddyCommandName(name))
329
- continue
330
-
331
- const args = Array.isArray(entry.arguments) ? entry.arguments as Array<Record<string, unknown>> : []
332
- result.push({
333
- name,
334
- description: typeof entry.description === 'string' ? entry.description : '',
335
- usage: typeof entry.usage === 'string' ? entry.usage.replace(/^\$\s*/, '') : undefined,
336
- arguments: args
337
- .filter(argument => typeof argument?.name === 'string')
338
- .map(argument => ({
339
- name: argument.name as string,
340
- required: argument.required === true,
341
- variadic: argument.variadic === true,
342
- })),
343
- })
344
- }
345
-
346
- return sortCommands(result)
347
- }
348
-
349
- /**
350
- * Parse the text `./buddy list` prints:
351
- *
352
- * ```text
353
- * Available Commands:
354
- *
355
- * Development:
356
- * dev Start development server
357
- *
358
- * make:
359
- * make:model Create a new Model
360
- *
361
- * Total: 338 commands
362
- * ```
363
- *
364
- * Commands are the indented lines; group headings and the total are not.
365
- * The text form carries no argument information.
366
- */
367
- export function parseBuddyListText(output: string): BuddyCommand[] {
368
- const seen = new Set<string>()
369
- const result: BuddyCommand[] = []
370
-
371
- for (const line of stripAnsi(output).split(/\r?\n/)) {
372
- const match = line.match(/^ {2,4}(\S+)(?:\s+(\S.*))?$/)
373
- const name = match?.[1]
374
- if (!name || !isBuddyCommandName(name) || seen.has(name))
375
- continue
376
-
377
- seen.add(name)
378
- result.push({ name, description: match[2]?.trim() ?? '', arguments: [] })
379
- }
380
-
381
- return sortCommands(result)
382
- }
383
-
384
- /** JSON inventory when the output is one, the text listing otherwise. */
385
- export function parseBuddyList(output: string): BuddyCommand[] {
386
- return parseBuddyListJson(output) ?? parseBuddyListText(output)
387
- }
388
-
389
- function sortCommands(commands: BuddyCommand[]): BuddyCommand[] {
390
- return commands.sort((a, b) => a.name.localeCompare(b.name))
391
- }
392
-
393
- /**
394
- * The project's own CLI, relative to the workspace root. Never `buddy` off the
395
- * PATH or from `node_modules/.bin`: there, `buddy` may well be the dependency
396
- * bot (`@buddysh/buddy`), a different program that happens to share the name.
397
- */
398
- export const BUDDY = './buddy'
399
-
400
- /** `./buddy <name> <args>`. Throws on a name that is not a buddy command. */
401
- export function buddyCommandLine(name: string, args: string | readonly string[] = ''): string {
402
- if (!isBuddyCommandName(name))
403
- throw new Error(`Not a buddy command name: ${JSON.stringify(name)}`)
404
-
405
- const rest = (typeof args === 'string' ? args : args.join(' ')).trim()
406
- return rest ? `${BUDDY} ${name} ${rest}` : `${BUDDY} ${name}`
407
- }
408
-
409
- /**
410
- * A command line from free text typed into the picker, e.g. `migrate --diff`.
411
- * A leading `buddy`, `./buddy`, `bud` or `stacks` is dropped so pasting a
412
- * command from the docs still runs the project's own `./buddy`.
413
- */
414
- export function freeTextCommandLine(text: string): string | undefined {
415
- const trimmed = text.trim()
416
- if (!trimmed)
417
- return undefined
418
-
419
- const rest = trimmed.replace(/^(?:\.\/)?(?:buddy|bud|stacks)(?=\s|$)/, '').trim()
420
- return rest ? `${BUDDY} ${rest}` : BUDDY
421
- }
422
-
423
- /** Placeholder text for a command's positional arguments, e.g. `<name> [type]`. */
424
- export function argumentHint(command: BuddyCommand): string {
425
- return command.arguments
426
- .map((argument) => {
427
- const name = argument.variadic ? `${argument.name}...` : argument.name
428
- return argument.required ? `<${name}>` : `[${name}]`
429
- })
430
- .join(' ')
431
- }
432
-
433
- export function requiresArguments(command: BuddyCommand): boolean {
434
- return command.arguments.some(argument => argument.required)
435
- }
436
-
437
- /**
438
- * Which workspace folder is the Stacks project: the preferred one (the active
439
- * editor's) when it qualifies, else the first that does.
440
- */
441
- export function pickProjectRoot(
442
- folders: readonly string[],
443
- preferred: string | undefined,
444
- isProject: (folder: string) => boolean,
445
- ): string | undefined {
446
- if (preferred && folders.includes(preferred) && isProject(preferred))
447
- return preferred
448
-
449
- return folders.find(folder => isProject(folder))
450
- }