@stacksjs/defaults 0.75.62 → 0.75.63

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.
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.62",
5
+ "version": "0.75.63",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.62",
5
+ "version": "0.75.63",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.35",
58
- "@stacksjs/mobile": "^0.75.62",
58
+ "@stacksjs/mobile": "^0.75.63",
59
59
  "@stacksjs/sanitizer": "^0.2.348",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -1,52 +0,0 @@
1
- /**
2
- * Builds the extension into dist/:
3
- *
4
- * extension.js the extension, for VS Code's Node host; only `vscode` is external
5
- * pickier-worker.js the Bun process that runs the project's pickier (src/pickier-worker.ts)
6
- * pickier-worker.bunfig.toml an empty bunfig, so the worker skips the project's preloads
7
- * stx/ grammar, language configuration and snippets from @stacksjs/stx-vscode
8
- * stx-typescript-plugin/ the stx TypeScript server plugin from @stacksjs/stx-vscode
9
- *
10
- * `vsce package` runs this first (`vscode:prepublish`), and `files` in
11
- * package.json ships exactly these. tsserver loads the plugin from
12
- * node_modules, so a forwarder to it is written there as well, which is what
13
- * a development host (`--extensionDevelopmentPath`) loads; scripts/package.ts
14
- * adds the same forwarder to the VSIX (see scripts/ts-plugin.ts).
15
- */
16
- import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
17
- import { dirname, join } from 'node:path'
18
- import process from 'node:process'
19
- import { copyTsPlugin, tsPluginName, writeTsPluginForwarder } from './scripts/ts-plugin'
20
-
21
- const here = import.meta.dir
22
- const dist = join(here, 'dist')
23
-
24
- rmSync(dist, { recursive: true, force: true })
25
- mkdirSync(dist, { recursive: true })
26
-
27
- const builds = await Promise.all([
28
- // bunfig stays out: the stx utility-class engine imports it only to load a
29
- // config file, which the extension never asks for, and its module-scope
30
- // `await` and `import.meta.require` would make the CommonJS bundle a syntax
31
- // error in VS Code's Node host. tests/build.test.ts parses the bundle.
32
- Bun.build({ entrypoints: [join(here, 'src/extension.ts')], outdir: dist, target: 'node', format: 'cjs', external: ['vscode', 'bunfig'], minify: true }),
33
- Bun.build({ entrypoints: [join(here, 'src/pickier-worker.ts')], outdir: dist, target: 'bun', format: 'esm' }),
34
- ])
35
-
36
- for (const build of builds) {
37
- if (!build.success) {
38
- console.error(build.logs)
39
- process.exit(1)
40
- }
41
- }
42
-
43
- writeFileSync(join(dist, 'pickier-worker.bunfig.toml'), '# Intentionally empty: the pickier worker must not run the project\'s bunfig preloads.\n')
44
-
45
- const stxAssets = join(dirname(Bun.resolveSync('@stacksjs/stx-vscode/contributes.json', here)), 'assets')
46
- cpSync(stxAssets, join(dist, 'stx'), { recursive: true })
47
-
48
- copyTsPlugin(here)
49
- const { version } = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8'))
50
- writeTsPluginForwarder(here, tsPluginName(here), version)
51
-
52
- console.log('Built dist/')
@@ -1,71 +0,0 @@
1
- /**
2
- * Package (and with `--publish`, publish) the extension.
3
- *
4
- * bun scripts/package.ts [--out <file.vsix>] [--publish]
5
- *
6
- * `vsce package --no-dependencies` keeps the VSIX to what `files` lists, and it
7
- * leaves out everything under node_modules - including the one package the
8
- * extension needs there: the stx TypeScript server plugin, which tsserver will
9
- * only load by package name from `<extension>/node_modules` (see ts-plugin.ts).
10
- * So its forwarder is added to the VSIX after vsce writes it, and a publish
11
- * uploads that VSIX rather than letting vsce package a second time.
12
- */
13
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
14
- import { tmpdir } from 'node:os'
15
- import { join, resolve } from 'node:path'
16
- import process from 'node:process'
17
- import { TS_PLUGIN_DIST, tsPluginModuleDir, tsPluginName, writeTsPluginForwarder } from './ts-plugin'
18
-
19
- const ROOT = join(import.meta.dir, '..')
20
-
21
- function run(command: string[], cwd = ROOT, capture = false): string {
22
- const result = Bun.spawnSync(command, { cwd, stdout: capture ? 'pipe' : 'inherit', stderr: 'inherit', env: process.env })
23
- if (result.exitCode !== 0)
24
- throw new Error(`${command.join(' ')} exited with ${result.exitCode}`)
25
- return capture ? String(result.stdout ?? '') : ''
26
- }
27
-
28
- /** Build the VSIX at `out`, with the TypeScript plugin's forwarder in it. */
29
- export function packageExtension(out: string): string {
30
- const manifest = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
31
- const name = tsPluginName(ROOT)
32
-
33
- // vsce runs `vscode:prepublish` (the build) first.
34
- run(['bun', 'x', '--bun', 'vsce', 'package', '--no-dependencies', '--out', out])
35
-
36
- // A staging tree whose layout is the VSIX's, so zip records the paths VS
37
- // Code extracts: extension/node_modules/<package>/...
38
- const stage = mkdtempSync(join(tmpdir(), 'stacks-vsix-'))
39
- try {
40
- writeTsPluginForwarder(join(stage, 'extension'), name, manifest.version)
41
- run(['zip', '-q', '-r', '-X', '-D', out, `extension/${tsPluginModuleDir(name)}`], stage)
42
- }
43
- finally {
44
- rmSync(stage, { recursive: true, force: true })
45
- }
46
-
47
- const listing = run(['unzip', '-Z1', out], ROOT, true).split('\n')
48
- for (const file of [`extension/${tsPluginModuleDir(name)}/index.js`, `extension/${TS_PLUGIN_DIST}/index.js`, `extension/${TS_PLUGIN_DIST}/typescript-stx-plugin.js`]) {
49
- if (!listing.includes(file))
50
- throw new Error(`${out} is missing ${file}`)
51
- }
52
-
53
- return out
54
- }
55
-
56
- if (import.meta.main) {
57
- const args = process.argv.slice(2)
58
- const outFlag = args.indexOf('--out')
59
- const manifest = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
60
- const out = resolve(outFlag === -1 ? join(ROOT, `${manifest.name}-${manifest.version}.vsix`) : args[outFlag + 1]!)
61
-
62
- packageExtension(out)
63
- console.log(`Packaged ${out}`)
64
-
65
- if (args.includes('--publish')) {
66
- if (!existsSync(out))
67
- throw new Error(`${out} was not written`)
68
- run(['bun', 'x', '--bun', 'vsce', 'publish', '--packagePath', out])
69
- console.log(`Published ${manifest.publisher}.${manifest.name}@${manifest.version}`)
70
- }
71
- }
@@ -1,78 +0,0 @@
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. That includes the TypeScript server plugin entry, which
10
- * type-checks `.stx` files (see ts-plugin.ts). Snippets are left out on purpose: the extension offers
11
- * them as completions only when the stx extension is not installed, because
12
- * a manifest entry would show every snippet twice next to it.
13
- *
14
- * bun scripts/stx-contributes.ts --write update package.json (`bun run sync:stx`)
15
- *
16
- * tests/manifest.test.ts fails while the two disagree.
17
- */
18
- import { readFileSync, writeFileSync } from 'node:fs'
19
- import { join } from 'node:path'
20
- import process from 'node:process'
21
-
22
- export interface StxContributes {
23
- languages: Array<Record<string, unknown> & { id: string, configuration: string }>
24
- grammars: Array<Record<string, unknown> & { language: string, path: string }>
25
- commands: Array<{ command: string, title: string, category?: string }>
26
- configuration: { title: string, properties: Record<string, unknown> }
27
- typescriptServerPlugins: Array<Record<string, unknown> & { name: string, languages?: string[] }>
28
- }
29
-
30
- const STX_DIR = './dist/stx/'
31
-
32
- function fromStx(path: string): string {
33
- return STX_DIR + path.replace(/^\.\//, '')
34
- }
35
-
36
- /** The namespace of the extension's own commands; every other one is stx's. */
37
- export const OWN_COMMAND_PREFIX = 'stacks.'
38
-
39
- /**
40
- * `manifest` with its stx contributions replaced by the library's.
41
- *
42
- * Commands are told apart by namespace, not by the library's current list: a
43
- * command stx renamed or dropped is no longer in that list, and filtering by
44
- * it kept the old entry as if it were this extension's own (`css.reload`
45
- * outlived its rename to `stx.reloadUtilityClasses` that way).
46
- */
47
- export function withStxContributes(manifest: any, stx: StxContributes): any {
48
- const contributes = manifest.contributes
49
- const sections: Array<{ title: string }> = Array.isArray(contributes.configuration) ? contributes.configuration : [contributes.configuration]
50
-
51
- return {
52
- ...manifest,
53
- contributes: {
54
- ...contributes,
55
- languages: stx.languages.map(language => ({ ...language, configuration: fromStx(language.configuration) })),
56
- grammars: stx.grammars.map(grammar => ({ ...grammar, path: fromStx(grammar.path) })),
57
- commands: [...contributes.commands.filter((command: { command: string }) => command.command.startsWith(OWN_COMMAND_PREFIX)), ...stx.commands],
58
- configuration: [...sections.filter(section => section.title !== stx.configuration.title), stx.configuration],
59
- typescriptServerPlugins: stx.typescriptServerPlugins,
60
- },
61
- }
62
- }
63
-
64
- export function libraryContributes(root: string): StxContributes {
65
- return JSON.parse(readFileSync(Bun.resolveSync('@stacksjs/stx-vscode/contributes.json', root), 'utf8'))
66
- }
67
-
68
- if (import.meta.main) {
69
- const root = join(import.meta.dir, '..')
70
- const path = join(root, 'package.json')
71
- const manifest = JSON.parse(readFileSync(path, 'utf8'))
72
- const next = `${JSON.stringify(withStxContributes(manifest, libraryContributes(root)), null, 2)}\n`
73
-
74
- if (process.argv.includes('--write'))
75
- writeFileSync(path, next)
76
- else
77
- process.stdout.write(next)
78
- }
@@ -1,72 +0,0 @@
1
- /**
2
- * The stx TypeScript server plugin, as this extension ships it.
3
- *
4
- * It is what type-checks the TypeScript in `.stx` files (script blocks and
5
- * template expressions) in the editor. `@stacksjs/stx-vscode` publishes it as a
6
- * complete package, `dist/typescript-plugin/`, and its `contributes.json`
7
- * carries the `typescriptServerPlugins` entry that `bun run sync:stx` copies
8
- * into package.json.
9
- *
10
- * tsserver loads a plugin only by package name, resolved from
11
- * `<extension>/node_modules`, the probe location VS Code passes for every
12
- * extension that contributes one. This extension is packaged with
13
- * `vsce --no-dependencies`, which leaves node_modules out of the VSIX, so:
14
- *
15
- * dist/stx-typescript-plugin/ the package itself, copied by build.ts
16
- * (shipped through `files`, like the rest of dist/)
17
- * node_modules/<name>/{package.json,index.js}
18
- * a forwarder to it, written by build.ts for
19
- * a development host and added to the VSIX
20
- * by scripts/package.ts after vsce writes it
21
- *
22
- * The stx extension (Stacks.vscode-stx) contributes the same plugin under the
23
- * same name. With both installed tsserver loads it twice, and the plugin
24
- * decorates each project only the first time (stacksjs/stx#2028).
25
- */
26
- import { cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
27
- import { dirname, join } from 'node:path'
28
- import { libraryContributes } from './stx-contributes'
29
-
30
- /** Where build.ts puts the plugin package, relative to the extension root. */
31
- export const TS_PLUGIN_DIST = 'dist/stx-typescript-plugin'
32
-
33
- /** The name the plugin is contributed and configured under. */
34
- export function tsPluginName(root: string): string {
35
- const [plugin] = libraryContributes(root).typescriptServerPlugins
36
- if (!plugin)
37
- throw new Error('@stacksjs/stx-vscode contributes no TypeScript server plugin')
38
- return plugin.name
39
- }
40
-
41
- /** Where tsserver resolves the plugin from, relative to the extension root. */
42
- export function tsPluginModuleDir(name: string): string {
43
- return `node_modules/${name}`
44
- }
45
-
46
- /** The forwarder's files, relative to its directory under node_modules. */
47
- export function tsPluginForwarder(name: string, version: string): Record<string, string> {
48
- const up = '../'.repeat(1 + name.split('/').length)
49
- return {
50
- 'package.json': `${JSON.stringify({ name, version, private: true, main: 'index.js' }, null, 2)}\n`,
51
- // tsserver calls `module.exports` as the factory, unwrapped.
52
- 'index.js': `module.exports = require('${up}${TS_PLUGIN_DIST}/index.js')\n`,
53
- }
54
- }
55
-
56
- /** Write the forwarder under `<root>/node_modules`, returning its directory. */
57
- export function writeTsPluginForwarder(root: string, name: string, version: string): string {
58
- const dir = join(root, tsPluginModuleDir(name))
59
- mkdirSync(dir, { recursive: true })
60
- for (const [file, content] of Object.entries(tsPluginForwarder(name, version)))
61
- writeFileSync(join(dir, file), content)
62
- return dir
63
- }
64
-
65
- /** Copy the plugin package from `@stacksjs/stx-vscode` into `<root>/dist`. */
66
- export function copyTsPlugin(root: string): string {
67
- const source = dirname(Bun.resolveSync('@stacksjs/stx-vscode/typescript-plugin/package.json', root))
68
- const target = join(root, TS_PLUGIN_DIST)
69
- rmSync(target, { recursive: true, force: true })
70
- cpSync(source, target, { recursive: true })
71
- return target
72
- }
@@ -1,103 +0,0 @@
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
- }
@@ -1,241 +0,0 @@
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
- }