mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Shares the control-plane call (token, base URL, redirect guard, zod) between the sandbox and secrets clients. Values go renderer -> control plane only; failures cross IPC as codes. Adds the .env paste parser and the client-side mirror of the server's name/value rules.
110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
// Parses pasted .env text into one entry per variable, each validated against the secret rules, so the
|
|
// preview can show what will be saved before anything leaves the desktop.
|
|
import { cloudSecretKind, cloudSecretNameError, cloudSecretValueError } from './cloud-secret-rules'
|
|
import type { CloudSecretKind, CloudSecretsErrorCode } from './cloud-secrets-wire'
|
|
|
|
export type DotenvPasteIssue =
|
|
| CloudSecretsErrorCode
|
|
| 'syntax'
|
|
| 'unterminated_quote'
|
|
| 'overridden'
|
|
|
|
export type DotenvPasteEntry = {
|
|
/** 1-based line where the entry starts. */
|
|
line: number
|
|
/** Empty when the line could not be read as NAME=value. */
|
|
name: string
|
|
value: string
|
|
kind: CloudSecretKind
|
|
issue: DotenvPasteIssue | null
|
|
}
|
|
|
|
const ASSIGNMENT = /^\s*(?:export\s+)?([^=\s]*)\s*=\s?(.*)$/
|
|
|
|
function unescapeDoubleQuoted(body: string): string {
|
|
return body.replace(/\\([nrt"\\$])/g, (_match, char: string) =>
|
|
char === 'n' ? '\n' : char === 'r' ? '\r' : char === 't' ? '\t' : char
|
|
)
|
|
}
|
|
|
|
/** Index of the closing quote in `text` from `start`, skipping backslash escapes for double quotes. */
|
|
function closingQuote(text: string, start: number, quote: string): number {
|
|
for (let index = start; index < text.length; index++) {
|
|
if (quote === '"' && text[index] === '\\') {
|
|
index++
|
|
continue
|
|
}
|
|
if (text[index] === quote) {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
export function parseDotenvPaste(text: string): DotenvPasteEntry[] {
|
|
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
|
const entries: DotenvPasteEntry[] = []
|
|
for (let index = 0; index < lines.length; index++) {
|
|
const raw = lines[index]
|
|
const trimmed = raw.trim()
|
|
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
continue
|
|
}
|
|
const lineNumber = index + 1
|
|
const match = ASSIGNMENT.exec(raw)
|
|
if (!match) {
|
|
entries.push({ line: lineNumber, name: '', value: '', kind: 'env', issue: 'syntax' })
|
|
continue
|
|
}
|
|
const name = match[1]
|
|
let rest = match[2].trimStart()
|
|
let value: string
|
|
let issue: DotenvPasteIssue | null = null
|
|
const quote = rest[0] === '"' || rest[0] === "'" ? rest[0] : null
|
|
if (quote) {
|
|
// Quoted values may run over several lines until the matching quote.
|
|
let close = closingQuote(rest, 1, quote)
|
|
while (close === -1 && index + 1 < lines.length) {
|
|
index++
|
|
rest = `${rest}\n${lines[index]}`
|
|
close = closingQuote(rest, 1, quote)
|
|
}
|
|
if (close === -1) {
|
|
issue = 'unterminated_quote'
|
|
value = ''
|
|
} else {
|
|
const body = rest.slice(1, close)
|
|
value = quote === '"' ? unescapeDoubleQuoted(body) : body
|
|
}
|
|
} else {
|
|
// Unquoted: an inline comment starts at whitespace followed by #.
|
|
value = rest.replace(/\s+#.*$/, '').trim()
|
|
}
|
|
issue ??= cloudSecretNameError(name) ?? cloudSecretValueError(name, value)
|
|
entries.push({ line: lineNumber, name, value, kind: cloudSecretKind(name), issue })
|
|
}
|
|
// Like a shell sourcing the file, the last assignment of a name wins.
|
|
const lastIndexByName = new Map<string, number>()
|
|
entries.forEach((entry, position) => {
|
|
if (entry.name) {
|
|
lastIndexByName.set(entry.name, position)
|
|
}
|
|
})
|
|
return entries.map((entry, position) =>
|
|
entry.name && lastIndexByName.get(entry.name) !== position && entry.issue === null
|
|
? { ...entry, issue: 'overridden' }
|
|
: entry
|
|
)
|
|
}
|
|
|
|
/** The NAME -> value map a batch save sends: only clean, final entries. */
|
|
export function dotenvPasteToBatch(entries: readonly DotenvPasteEntry[]): Record<string, string> {
|
|
const set: Record<string, string> = {}
|
|
for (const entry of entries) {
|
|
if (entry.issue === null) {
|
|
set[entry.name] = entry.value
|
|
}
|
|
}
|
|
return set
|
|
}
|