-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.ts
More file actions
240 lines (204 loc) · 7.39 KB
/
Copy pathinstall.ts
File metadata and controls
240 lines (204 loc) · 7.39 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { basename, dirname, join, relative } from 'node:path'
const ROOT = import.meta.dir
const CLACK_PATH = `${ROOT}/node_modules/@clack/prompts/dist/index.mjs`
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.nuxt', '__pycache__', '.venv', 'venv'])
const ONENV_VAULT = 'onenv'
const ONENV_CATEGORY = 'API Credential'
async function run(cmd: string[], cwd: string): Promise<void> {
const proc = Bun.spawn(cmd, { cwd, stdout: 'ignore', stderr: 'pipe' })
const code = await proc.exited
if (code !== 0) {
const stderr = await new Response(proc.stderr).text()
throw new Error(stderr.trim() || `${cmd.join(' ')} exited with ${code}`)
}
}
async function bootstrap(): Promise<void> {
if (await Bun.file(CLACK_PATH).exists()) return
console.log('Installing dependencies (needed for installer UI)...')
await run(['bun', 'install'], ROOT)
console.log('Done.\n')
}
await bootstrap()
const p: typeof import('@clack/prompts') = await import(CLACK_PATH)
function guard<T>(value: T | symbol): T {
if (p.isCancel(value)) {
p.cancel('Cancelled')
process.exit(0)
}
return value
}
async function spin(message: string, fn: () => Promise<void>): Promise<void> {
const s = p.spinner()
s.start(message)
try {
await fn()
s.stop(`${message} — done`)
} catch (e) {
s.stop(`${message} — failed`)
throw e
}
}
// --- setup helpers ---
async function installDeps(name: string, cwd: string): Promise<void> {
if (existsSync(`${cwd}/node_modules`)) {
const reinstall = guard(await p.confirm({ message: `${name}: node_modules exists — reinstall?`, initialValue: false }))
if (!reinstall) return
}
await spin(`Installing ${name} dependencies`, () => run(['bun', 'install'], cwd))
}
async function build(name: string, cwd: string): Promise<void> {
if (existsSync(`${cwd}/dist`)) {
const rebuild = guard(await p.confirm({ message: `${name}: dist/ exists — rebuild?`, initialValue: false }))
if (!rebuild) return
}
await spin(`Building ${name}`, () => run(['bun', 'run', 'build'], cwd))
}
async function ensureVault(): Promise<void> {
const check = Bun.spawn([OP_BIN, 'vault', 'get', ONENV_VAULT], { stdout: 'ignore', stderr: 'ignore' })
if ((await check.exited) === 0) {
p.log.success(`Vault "${ONENV_VAULT}" exists`)
return
}
const create = guard(await p.confirm({
message: `Create 1Password vault "${ONENV_VAULT}"?`,
initialValue: true,
}))
if (!create) {
p.log.warn('Skipped vault creation — you must create it manually before use')
return
}
await spin(`Creating vault "${ONENV_VAULT}"`, async () => {
await run([OP_BIN, 'vault', 'create', ONENV_VAULT], ROOT)
})
}
// --- env scanning & migration ---
function findEnvFiles(root: string, maxDepth = 4): string[] {
const results: string[] = []
function walk(dir: string, depth: number): void {
if (depth > maxDepth) return
let entries: string[]
try { entries = readdirSync(dir) } catch { return }
for (const name of entries) {
if (SKIP_DIRS.has(name)) continue
const full = join(dir, name)
try {
const stat = statSync(full)
if (stat.isDirectory()) walk(full, depth + 1)
else if (name === '.env' && stat.isFile()) results.push(full)
} catch { /* skip unreadable */ }
}
}
walk(root, 0)
return results.sort()
}
interface EnvEntry { key: string; value: string }
function parseEnvFile(path: string): EnvEntry[] {
const content = readFileSync(path, 'utf-8')
const entries: EnvEntry[] = []
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq <= 0) continue
entries.push({ key: trimmed.slice(0, eq), value: trimmed.slice(eq + 1) })
}
return entries
}
async function onenvSet(namespace: string, key: string, value: string): Promise<void> {
const title = `${namespace}/${key}`
const template = JSON.stringify({
title,
category: 'API_CREDENTIAL',
vault: { name: ONENV_VAULT },
tags: [namespace],
fields: [{ id: 'credential', type: 'CONCEALED', value, label: 'credential' }],
})
const proc = Bun.spawn(
[OP_BIN, 'item', 'create', '-', '--vault', ONENV_VAULT, '--category', ONENV_CATEGORY],
{ stdin: 'pipe', stdout: 'ignore', stderr: 'pipe' },
)
proc.stdin.write(template)
await proc.stdin.end()
const code = await proc.exited
if (code !== 0) {
const stderr = await new Response(proc.stderr).text()
throw new Error(`op item create ${title} failed: ${stderr.trim()}`)
}
}
async function migrateEnvFiles(): Promise<void> {
p.log.step('Scan projects for .env files')
const scanDir = guard(await p.text({
message: 'Directory to scan for .env files',
placeholder: dirname(ROOT),
defaultValue: dirname(ROOT),
})) as string
const s = p.spinner()
s.start('Scanning...')
const files = findEnvFiles(scanDir)
s.stop(`Found ${files.length} .env file${files.length === 1 ? '' : 's'}`)
if (files.length === 0) return
const selected = guard(await p.multiselect({
message: 'Which .env files should be imported into 1Password?',
options: files.map((f) => ({
label: relative(scanDir, f),
value: f,
hint: `${parseEnvFile(f).length} vars`,
})),
required: false,
})) as string[]
if (selected.length === 0) return
for (const file of selected) {
const entries = parseEnvFile(file)
const projectName = basename(dirname(file))
p.log.step(`${relative(scanDir, file)} (${entries.length} vars)`)
const namespace = guard(await p.text({
message: '1Password namespace (tag)',
defaultValue: projectName,
placeholder: projectName,
})) as string
const keys = guard(await p.multiselect({
message: `Select variables to import into "${namespace}"`,
options: entries.map((e) => ({
label: e.key,
value: e.key,
hint: e.value.length > 20 ? `${e.value.slice(0, 20)}…` : e.value,
})),
required: false,
})) as string[]
if (keys.length === 0) continue
const entryMap = new Map(entries.map((e) => [e.key, e.value]))
await spin(`Importing ${keys.length} vars into ${namespace}`, async () => {
for (const key of keys) {
await onenvSet(namespace, key, entryMap.get(key)!)
}
})
p.log.success(`Imported ${keys.length} vars into "${namespace}"`)
}
}
// --- main ---
p.intro('onenv installer')
const OP_BIN = Bun.which('op')
if (!OP_BIN) {
p.log.error('op CLI not found — install 1Password CLI first: brew install 1password-cli')
process.exit(1)
}
p.log.success('op CLI found')
if (!Bun.which('just')) p.log.info('just not found — optional but recommended: brew install just')
await ensureVault()
await installDeps('onenv', ROOT)
await build('onenv', ROOT)
await spin('Linking onenv CLI', () => run(['bun', 'link'], ROOT))
try {
const proc = Bun.spawn(['onenv', 'list'], { stdout: 'ignore', stderr: 'ignore' })
if ((await proc.exited) === 0) p.log.success('onenv CLI works')
else p.log.warn('onenv exited with non-zero — check your setup')
} catch {
p.log.warn('Could not run onenv — you may need to restart your shell')
}
const wantScan = guard(await p.confirm({
message: 'Scan your projects for .env files and import them into 1Password?',
initialValue: true,
}))
if (wantScan) await migrateEnvFiles()
p.outro('Installation complete')