@stacksjs/defaults 0.75.62 → 0.75.64

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,482 +0,0 @@
1
- import type * as vscode from 'vscode'
2
- import type { BuddyCommand, PreviewPlan, Probe } from './project'
3
- import { join } from 'node:path'
4
- import {
5
- argumentHint,
6
- buddyCommandLine,
7
- choosePreviewUrl,
8
- freeTextCommandLine,
9
- isLoopbackUrl,
10
- loadProjectEnv,
11
- parseBuddyListJson,
12
- parseBuddyListText,
13
- parseDevBannerUrl,
14
- pickProjectRoot,
15
- planPreview,
16
- requiresArguments,
17
- } from './project'
18
-
19
- type Vscode = typeof vscode
20
-
21
- /**
22
- * What the extension needs from the machine, injected so the controller can
23
- * be tested with a fake `vscode` and no real processes, sockets or files.
24
- */
25
- export interface Host {
26
- exists: (path: string) => boolean
27
- /** Decrypt an env file's `encrypted:` value for the preview; ./env.ts builds it. */
28
- decryptEnv?: (root: string) => (file: string, value: string) => string | undefined
29
- /** File contents, or `undefined` when the file does not exist. */
30
- read: (path: string) => string | undefined
31
- /** Run `file` with `args` in `cwd` and resolve its stdout. */
32
- exec: (file: string, args: string[], cwd: string) => Promise<string>
33
- probe: Probe
34
- sleep: (ms: number) => Promise<void>
35
- }
36
-
37
- export const COMMANDS = {
38
- openPreview: 'stacks.openPreview',
39
- startDevServer: 'stacks.startDevServer',
40
- runBuddyCommand: 'stacks.runBuddyCommand',
41
- } as const
42
-
43
- export const DEV_TERMINAL = 'Stacks Dev'
44
- export const BUDDY_TERMINAL = 'Stacks Buddy'
45
-
46
- export interface Timing {
47
- /** How long to wait for a new terminal's shell integration before typing blind. */
48
- shellIntegrationMs: number
49
- /** How long `Start Dev Server` waits for the server before giving up on the preview. */
50
- devReadyMs: number
51
- /** How long to trust the banner alone before also probing ports. */
52
- bannerGraceMs: number
53
- }
54
-
55
- const DEFAULT_TIMING: Timing = {
56
- shellIntegrationMs: 3000,
57
- devReadyMs: 120_000,
58
- bannerGraceMs: 15_000,
59
- }
60
-
61
- interface DevRun {
62
- terminal: vscode.Terminal
63
- running: boolean
64
- /** Whether output is being read through shell integration (so the banner URL will arrive). */
65
- capturing: boolean
66
- url?: string
67
- }
68
-
69
- interface CommandItem extends vscode.QuickPickItem {
70
- command?: BuddyCommand
71
- freeText?: string
72
- }
73
-
74
- export function createStacksExtension(api: Vscode, host: Host, timingOverrides: Partial<Timing> = {}): {
75
- activate: (context: { subscriptions: Array<{ dispose: () => unknown }> }) => void
76
- deactivate: () => void
77
- } {
78
- const timing: Timing = { ...DEFAULT_TIMING, ...timingOverrides }
79
- let devRun: DevRun | undefined
80
- let statusItem: vscode.StatusBarItem | undefined
81
- const commandLists = new Map<string, Promise<BuddyCommand[]>>()
82
-
83
- const settings = () => api.workspace.getConfiguration('stacks')
84
-
85
- function isProject(folder: string): boolean {
86
- return host.exists(join(folder, 'buddy')) || host.exists(join(folder, 'config', 'app.ts'))
87
- }
88
-
89
- function projectRoot(): string | undefined {
90
- const folders = (api.workspace.workspaceFolders ?? []).map(folder => folder.uri.fsPath)
91
- const activeUri = api.window.activeTextEditor?.document.uri
92
- const preferred = activeUri ? api.workspace.getWorkspaceFolder(activeUri)?.uri.fsPath : undefined
93
- const root = pickProjectRoot(folders, preferred, isProject)
94
-
95
- if (!root)
96
- void api.window.showErrorMessage('Stacks: open a Stacks project folder (one containing ./buddy) first.')
97
-
98
- return root
99
- }
100
-
101
- /** The root, if it has the `./buddy` the command is about to run. */
102
- function buddyRoot(): string | undefined {
103
- const root = projectRoot()
104
- if (root && !host.exists(join(root, 'buddy'))) {
105
- void api.window.showErrorMessage(`Stacks: there is no ./buddy in ${root}. Run the command from the project root, or run \`buddy install\` there first.`)
106
- return undefined
107
- }
108
-
109
- return root
110
- }
111
-
112
- function previewPlan(root: string): PreviewPlan {
113
- return planPreview({
114
- env: loadProjectEnv(file => host.read(join(root, file)), undefined, host.decryptEnv?.(root)),
115
- appConfigSource: host.read(join(root, 'config', 'app.ts')),
116
- preferLocalhost: settings().get<boolean>('preview.preferLocalhost', false),
117
- })
118
- }
119
-
120
- async function openInSimpleBrowser(url: string): Promise<void> {
121
- let target = api.Uri.parse(url)
122
- // In a remote window (SSH, WSL, Codespaces) the dev server's localhost is
123
- // the remote machine's; this forwards the port and returns the local URL.
124
- if (isLoopbackUrl(url))
125
- target = await api.env.asExternalUri(target)
126
-
127
- try {
128
- // The Simple Browser's extension-facing command, which takes a column,
129
- // so the preview opens beside the editor instead of replacing it.
130
- await api.commands.executeCommand('simpleBrowser.api.open', target, {
131
- viewColumn: api.ViewColumn.Beside,
132
- preserveFocus: true,
133
- })
134
- }
135
- catch {
136
- await api.commands.executeCommand('simpleBrowser.show', target.toString(true))
137
- }
138
- }
139
-
140
- async function openPreview(): Promise<void> {
141
- const override = settings().get<string>('preview.url', '').trim()
142
- if (override)
143
- return openInSimpleBrowser(override)
144
-
145
- const root = projectRoot()
146
- if (!root)
147
- return
148
-
149
- if (devRun?.running && devRun.url)
150
- return openInSimpleBrowser(devRun.url)
151
-
152
- const plan = previewPlan(root)
153
- const url = await choosePreviewUrl(plan, host.probe)
154
- if (url)
155
- return openInSimpleBrowser(url)
156
-
157
- const choice = await api.window.showWarningMessage(
158
- `Stacks: nothing is listening on ${plan.server.host}:${plan.server.port}. Is the dev server running?`,
159
- 'Start Dev Server',
160
- 'Open Anyway',
161
- )
162
-
163
- if (choice === 'Start Dev Server')
164
- await startDevServer({ openPreview: true })
165
- else if (choice === 'Open Anyway')
166
- await openInSimpleBrowser(plan.candidates[0]!.url)
167
- }
168
-
169
- function findTerminal(name: string): vscode.Terminal | undefined {
170
- return api.window.terminals.find(terminal => terminal.name === name && terminal.exitStatus === undefined)
171
- }
172
-
173
- function waitForShellIntegration(terminal: vscode.Terminal): Promise<vscode.TerminalShellIntegration | undefined> {
174
- if (terminal.shellIntegration)
175
- return Promise.resolve(terminal.shellIntegration)
176
-
177
- return new Promise((resolve) => {
178
- const timer = setTimeout(() => {
179
- subscription.dispose()
180
- resolve(undefined)
181
- }, timing.shellIntegrationMs)
182
-
183
- const subscription = api.window.onDidChangeTerminalShellIntegration((event) => {
184
- if (event.terminal !== terminal)
185
- return
186
-
187
- clearTimeout(timer)
188
- subscription.dispose()
189
- resolve(event.shellIntegration)
190
- })
191
- })
192
- }
193
-
194
- /**
195
- * Run the dev server in `run.terminal`. With shell integration, the output is
196
- * read to pick up the banner URL and the end of the command; without it the
197
- * command is typed into the terminal and the ports are probed instead.
198
- */
199
- async function launchDevServer(run: DevRun, commandLine: string): Promise<void> {
200
- const integration = await waitForShellIntegration(run.terminal)
201
- if (!integration) {
202
- run.terminal.sendText(commandLine)
203
- return
204
- }
205
-
206
- const execution = integration.executeCommand(commandLine)
207
- run.capturing = true
208
-
209
- const ended = api.window.onDidEndTerminalShellExecution((event) => {
210
- if (event.execution !== execution)
211
- return
212
-
213
- run.running = false
214
- run.url = undefined
215
- ended.dispose()
216
- updateStatus()
217
- })
218
-
219
- void (async () => {
220
- let tail = ''
221
- for await (const chunk of execution.read()) {
222
- if (run.url)
223
- continue
224
-
225
- tail = (tail + chunk).slice(-16_384)
226
- run.url = parseDevBannerUrl(tail)
227
- }
228
- })().catch(() => {
229
- run.capturing = false
230
- })
231
- }
232
-
233
- async function openWhenReady(run: DevRun, root: string): Promise<void> {
234
- const plan = previewPlan(root)
235
- const startedAt = Date.now()
236
- updateStatus('starting')
237
-
238
- try {
239
- while (devRun === run && run.running && Date.now() - startedAt < timing.devReadyMs) {
240
- if (run.url)
241
- return await openInSimpleBrowser(run.url)
242
-
243
- if (!run.capturing || Date.now() - startedAt > timing.bannerGraceMs) {
244
- const url = await choosePreviewUrl(plan, host.probe)
245
- if (url)
246
- return await openInSimpleBrowser(url)
247
- }
248
-
249
- await host.sleep(1000)
250
- }
251
- }
252
- finally {
253
- updateStatus()
254
- }
255
- }
256
-
257
- async function startDevServer(options: { openPreview?: boolean } = {}): Promise<void> {
258
- const root = buddyRoot()
259
- if (!root)
260
- return
261
-
262
- const existing = findTerminal(DEV_TERMINAL)
263
- const knownFinished = existing !== undefined && devRun?.terminal === existing && !devRun.running
264
-
265
- // A dev terminal this extension did not see finish may still be serving:
266
- // it was started earlier in this window, or revived with the window.
267
- // Typing `./buddy dev` into it would go to the running server's stdin.
268
- if (existing && !knownFinished) {
269
- existing.show(true)
270
- const choice = await api.window.showInformationMessage(
271
- 'Stacks: the dev server terminal is already open.',
272
- 'Open Preview',
273
- 'Restart',
274
- )
275
-
276
- if (choice === 'Open Preview')
277
- return openPreview()
278
- if (choice !== 'Restart')
279
- return
280
-
281
- existing.dispose()
282
- devRun = undefined
283
- }
284
-
285
- const shouldOpenPreview = options.openPreview ?? settings().get<boolean>('devServer.openPreview', true)
286
- const terminal = knownFinished && existing
287
- ? existing
288
- : api.window.createTerminal({
289
- name: DEV_TERMINAL,
290
- cwd: root,
291
- // The preview opens inside VS Code, so `buddy dev` should not also
292
- // open the system browser (core/buddy/src/commands/dev.ts).
293
- env: shouldOpenPreview ? { STACKS_DEV_NO_OPEN: '1' } : undefined,
294
- })
295
-
296
- terminal.show(true)
297
-
298
- const run: DevRun = { terminal, running: true, capturing: false }
299
- devRun = run
300
- await launchDevServer(run, buddyCommandLine('dev', settings().get<string[]>('devServer.args', [])))
301
-
302
- if (shouldOpenPreview)
303
- void openWhenReady(run, root)
304
- }
305
-
306
- function listBuddyCommands(root: string): Promise<BuddyCommand[]> {
307
- const cached = commandLists.get(root)
308
- if (cached)
309
- return cached
310
-
311
- const buddy = join(root, 'buddy')
312
- const listing = (async () => {
313
- const json = await host.exec(buddy, ['list', '--json'], root).catch(() => '')
314
- const fromJson = parseBuddyListJson(json)
315
- if (fromJson?.length)
316
- return fromJson
317
-
318
- const fromText = parseBuddyListText(await host.exec(buddy, ['list'], root))
319
- if (!fromText.length)
320
- throw new Error('`./buddy list` printed no commands')
321
-
322
- return fromText
323
- })()
324
-
325
- commandLists.set(root, listing)
326
- listing.catch(() => commandLists.delete(root))
327
- return listing
328
- }
329
-
330
- function runInBuddyTerminal(root: string, commandLine: string): void {
331
- const terminal = findTerminal(BUDDY_TERMINAL) ?? api.window.createTerminal({ name: BUDDY_TERMINAL, cwd: root })
332
- terminal.show()
333
- terminal.sendText(commandLine)
334
- }
335
-
336
- async function runPickedCommand(root: string, item: CommandItem): Promise<void> {
337
- if (item.freeText !== undefined) {
338
- const commandLine = freeTextCommandLine(item.freeText)
339
- if (commandLine)
340
- runInBuddyTerminal(root, commandLine)
341
- return
342
- }
343
-
344
- const command = item.command
345
- if (!command)
346
- return
347
-
348
- let args = ''
349
- if (command.arguments.length) {
350
- const required = requiresArguments(command)
351
- const answer = await api.window.showInputBox({
352
- title: buddyCommandLine(command.name),
353
- prompt: command.usage ? `Usage: ${command.usage}` : command.description,
354
- placeHolder: argumentHint(command),
355
- ignoreFocusOut: true,
356
- validateInput: value => required && !value.trim() ? `Arguments: ${argumentHint(command)}` : undefined,
357
- })
358
-
359
- if (answer === undefined)
360
- return
361
-
362
- args = answer
363
- }
364
-
365
- runInBuddyTerminal(root, buddyCommandLine(command.name, args))
366
- }
367
-
368
- async function runBuddyCommand(): Promise<void> {
369
- const root = buddyRoot()
370
- if (!root)
371
- return
372
-
373
- const picker = api.window.createQuickPick<CommandItem>()
374
- picker.title = 'Run Buddy Command'
375
- picker.placeholder = 'Loading ./buddy list ... or type any command, e.g. migrate --diff'
376
- picker.matchOnDescription = true
377
- picker.busy = true
378
-
379
- let commands: BuddyCommand[] = []
380
- const render = () => {
381
- const value = picker.value.trim()
382
- const items: CommandItem[] = commands.map(command => ({
383
- label: command.name,
384
- description: command.description,
385
- detail: command.arguments.length ? `${command.name} ${argumentHint(command)}` : undefined,
386
- command,
387
- }))
388
-
389
- // Typed text that is not exactly a command name runs as typed. It goes
390
- // last so Enter on a partial name still picks the filtered command.
391
- if (value && !commands.some(command => command.name === value))
392
- items.push({ label: freeTextCommandLine(value) ?? value, description: 'Run as typed', alwaysShow: true, freeText: value })
393
-
394
- picker.items = items
395
- }
396
-
397
- picker.onDidChangeValue(render)
398
- picker.onDidHide(() => picker.dispose())
399
- picker.onDidAccept(() => {
400
- const typed = picker.value.trim()
401
- const item = picker.selectedItems[0] ?? (typed ? { label: typed, freeText: typed } : undefined)
402
- picker.hide()
403
- if (item)
404
- void runPickedCommand(root, item)
405
- })
406
- picker.show()
407
-
408
- try {
409
- commands = await listBuddyCommands(root)
410
- picker.placeholder = 'Pick a buddy command, or type one with arguments'
411
- }
412
- catch {
413
- picker.placeholder = 'Could not read ./buddy list - type a command to run, e.g. migrate --diff'
414
- }
415
- finally {
416
- picker.busy = false
417
- render()
418
- }
419
- }
420
-
421
- function updateStatus(state: 'idle' | 'starting' = 'idle'): void {
422
- if (!statusItem)
423
- return
424
-
425
- if (!settings().get<boolean>('statusBar.enabled', true)) {
426
- statusItem.hide()
427
- return
428
- }
429
-
430
- statusItem.text = state === 'starting' ? '$(loading~spin) Stacks' : '$(globe) Stacks'
431
- statusItem.tooltip = state === 'starting'
432
- ? 'Waiting for the Stacks dev server...'
433
- : 'Open the Stacks dev server in the Simple Browser'
434
- statusItem.show()
435
- }
436
-
437
- function activate(context: { subscriptions: Array<{ dispose: () => unknown }> }): void {
438
- // The extension also activates for `.stx` files outside Stacks projects
439
- // (it provides stx support everywhere), so the Stacks commands and the
440
- // status item only appear when a workspace folder is a Stacks project.
441
- const project = (api.workspace.workspaceFolders ?? []).some(folder => isProject(folder.uri.fsPath))
442
- void api.commands.executeCommand('setContext', 'stacks.isProject', project)
443
-
444
- if (project) {
445
- statusItem = api.window.createStatusBarItem('stacks.preview', api.StatusBarAlignment.Left, 0)
446
- statusItem.name = 'Stacks Preview'
447
- statusItem.command = COMMANDS.openPreview
448
- context.subscriptions.push(statusItem)
449
- updateStatus()
450
- }
451
-
452
- // Custom commands live in app/Commands and need no registration, so a new
453
- // file there is a new buddy command: forget the cached list.
454
- const commandFiles = api.workspace.createFileSystemWatcher('**/app/Commands/**')
455
- const forgetCommands = () => commandLists.clear()
456
-
457
- context.subscriptions.push(
458
- commandFiles,
459
- commandFiles.onDidCreate(forgetCommands),
460
- commandFiles.onDidChange(forgetCommands),
461
- commandFiles.onDidDelete(forgetCommands),
462
- api.commands.registerCommand(COMMANDS.openPreview, openPreview),
463
- api.commands.registerCommand(COMMANDS.startDevServer, () => startDevServer()),
464
- api.commands.registerCommand(COMMANDS.runBuddyCommand, runBuddyCommand),
465
- api.window.onDidCloseTerminal((terminal) => {
466
- if (devRun?.terminal === terminal)
467
- devRun = undefined
468
- }),
469
- api.workspace.onDidChangeConfiguration((event) => {
470
- if (event.affectsConfiguration('stacks.statusBar'))
471
- updateStatus()
472
- }),
473
- )
474
- }
475
-
476
- function deactivate(): void {
477
- devRun = undefined
478
- commandLists.clear()
479
- }
480
-
481
- return { activate, deactivate }
482
- }
@@ -1,82 +0,0 @@
1
- /**
2
- * The built extension, as VS Code will load it: dist/extension.js must parse
3
- * as CommonJS on Node and need nothing the .vsix does not ship (it is packaged
4
- * with `--no-dependencies`), and every asset the manifest names must exist.
5
- */
6
- import { beforeAll, describe, expect, it } from 'bun:test'
7
- import { existsSync, readFileSync } from 'node:fs'
8
- import { builtinModules } from 'node:module'
9
- import { join } from 'node:path'
10
- import process from 'node:process'
11
- import { TS_PLUGIN_DIST, tsPluginName } from '../scripts/ts-plugin'
12
- import { expectDeclarationsFound, loadLikeTsserver } from './ts-plugin-harness'
13
-
14
- const root = join(import.meta.dir, '..')
15
- const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
16
-
17
- beforeAll(() => {
18
- const build = Bun.spawnSync([process.execPath, 'build.ts'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
19
- if (build.exitCode !== 0)
20
- throw new Error(`build failed: ${build.stderr.toString()}`)
21
- }, 120_000)
22
-
23
- describe('dist/extension.js', () => {
24
- it('parses as CommonJS, the way VS Code\'s Node host loads it', () => {
25
- const bundle = readFileSync(join(root, 'dist/extension.js'), 'utf8')
26
- // An `import.meta` or a module-scope `await` anywhere in the file is a
27
- // syntax error inside Node's CommonJS wrapper, and Node then refuses the
28
- // whole extension.
29
- // eslint-disable-next-line no-new-func
30
- expect(() => new Function('exports', 'require', 'module', '__filename', '__dirname', bundle)).not.toThrow()
31
- })
32
-
33
- it('requires only vscode and Node built-ins', () => {
34
- const bundle = readFileSync(join(root, 'dist/extension.js'), 'utf8')
35
- const specifiers = new Set([...bundle.matchAll(/\b(?:require|import)\("([^"]+)"\)/g)].map(match => match[1]!))
36
- const builtins = new Set(builtinModules)
37
- // bunfig is only reached when the stx utility-class engine loads a config
38
- // file, which the extension never asks it to.
39
- const unshipped = [...specifiers].filter(specifier =>
40
- specifier !== 'vscode' && specifier !== 'bunfig' && !specifier.startsWith('node:') && !builtins.has(specifier))
41
-
42
- expect(unshipped).toEqual([])
43
- })
44
-
45
- it('starts the TypeScript extension and configures the stx plugin by the name the manifest contributes', () => {
46
- const bundle = readFileSync(join(root, 'dist/extension.js'), 'utf8')
47
- expect(bundle).toContain('vscode.typescript-language-features')
48
- expect(bundle).toContain('configurePlugin')
49
- for (const plugin of manifest.contributes.typescriptServerPlugins)
50
- expect(bundle).toContain(plugin.name)
51
- })
52
- })
53
-
54
- describe('the stx TypeScript plugin', () => {
55
- it('is in dist/ as the package the manifest contributes', () => {
56
- const plugin = JSON.parse(readFileSync(join(root, TS_PLUGIN_DIST, 'package.json'), 'utf8'))
57
- expect(manifest.contributes.typescriptServerPlugins.map((entry: { name: string }) => entry.name)).toEqual([plugin.name])
58
- expect(manifest.files).toContain(`${TS_PLUGIN_DIST}/**`)
59
- })
60
-
61
- it('loads from node_modules the way tsserver loads it, as in a development host', () => {
62
- const name = tsPluginName(root)
63
- expect(typeof loadLikeTsserver(root, name)).toBe('function')
64
- expectDeclarationsFound(root, name, join(root, TS_PLUGIN_DIST, 'types'))
65
- })
66
- })
67
-
68
- describe('dist/', () => {
69
- it('has every asset the manifest points at', () => {
70
- const paths = [
71
- manifest.main,
72
- ...manifest.contributes.languages.map((language: { configuration: string }) => language.configuration),
73
- ...manifest.contributes.grammars.map((grammar: { path: string }) => grammar.path),
74
- './dist/pickier-worker.js',
75
- './dist/pickier-worker.bunfig.toml',
76
- './dist/stx/snippets/stx.json',
77
- ]
78
-
79
- for (const path of paths)
80
- expect(existsSync(join(root, path))).toBeTrue()
81
- })
82
- })