@stacksjs/defaults 0.75.41 → 0.75.42

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.
Files changed (98) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  3. package/ai/skills/stacks-chat/SKILL.md +43 -1
  4. package/ai/skills/stacks-cloud/SKILL.md +36 -0
  5. package/ai/skills/stacks-dashboard/SKILL.md +16 -2
  6. package/ai/skills/stacks-database/SKILL.md +24 -3
  7. package/ai/skills/stacks-deploy/SKILL.md +7 -1
  8. package/ai/skills/stacks-desktop/SKILL.md +10 -0
  9. package/ai/skills/stacks-development/SKILL.md +1 -1
  10. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  11. package/ai/skills/stacks-models/SKILL.md +38 -2
  12. package/ai/skills/stacks-orm/SKILL.md +2 -2
  13. package/ai/skills/stacks-security/SKILL.md +28 -1
  14. package/app/Actions/Auth/DataExportAction.test.ts +84 -0
  15. package/app/Actions/Auth/DataExportAction.ts +40 -0
  16. package/app/Actions/Dashboard/Content/FileReprocessAction.ts +1 -1
  17. package/app/Actions/Dashboard/Content/file-manager.ts +120 -8
  18. package/app/Actions/Dashboard/Content/file-metadata.ts +108 -12
  19. package/app/Actions/Dashboard/Content/file-pipeline.test.ts +13 -9
  20. package/app/Actions/Dashboard/Content/file-preview.test.ts +487 -0
  21. package/app/Actions/Dashboard/Content/file-preview.ts +548 -0
  22. package/app/Jobs/GenerateStoragePreviewJob.ts +47 -0
  23. package/app/Jobs/OptimizeStorageImageJob.ts +7 -3
  24. package/app/Jobs/PruneRetainedDataJob.ts +46 -0
  25. package/app/Models/Activity.ts +2 -0
  26. package/app/Models/CampaignSend.ts +2 -0
  27. package/app/Models/Card.ts +1 -0
  28. package/app/Models/CardComment.ts +1 -0
  29. package/app/Models/Comment.ts +5 -0
  30. package/app/Models/CommunicationSuppression.ts +2 -1
  31. package/app/Models/ConsentEvent.ts +4 -3
  32. package/app/Models/Content/Author.ts +5 -0
  33. package/app/Models/EmailListSubscriber.ts +1 -0
  34. package/app/Models/EmailSuppression.ts +2 -0
  35. package/app/Models/Forms/FormSubmission.ts +5 -0
  36. package/app/Models/GdprRequest.ts +77 -0
  37. package/app/Models/MagicLinkToken.ts +2 -0
  38. package/app/Models/Notification.ts +2 -0
  39. package/app/Models/NotificationDelivery.ts +4 -0
  40. package/app/Models/PaymentMethod.ts +5 -0
  41. package/app/Models/PaymentTransaction.ts +1 -0
  42. package/app/Models/PersonalAccessToken.ts +3 -0
  43. package/app/Models/Referral.ts +4 -1
  44. package/app/Models/ReferralCode.ts +4 -1
  45. package/app/Models/Request.ts +3 -0
  46. package/app/Models/SmsOptOut.ts +2 -0
  47. package/app/Models/SocialAccount.ts +3 -0
  48. package/app/Models/SocialPost.ts +2 -0
  49. package/app/Models/StorageItemTask.ts +14 -8
  50. package/app/Models/Subscriber.ts +2 -0
  51. package/app/Models/SubscriberEmail.ts +2 -0
  52. package/app/Models/Subscription.ts +1 -0
  53. package/app/Models/TeamInvitation.ts +2 -0
  54. package/app/Models/TeamMember.ts +1 -0
  55. package/app/Models/User.ts +5 -0
  56. package/app/Models/commerce/Bid.ts +3 -0
  57. package/app/Models/commerce/Cart.ts +2 -0
  58. package/app/Models/commerce/Courier.ts +7 -0
  59. package/app/Models/commerce/CourierPing.ts +3 -0
  60. package/app/Models/commerce/Customer.ts +5 -0
  61. package/app/Models/commerce/DeliveryStop.ts +7 -0
  62. package/app/Models/commerce/GiftCard.ts +4 -0
  63. package/app/Models/commerce/Order.ts +5 -0
  64. package/app/Models/commerce/OrderItem.ts +2 -0
  65. package/app/Models/commerce/Payment.ts +5 -0
  66. package/app/Models/commerce/Pledge.ts +3 -0
  67. package/app/Models/commerce/Transaction.ts +2 -0
  68. package/app/Models/commerce/WaitlistProduct.ts +5 -0
  69. package/app/Models/commerce/WaitlistRestaurant.ts +4 -0
  70. package/ide/vscode/.vscode/extensions.json +1 -1
  71. package/ide/vscode/.vscode/settings.json +8 -149
  72. package/ide/vscode/README.md +48 -23
  73. package/ide/vscode/build.ts +43 -0
  74. package/ide/vscode/package.json +305 -56
  75. package/ide/vscode/scripts/stx-contributes.ts +66 -0
  76. package/ide/vscode/src/env-file.ts +103 -0
  77. package/ide/vscode/src/env.ts +241 -0
  78. package/ide/vscode/src/extension.ts +120 -26
  79. package/ide/vscode/src/pickier-protocol.ts +51 -0
  80. package/ide/vscode/src/pickier-worker.ts +92 -0
  81. package/ide/vscode/src/pickier.ts +433 -0
  82. package/ide/vscode/src/project.ts +14 -4
  83. package/ide/vscode/src/stacks.ts +18 -11
  84. package/ide/vscode/tests/build.test.ts +58 -0
  85. package/ide/vscode/tests/env.test.ts +166 -0
  86. package/ide/vscode/tests/extension.test.ts +8 -0
  87. package/ide/vscode/tests/fake-vscode.ts +228 -0
  88. package/ide/vscode/tests/manifest.test.ts +88 -0
  89. package/ide/vscode/tests/pickier.test.ts +202 -0
  90. package/ide/vscode/tsconfig.json +2 -2
  91. package/package.json +2 -2
  92. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +2 -0
  93. package/resources/components/Dashboard/FileViewer/MediaCard.stx +14 -3
  94. package/routes/auth.ts +4 -0
  95. package/vcs/github/workflows/buddy-bot.yml +2 -2
  96. package/vcs/github/workflows/ci.yml +3 -3
  97. package/vcs/github/workflows/release.yml +1 -1
  98. package/ide/vscode/src/vscode.d.ts +0 -181
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Pickier, built in: lint diagnostics, a formatter, `source.fixAll.pickier`
3
+ * and quick fixes, all from the project's own pickier and config - the same
4
+ * ones `./buddy lint` and `./buddy format` use.
5
+ *
6
+ * Pickier itself runs in a Bun process per project (./pickier-worker.ts), so
7
+ * this module only manages those processes and turns their answers into
8
+ * editor features. Everything it needs from the machine comes through
9
+ * `PickierHost`, so tests drive it with fakes.
10
+ */
11
+ import type * as vscode from 'vscode'
12
+ import type { PickierIssue, PickierOperation, PickierReady, PickierRequest, PickierResponse, PickierResult } from './pickier-protocol'
13
+ import { join, relative, sep } from 'node:path'
14
+ import { isLintablePath } from '../../../../core/actions/src/lint/files'
15
+
16
+ type Vscode = typeof vscode
17
+
18
+ export interface PickierProcess {
19
+ write: (line: string) => void
20
+ onLine: (listener: (line: string) => void) => void
21
+ onExit: (listener: () => void) => void
22
+ kill: () => void
23
+ }
24
+
25
+ export interface PickierHost {
26
+ exists: (path: string) => boolean
27
+ /** Start `bun <script>` in `cwd`; stdout is delivered line by line. */
28
+ spawnBun: (bun: string, script: string, cwd: string) => PickierProcess
29
+ }
30
+
31
+ export interface PickierOptions {
32
+ /** Absolute path of dist/pickier-worker.js. */
33
+ workerPath: string
34
+ lintDelayMs?: number
35
+ }
36
+
37
+ export const PICKIER_COMMANDS = {
38
+ fixAll: 'stacks.pickier.fixAll',
39
+ restart: 'stacks.pickier.restart',
40
+ } as const
41
+
42
+ /** The language ids of the files `buddy lint` covers (core/actions/src/lint/files.ts). */
43
+ export const PICKIER_LANGUAGES = ['typescript', 'javascript', 'json', 'jsonc', 'markdown', 'yaml'] as const
44
+
45
+ /** Comment syntax for `pickier-disable-next-line`, where the language has line comments. */
46
+ const LINE_COMMENT: Record<string, string> = { typescript: '//', javascript: '//', jsonc: '//' }
47
+
48
+ /**
49
+ * The Bun that runs pickier: the setting, then the project's pinned Bun
50
+ * (`pantry/.bin/bun`, the one `./buddy` itself runs on), then `bun` on the PATH.
51
+ */
52
+ export function chooseBun(setting: string, root: string, exists: (path: string) => boolean): string {
53
+ if (setting.trim())
54
+ return setting.trim()
55
+ const pinned = join(root, 'pantry', '.bin', 'bun')
56
+ return exists(pinned) ? pinned : 'bun'
57
+ }
58
+
59
+ /** Whether saving this file changes the config a running pickier has loaded. */
60
+ export function isPickierConfigPath(path: string): boolean {
61
+ return /(?:^|[/\\])(?:pickier\.config|\.config[/\\]pickier|config[/\\]code-style)\.(?:[cm]?[jt]s|json)$/.test(path)
62
+ }
63
+
64
+ /** The comment that silences `ruleId` on the line below, indented to match it. */
65
+ export function disableNextLineComment(lineText: string, ruleId: string, comment: string): string {
66
+ const indent = lineText.match(/^\s*/)?.[0] ?? ''
67
+ return `${indent}${comment} pickier-disable-next-line ${ruleId}\n`
68
+ }
69
+
70
+ /** Whether `buddy lint` would lint `file`, given the project it belongs to. */
71
+ export function isLintedFile(root: string, file: string): boolean {
72
+ const rel = relative(root, file)
73
+ if (!rel || rel.startsWith('..'))
74
+ return false
75
+ return isLintablePath(rel.split(sep).join('/'))
76
+ }
77
+
78
+ /** One pickier worker process, for one project root. */
79
+ export class PickierClient {
80
+ private process: PickierProcess | undefined
81
+ private nextId = 1
82
+ private pending = new Map<number, (response: PickierResponse | undefined) => void>()
83
+ private readyWaiters: Array<(ready: PickierReady | undefined) => void> = []
84
+ /** Set once the worker has said whether it found pickier. */
85
+ status: 'starting' | 'ready' | 'unavailable' = 'starting'
86
+ info: PickierReady | undefined
87
+ unavailableReason = ''
88
+ private exits = 0
89
+
90
+ constructor(
91
+ readonly root: string,
92
+ private readonly start: () => PickierProcess,
93
+ private readonly log: (message: string) => void,
94
+ ) {}
95
+
96
+ private ensureProcess(): PickierProcess | undefined {
97
+ if (this.status === 'unavailable')
98
+ return undefined
99
+ if (this.process)
100
+ return this.process
101
+
102
+ const child = this.start()
103
+ this.process = child
104
+ this.status = 'starting'
105
+
106
+ child.onLine((line) => {
107
+ let message: any
108
+ try {
109
+ message = JSON.parse(line)
110
+ }
111
+ catch {
112
+ return
113
+ }
114
+
115
+ if ('ready' in message) {
116
+ if (message.ready) {
117
+ this.status = 'ready'
118
+ this.info = message as PickierReady
119
+ this.log(`pickier ${this.info.version} ready for ${this.root}`)
120
+ }
121
+ else {
122
+ this.status = 'unavailable'
123
+ this.unavailableReason = message.error
124
+ this.log(`pickier is not available in ${this.root}, so it is not linted: ${message.error}`)
125
+ }
126
+ for (const waiter of this.readyWaiters.splice(0))
127
+ waiter(this.info)
128
+ return
129
+ }
130
+
131
+ const resolve = this.pending.get(message.id)
132
+ if (resolve) {
133
+ this.pending.delete(message.id)
134
+ resolve(message as PickierResponse)
135
+ }
136
+ })
137
+
138
+ child.onExit(() => {
139
+ if (this.process !== child)
140
+ return
141
+ this.process = undefined
142
+ for (const resolve of this.pending.values())
143
+ resolve(undefined)
144
+ this.pending.clear()
145
+ for (const waiter of this.readyWaiters.splice(0))
146
+ waiter(undefined)
147
+
148
+ // A worker that keeps dying (a broken Bun, a config that throws) would
149
+ // otherwise be restarted on every keystroke.
150
+ if (this.status !== 'unavailable' && ++this.exits >= 3) {
151
+ this.status = 'unavailable'
152
+ this.unavailableReason = 'the pickier process exited three times'
153
+ this.log(`pickier keeps exiting in ${this.root}; run "Stacks: Restart Pickier" after fixing it`)
154
+ }
155
+ })
156
+
157
+ return child
158
+ }
159
+
160
+ private whenReady(): Promise<PickierReady | undefined> {
161
+ if (this.status === 'ready')
162
+ return Promise.resolve(this.info)
163
+ if (this.status === 'unavailable')
164
+ return Promise.resolve(undefined)
165
+ return new Promise(resolve => this.readyWaiters.push(resolve))
166
+ }
167
+
168
+ async request(op: PickierOperation, file: string, text: string): Promise<PickierResult | undefined> {
169
+ const child = this.ensureProcess()
170
+ if (!child || !(await this.whenReady()) || !this.process)
171
+ return undefined
172
+
173
+ const id = this.nextId++
174
+ const request: PickierRequest = { id, op, file, text }
175
+ const response = await new Promise<PickierResponse | undefined>((resolve) => {
176
+ this.pending.set(id, resolve)
177
+ this.process!.write(`${JSON.stringify(request)}\n`)
178
+ })
179
+
180
+ if (!response)
181
+ return undefined
182
+ if (!response.ok) {
183
+ this.log(`pickier could not ${op} ${file}: ${response.error}`)
184
+ return undefined
185
+ }
186
+ return response.result
187
+ }
188
+
189
+ dispose(): void {
190
+ const child = this.process
191
+ this.process = undefined
192
+ child?.kill()
193
+ for (const resolve of this.pending.values())
194
+ resolve(undefined)
195
+ this.pending.clear()
196
+ }
197
+ }
198
+
199
+ export function createPickierSupport(api: Vscode, host: PickierHost, options: PickierOptions): {
200
+ activate: (context: { subscriptions: Array<{ dispose: () => unknown }> }) => void
201
+ deactivate: () => void
202
+ } {
203
+ const clients = new Map<string, PickierClient>()
204
+ const timers = new Map<string, ReturnType<typeof setTimeout>>()
205
+ const output = api.window.createOutputChannel('Stacks: Pickier')
206
+ const diagnostics = api.languages.createDiagnosticCollection('pickier')
207
+ const fixAllKind = api.CodeActionKind.SourceFixAll.append('pickier')
208
+ const settings = () => api.workspace.getConfiguration('stacks')
209
+
210
+ const enabled = () => settings().get<boolean>('pickier.enable', true)
211
+
212
+ function rootOf(document: vscode.TextDocument): string | undefined {
213
+ if (document.uri.scheme !== 'file')
214
+ return undefined
215
+ const folder = api.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath
216
+ if (!folder || !(host.exists(join(folder, 'buddy')) || host.exists(join(folder, 'config', 'app.ts'))))
217
+ return undefined
218
+ return isLintedFile(folder, document.uri.fsPath) ? folder : undefined
219
+ }
220
+
221
+ function client(root: string): PickierClient {
222
+ let existing = clients.get(root)
223
+ if (!existing) {
224
+ existing = new PickierClient(
225
+ root,
226
+ () => host.spawnBun(chooseBun(settings().get<string>('bunPath', ''), root, host.exists), options.workerPath, root),
227
+ message => output.appendLine(message),
228
+ )
229
+ clients.set(root, existing)
230
+ }
231
+ return existing
232
+ }
233
+
234
+ async function run(document: vscode.TextDocument, op: PickierOperation): Promise<PickierResult | undefined> {
235
+ const root = enabled() ? rootOf(document) : undefined
236
+ if (!root)
237
+ return undefined
238
+ return client(root).request(op, document.uri.fsPath, document.getText())
239
+ }
240
+
241
+ function toDiagnostic(document: vscode.TextDocument, issue: PickierIssue): vscode.Diagnostic {
242
+ const line = Math.min(Math.max(issue.line - 1, 0), Math.max(document.lineCount - 1, 0))
243
+ const start = new api.Position(line, Math.max(issue.column - 1, 0))
244
+ const range = document.getWordRangeAtPosition(start) ?? new api.Range(start, start.translate(0, 1))
245
+ const diagnostic = new api.Diagnostic(
246
+ range,
247
+ issue.help ? `${issue.message}\n${issue.help}` : issue.message,
248
+ issue.severity === 'error' ? api.DiagnosticSeverity.Error : api.DiagnosticSeverity.Warning,
249
+ )
250
+ diagnostic.source = 'pickier'
251
+ diagnostic.code = issue.ruleId
252
+ return diagnostic
253
+ }
254
+
255
+ async function lint(document: vscode.TextDocument): Promise<void> {
256
+ const version = document.version
257
+ const result = await run(document, 'lint')
258
+ if (document.isClosed || document.version !== version)
259
+ return
260
+ if (!result || result.op !== 'lint') {
261
+ diagnostics.delete(document.uri)
262
+ return
263
+ }
264
+ diagnostics.set(document.uri, result.issues.map(issue => toDiagnostic(document, issue)))
265
+ }
266
+
267
+ function scheduleLint(document: vscode.TextDocument, delay = options.lintDelayMs ?? 300): void {
268
+ const key = document.uri.toString()
269
+ clearTimeout(timers.get(key))
270
+ timers.set(key, setTimeout(() => {
271
+ timers.delete(key)
272
+ void lint(document)
273
+ }, delay))
274
+ }
275
+
276
+ function wholeDocument(document: vscode.TextDocument): vscode.Range {
277
+ return new api.Range(new api.Position(0, 0), document.lineAt(Math.max(document.lineCount - 1, 0)).range.end)
278
+ }
279
+
280
+ async function fixedText(document: vscode.TextDocument): Promise<string | undefined> {
281
+ const result = await run(document, 'fix')
282
+ if (!result || result.op === 'lint' || result.text === null || result.text === document.getText())
283
+ return undefined
284
+ return result.text
285
+ }
286
+
287
+ async function fixAllEdit(document: vscode.TextDocument): Promise<vscode.WorkspaceEdit | undefined> {
288
+ const text = await fixedText(document)
289
+ if (text === undefined)
290
+ return undefined
291
+ const edit = new api.WorkspaceEdit()
292
+ edit.replace(document.uri, wholeDocument(document), text)
293
+ return edit
294
+ }
295
+
296
+ function relintOpenDocuments(): void {
297
+ diagnostics.clear()
298
+ for (const document of api.workspace.textDocuments)
299
+ scheduleLint(document, 0)
300
+ }
301
+
302
+ function stopClients(root?: string): void {
303
+ for (const [key, existing] of clients) {
304
+ if (root && key !== root)
305
+ continue
306
+ existing.dispose()
307
+ clients.delete(key)
308
+ }
309
+ }
310
+
311
+ function restart(root?: string): void {
312
+ stopClients(root)
313
+ relintOpenDocuments()
314
+ }
315
+
316
+ function stop(): void {
317
+ for (const timer of timers.values())
318
+ clearTimeout(timer)
319
+ timers.clear()
320
+ stopClients()
321
+ }
322
+
323
+ const codeActions: vscode.CodeActionProvider = {
324
+ async provideCodeActions(document, range, context) {
325
+ const actions: vscode.CodeAction[] = []
326
+ const only = context.only
327
+
328
+ if (only && (only.contains(fixAllKind) || fixAllKind.contains(only))) {
329
+ const edit = await fixAllEdit(document)
330
+ if (edit) {
331
+ const action = new api.CodeAction('Fix all pickier problems', fixAllKind)
332
+ action.edit = edit
333
+ actions.push(action)
334
+ }
335
+ return actions
336
+ }
337
+
338
+ if (only && !only.contains(api.CodeActionKind.QuickFix))
339
+ return actions
340
+
341
+ const ours = context.diagnostics.filter(diagnostic => diagnostic.source === 'pickier')
342
+ if (!ours.length)
343
+ return actions
344
+
345
+ const edit = await fixAllEdit(document)
346
+ if (edit) {
347
+ const action = new api.CodeAction('Fix all pickier problems', api.CodeActionKind.QuickFix)
348
+ action.edit = edit
349
+ action.diagnostics = ours
350
+ actions.push(action)
351
+ }
352
+
353
+ const comment = LINE_COMMENT[document.languageId]
354
+ if (comment) {
355
+ for (const diagnostic of ours) {
356
+ const ruleId = String(diagnostic.code ?? '')
357
+ if (!ruleId)
358
+ continue
359
+ const line = document.lineAt(diagnostic.range.start.line)
360
+ const disable = new api.CodeAction(`Disable ${ruleId} for this line`, api.CodeActionKind.QuickFix)
361
+ disable.edit = new api.WorkspaceEdit()
362
+ disable.edit.insert(document.uri, new api.Position(line.lineNumber, 0), disableNextLineComment(line.text, ruleId, comment))
363
+ disable.diagnostics = [diagnostic]
364
+ actions.push(disable)
365
+ }
366
+ }
367
+
368
+ return actions
369
+ },
370
+ }
371
+
372
+ const formatter: vscode.DocumentFormattingEditProvider = {
373
+ async provideDocumentFormattingEdits(document) {
374
+ const result = await run(document, 'format')
375
+ if (!result || result.op === 'lint' || result.text === null || result.text === document.getText())
376
+ return []
377
+ return [api.TextEdit.replace(wholeDocument(document), result.text)]
378
+ },
379
+ }
380
+
381
+ return {
382
+ activate(context) {
383
+ const selector = PICKIER_LANGUAGES.map(language => ({ language, scheme: 'file' }))
384
+
385
+ context.subscriptions.push(
386
+ output,
387
+ diagnostics,
388
+ api.languages.registerCodeActionsProvider(selector, codeActions, {
389
+ providedCodeActionKinds: [fixAllKind, api.CodeActionKind.QuickFix],
390
+ }),
391
+ api.languages.registerDocumentFormattingEditProvider(selector, formatter),
392
+ api.commands.registerCommand(PICKIER_COMMANDS.fixAll, async () => {
393
+ const document = api.window.activeTextEditor?.document
394
+ if (!document)
395
+ return
396
+ const edit = await fixAllEdit(document)
397
+ if (edit)
398
+ await api.workspace.applyEdit(edit)
399
+ else
400
+ void api.window.showInformationMessage('Stacks: pickier has nothing to fix in this file.')
401
+ }),
402
+ api.commands.registerCommand(PICKIER_COMMANDS.restart, () => restart()),
403
+ api.workspace.onDidOpenTextDocument(document => scheduleLint(document, 0)),
404
+ api.workspace.onDidChangeTextDocument((event) => {
405
+ if (settings().get<string>('pickier.run', 'onType') === 'onType')
406
+ scheduleLint(event.document)
407
+ }),
408
+ api.workspace.onDidSaveTextDocument((document) => {
409
+ if (isPickierConfigPath(document.uri.fsPath)) {
410
+ const folder = api.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath
411
+ restart(folder)
412
+ return
413
+ }
414
+ scheduleLint(document, 0)
415
+ }),
416
+ api.workspace.onDidCloseTextDocument((document) => {
417
+ clearTimeout(timers.get(document.uri.toString()))
418
+ diagnostics.delete(document.uri)
419
+ }),
420
+ api.workspace.onDidChangeConfiguration((event) => {
421
+ if (event.affectsConfiguration('stacks.pickier') || event.affectsConfiguration('stacks.bunPath'))
422
+ restart()
423
+ }),
424
+ { dispose: stop },
425
+ )
426
+
427
+ for (const document of api.workspace.textDocuments)
428
+ scheduleLint(document, 0)
429
+ },
430
+
431
+ deactivate: stop,
432
+ }
433
+ }
@@ -67,13 +67,17 @@ export function parseEnvFile(source: string): Record<string, string> {
67
67
  * Merge the development env files of a project, later files winning.
68
68
  *
69
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.
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.
73
76
  */
74
77
  export function loadProjectEnv(
75
78
  read: (file: string) => string | undefined,
76
79
  files: readonly string[] = DEV_ENV_FILES,
80
+ decrypt?: (file: string, value: string) => string | undefined,
77
81
  ): Record<string, string> {
78
82
  const env: Record<string, string> = {}
79
83
 
@@ -83,8 +87,14 @@ export function loadProjectEnv(
83
87
  continue
84
88
 
85
89
  for (const [key, value] of Object.entries(parseEnvFile(source))) {
86
- if (!value.startsWith('encrypted:'))
90
+ if (!value.startsWith('encrypted:')) {
87
91
  env[key] = value
92
+ continue
93
+ }
94
+
95
+ const plaintext = decrypt?.(file, value)
96
+ if (plaintext !== undefined)
97
+ env[key] = plaintext
88
98
  }
89
99
  }
90
100
 
@@ -24,6 +24,8 @@ type Vscode = typeof vscode
24
24
  */
25
25
  export interface Host {
26
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
27
29
  /** File contents, or `undefined` when the file does not exist. */
28
30
  read: (path: string) => string | undefined
29
31
  /** Run `file` with `args` in `cwd` and resolve its stdout. */
@@ -70,7 +72,7 @@ interface CommandItem extends vscode.QuickPickItem {
70
72
  }
71
73
 
72
74
  export function createStacksExtension(api: Vscode, host: Host, timingOverrides: Partial<Timing> = {}): {
73
- activate: (context: vscode.ExtensionContext) => void
75
+ activate: (context: { subscriptions: Array<{ dispose: () => unknown }> }) => void
74
76
  deactivate: () => void
75
77
  } {
76
78
  const timing: Timing = { ...DEFAULT_TIMING, ...timingOverrides }
@@ -109,7 +111,7 @@ export function createStacksExtension(api: Vscode, host: Host, timingOverrides:
109
111
 
110
112
  function previewPlan(root: string): PreviewPlan {
111
113
  return planPreview({
112
- env: loadProjectEnv(file => host.read(join(root, file))),
114
+ env: loadProjectEnv(file => host.read(join(root, file)), undefined, host.decryptEnv?.(root)),
113
115
  appConfigSource: host.read(join(root, 'config', 'app.ts')),
114
116
  preferLocalhost: settings().get<boolean>('preview.preferLocalhost', false),
115
117
  })
@@ -432,14 +434,20 @@ export function createStacksExtension(api: Vscode, host: Host, timingOverrides:
432
434
  statusItem.show()
433
435
  }
434
436
 
435
- function activate(context: vscode.ExtensionContext): void {
436
- // Commands are hidden from the palette until a Stacks project activated us.
437
- void api.commands.executeCommand('setContext', 'stacks.isProject', true)
438
-
439
- statusItem = api.window.createStatusBarItem('stacks.preview', api.StatusBarAlignment.Left, 0)
440
- statusItem.name = 'Stacks Preview'
441
- statusItem.command = COMMANDS.openPreview
442
- updateStatus()
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
+ }
443
451
 
444
452
  // Custom commands live in app/Commands and need no registration, so a new
445
453
  // file there is a new buddy command: forget the cached list.
@@ -447,7 +455,6 @@ export function createStacksExtension(api: Vscode, host: Host, timingOverrides:
447
455
  const forgetCommands = () => commandLists.clear()
448
456
 
449
457
  context.subscriptions.push(
450
- statusItem,
451
458
  commandFiles,
452
459
  commandFiles.onDidCreate(forgetCommands),
453
460
  commandFiles.onDidChange(forgetCommands),
@@ -0,0 +1,58 @@
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
+
12
+ const root = join(import.meta.dir, '..')
13
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
14
+
15
+ beforeAll(() => {
16
+ const build = Bun.spawnSync([process.execPath, 'build.ts'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
17
+ if (build.exitCode !== 0)
18
+ throw new Error(`build failed: ${build.stderr.toString()}`)
19
+ }, 120_000)
20
+
21
+ describe('dist/extension.js', () => {
22
+ it('parses as CommonJS, the way VS Code\'s Node host loads it', () => {
23
+ const bundle = readFileSync(join(root, 'dist/extension.js'), 'utf8')
24
+ // An `import.meta` or a module-scope `await` anywhere in the file is a
25
+ // syntax error inside Node's CommonJS wrapper, and Node then refuses the
26
+ // whole extension.
27
+ // eslint-disable-next-line no-new-func
28
+ expect(() => new Function('exports', 'require', 'module', '__filename', '__dirname', bundle)).not.toThrow()
29
+ })
30
+
31
+ it('requires only vscode and Node built-ins', () => {
32
+ const bundle = readFileSync(join(root, 'dist/extension.js'), 'utf8')
33
+ const specifiers = new Set([...bundle.matchAll(/\b(?:require|import)\("([^"]+)"\)/g)].map(match => match[1]!))
34
+ const builtins = new Set(builtinModules)
35
+ // bunfig is only reached when the stx utility-class engine loads a config
36
+ // file, which the extension never asks it to.
37
+ const unshipped = [...specifiers].filter(specifier =>
38
+ specifier !== 'vscode' && specifier !== 'bunfig' && !specifier.startsWith('node:') && !builtins.has(specifier))
39
+
40
+ expect(unshipped).toEqual([])
41
+ })
42
+ })
43
+
44
+ describe('dist/', () => {
45
+ it('has every asset the manifest points at', () => {
46
+ const paths = [
47
+ manifest.main,
48
+ ...manifest.contributes.languages.map((language: { configuration: string }) => language.configuration),
49
+ ...manifest.contributes.grammars.map((grammar: { path: string }) => grammar.path),
50
+ './dist/pickier-worker.js',
51
+ './dist/pickier-worker.bunfig.toml',
52
+ './dist/stx/snippets/stx.json',
53
+ ]
54
+
55
+ for (const path of paths)
56
+ expect(existsSync(join(root, path))).toBeTrue()
57
+ })
58
+ })