@stacksjs/defaults 0.75.40 → 0.75.41

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