@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.
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/ide/vscode/build.ts +0 -52
- package/ide/vscode/scripts/package.ts +0 -71
- package/ide/vscode/scripts/stx-contributes.ts +0 -78
- package/ide/vscode/scripts/ts-plugin.ts +0 -72
- package/ide/vscode/src/env-file.ts +0 -103
- package/ide/vscode/src/env.ts +0 -241
- package/ide/vscode/src/extension.ts +0 -163
- package/ide/vscode/src/pickier-protocol.ts +0 -51
- package/ide/vscode/src/pickier-worker.ts +0 -92
- package/ide/vscode/src/pickier.ts +0 -433
- package/ide/vscode/src/project.ts +0 -450
- package/ide/vscode/src/stacks.ts +0 -482
- package/ide/vscode/tests/build.test.ts +0 -82
- package/ide/vscode/tests/env.test.ts +0 -166
- package/ide/vscode/tests/extension.test.ts +0 -428
- package/ide/vscode/tests/fake-vscode.ts +0 -228
- package/ide/vscode/tests/manifest.test.ts +0 -141
- package/ide/vscode/tests/package.test.ts +0 -47
- package/ide/vscode/tests/pickier.test.ts +0 -202
- package/ide/vscode/tests/project.test.ts +0 -268
- package/ide/vscode/tests/ts-plugin-harness.ts +0 -39
- package/ide/vscode/tsconfig.json +0 -19
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
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.
|
|
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.
|
|
58
|
+
"@stacksjs/mobile": "^0.75.63",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.348",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|
package/ide/vscode/build.ts
DELETED
|
@@ -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
|
-
}
|
package/ide/vscode/src/env.ts
DELETED
|
@@ -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
|
-
}
|