Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 74 additions & 4 deletions src/runConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ export interface Parameter {
}

const PARAMS_KEY = 'fallout.parameters';
const SECRET_NAMES_KEY = 'fallout.secretNames';
const SECRET_PREFIX = 'fallout.secret.';

/**
* Persists the local run configuration. Parameters live in workspace state — they are
* per-workspace by nature (a configuration for *this* build), and plain enough to sit
* in plugin storage. They are rendered as CLI args (`--name value`) for a local run.
* Persists the local run configuration: parameters live in workspace state (plain,
* per-workspace); secret *values* live in VS Code's SecretStorage (OS keychain-backed)
* and are never rendered into the webview — only their names are. Parameters become
* CLI args (`--name value`); secrets become environment variables so they don't leak
* into shell history or the process list.
*/
export class RunConfigStore {
private readonly onDidChangeEmitter = new vscode.EventEmitter<void>();
Expand All @@ -36,19 +40,49 @@ export class RunConfigStore {
this.onDidChangeEmitter.fire();
}

getSecretNames(): string[] {
return this.context.workspaceState.get<string[]>(SECRET_NAMES_KEY, []);
}

async setSecret(name: string, value: string): Promise<void> {
await this.context.secrets.store(SECRET_PREFIX + name, value);
if (!this.getSecretNames().includes(name)) {
await this.context.workspaceState.update(SECRET_NAMES_KEY, [...this.getSecretNames(), name].sort());
}
this.onDidChangeEmitter.fire();
}

async removeSecret(name: string): Promise<void> {
await this.context.secrets.delete(SECRET_PREFIX + name);
await this.context.workspaceState.update(SECRET_NAMES_KEY, this.getSecretNames().filter(n => n !== name));
this.onDidChangeEmitter.fire();
}

/** Parameters rendered as a CLI argument string, e.g. `--configuration Release`. */
buildArgs(): string {
return this.getParameters()
.map(p => `--${p.name} ${quoteArg(p.value)}`)
.join(' ');
}

/** Resolves all stored secrets into an environment map for a local run. */
async buildEnv(): Promise<Record<string, string>> {
const env: Record<string, string> = {};
for (const name of this.getSecretNames()) {
const value = await this.context.secrets.get(SECRET_PREFIX + name);
if (value !== undefined) {
env[name] = value;
}
}
return env;
}
}

function quoteArg(value: string): string {
return /\s/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
}

/** The "Run Configuration" webview view — a form for build parameters. */
/** The "Run Configuration" webview view — a form for parameters and secret names. */
export class RunConfigViewProvider implements vscode.WebviewViewProvider {
private view: vscode.WebviewView | undefined;

Expand All @@ -75,6 +109,12 @@ export class RunConfigViewProvider implements vscode.WebviewViewProvider {
case 'removeParameter':
await this.store.removeParameter(String(message.name));
break;
case 'setSecret':
if (message.name && message.value) { await this.store.setSecret(String(message.name), String(message.value)); }
break;
case 'removeSecret':
await this.store.removeSecret(String(message.name));
break;
}
});
}
Expand All @@ -83,6 +123,7 @@ export class RunConfigViewProvider implements vscode.WebviewViewProvider {
void this.view?.webview.postMessage({
type: 'state',
parameters: this.store.getParameters(),
secretNames: this.store.getSecretNames(),
});
}

Expand All @@ -106,6 +147,7 @@ export class RunConfigViewProvider implements vscode.WebviewViewProvider {
button.icon { background: transparent; color: var(--vscode-foreground); padding: 3px 6px; }
.name { flex: 0 0 40%; font-family: var(--vscode-editor-font-family); }
.hint { opacity: 0.65; font-size: 11px; margin: 2px 0 8px; }
.secret-val { font-family: var(--vscode-editor-font-family); opacity: 0.6; }
</style>
</head>
<body>
Expand All @@ -118,6 +160,15 @@ export class RunConfigViewProvider implements vscode.WebviewViewProvider {
<button id="pAdd">Add</button>
</div>

<h4>Secrets</h4>
<div class="hint">Stored in the OS keychain (SecretStorage); passed as environment variables. Values are never shown.</div>
<div id="secrets"></div>
<div class="row">
<input id="sName" class="name" placeholder="name" />
<input id="sValue" type="password" placeholder="value" />
<button id="sAdd">Set</button>
</div>

<script nonce="${nonce}">
const vscodeApi = acquireVsCodeApi();
const el = id => document.getElementById(id);
Expand All @@ -136,6 +187,18 @@ export class RunConfigViewProvider implements vscode.WebviewViewProvider {
row.append(name, val, rm);
params.append(row);
}
const secrets = el('secrets');
secrets.innerHTML = '';
for (const name of state.secretNames) {
const row = document.createElement('div');
row.className = 'row';
const n = document.createElement('span'); n.className = 'name'; n.textContent = name;
const masked = document.createElement('span'); masked.className = 'secret-val'; masked.textContent = '••••••••';
const rm = document.createElement('button'); rm.className = 'icon'; rm.textContent = '✕';
rm.addEventListener('click', () => vscodeApi.postMessage({ type: 'removeSecret', name }));
row.append(n, masked, rm);
secrets.append(row);
}
}

el('pAdd').addEventListener('click', () => {
Expand All @@ -144,6 +207,13 @@ export class RunConfigViewProvider implements vscode.WebviewViewProvider {
vscodeApi.postMessage({ type: 'setParameter', name, value: el('pValue').value });
el('pName').value = ''; el('pValue').value = '';
});
el('sAdd').addEventListener('click', () => {
const name = el('sName').value.trim();
const value = el('sValue').value;
if (!name || !value) { return; }
vscodeApi.postMessage({ type: 'setSecret', name, value });
el('sName').value = ''; el('sValue').value = '';
});

window.addEventListener('message', e => { if (e.data?.type === 'state') { render(e.data); } });
vscodeApi.postMessage({ type: 'ready' });
Expand Down