@stacksjs/defaults 0.75.40 → 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 (100) 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 +22 -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/Actions/Payment/CreatePaymentIntentAction.ts +1 -1
  23. package/app/Actions/Payment/CreateSetupIntentAction.ts +1 -1
  24. package/app/Jobs/GenerateStoragePreviewJob.ts +47 -0
  25. package/app/Jobs/OptimizeStorageImageJob.ts +7 -3
  26. package/app/Jobs/PruneRetainedDataJob.ts +46 -0
  27. package/app/Models/Activity.ts +2 -0
  28. package/app/Models/CampaignSend.ts +2 -0
  29. package/app/Models/Card.ts +1 -0
  30. package/app/Models/CardComment.ts +1 -0
  31. package/app/Models/Comment.ts +5 -0
  32. package/app/Models/CommunicationSuppression.ts +2 -1
  33. package/app/Models/ConsentEvent.ts +4 -3
  34. package/app/Models/Content/Author.ts +5 -0
  35. package/app/Models/EmailListSubscriber.ts +1 -0
  36. package/app/Models/EmailSuppression.ts +2 -0
  37. package/app/Models/Forms/FormSubmission.ts +5 -0
  38. package/app/Models/GdprRequest.ts +77 -0
  39. package/app/Models/MagicLinkToken.ts +2 -0
  40. package/app/Models/Notification.ts +2 -0
  41. package/app/Models/NotificationDelivery.ts +4 -0
  42. package/app/Models/PaymentMethod.ts +5 -0
  43. package/app/Models/PaymentTransaction.ts +1 -0
  44. package/app/Models/PersonalAccessToken.ts +3 -0
  45. package/app/Models/Referral.ts +4 -1
  46. package/app/Models/ReferralCode.ts +4 -1
  47. package/app/Models/Request.ts +3 -0
  48. package/app/Models/SmsOptOut.ts +2 -0
  49. package/app/Models/SocialAccount.ts +3 -0
  50. package/app/Models/SocialPost.ts +2 -0
  51. package/app/Models/StorageItemTask.ts +14 -8
  52. package/app/Models/Subscriber.ts +2 -0
  53. package/app/Models/SubscriberEmail.ts +2 -0
  54. package/app/Models/Subscription.ts +1 -0
  55. package/app/Models/TeamInvitation.ts +2 -0
  56. package/app/Models/TeamMember.ts +1 -0
  57. package/app/Models/User.ts +5 -0
  58. package/app/Models/commerce/Bid.ts +3 -0
  59. package/app/Models/commerce/Cart.ts +2 -0
  60. package/app/Models/commerce/Courier.ts +7 -0
  61. package/app/Models/commerce/CourierPing.ts +3 -0
  62. package/app/Models/commerce/Customer.ts +5 -0
  63. package/app/Models/commerce/DeliveryStop.ts +7 -0
  64. package/app/Models/commerce/GiftCard.ts +4 -0
  65. package/app/Models/commerce/Order.ts +5 -0
  66. package/app/Models/commerce/OrderItem.ts +2 -0
  67. package/app/Models/commerce/Payment.ts +5 -0
  68. package/app/Models/commerce/Pledge.ts +3 -0
  69. package/app/Models/commerce/Transaction.ts +2 -0
  70. package/app/Models/commerce/WaitlistProduct.ts +5 -0
  71. package/app/Models/commerce/WaitlistRestaurant.ts +4 -0
  72. package/ide/vscode/.vscode/extensions.json +1 -1
  73. package/ide/vscode/.vscode/settings.json +8 -149
  74. package/ide/vscode/README.md +92 -22
  75. package/ide/vscode/build.ts +43 -0
  76. package/ide/vscode/package.json +368 -29
  77. package/ide/vscode/scripts/stx-contributes.ts +66 -0
  78. package/ide/vscode/src/env-file.ts +103 -0
  79. package/ide/vscode/src/env.ts +241 -0
  80. package/ide/vscode/src/extension.ts +153 -0
  81. package/ide/vscode/src/pickier-protocol.ts +51 -0
  82. package/ide/vscode/src/pickier-worker.ts +92 -0
  83. package/ide/vscode/src/pickier.ts +433 -0
  84. package/ide/vscode/src/project.ts +450 -0
  85. package/ide/vscode/src/stacks.ts +482 -0
  86. package/ide/vscode/tests/build.test.ts +58 -0
  87. package/ide/vscode/tests/env.test.ts +166 -0
  88. package/ide/vscode/tests/extension.test.ts +428 -0
  89. package/ide/vscode/tests/fake-vscode.ts +228 -0
  90. package/ide/vscode/tests/manifest.test.ts +88 -0
  91. package/ide/vscode/tests/pickier.test.ts +202 -0
  92. package/ide/vscode/tests/project.test.ts +268 -0
  93. package/ide/vscode/tsconfig.json +19 -0
  94. package/package.json +4 -4
  95. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +2 -0
  96. package/resources/components/Dashboard/FileViewer/MediaCard.stx +14 -3
  97. package/routes/auth.ts +4 -0
  98. package/vcs/github/workflows/buddy-bot.yml +2 -2
  99. package/vcs/github/workflows/ci.yml +3 -3
  100. package/vcs/github/workflows/release.yml +1 -1
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Keeps the stx half of package.json's `contributes` equal to what
3
+ * `@stacksjs/stx-vscode` ships.
4
+ *
5
+ * VS Code reads languages, grammars, commands and settings only from the
6
+ * manifest, so the built-in stx support needs them declared here, pointing at
7
+ * dist/stx/ (copied there by build.ts). The library describes them in
8
+ * `contributes.json`; this derives the manifest from it rather than keeping a
9
+ * second copy by hand. Snippets are left out on purpose: the extension offers
10
+ * them as completions only when the stx extension is not installed, because
11
+ * a manifest entry would show every snippet twice next to it.
12
+ *
13
+ * bun scripts/stx-contributes.ts --write update package.json (`bun run sync:stx`)
14
+ *
15
+ * tests/manifest.test.ts fails while the two disagree.
16
+ */
17
+ import { readFileSync, writeFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import process from 'node:process'
20
+
21
+ export interface StxContributes {
22
+ languages: Array<Record<string, unknown> & { id: string, configuration: string }>
23
+ grammars: Array<Record<string, unknown> & { language: string, path: string }>
24
+ commands: Array<{ command: string, title: string, category?: string }>
25
+ configuration: { title: string, properties: Record<string, unknown> }
26
+ }
27
+
28
+ const STX_DIR = './dist/stx/'
29
+
30
+ function fromStx(path: string): string {
31
+ return STX_DIR + path.replace(/^\.\//, '')
32
+ }
33
+
34
+ /** `manifest` with its stx contributions replaced by the library's. */
35
+ export function withStxContributes(manifest: any, stx: StxContributes): any {
36
+ const stxCommands = new Set(stx.commands.map(command => command.command))
37
+ const contributes = manifest.contributes
38
+ const sections: Array<{ title: string }> = Array.isArray(contributes.configuration) ? contributes.configuration : [contributes.configuration]
39
+
40
+ return {
41
+ ...manifest,
42
+ contributes: {
43
+ ...contributes,
44
+ languages: stx.languages.map(language => ({ ...language, configuration: fromStx(language.configuration) })),
45
+ grammars: stx.grammars.map(grammar => ({ ...grammar, path: fromStx(grammar.path) })),
46
+ commands: [...contributes.commands.filter((command: { command: string }) => !stxCommands.has(command.command)), ...stx.commands],
47
+ configuration: [...sections.filter(section => section.title !== stx.configuration.title), stx.configuration],
48
+ },
49
+ }
50
+ }
51
+
52
+ export function libraryContributes(root: string): StxContributes {
53
+ return JSON.parse(readFileSync(Bun.resolveSync('@stacksjs/stx-vscode/contributes.json', root), 'utf8'))
54
+ }
55
+
56
+ if (import.meta.main) {
57
+ const root = join(import.meta.dir, '..')
58
+ const path = join(root, 'package.json')
59
+ const manifest = JSON.parse(readFileSync(path, 'utf8'))
60
+ const next = `${JSON.stringify(withStxContributes(manifest, libraryContributes(root)), null, 2)}\n`
61
+
62
+ if (process.argv.includes('--write'))
63
+ writeFileSync(path, next)
64
+ else
65
+ process.stdout.write(next)
66
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Env files, read the way Stacks reads them - the pure half of ./env.ts.
3
+ *
4
+ * Decryption and key lookup are not reimplemented here: they are
5
+ * `@stacksjs/env`'s own (core/env/src), bundled into the extension, so a value
6
+ * the hover decrypts is exactly what `./buddy env:get` prints.
7
+ */
8
+ import { basename } from 'node:path'
9
+ import { envPrivateKeyNames } from '../../../../core/env/src/cli'
10
+ import { decryptValue } from '../../../../core/env/src/crypto'
11
+ import { parseEnvFile } from './project'
12
+
13
+ /** `.env`, `.env.local`, `.env.production`, ... - not `.env.keys`, which holds the private keys themselves. */
14
+ export function isEnvFile(path: string): boolean {
15
+ const name = basename(path)
16
+ return /^\.env(?:\..+)?$/.test(name) && name !== '.env.keys'
17
+ }
18
+
19
+ export function isEncrypted(value: string): boolean {
20
+ return value.startsWith('encrypted:')
21
+ }
22
+
23
+ export interface EnvEntry {
24
+ key: string
25
+ value: string
26
+ /** Character offsets of the value on its line, quotes excluded. */
27
+ start: number
28
+ end: number
29
+ }
30
+
31
+ /** The `KEY=value` on one line, with where its value sits, by `parseEnvFile`'s rules. */
32
+ export function envEntryAt(line: string): EnvEntry | undefined {
33
+ const match = line.match(/^(\s*(?:export\s+)?([A-Z_a-z][\w.-]*)\s*=\s*)(.*)$/)
34
+ if (!match)
35
+ return undefined
36
+
37
+ const key = match[2] as string
38
+ const value = parseEnvFile(line)[key]
39
+ if (value === undefined)
40
+ return undefined
41
+
42
+ const rest = match[3] as string
43
+ const quoted = rest[0] === '"' || rest[0] === '\'' || rest[0] === '`'
44
+ const start = (match[1] as string).length + (quoted ? 1 : 0)
45
+ return { key, value, start, end: start + value.length }
46
+ }
47
+
48
+ /** Keys `.env.example` declares that the env file does not set, in example order. */
49
+ export function missingKeys(example: string, actual: string): string[] {
50
+ const present = parseEnvFile(actual)
51
+ return Object.keys(parseEnvFile(example)).filter(key => !(key in present))
52
+ }
53
+
54
+ export interface PrivateKey {
55
+ name: string
56
+ key: string
57
+ from: '.env.keys' | 'environment'
58
+ }
59
+
60
+ /**
61
+ * The private key for an env file, looked up as `./buddy env:get` does:
62
+ * `envPrivateKeyNames` (`DOTENV_PRIVATE_KEY_<ENV>`, then `DOTENV_PRIVATE_KEY`)
63
+ * in `.env.keys`, then in the environment, the dotenvx convention for CI and
64
+ * servers where `.env.keys` must not exist.
65
+ */
66
+ export function findPrivateKey(envFile: string, keysFile: string | undefined, environment: Record<string, string | undefined>): PrivateKey | undefined {
67
+ const names = envPrivateKeyNames(basename(envFile))
68
+ const keys = keysFile === undefined ? {} : parseEnvFile(keysFile)
69
+
70
+ for (const name of names) {
71
+ if (keys[name])
72
+ return { name, key: keys[name] as string, from: '.env.keys' }
73
+ }
74
+ for (const name of names) {
75
+ if (environment[name])
76
+ return { name, key: environment[name] as string, from: 'environment' }
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ export type Decryption =
82
+ | { ok: true, value: string, key: PrivateKey }
83
+ | { ok: false, reason: 'no-key', looked: string[] }
84
+ | { ok: false, reason: 'failed', key: PrivateKey }
85
+
86
+ /** Decrypt one value in memory. The result is never cached or written anywhere. */
87
+ export function decryptEnvValue(
88
+ envFile: string,
89
+ value: string,
90
+ keysFile: string | undefined,
91
+ environment: Record<string, string | undefined>,
92
+ ): Decryption {
93
+ const key = findPrivateKey(envFile, keysFile, environment)
94
+ if (!key)
95
+ return { ok: false, reason: 'no-key', looked: envPrivateKeyNames(basename(envFile)) }
96
+
97
+ try {
98
+ return { ok: true, value: decryptValue(value, key.key), key }
99
+ }
100
+ catch {
101
+ return { ok: false, reason: 'failed', key }
102
+ }
103
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Stacks env files, built in: decrypted values on hover, keys missing against
3
+ * `.env.example`, and the `buddy env:*` commands from the Command Palette.
4
+ *
5
+ * Plaintext only ever exists in memory and on screen. The hover decrypts with
6
+ * `@stacksjs/env`'s own code (./env-file.ts) and nothing is cached, logged or
7
+ * written; the commands run the project's `./buddy` directly (no shell, so a
8
+ * value typed for `env:set` does not land in shell history).
9
+ */
10
+ import type * as vscode from 'vscode'
11
+ import { basename, dirname, join, relative } from 'node:path'
12
+ import { decryptEnvValue, envEntryAt, isEncrypted, isEnvFile, missingKeys } from './env-file'
13
+
14
+ type Vscode = typeof vscode
15
+
16
+ export interface EnvHost {
17
+ exists: (path: string) => boolean
18
+ read: (path: string) => string | undefined
19
+ exec: (file: string, args: string[], cwd: string) => Promise<string>
20
+ environment: Record<string, string | undefined>
21
+ }
22
+
23
+ export const ENV_COMMANDS = {
24
+ get: 'stacks.env.get',
25
+ set: 'stacks.env.set',
26
+ encrypt: 'stacks.env.encrypt',
27
+ decrypt: 'stacks.env.decrypt',
28
+ rotate: 'stacks.env.rotate',
29
+ } as const
30
+
31
+ function isProject(host: EnvHost, folder: string): boolean {
32
+ return host.exists(join(folder, 'buddy')) || host.exists(join(folder, 'config', 'app.ts'))
33
+ }
34
+
35
+ /**
36
+ * The `.env.keys` for an env file: beside it, else at the project root, where
37
+ * `./buddy env:*` looks for it.
38
+ */
39
+ export function keysFileFor(host: EnvHost, envFile: string, root: string | undefined): string | undefined {
40
+ for (const dir of [dirname(envFile), root]) {
41
+ if (!dir)
42
+ continue
43
+ const source = host.read(join(dir, '.env.keys'))
44
+ if (source !== undefined)
45
+ return source
46
+ }
47
+ return undefined
48
+ }
49
+
50
+ /**
51
+ * A decryptor for `loadProjectEnv`, so the preview can read an encrypted
52
+ * `APP_URL` or `PORT` with the same keys the hover uses.
53
+ */
54
+ export function projectDecryptor(host: EnvHost, root: string): (file: string, value: string) => string | undefined {
55
+ return (file, value) => {
56
+ const path = join(root, file)
57
+ const result = decryptEnvValue(path, value, keysFileFor(host, path, root), host.environment)
58
+ return result.ok ? result.value : undefined
59
+ }
60
+ }
61
+
62
+ export function createEnvSupport(api: Vscode, host: EnvHost): {
63
+ activate: (context: { subscriptions: Array<{ dispose: () => unknown }> }) => void
64
+ } {
65
+ const diagnostics = api.languages.createDiagnosticCollection('stacks-env')
66
+ const settings = () => api.workspace.getConfiguration('stacks')
67
+
68
+ function rootFor(uri: vscode.Uri): string | undefined {
69
+ const folder = api.workspace.getWorkspaceFolder(uri)?.uri.fsPath
70
+ return folder && isProject(host, folder) ? folder : undefined
71
+ }
72
+
73
+ function projectRoot(): string | undefined {
74
+ const active = api.window.activeTextEditor?.document.uri
75
+ const fromActive = active ? rootFor(active) : undefined
76
+ if (fromActive)
77
+ return fromActive
78
+ return (api.workspace.workspaceFolders ?? []).map(folder => folder.uri.fsPath).find(folder => isProject(host, folder))
79
+ }
80
+
81
+ const hover: vscode.HoverProvider = {
82
+ provideHover(document, position) {
83
+ if (!isEnvFile(document.uri.fsPath) || !settings().get<boolean>('env.decryptOnHover', true))
84
+ return undefined
85
+
86
+ const entry = envEntryAt(document.lineAt(position.line).text)
87
+ if (!entry || !isEncrypted(entry.value) || position.character < entry.start - 1 || position.character > entry.end + 1)
88
+ return undefined
89
+
90
+ const root = rootFor(document.uri)
91
+ const result = decryptEnvValue(document.uri.fsPath, entry.value, keysFileFor(host, document.uri.fsPath, root), host.environment)
92
+ const markdown = new api.MarkdownString()
93
+ const range = new api.Range(position.line, entry.start, position.line, entry.end)
94
+
95
+ if (result.ok) {
96
+ markdown.appendMarkdown(`**${entry.key}**, decrypted with \`${result.key.name}\` from ${result.key.from === '.env.keys' ? '`.env.keys`' : 'the environment'}:`)
97
+ markdown.appendCodeblock(result.value, 'text')
98
+ }
99
+ else if (result.reason === 'no-key') {
100
+ markdown.appendMarkdown(`**${entry.key}** is encrypted. No private key to decrypt it: looked for ${result.looked.map(name => `\`${name}\``).join(' and ')} in \`.env.keys\` and the environment.`)
101
+ }
102
+ else {
103
+ markdown.appendMarkdown(`**${entry.key}** did not decrypt with \`${result.key.name}\`. The value or the key is wrong, or it was encrypted for another key.`)
104
+ }
105
+
106
+ return new api.Hover(markdown, range)
107
+ },
108
+ }
109
+
110
+ function checkMissingKeys(document: vscode.TextDocument): void {
111
+ const path = document.uri.fsPath
112
+ if (basename(path) === '.env.example') {
113
+ const env = api.workspace.textDocuments.find(open => open.uri.fsPath === join(dirname(path), '.env'))
114
+ if (env)
115
+ checkMissingKeys(env)
116
+ return
117
+ }
118
+
119
+ if (basename(path) !== '.env' || !settings().get<boolean>('env.missingKeys', true)) {
120
+ diagnostics.delete(document.uri)
121
+ return
122
+ }
123
+
124
+ const example = host.read(join(dirname(path), '.env.example'))
125
+ if (example === undefined) {
126
+ diagnostics.delete(document.uri)
127
+ return
128
+ }
129
+
130
+ const top = new api.Range(0, 0, 0, Math.max(document.lineAt(0).text.length, 1))
131
+ diagnostics.set(document.uri, missingKeys(example, document.getText()).map((key) => {
132
+ const diagnostic = new api.Diagnostic(top, `${key} is in .env.example but not set here.`, api.DiagnosticSeverity.Information)
133
+ diagnostic.source = 'stacks'
134
+ diagnostic.code = 'missing-env-key'
135
+ return diagnostic
136
+ }))
137
+ }
138
+
139
+ /** The env file a command acts on: the open one, or `.env`. */
140
+ function targetFile(root: string): string {
141
+ const active = api.window.activeTextEditor?.document.uri.fsPath
142
+ return active && isEnvFile(active) && !relative(root, active).startsWith('..') ? relative(root, active) : '.env'
143
+ }
144
+
145
+ async function buddy(root: string, args: string[]): Promise<string | undefined> {
146
+ try {
147
+ return await host.exec(join(root, 'buddy'), args, root)
148
+ }
149
+ catch (error) {
150
+ // The error carries the command's stderr, never the value: values are
151
+ // only ever arguments, and execFile does not echo arguments.
152
+ void api.window.showErrorMessage(`Stacks: ./buddy ${args[0]} failed. ${error instanceof Error ? error.message.split('\n')[0] : ''}`)
153
+ return undefined
154
+ }
155
+ }
156
+
157
+ async function askKey(root: string, file: string, prompt: string): Promise<string | undefined> {
158
+ const keys = Object.keys(parseKeys(host.read(join(root, file))))
159
+ if (keys.length) {
160
+ const picked = await api.window.showQuickPick(keys, { title: `${prompt} (${file})`, placeHolder: 'Key' })
161
+ return picked
162
+ }
163
+ return (await api.window.showInputBox({ title: `${prompt} (${file})`, prompt: 'Key' }))?.trim() || undefined
164
+ }
165
+
166
+ function parseKeys(source: string | undefined): Record<string, true> {
167
+ const keys: Record<string, true> = {}
168
+ for (const line of (source ?? '').split(/\r?\n/)) {
169
+ const entry = envEntryAt(line)
170
+ if (entry && !entry.key.startsWith('DOTENV_PUBLIC_KEY'))
171
+ keys[entry.key] = true
172
+ }
173
+ return keys
174
+ }
175
+
176
+ async function getValue(): Promise<void> {
177
+ const root = projectRoot()
178
+ if (!root)
179
+ return
180
+ const file = targetFile(root)
181
+ const key = await askKey(root, file, 'Get an env value')
182
+ if (!key)
183
+ return
184
+ const value = await buddy(root, ['env:get', key, '--file', file])
185
+ if (value === undefined)
186
+ return
187
+ const choice = await api.window.showInformationMessage(`${key} is set in ${file}.`, 'Copy Value')
188
+ if (choice === 'Copy Value')
189
+ await api.env.clipboard.writeText(value.replace(/\r?\n$/, ''))
190
+ }
191
+
192
+ async function setValue(): Promise<void> {
193
+ const root = projectRoot()
194
+ if (!root)
195
+ return
196
+ const file = targetFile(root)
197
+ const key = (await api.window.showInputBox({ title: `Set an env value (${file})`, prompt: 'Key', validateInput: value => /^[A-Z_a-z][\w.-]*$/.test(value.trim()) ? undefined : 'Letters, digits and underscores, not starting with a digit' }))?.trim()
198
+ if (!key)
199
+ return
200
+ const value = await api.window.showInputBox({ title: `Set ${key} (${file})`, prompt: 'Value. It is encrypted when the file is.', password: true })
201
+ if (value === undefined)
202
+ return
203
+ if (await buddy(root, ['env:set', key, value, '--file', file]) !== undefined)
204
+ void api.window.showInformationMessage(`Stacks: set ${key} in ${file}.`)
205
+ }
206
+
207
+ async function wholeFile(command: 'env:encrypt' | 'env:decrypt' | 'env:rotate', warning?: string): Promise<void> {
208
+ const root = projectRoot()
209
+ if (!root)
210
+ return
211
+ const file = targetFile(root)
212
+ if (warning) {
213
+ const confirmed = await api.window.showWarningMessage(warning.replace('{file}', file), { modal: true }, 'Continue')
214
+ if (confirmed !== 'Continue')
215
+ return
216
+ }
217
+ if (await buddy(root, [command, '--file', file]) !== undefined)
218
+ void api.window.showInformationMessage(`Stacks: ./buddy ${command} finished for ${file}.`)
219
+ }
220
+
221
+ return {
222
+ activate(context) {
223
+ context.subscriptions.push(
224
+ diagnostics,
225
+ api.languages.registerHoverProvider([{ scheme: 'file', pattern: '**/.env*' }, { language: 'dotenv' }], hover),
226
+ api.workspace.onDidOpenTextDocument(checkMissingKeys),
227
+ api.workspace.onDidChangeTextDocument(event => checkMissingKeys(event.document)),
228
+ api.workspace.onDidSaveTextDocument(checkMissingKeys),
229
+ api.workspace.onDidCloseTextDocument(document => diagnostics.delete(document.uri)),
230
+ api.commands.registerCommand(ENV_COMMANDS.get, getValue),
231
+ api.commands.registerCommand(ENV_COMMANDS.set, setValue),
232
+ api.commands.registerCommand(ENV_COMMANDS.encrypt, () => wholeFile('env:encrypt')),
233
+ api.commands.registerCommand(ENV_COMMANDS.decrypt, () => wholeFile('env:decrypt', 'This writes every value in {file} to disk in plaintext. Encrypt it again before committing.')),
234
+ api.commands.registerCommand(ENV_COMMANDS.rotate, () => wholeFile('env:rotate', 'This generates a new keypair for {file} and re-encrypts every value with it. Deployments still holding the old private key will no longer decrypt it.')),
235
+ )
236
+
237
+ for (const document of api.workspace.textDocuments)
238
+ checkMissingKeys(document)
239
+ },
240
+ }
241
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The extension entry VS Code loads (`main` in package.json, bundled to
3
+ * dist/extension.js). It wires the real machine into the controllers and
4
+ * starts each part; the behavior lives in the modules it imports:
5
+ *
6
+ * - ./stacks.ts preview, dev server and buddy commands
7
+ * - ./pickier.ts lint diagnostics, formatting and fix-all from the project's pickier
8
+ * - ./env.ts decrypted env values on hover, missing keys, `buddy env:*` commands
9
+ * - stx the stx extension's language support, built in from `@stacksjs/stx-vscode`
10
+ *
11
+ * Nothing here depends on another extension being installed.
12
+ */
13
+ import type { PickierProcess } from './pickier'
14
+ import type { Host } from './stacks'
15
+ import { execFile, spawn } from 'node:child_process'
16
+ import { existsSync, readFileSync } from 'node:fs'
17
+ import { connect } from 'node:net'
18
+ import { join } from 'node:path'
19
+ import process from 'node:process'
20
+ import { createInterface } from 'node:readline'
21
+ import { activateStxLanguage, deactivateStxLanguage, STX_EXTENSION_ID } from '@stacksjs/stx-vscode'
22
+ import * as vscode from 'vscode'
23
+ import { createEnvSupport, projectDecryptor } from './env'
24
+ import { createPickierSupport } from './pickier'
25
+ import { createStacksExtension } from './stacks'
26
+
27
+ const PROBE_TIMEOUT_MS = 500
28
+
29
+ function read(path: string): string | undefined {
30
+ try {
31
+ return readFileSync(path, 'utf8')
32
+ }
33
+ catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ function exec(file: string, args: string[], cwd: string): Promise<string> {
39
+ return new Promise((resolve, reject) => {
40
+ execFile(file, args, {
41
+ cwd,
42
+ timeout: 60_000,
43
+ maxBuffer: 32 * 1024 * 1024,
44
+ env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },
45
+ }, (error, stdout) => error ? reject(error) : resolve(String(stdout)))
46
+ })
47
+ }
48
+
49
+ const envHost = { exists: existsSync, read, exec, environment: process.env }
50
+
51
+ const host: Host = {
52
+ exists: path => existsSync(path),
53
+ read,
54
+ exec,
55
+
56
+ probe(hostname, port) {
57
+ return new Promise((resolve) => {
58
+ const socket = connect({ host: hostname, port })
59
+ const finish = (open: boolean) => {
60
+ socket.destroy()
61
+ resolve(open)
62
+ }
63
+
64
+ socket.setTimeout(PROBE_TIMEOUT_MS)
65
+ socket.once('connect', () => finish(true))
66
+ socket.once('timeout', () => finish(false))
67
+ socket.once('error', () => finish(false))
68
+ })
69
+ },
70
+
71
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
72
+
73
+ decryptEnv: root => projectDecryptor(envHost, root),
74
+ }
75
+
76
+ /**
77
+ * `bun --config=<empty> dist/pickier-worker.js` in the project root. The
78
+ * explicit config keeps the project's bunfig.toml out: its `preload` boots the
79
+ * framework (env decryption, auto-imports), which pickier does not need.
80
+ */
81
+ function spawnBun(bun: string, script: string, cwd: string): PickierProcess {
82
+ const child = spawn(bun, [`--config=${join(script, '..', 'pickier-worker.bunfig.toml')}`, script], {
83
+ cwd,
84
+ stdio: ['pipe', 'pipe', 'pipe'],
85
+ env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' },
86
+ })
87
+ const lines = createInterface({ input: child.stdout })
88
+ let exited = false
89
+ const exitListeners: Array<() => void> = []
90
+ const exit = () => {
91
+ if (exited)
92
+ return
93
+ exited = true
94
+ for (const listener of exitListeners)
95
+ listener()
96
+ }
97
+ child.once('exit', exit)
98
+ child.once('error', exit)
99
+ child.stderr.resume()
100
+
101
+ return {
102
+ write: line => child.stdin.write(line),
103
+ onLine: listener => lines.on('line', listener),
104
+ onExit: listener => exitListeners.push(listener),
105
+ kill: () => child.kill(),
106
+ }
107
+ }
108
+
109
+ const stacks = createStacksExtension(vscode, host)
110
+ const env = createEnvSupport(vscode, envHost)
111
+ let pickier: ReturnType<typeof createPickierSupport> | undefined
112
+
113
+ /**
114
+ * What `activate` resolves to. The stx extension (Stacks.vscode-stx) reads
115
+ * `stx.active` and stands down when it is true, so the two never register
116
+ * stx support twice in one window.
117
+ */
118
+ export interface StacksExtensionApi {
119
+ stx: { active: boolean }
120
+ }
121
+
122
+ async function startStx(context: vscode.ExtensionContext): Promise<boolean> {
123
+ try {
124
+ await activateStxLanguage(context, {
125
+ assetsPath: join(context.extensionPath, 'dist', 'stx'),
126
+ // The stx extension contributes the snippets in its manifest; offering
127
+ // them here as well would show each one twice.
128
+ snippets: !vscode.extensions.getExtension(STX_EXTENSION_ID),
129
+ })
130
+ return true
131
+ }
132
+ catch (error) {
133
+ console.error('Stacks: stx support failed to start', error)
134
+ return false
135
+ }
136
+ }
137
+
138
+ export async function activate(context: vscode.ExtensionContext): Promise<StacksExtensionApi> {
139
+ stacks.activate(context)
140
+ env.activate(context)
141
+ pickier = createPickierSupport(vscode, { exists: existsSync, spawnBun }, {
142
+ workerPath: join(context.extensionPath, 'dist', 'pickier-worker.js'),
143
+ })
144
+ pickier.activate(context)
145
+
146
+ return { stx: { active: await startStx(context) } }
147
+ }
148
+
149
+ export function deactivate(): void {
150
+ stacks.deactivate()
151
+ pickier?.deactivate()
152
+ deactivateStxLanguage()
153
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The line protocol between the extension and dist/pickier-worker.js.
3
+ *
4
+ * Pickier runs on Bun (its config is a TypeScript file, and it uses Bun's glob
5
+ * and YAML APIs), and VS Code runs extensions on Node, so the extension keeps
6
+ * one Bun process per project and talks to it over stdin/stdout: one JSON
7
+ * object per line each way, answers matched to requests by `id`.
8
+ */
9
+
10
+ export type PickierOperation = 'lint' | 'fix' | 'format'
11
+
12
+ export interface PickierRequest {
13
+ id: number
14
+ op: PickierOperation
15
+ /** Absolute path, for config ignores and the per-extension rules. */
16
+ file: string
17
+ text: string
18
+ }
19
+
20
+ /** One issue, as pickier's `lintText` reports it: 1-based line and column. */
21
+ export interface PickierIssue {
22
+ line: number
23
+ column: number
24
+ ruleId: string
25
+ message: string
26
+ severity: 'error' | 'warning'
27
+ help?: string
28
+ }
29
+
30
+ export type PickierResult =
31
+ | { op: 'lint', issues: PickierIssue[] }
32
+ /** `text` is null when the project's pickier predates `fixText`, or the file is ignored. */
33
+ | { op: 'fix' | 'format', text: string | null }
34
+
35
+ export type PickierResponse =
36
+ | { id: number, ok: true, result: PickierResult }
37
+ | { id: number, ok: false, error: string, missing?: boolean }
38
+
39
+ /** The first line the worker writes, once it has loaded the project's pickier and config. */
40
+ export interface PickierReady {
41
+ ready: true
42
+ version: string
43
+ fixText: boolean
44
+ }
45
+
46
+ /** What the worker writes instead when the project has no pickier it can load. */
47
+ export interface PickierUnavailable {
48
+ ready: false
49
+ error: string
50
+ missing: boolean
51
+ }