@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.
- 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 +43 -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,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env file support (src/env.ts, src/env-file.ts). Values are encrypted with
|
|
3
|
+
* `@stacksjs/env`'s own `encryptValue`, so the round trip is the real one.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'bun:test'
|
|
6
|
+
import { encryptValue, generateKeypair } from '../../../../core/env/src/crypto'
|
|
7
|
+
import { createEnvSupport, projectDecryptor } from '../src/env'
|
|
8
|
+
import { decryptEnvValue, envEntryAt, findPrivateKey, isEnvFile, missingKeys } from '../src/env-file'
|
|
9
|
+
import { loadProjectEnv } from '../src/project'
|
|
10
|
+
import { document, fakeVscode, flush, Position } from './fake-vscode'
|
|
11
|
+
|
|
12
|
+
const ROOT = '/work/app'
|
|
13
|
+
const { publicKey, privateKey } = generateKeypair()
|
|
14
|
+
const other = generateKeypair()
|
|
15
|
+
const SECRET = 's3cret value with spaces'
|
|
16
|
+
const ENCRYPTED = encryptValue(SECRET, publicKey)
|
|
17
|
+
|
|
18
|
+
describe('env files', () => {
|
|
19
|
+
it('knows env files from the private key file', () => {
|
|
20
|
+
expect(['.env', '.env.local', '.env.production', '.env.example'].every(name => isEnvFile(`${ROOT}/${name}`))).toBeTrue()
|
|
21
|
+
expect(isEnvFile(`${ROOT}/.env.keys`)).toBeFalse()
|
|
22
|
+
expect(isEnvFile(`${ROOT}/env.ts`)).toBeFalse()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('finds the value on a line, quotes excluded', () => {
|
|
26
|
+
expect(envEntryAt('export API_KEY="abc"')).toEqual({ key: 'API_KEY', value: 'abc', start: 16, end: 19 })
|
|
27
|
+
expect(envEntryAt('PORT=3000 # dev')).toEqual({ key: 'PORT', value: '3000', start: 5, end: 9 })
|
|
28
|
+
expect(envEntryAt('# just a comment')).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('lists the keys .env.example has and .env does not', () => {
|
|
32
|
+
expect(missingKeys('APP_NAME=\nAPP_KEY=\n# note\nDB_HOST=', 'APP_NAME=Stacks\nexport DB_HOST=x')).toEqual(['APP_KEY'])
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('private keys', () => {
|
|
37
|
+
it('looks them up the way ./buddy env:get does: the file\'s key, then the plain one, .env.keys before the environment', () => {
|
|
38
|
+
const keys = 'DOTENV_PRIVATE_KEY=plain\nDOTENV_PRIVATE_KEY_PRODUCTION=prod'
|
|
39
|
+
expect(findPrivateKey(`${ROOT}/.env.production`, keys, {})).toEqual({ name: 'DOTENV_PRIVATE_KEY_PRODUCTION', key: 'prod', from: '.env.keys' })
|
|
40
|
+
expect(findPrivateKey(`${ROOT}/.env.staging`, keys, {})).toEqual({ name: 'DOTENV_PRIVATE_KEY', key: 'plain', from: '.env.keys' })
|
|
41
|
+
expect(findPrivateKey(`${ROOT}/.env.production`, undefined, { DOTENV_PRIVATE_KEY_PRODUCTION: 'ci' })).toEqual({ name: 'DOTENV_PRIVATE_KEY_PRODUCTION', key: 'ci', from: 'environment' })
|
|
42
|
+
expect(findPrivateKey(`${ROOT}/.env`, undefined, {})).toBeUndefined()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('decrypts with @stacksjs/env, and says why when it cannot', () => {
|
|
46
|
+
expect(decryptEnvValue(`${ROOT}/.env`, ENCRYPTED, `DOTENV_PRIVATE_KEY=${privateKey}`, {})).toMatchObject({ ok: true, value: SECRET })
|
|
47
|
+
expect(decryptEnvValue(`${ROOT}/.env`, ENCRYPTED, undefined, {})).toEqual({ ok: false, reason: 'no-key', looked: ['DOTENV_PRIVATE_KEY'] })
|
|
48
|
+
expect(decryptEnvValue(`${ROOT}/.env`, ENCRYPTED, `DOTENV_PRIVATE_KEY=${other.privateKey}`, {})).toMatchObject({ ok: false, reason: 'failed' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('lets the preview read an encrypted APP_URL, and still skips one it cannot decrypt', () => {
|
|
52
|
+
const files: Record<string, string> = {
|
|
53
|
+
[`${ROOT}/.env`]: `APP_URL=${ENCRYPTED}\nPORT=${encryptValue('3100', other.publicKey)}`,
|
|
54
|
+
[`${ROOT}/.env.keys`]: `DOTENV_PRIVATE_KEY=${privateKey}`,
|
|
55
|
+
}
|
|
56
|
+
const host = { exists: (path: string) => path in files, read: (path: string) => files[path], exec: async () => '', environment: {} }
|
|
57
|
+
const env = loadProjectEnv(file => files[`${ROOT}/${file}`], ['.env'], projectDecryptor(host, ROOT))
|
|
58
|
+
expect(env).toEqual({ APP_URL: SECRET })
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
function setup(files: Record<string, string>, settings: Record<string, unknown> = {}) {
|
|
63
|
+
const host = {
|
|
64
|
+
exists: (path: string) => path in files,
|
|
65
|
+
read: (path: string) => files[path],
|
|
66
|
+
execs: [] as string[][],
|
|
67
|
+
async exec(file: string, args: string[], cwd: string) {
|
|
68
|
+
host.execs.push([file, ...args, `(in ${cwd})`])
|
|
69
|
+
return 'the value\n'
|
|
70
|
+
},
|
|
71
|
+
environment: {},
|
|
72
|
+
}
|
|
73
|
+
const fake = fakeVscode({ folders: [ROOT], settings })
|
|
74
|
+
const support = createEnvSupport(fake.api as any, host)
|
|
75
|
+
support.activate(fake.context)
|
|
76
|
+
return { ...fake, host }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('hover', () => {
|
|
80
|
+
const line = `API_KEY="${ENCRYPTED}"`
|
|
81
|
+
const files = { [`${ROOT}/buddy`]: '', [`${ROOT}/.env.keys`]: `DOTENV_PRIVATE_KEY=${privateKey}` }
|
|
82
|
+
|
|
83
|
+
it('shows an encrypted value decrypted, and names the key it used', () => {
|
|
84
|
+
const { hovers } = setup(files)
|
|
85
|
+
const hover = hovers[0].provideHover(document(`${ROOT}/.env`, line, 'dotenv'), new Position(0, 20))
|
|
86
|
+
|
|
87
|
+
expect(hover.contents.value).toContain(SECRET)
|
|
88
|
+
expect(hover.contents.value).toContain('`DOTENV_PRIVATE_KEY` from `.env.keys`')
|
|
89
|
+
expect(hover.range.start.character).toBe(9)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('explains a value it has no key for', () => {
|
|
93
|
+
const { hovers } = setup({ [`${ROOT}/buddy`]: '' })
|
|
94
|
+
const hover = hovers[0].provideHover(document(`${ROOT}/.env`, line, 'dotenv'), new Position(0, 20))
|
|
95
|
+
expect(hover.contents.value).toContain('No private key')
|
|
96
|
+
expect(hover.contents.value).not.toContain(SECRET)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('stays quiet on plaintext, on the key name, in other files and when turned off', () => {
|
|
100
|
+
expect(setup(files).hovers[0].provideHover(document(`${ROOT}/.env`, 'PORT=3000', 'dotenv'), new Position(0, 6))).toBeUndefined()
|
|
101
|
+
expect(setup(files).hovers[0].provideHover(document(`${ROOT}/.env`, line, 'dotenv'), new Position(0, 2))).toBeUndefined()
|
|
102
|
+
expect(setup(files).hovers[0].provideHover(document(`${ROOT}/app/x.ts`, line), new Position(0, 20))).toBeUndefined()
|
|
103
|
+
expect(setup(files, { 'env.decryptOnHover': false }).hovers[0].provideHover(document(`${ROOT}/.env`, line, 'dotenv'), new Position(0, 20))).toBeUndefined()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('missing keys', () => {
|
|
108
|
+
it('flags keys .env.example declares that .env does not set', async () => {
|
|
109
|
+
const { diagnostics, events } = setup({ [`${ROOT}/.env.example`]: 'APP_NAME=\nAPP_KEY=\n' })
|
|
110
|
+
events.opened.fire(document(`${ROOT}/.env`, 'APP_NAME=Stacks\n', 'dotenv') as any)
|
|
111
|
+
await flush()
|
|
112
|
+
|
|
113
|
+
const list = diagnostics.get(`file://${ROOT}/.env`)!
|
|
114
|
+
expect(list.map((diagnostic: any) => diagnostic.message)).toEqual(['APP_KEY is in .env.example but not set here.'])
|
|
115
|
+
expect(list[0].severity).toBe(2)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('does not compare other env files, which legitimately differ', async () => {
|
|
119
|
+
const { diagnostics, events } = setup({ [`${ROOT}/.env.example`]: 'APP_KEY=\n' })
|
|
120
|
+
events.opened.fire(document(`${ROOT}/.env.production`, '', 'dotenv') as any)
|
|
121
|
+
await flush()
|
|
122
|
+
expect(diagnostics.get(`file://${ROOT}/.env.production`)).toBeUndefined()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('buddy env commands', () => {
|
|
127
|
+
const files = { [`${ROOT}/buddy`]: '', [`${ROOT}/.env`]: 'APP_NAME=Stacks\nAPI_KEY=x\n' }
|
|
128
|
+
|
|
129
|
+
it('sets a value through ./buddy env:set, without a shell, from a password box', async () => {
|
|
130
|
+
const fake = setup(files)
|
|
131
|
+
fake.answers.push('API_KEY', 'hunter2')
|
|
132
|
+
await fake.handlers.get('stacks.env.set')!()
|
|
133
|
+
|
|
134
|
+
expect(fake.host.execs).toEqual([[`${ROOT}/buddy`, 'env:set', 'API_KEY', 'hunter2', '--file', '.env', `(in ${ROOT})`]])
|
|
135
|
+
expect(fake.inputs[1].password).toBeTrue()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('gets a value through ./buddy env:get and copies it only when asked', async () => {
|
|
139
|
+
const fake = setup(files)
|
|
140
|
+
fake.answers.push('API_KEY', 'Copy Value')
|
|
141
|
+
await fake.handlers.get('stacks.env.get')!()
|
|
142
|
+
|
|
143
|
+
expect(fake.host.execs[0]!.slice(1, 5)).toEqual(['env:get', 'API_KEY', '--file', '.env'])
|
|
144
|
+
expect(fake.messages[0]).not.toContain('the value')
|
|
145
|
+
expect(fake.clipboard).toEqual(['the value'])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('asks before writing plaintext to disk', async () => {
|
|
149
|
+
const fake = setup(files)
|
|
150
|
+
fake.answers.push(undefined)
|
|
151
|
+
await fake.handlers.get('stacks.env.decrypt')!()
|
|
152
|
+
expect(fake.host.execs).toEqual([])
|
|
153
|
+
expect(fake.messages[0]).toContain('plaintext')
|
|
154
|
+
|
|
155
|
+
fake.answers.push('Continue', undefined)
|
|
156
|
+
await fake.handlers.get('stacks.env.decrypt')!()
|
|
157
|
+
expect(fake.host.execs[0]!.slice(1, 4)).toEqual(['env:decrypt', '--file', '.env'])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('acts on the env file that is open', async () => {
|
|
161
|
+
const fake = setup({ ...files, [`${ROOT}/.env.production`]: 'APP_KEY=x\n' })
|
|
162
|
+
fake.api.window.activeTextEditor = { document: document(`${ROOT}/.env.production`, '', 'dotenv') }
|
|
163
|
+
await fake.handlers.get('stacks.env.encrypt')!()
|
|
164
|
+
expect(fake.host.execs[0]!.slice(1, 4)).toEqual(['env:encrypt', '--file', '.env.production'])
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -216,6 +216,14 @@ describe('activation', () => {
|
|
|
216
216
|
expect([...handlers.keys()].sort()).toEqual([COMMANDS.openPreview, COMMANDS.runBuddyCommand, COMMANDS.startDevServer].sort())
|
|
217
217
|
expect(executed).toContainEqual({ command: 'setContext', args: ['stacks.isProject', true] })
|
|
218
218
|
})
|
|
219
|
+
|
|
220
|
+
it('stays out of the way in a workspace that is not a Stacks project', () => {
|
|
221
|
+
// The extension also activates for .stx files anywhere, for its stx support.
|
|
222
|
+
const { handlers, executed, context } = setup({ noBuddy: true })
|
|
223
|
+
expect(executed).toContainEqual({ command: 'setContext', args: ['stacks.isProject', false] })
|
|
224
|
+
expect(handlers.size).toBe(3)
|
|
225
|
+
expect(context.subscriptions.some((item: any) => 'text' in item)).toBeFalse()
|
|
226
|
+
})
|
|
219
227
|
})
|
|
220
228
|
|
|
221
229
|
describe('Stacks: Open Preview', () => {
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough of the `vscode` API for ./pickier.test.ts and ./env.test.ts:
|
|
3
|
+
* documents, diagnostics, code actions, hovers and the provider registries,
|
|
4
|
+
* recorded so tests can call the providers the way the editor would.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type Listener<T> = (event: T) => unknown
|
|
8
|
+
|
|
9
|
+
export function emitter<T>() {
|
|
10
|
+
const listeners = new Set<Listener<T>>()
|
|
11
|
+
return {
|
|
12
|
+
event: (listener: Listener<T>) => {
|
|
13
|
+
listeners.add(listener)
|
|
14
|
+
return { dispose: () => listeners.delete(listener) }
|
|
15
|
+
},
|
|
16
|
+
fire: (value: T) => {
|
|
17
|
+
for (const listener of [...listeners]) listener(value)
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Position {
|
|
23
|
+
constructor(readonly line: number, readonly character: number) {}
|
|
24
|
+
translate(lines = 0, characters = 0) {
|
|
25
|
+
return new Position(this.line + lines, this.character + characters)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class Range {
|
|
30
|
+
readonly start: Position
|
|
31
|
+
readonly end: Position
|
|
32
|
+
constructor(a: Position | number, b: Position | number, c?: number, d?: number) {
|
|
33
|
+
this.start = typeof a === 'number' ? new Position(a, b as number) : a
|
|
34
|
+
this.end = typeof a === 'number' ? new Position(c as number, d as number) : b as Position
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CodeActionKind {
|
|
39
|
+
static readonly QuickFix = new CodeActionKind('quickfix')
|
|
40
|
+
static readonly SourceFixAll = new CodeActionKind('source.fixAll')
|
|
41
|
+
constructor(readonly value: string) {}
|
|
42
|
+
append(part: string) {
|
|
43
|
+
return new CodeActionKind(`${this.value}.${part}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
contains(other: CodeActionKind) {
|
|
47
|
+
return other.value === this.value || other.value.startsWith(`${this.value}.`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class WorkspaceEdit {
|
|
52
|
+
readonly edits: Array<{ uri: unknown, range?: Range, position?: Position, text: string }> = []
|
|
53
|
+
replace(uri: unknown, range: Range, text: string) {
|
|
54
|
+
this.edits.push({ uri, range, text })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
insert(uri: unknown, position: Position, text: string) {
|
|
58
|
+
this.edits.push({ uri, position, text })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class MarkdownString {
|
|
63
|
+
value = ''
|
|
64
|
+
appendMarkdown(text: string) {
|
|
65
|
+
this.value += text
|
|
66
|
+
return this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
appendCodeblock(code: string, language = '') {
|
|
70
|
+
this.value += `\n\`\`\`${language}\n${code}\n\`\`\`\n`
|
|
71
|
+
return this
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FakeDocument {
|
|
76
|
+
uri: { fsPath: string, scheme: string, toString: () => string }
|
|
77
|
+
languageId: string
|
|
78
|
+
version: number
|
|
79
|
+
isClosed: boolean
|
|
80
|
+
lineCount: number
|
|
81
|
+
getText: () => string
|
|
82
|
+
lineAt: (line: number) => { text: string, lineNumber: number, range: Range }
|
|
83
|
+
getWordRangeAtPosition: (position: Position) => Range | undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function document(path: string, text: string, languageId = 'typescript'): FakeDocument {
|
|
87
|
+
const lines = text.split('\n')
|
|
88
|
+
return {
|
|
89
|
+
uri: { fsPath: path, scheme: 'file', toString: () => `file://${path}` },
|
|
90
|
+
languageId,
|
|
91
|
+
version: 1,
|
|
92
|
+
isClosed: false,
|
|
93
|
+
lineCount: lines.length,
|
|
94
|
+
getText: () => text,
|
|
95
|
+
lineAt: (line: number) => ({ text: lines[line] ?? '', lineNumber: line, range: new Range(line, 0, line, (lines[line] ?? '').length) }),
|
|
96
|
+
getWordRangeAtPosition: (position: Position) => {
|
|
97
|
+
const line = lines[position.line] ?? ''
|
|
98
|
+
const match = /\w+/.exec(line.slice(position.character))
|
|
99
|
+
return match && match.index === 0 ? new Range(position.line, position.character, position.line, position.character + match[0].length) : undefined
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function fakeVscode(options: { folders: string[], settings?: Record<string, unknown>, documents?: FakeDocument[] }) {
|
|
105
|
+
const settings = options.settings ?? {}
|
|
106
|
+
const diagnostics = new Map<string, any[]>()
|
|
107
|
+
const handlers = new Map<string, (...args: any[]) => any>()
|
|
108
|
+
const codeActionProviders: any[] = []
|
|
109
|
+
const formatters: any[] = []
|
|
110
|
+
const hovers: any[] = []
|
|
111
|
+
const messages: string[] = []
|
|
112
|
+
const answers: Array<string | undefined> = []
|
|
113
|
+
const inputs: any[] = []
|
|
114
|
+
const output: string[] = []
|
|
115
|
+
const applied: WorkspaceEdit[] = []
|
|
116
|
+
const clipboard: string[] = []
|
|
117
|
+
const opened = emitter<FakeDocument>()
|
|
118
|
+
const changed = emitter<{ document: FakeDocument }>()
|
|
119
|
+
const saved = emitter<FakeDocument>()
|
|
120
|
+
const closed = emitter<FakeDocument>()
|
|
121
|
+
|
|
122
|
+
const message = (text: string) => {
|
|
123
|
+
messages.push(text)
|
|
124
|
+
return Promise.resolve(answers.shift())
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const api = {
|
|
128
|
+
Position,
|
|
129
|
+
Range,
|
|
130
|
+
CodeActionKind,
|
|
131
|
+
WorkspaceEdit,
|
|
132
|
+
MarkdownString,
|
|
133
|
+
DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 },
|
|
134
|
+
Diagnostic: class {
|
|
135
|
+
source?: string
|
|
136
|
+
code?: string
|
|
137
|
+
constructor(readonly range: Range, readonly message: string, readonly severity: number) {}
|
|
138
|
+
},
|
|
139
|
+
CodeAction: class {
|
|
140
|
+
edit?: WorkspaceEdit
|
|
141
|
+
diagnostics?: unknown[]
|
|
142
|
+
constructor(readonly title: string, readonly kind: CodeActionKind) {}
|
|
143
|
+
},
|
|
144
|
+
Hover: class {
|
|
145
|
+
constructor(readonly contents: MarkdownString, readonly range?: Range) {}
|
|
146
|
+
},
|
|
147
|
+
TextEdit: { replace: (range: Range, newText: string) => ({ range, newText }) },
|
|
148
|
+
commands: {
|
|
149
|
+
registerCommand: (id: string, handler: (...args: any[]) => any) => {
|
|
150
|
+
handlers.set(id, handler)
|
|
151
|
+
return { dispose: () => handlers.delete(id) }
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
env: { clipboard: { writeText: async (text: string) => { clipboard.push(text) } } },
|
|
155
|
+
languages: {
|
|
156
|
+
createDiagnosticCollection: () => ({
|
|
157
|
+
set: (uri: { toString: () => string }, list: any[]) => diagnostics.set(uri.toString(), list),
|
|
158
|
+
delete: (uri: { toString: () => string }) => diagnostics.delete(uri.toString()),
|
|
159
|
+
clear: () => diagnostics.clear(),
|
|
160
|
+
dispose: () => {},
|
|
161
|
+
}),
|
|
162
|
+
registerCodeActionsProvider: (_selector: unknown, provider: unknown) => {
|
|
163
|
+
codeActionProviders.push(provider)
|
|
164
|
+
return { dispose: () => {} }
|
|
165
|
+
},
|
|
166
|
+
registerDocumentFormattingEditProvider: (_selector: unknown, provider: unknown) => {
|
|
167
|
+
formatters.push(provider)
|
|
168
|
+
return { dispose: () => {} }
|
|
169
|
+
},
|
|
170
|
+
registerHoverProvider: (_selector: unknown, provider: unknown) => {
|
|
171
|
+
hovers.push(provider)
|
|
172
|
+
return { dispose: () => {} }
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
window: {
|
|
176
|
+
activeTextEditor: undefined as undefined | { document: FakeDocument },
|
|
177
|
+
createOutputChannel: () => ({ appendLine: (line: string) => output.push(line), dispose: () => {} }),
|
|
178
|
+
showInformationMessage: message,
|
|
179
|
+
showWarningMessage: message,
|
|
180
|
+
showErrorMessage: message,
|
|
181
|
+
showInputBox: (inputOptions: unknown) => {
|
|
182
|
+
inputs.push(inputOptions)
|
|
183
|
+
return Promise.resolve(answers.shift())
|
|
184
|
+
},
|
|
185
|
+
showQuickPick: (items: string[], pickOptions: unknown) => {
|
|
186
|
+
inputs.push({ items, ...pickOptions as object })
|
|
187
|
+
return Promise.resolve(answers.shift())
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
workspace: {
|
|
191
|
+
workspaceFolders: options.folders.map((folder, index) => ({ uri: { fsPath: folder }, name: folder, index })),
|
|
192
|
+
textDocuments: options.documents ?? [],
|
|
193
|
+
getConfiguration: () => ({ get: (key: string, fallback: unknown) => key in settings ? settings[key] : fallback }),
|
|
194
|
+
getWorkspaceFolder: (uri: { fsPath: string }) => {
|
|
195
|
+
const folder = options.folders.find(candidate => uri.fsPath.startsWith(`${candidate}/`))
|
|
196
|
+
return folder ? { uri: { fsPath: folder } } : undefined
|
|
197
|
+
},
|
|
198
|
+
applyEdit: async (edit: WorkspaceEdit) => {
|
|
199
|
+
applied.push(edit)
|
|
200
|
+
return true
|
|
201
|
+
},
|
|
202
|
+
onDidOpenTextDocument: opened.event,
|
|
203
|
+
onDidChangeTextDocument: changed.event,
|
|
204
|
+
onDidSaveTextDocument: saved.event,
|
|
205
|
+
onDidCloseTextDocument: closed.event,
|
|
206
|
+
onDidChangeConfiguration: emitter<any>().event,
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
api,
|
|
212
|
+
diagnostics,
|
|
213
|
+
handlers,
|
|
214
|
+
codeActionProviders,
|
|
215
|
+
formatters,
|
|
216
|
+
hovers,
|
|
217
|
+
messages,
|
|
218
|
+
answers,
|
|
219
|
+
inputs,
|
|
220
|
+
output,
|
|
221
|
+
applied,
|
|
222
|
+
clipboard,
|
|
223
|
+
events: { opened, changed, saved, closed },
|
|
224
|
+
context: { subscriptions: [] as Array<{ dispose: () => unknown }> },
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const flush = () => new Promise(resolve => setTimeout(resolve, 10))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extension manifest.
|
|
3
|
+
*
|
|
4
|
+
* The Stacks extension is self-contained: stx support, pickier and env files
|
|
5
|
+
* are built in, so it installs no extension pack and depends on no other
|
|
6
|
+
* extension. stx support comes from `@stacksjs/stx-vscode`, the library build
|
|
7
|
+
* of the stx extension (Stacks.vscode-stx); with both installed, the stx
|
|
8
|
+
* extension stands down (stacksjs/stx#2020).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from 'bun:test'
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { libraryContributes, withStxContributes } from '../scripts/stx-contributes'
|
|
14
|
+
|
|
15
|
+
const root = join(import.meta.dir, '..')
|
|
16
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
17
|
+
const STX_EXTENSION_ID = 'Stacks.vscode-stx'
|
|
18
|
+
const projectSettings = Bun.JSONC.parse(readFileSync(join(root, '.vscode/settings.json'), 'utf8')) as Record<string, any>
|
|
19
|
+
const recommendations = (Bun.JSONC.parse(readFileSync(join(root, '.vscode/extensions.json'), 'utf8')) as { recommendations: string[] }).recommendations
|
|
20
|
+
|
|
21
|
+
describe('marketplace identity', () => {
|
|
22
|
+
it('is Stacks.vscode-stacks, not the stx extension', () => {
|
|
23
|
+
const id = `${manifest.publisher}.${manifest.name}`
|
|
24
|
+
expect(id).toBe('Stacks.vscode-stacks')
|
|
25
|
+
expect(id.toLowerCase()).not.toBe(STX_EXTENSION_ID.toLowerCase())
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('self-contained', () => {
|
|
30
|
+
it('installs no extension pack and depends on no extension', () => {
|
|
31
|
+
expect(manifest.extensionPack).toBeUndefined()
|
|
32
|
+
expect(manifest.extensionDependencies).toBeUndefined()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('has no runtime dependencies to ship', () => {
|
|
36
|
+
expect(manifest.dependencies).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('recommends only itself to new projects', () => {
|
|
40
|
+
expect(recommendations.map(id => id.toLowerCase())).toEqual(['stacks.vscode-stacks'])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('configures new projects for no other extension', () => {
|
|
44
|
+
// Settings that only mean something to an extension the project no longer
|
|
45
|
+
// asks for: ESLint, Prettier, Biome, markdownlint, shell-format, cSpell,
|
|
46
|
+
// Grammarly, vscode-icons, Todo Tree.
|
|
47
|
+
const foreign = Object.keys(projectSettings).filter(key =>
|
|
48
|
+
/^(?:eslint|prettier|biome|markdownlint|cSpell|grammarly|vsicons|todo-tree)\./.test(key))
|
|
49
|
+
expect(foreign).toEqual([])
|
|
50
|
+
expect(JSON.stringify(projectSettings)).not.toMatch(/dbaeumer|foxundermoon|DavidAnson|esbenp/i)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('formats and fixes with pickier through this extension', () => {
|
|
54
|
+
expect(projectSettings['editor.defaultFormatter']).toBe('Stacks.vscode-stacks')
|
|
55
|
+
expect(projectSettings['editor.codeActionsOnSave']['source.fixAll.pickier']).toBe('explicit')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('stx language support', () => {
|
|
60
|
+
it('declares what @stacksjs/stx-vscode ships (run `bun run sync:stx` after updating it)', () => {
|
|
61
|
+
expect(withStxContributes(manifest, libraryContributes(root))).toEqual(manifest)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('declares the stx language and its grammar from the bundled assets', () => {
|
|
65
|
+
expect(manifest.contributes.languages.map((language: { id: string }) => language.id)).toContain('stx')
|
|
66
|
+
expect(manifest.contributes.grammars[0].path).toStartWith('./dist/stx/')
|
|
67
|
+
expect(manifest.files).toContain('dist/stx/**')
|
|
68
|
+
expect(manifest.activationEvents).toContain('onLanguage:stx')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('leaves snippets to runtime, so they never show twice next to the stx extension', () => {
|
|
72
|
+
expect(manifest.contributes.snippets).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('does not map .stx files to another language', () => {
|
|
76
|
+
const extensionDefaults = manifest.contributes.configurationDefaults['files.associations'] ?? {}
|
|
77
|
+
for (const associations of [extensionDefaults, projectSettings['files.associations'] ?? {}])
|
|
78
|
+
expect(Object.keys(associations).filter(pattern => pattern.endsWith('.stx'))).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('packaging', () => {
|
|
83
|
+
it('ships the files the extension loads at runtime', () => {
|
|
84
|
+
for (const file of ['dist/extension.js', 'dist/pickier-worker.js', 'dist/pickier-worker.bunfig.toml'])
|
|
85
|
+
expect(manifest.files).toContain(file)
|
|
86
|
+
expect(manifest.main).toBe('./dist/extension.js')
|
|
87
|
+
})
|
|
88
|
+
})
|