Add way for infisical to switch accounts when signed into wrong account
Build and Push Next App / quality (push) Successful in 1m34s
Build and Push Next App / build-next (push) Successful in 4m11s

This commit is contained in:
Gabriel Brown
2026-06-22 13:14:25 -04:00
parent 42f95530de
commit 7e7bec56d5
6 changed files with 440 additions and 1 deletions
+3
View File
@@ -33,6 +33,9 @@
- Local `dev` and `staging` come only from Infisical via
`scripts/with-env`; it never falls back to `.env*`.
- Run `infisical login` and `infisical init` before local development.
- `scripts/export-env` enforces `.local/infisical.env` when multiple local
Infisical accounts are logged in. Put `INFISICAL_EMAIL=you@example.com` there
for this project and do not commit it.
- Machine-generated values belong in `.local/<env>.generated.env`; never put
the generated Convex admin key in shared Infisical.
- `scripts/sync-convex-env <dev|staging>` copies Authentik, GitHub App,
+29
View File
@@ -287,6 +287,35 @@ native simulator.
This project is currently private, so this section is a reference for what the
application expects rather than public setup documentation.
<details>
<summary><strong>Local Infisical account selection</strong></summary>
Local `dev` and `staging` commands export secrets through Infisical. Spoon runs
`scripts/infisical-account ensure` from `scripts/export-env` before exporting so
machines logged into multiple Infisical accounts do not accidentally use the
wrong organization.
If your machine has only one local Infisical account, no extra setup is needed.
If it has multiple accounts, create this ignored local file:
```sh
mkdir -p .local
printf "INFISICAL_EMAIL=me@gbrown.org\n" > .local/infisical.env
```
Log into each needed account once with `infisical login`. You can inspect local
profiles without printing tokens:
```sh
jq '.loggedInUsers[] | {email, domain}' ~/.infisical/infisical-config.json
```
`.local/infisical.env` supports only `INFISICAL_EMAIL=...` and must not be
committed. CI is unchanged; it uses injected environment files/secrets and must
not call Infisical.
</details>
<details open>
<summary><strong>Public Next variables</strong></summary>
@@ -0,0 +1,229 @@
import { spawn, spawnSync } from 'node:child_process';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
type TestWorkspace = {
binDir: string;
homeDir: string;
localFile: string;
projectDir: string;
};
const scriptPath = fileURLToPath(
new URL('../../../../scripts/infisical-account', import.meta.url),
);
let workspaces: TestWorkspace[] = [];
const createWorkspace = async (): Promise<TestWorkspace> => {
const root = await realpathTemp();
const homeDir = path.join(root, 'home');
const projectDir = path.join(root, 'project');
const binDir = path.join(root, 'bin');
const localFile = path.join(projectDir, '.local', 'infisical.env');
await mkdir(path.join(homeDir, '.infisical'), { recursive: true });
await mkdir(path.dirname(localFile), { recursive: true });
await mkdir(binDir, { recursive: true });
const fakeInfisical = path.join(binDir, 'infisical');
await writeFile(fakeInfisical, '#!/usr/bin/env sh\nexit 0\n');
await chmod(fakeInfisical, 0o755);
const workspace = { binDir, homeDir, localFile, projectDir };
workspaces.push(workspace);
return workspace;
};
const realpathTemp = async (): Promise<string> => {
const base = path.join(tmpdir(), 'spoon-infisical-account-');
const { mkdtemp } = await import('node:fs/promises');
return mkdtemp(base);
};
const configPath = (workspace: TestWorkspace) =>
path.join(workspace.homeDir, '.infisical', 'infisical-config.json');
const writeConfig = async (
workspace: TestWorkspace,
config: Record<string, unknown> | string,
) => {
const content =
typeof config === 'string' ? config : `${JSON.stringify(config, null, 2)}\n`;
await writeFile(configPath(workspace), content);
};
const readConfig = async (
workspace: TestWorkspace,
): Promise<Record<string, unknown>> =>
JSON.parse(await readFile(configPath(workspace), 'utf8')) as Record<
string,
unknown
>;
const envFor = (workspace: TestWorkspace): NodeJS.ProcessEnv => ({
...process.env,
HOME: workspace.homeDir,
PATH: `${workspace.binDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
SPOON_INFISICAL_LOCAL_FILE: workspace.localFile,
});
const runEnsure = (workspace: TestWorkspace) =>
spawnSync(scriptPath, ['ensure'], {
encoding: 'utf8',
env: envFor(workspace),
});
const writeLocalEmail = async (workspace: TestWorkspace, emailLine: string) => {
await mkdir(path.dirname(workspace.localFile), { recursive: true });
await writeFile(workspace.localFile, `${emailLine}\n`);
};
const twoAccountConfig = {
loggedInUsers: [
{ email: 'work@example.com', domain: 'https://app.infisical.com' },
{ email: 'home@example.com', domain: 'https://infisical.gbrown.org' },
],
loggedInUserEmail: 'work@example.com',
LoggedInUserDomain: 'https://app.infisical.com',
};
beforeEach(() => {
workspaces = [];
});
afterEach(async () => {
await Promise.all(
workspaces.map((workspace) =>
rm(path.dirname(workspace.homeDir), { force: true, recursive: true }),
),
);
});
describe('infisical-account', () => {
test('single account no-ops without local file', async () => {
const workspace = await createWorkspace();
await writeConfig(workspace, {
loggedInUsers: [
{ email: 'work@example.com', domain: 'https://app.infisical.com' },
],
loggedInUserEmail: 'work@example.com',
LoggedInUserDomain: 'https://app.infisical.com',
});
const result = runEnsure(workspace);
const config = await readConfig(workspace);
expect(result.status).toBe(0);
expect(config.loggedInUserEmail).toBe('work@example.com');
expect(config.LoggedInUserDomain).toBe('https://app.infisical.com');
});
test('multiple accounts require local project config', async () => {
const workspace = await createWorkspace();
await writeConfig(workspace, twoAccountConfig);
const result = runEnsure(workspace);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('.local/infisical.env');
expect(result.stderr).toContain('work@example.com');
expect(result.stderr).toContain('home@example.com');
});
test('multiple accounts switch to configured email', async () => {
const workspace = await createWorkspace();
await writeConfig(workspace, twoAccountConfig);
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=home@example.com');
const result = runEnsure(workspace);
const config = await readConfig(workspace);
expect(result.status).toBe(0);
expect(config.loggedInUserEmail).toBe('home@example.com');
expect(config.LoggedInUserDomain).toBe('https://infisical.gbrown.org');
});
test('configured email missing from local accounts fails clearly', async () => {
const workspace = await createWorkspace();
await writeConfig(workspace, twoAccountConfig);
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=missing@example.com');
const result = runEnsure(workspace);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
'not logged in locally: missing@example.com',
);
});
test.each([
'INFISICAL_EMAIL="home@example.com"',
"INFISICAL_EMAIL='home@example.com'",
])('quoted email parses correctly: %s', async (line) => {
const workspace = await createWorkspace();
await writeConfig(workspace, twoAccountConfig);
await writeLocalEmail(workspace, line);
const result = runEnsure(workspace);
const config = await readConfig(workspace);
expect(result.status).toBe(0);
expect(config.loggedInUserEmail).toBe('home@example.com');
});
test('empty email fails clearly', async () => {
const workspace = await createWorkspace();
await writeConfig(workspace, twoAccountConfig);
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=');
const result = runEnsure(workspace);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
'.local/infisical.env must contain INFISICAL_EMAIL',
);
});
test('corrupt config fails clearly', async () => {
const workspace = await createWorkspace();
await writeConfig(workspace, '{not-json');
const result = runEnsure(workspace);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
'Infisical config is invalid or missing loggedInUsers',
);
});
test('concurrent ensure calls do not corrupt config', async () => {
const workspace = await createWorkspace();
await writeConfig(workspace, twoAccountConfig);
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=home@example.com');
const run = () =>
new Promise<{ status: number | null; stderr: string }>((resolve) => {
const child = spawn(scriptPath, ['ensure'], { env: envFor(workspace) });
let stderr = '';
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('close', (status) => {
resolve({ status, stderr });
});
});
const [first, second] = await Promise.all([run(), run()]);
const config = await readConfig(workspace);
expect(first).toEqual({ status: 0, stderr: '' });
expect(second).toEqual({ status: 0, stderr: '' });
expect(config.loggedInUserEmail).toBe('home@example.com');
expect(config.LoggedInUserDomain).toBe('https://infisical.gbrown.org');
});
});
+1 -1
View File
@@ -4,6 +4,6 @@
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src", "eslint.config.ts", "vitest.config.ts"],
"include": ["src", "tests", "eslint.config.ts", "vitest.config.ts"],
"exclude": ["node_modules"]
}
+1
View File
@@ -22,6 +22,7 @@ fi
[ -f "$ROOT_DIR/.infisical.json" ] || { echo "export-env: run 'infisical init' in this repository." >&2; exit 1; }
command -v infisical >/dev/null 2>&1 || { echo "export-env: Infisical CLI is required." >&2; exit 1; }
"$ROOT_DIR/scripts/infisical-account" ensure
(cd "$ROOT_DIR" && infisical export --env="$INFISICAL_ENV" --format=dotenv --silent) || {
echo "export-env: failed to export '$INFISICAL_ENV'; check login and project access." >&2
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_FILE="${SPOON_INFISICAL_LOCAL_FILE:-$ROOT_DIR/.local/infisical.env}"
INFISICAL_DIR="${HOME}/.infisical"
CONFIG_FILE="$INFISICAL_DIR/infisical-config.json"
LOCK_FILE="$INFISICAL_DIR/.switch.lock"
usage() {
printf 'usage: infisical-account <ensure|status>\n' >&2
exit 2
}
require_command() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
printf 'infisical-account: required command not found: %s\n' "$command_name" >&2
exit 1
fi
}
require_dependencies() {
require_command infisical
require_command jq
require_command flock
}
require_config() {
if [[ ! -f "$CONFIG_FILE" ]]; then
printf 'infisical-account: Infisical config not found at ~/.infisical/infisical-config.json.\n' >&2
printf 'Run: infisical login\n' >&2
exit 1
fi
}
logged_in_user_count() {
jq -er '
if (.loggedInUsers | type) == "array" then
.loggedInUsers | length
else
error("loggedInUsers missing")
end
' "$CONFIG_FILE" 2>/dev/null || {
printf 'infisical-account: Infisical config is invalid or missing loggedInUsers.\n' >&2
printf 'Run: infisical login\n' >&2
exit 1
}
}
print_logged_in_accounts() {
jq -r '(.loggedInUsers // [])[] | " - \(.email)"' "$CONFIG_FILE"
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
strip_matching_quotes() {
local value="$1"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
\'*\') value="${value#\'}"; value="${value%\'}" ;;
esac
printf '%s' "$value"
}
read_project_email() {
if [[ ! -f "$LOCAL_FILE" ]]; then
printf 'infisical-account: multiple Infisical users are logged in, but .local/infisical.env is missing.\n\n' >&2
printf 'Create it with:\n' >&2
printf ' mkdir -p .local\n' >&2
printf ' printf "INFISICAL_EMAIL=you@example.com\\n" > .local/infisical.env\n\n' >&2
printf 'Logged-in accounts:\n' >&2
print_logged_in_accounts >&2
exit 1
fi
local email=""
local line key value
while IFS= read -r line || [[ -n "$line" ]]; do
line="$(trim "$line")"
case "$line" in
''|'#'*) continue ;;
esac
key="$(trim "${line%%=*}")"
value="${line#*=}"
if [[ "$key" == "$line" ]]; then
continue
fi
value="$(strip_matching_quotes "$(trim "$value")")"
if [[ "$key" == "INFISICAL_EMAIL" ]]; then
email="$value"
break
fi
done < "$LOCAL_FILE"
if [[ -z "$email" ]]; then
printf 'infisical-account: .local/infisical.env must contain INFISICAL_EMAIL=you@example.com.\n' >&2
exit 1
fi
printf '%s' "$email"
}
domain_for_email() {
local email="$1"
jq -er --arg email "$email" '
first((.loggedInUsers // [])[] | select(.email == $email) | .domain)
' "$CONFIG_FILE"
}
switch_to_email() {
local email="$1"
local domain
domain="$(domain_for_email "$email")" || {
printf 'infisical-account: configured Infisical user is not logged in locally: %s\n' "$email" >&2
printf 'Run: infisical login\n' >&2
exit 1
}
local tmp
tmp="$(mktemp "$INFISICAL_DIR/infisical-config.XXXXXX")"
jq --arg email "$email" --arg domain "$domain" '
.loggedInUserEmail = $email
| .LoggedInUserDomain = $domain
' "$CONFIG_FILE" > "$tmp"
chmod 600 "$tmp"
mv "$tmp" "$CONFIG_FILE"
}
ensure_account() {
require_dependencies
require_config
(
flock 9
local count
count="$(logged_in_user_count)"
if [[ "$count" -eq 0 ]]; then
printf 'infisical-account: no local Infisical users are logged in.\n' >&2
printf 'Run: infisical login\n' >&2
exit 1
fi
if [[ "$count" -eq 1 ]]; then
exit 0
fi
local email
email="$(read_project_email)"
switch_to_email "$email"
) 9>"$LOCK_FILE"
}
status() {
ensure_account
local count configured active
count="$(logged_in_user_count)"
configured="not required"
if [[ -f "$LOCAL_FILE" ]]; then
configured="$(read_project_email 2>/dev/null || printf 'invalid')"
fi
active="$(jq -r '.loggedInUserEmail // "unknown"' "$CONFIG_FILE")"
printf 'Infisical accounts logged in: %s\n' "$count"
printf 'Configured project email: %s\n' "$configured"
printf 'Active account after ensure: %s\n' "$active"
}
case "${1:-}" in
ensure) ensure_account ;;
status) status ;;
*) usage ;;
esac