@stacksjs/defaults 0.75.41 → 0.75.43
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.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-chat/SKILL.md +51 -1
- package/ai/skills/stacks-cloud/SKILL.md +36 -0
- package/ai/skills/stacks-dashboard/SKILL.md +16 -2
- package/ai/skills/stacks-database/SKILL.md +24 -3
- package/ai/skills/stacks-deploy/SKILL.md +7 -1
- package/ai/skills/stacks-desktop/SKILL.md +10 -0
- package/ai/skills/stacks-development/SKILL.md +1 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +38 -2
- package/ai/skills/stacks-orm/SKILL.md +2 -2
- package/ai/skills/stacks-security/SKILL.md +28 -1
- package/app/Actions/Auth/DataExportAction.test.ts +84 -0
- package/app/Actions/Auth/DataExportAction.ts +40 -0
- package/app/Actions/Dashboard/Content/FileReprocessAction.ts +1 -1
- package/app/Actions/Dashboard/Content/file-manager.ts +120 -8
- package/app/Actions/Dashboard/Content/file-metadata.ts +108 -12
- package/app/Actions/Dashboard/Content/file-pipeline.test.ts +13 -9
- package/app/Actions/Dashboard/Content/file-preview.test.ts +487 -0
- package/app/Actions/Dashboard/Content/file-preview.ts +548 -0
- package/app/Jobs/GenerateStoragePreviewJob.ts +47 -0
- package/app/Jobs/OptimizeStorageImageJob.ts +7 -3
- package/app/Jobs/PruneRetainedDataJob.ts +46 -0
- package/app/Models/Activity.ts +2 -0
- package/app/Models/CampaignSend.ts +2 -0
- package/app/Models/Card.ts +1 -0
- package/app/Models/CardComment.ts +1 -0
- package/app/Models/Comment.ts +5 -0
- package/app/Models/CommunicationSuppression.ts +2 -1
- package/app/Models/ConsentEvent.ts +4 -3
- package/app/Models/Content/Author.ts +5 -0
- package/app/Models/EmailListSubscriber.ts +1 -0
- package/app/Models/EmailSuppression.ts +2 -0
- package/app/Models/Forms/FormSubmission.ts +5 -0
- package/app/Models/GdprRequest.ts +77 -0
- package/app/Models/MagicLinkToken.ts +2 -0
- package/app/Models/Notification.ts +2 -0
- package/app/Models/NotificationDelivery.ts +4 -0
- package/app/Models/PaymentMethod.ts +5 -0
- package/app/Models/PaymentTransaction.ts +1 -0
- package/app/Models/PersonalAccessToken.ts +3 -0
- package/app/Models/Referral.ts +4 -1
- package/app/Models/ReferralCode.ts +4 -1
- package/app/Models/Request.ts +3 -0
- package/app/Models/SmsOptOut.ts +2 -0
- package/app/Models/SocialAccount.ts +3 -0
- package/app/Models/SocialPost.ts +2 -0
- package/app/Models/StorageItemTask.ts +14 -8
- package/app/Models/Subscriber.ts +2 -0
- package/app/Models/SubscriberEmail.ts +2 -0
- package/app/Models/Subscription.ts +1 -0
- package/app/Models/TeamInvitation.ts +2 -0
- package/app/Models/TeamMember.ts +1 -0
- package/app/Models/User.ts +5 -0
- package/app/Models/commerce/Bid.ts +3 -0
- package/app/Models/commerce/Cart.ts +2 -0
- package/app/Models/commerce/Courier.ts +7 -0
- package/app/Models/commerce/CourierPing.ts +3 -0
- package/app/Models/commerce/Customer.ts +5 -0
- package/app/Models/commerce/DeliveryStop.ts +7 -0
- package/app/Models/commerce/GiftCard.ts +4 -0
- package/app/Models/commerce/Order.ts +5 -0
- package/app/Models/commerce/OrderItem.ts +2 -0
- package/app/Models/commerce/Payment.ts +5 -0
- package/app/Models/commerce/Pledge.ts +3 -0
- package/app/Models/commerce/Transaction.ts +2 -0
- package/app/Models/commerce/WaitlistProduct.ts +5 -0
- package/app/Models/commerce/WaitlistRestaurant.ts +4 -0
- package/ide/vscode/.vscode/extensions.json +1 -1
- package/ide/vscode/.vscode/settings.json +8 -149
- package/ide/vscode/README.md +48 -23
- package/ide/vscode/build.ts +43 -0
- package/ide/vscode/package.json +305 -56
- package/ide/vscode/scripts/stx-contributes.ts +66 -0
- package/ide/vscode/src/env-file.ts +103 -0
- package/ide/vscode/src/env.ts +241 -0
- package/ide/vscode/src/extension.ts +120 -26
- package/ide/vscode/src/pickier-protocol.ts +51 -0
- package/ide/vscode/src/pickier-worker.ts +92 -0
- package/ide/vscode/src/pickier.ts +433 -0
- package/ide/vscode/src/project.ts +14 -4
- package/ide/vscode/src/stacks.ts +18 -11
- package/ide/vscode/tests/build.test.ts +58 -0
- package/ide/vscode/tests/env.test.ts +166 -0
- package/ide/vscode/tests/extension.test.ts +8 -0
- package/ide/vscode/tests/fake-vscode.ts +228 -0
- package/ide/vscode/tests/manifest.test.ts +88 -0
- package/ide/vscode/tests/pickier.test.ts +202 -0
- package/ide/vscode/tsconfig.json +2 -2
- package/package.json +2 -2
- package/resources/components/Dashboard/Content/FileManagerDashboard.stx +2 -0
- package/resources/components/Dashboard/FileViewer/MediaCard.stx +14 -3
- package/routes/auth.ts +4 -0
- package/vcs/github/workflows/buddy-bot.yml +2 -2
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
- 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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* `APP_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
|
|
package/ide/vscode/src/stacks.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
+
})
|