feat: improve auth setup flows
This commit is contained in:
@@ -55,6 +55,8 @@
|
||||
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
|
||||
- Daemon runtime: remove Bun from selection options.
|
||||
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
|
||||
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
|
||||
- Configure/Onboarding: show Control UI docs with gateway reachability status and only offer to open when a gateway is detected; default model prompt now prefers Opus 4.5 for Anthropic auth.
|
||||
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
|
||||
- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
|
||||
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
|
||||
|
||||
@@ -169,8 +169,9 @@ Options:
|
||||
- `--workspace <dir>`
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--auth-choice <oauth|claude-cli|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip>`
|
||||
- `--auth-choice <oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip>`
|
||||
- `--anthropic-api-key <key>`
|
||||
- `--openai-api-key <key>`
|
||||
- `--gemini-api-key <key>`
|
||||
- `--gateway-port <port>`
|
||||
- `--gateway-bind <loopback|lan|tailnet|auto>`
|
||||
|
||||
@@ -71,11 +71,12 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
|
||||
2) **Model/Auth**
|
||||
- **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
||||
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
|
||||
- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
|
||||
- Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
|
||||
- **API key**: stores the key for you.
|
||||
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
|
||||
- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
|
||||
- Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
|
||||
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it.
|
||||
- **API key**: stores the key for you.
|
||||
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
|
||||
- **Skip**: no auth configured yet.
|
||||
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||
|
||||
@@ -239,9 +239,10 @@ export function buildProgram() {
|
||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||
.option(
|
||||
"--auth-choice <choice>",
|
||||
"Auth: oauth|claude-cli|token|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
|
||||
"Auth: oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
|
||||
)
|
||||
.option("--anthropic-api-key <key>", "Anthropic API key")
|
||||
.option("--openai-api-key <key>", "OpenAI API key")
|
||||
.option("--gemini-api-key <key>", "Gemini API key")
|
||||
.option("--gateway-port <port>", "Gateway port")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
|
||||
@@ -270,6 +271,7 @@ export function buildProgram() {
|
||||
| "claude-cli"
|
||||
| "token"
|
||||
| "openai-codex"
|
||||
| "openai-api-key"
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
| "gemini-api-key"
|
||||
@@ -278,6 +280,7 @@ export function buildProgram() {
|
||||
| "skip"
|
||||
| undefined,
|
||||
anthropicApiKey: opts.anthropicApiKey as string | undefined,
|
||||
openaiApiKey: opts.openaiApiKey as string | undefined,
|
||||
geminiApiKey: opts.geminiApiKey as string | undefined,
|
||||
gatewayPort:
|
||||
typeof opts.gatewayPort === "string"
|
||||
|
||||
@@ -85,6 +85,7 @@ export function buildAuthChoiceOptions(params: {
|
||||
value: "openai-codex",
|
||||
label: "OpenAI Codex (ChatGPT OAuth)",
|
||||
});
|
||||
options.push({ value: "openai-api-key", label: "OpenAI API key" });
|
||||
options.push({
|
||||
value: "antigravity",
|
||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
} from "../agents/model-auth.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { upsertSharedEnvVar } from "../infra/env-file.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import {
|
||||
@@ -210,38 +210,10 @@ export async function applyAuthChoice(params: {
|
||||
mode: "token",
|
||||
});
|
||||
} else if (params.authChoice === "token" || params.authChoice === "oauth") {
|
||||
const profileNameRaw = await params.prompter.text({
|
||||
message: "Token name (blank = default)",
|
||||
placeholder: "default",
|
||||
});
|
||||
const provider = (await params.prompter.select({
|
||||
message: "Token provider",
|
||||
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
|
||||
})) as "anthropic";
|
||||
const profileId = buildTokenProfileId({
|
||||
provider,
|
||||
name: String(profileNameRaw ?? ""),
|
||||
});
|
||||
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const existing = store.profiles[profileId];
|
||||
if (existing?.type === "token") {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing token "${profileId}"?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId,
|
||||
provider,
|
||||
mode: "token",
|
||||
});
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
}
|
||||
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Run `claude setup-token` in your terminal.",
|
||||
@@ -256,46 +228,67 @@ export async function applyAuthChoice(params: {
|
||||
});
|
||||
const token = String(tokenRaw).trim();
|
||||
|
||||
const wantsExpiry = await params.prompter.confirm({
|
||||
message: "Does this token expire?",
|
||||
initialValue: false,
|
||||
const profileNameRaw = await params.prompter.text({
|
||||
message: "Token name (blank = default)",
|
||||
placeholder: "default",
|
||||
});
|
||||
const namedProfileId = buildTokenProfileId({
|
||||
provider,
|
||||
name: String(profileNameRaw ?? ""),
|
||||
});
|
||||
const expiresInRaw = wantsExpiry
|
||||
? await params.prompter.text({
|
||||
message: "Expires in (duration)",
|
||||
initialValue: "365d",
|
||||
validate: (value) => {
|
||||
try {
|
||||
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Invalid duration (e.g. 365d, 12h, 30m)";
|
||||
}
|
||||
},
|
||||
})
|
||||
: "";
|
||||
|
||||
const expiresIn = String(expiresInRaw).trim();
|
||||
const expires = expiresIn
|
||||
? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
|
||||
: undefined;
|
||||
|
||||
upsertAuthProfile({
|
||||
profileId,
|
||||
profileId: namedProfileId,
|
||||
agentDir: params.agentDir,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider,
|
||||
token,
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId,
|
||||
profileId: namedProfileId,
|
||||
provider,
|
||||
mode: "token",
|
||||
});
|
||||
} else if (params.authChoice === "openai-api-key") {
|
||||
const envKey = resolveEnvApiKey("openai");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
const result = upsertSharedEnvVar({
|
||||
key: "OPENAI_API_KEY",
|
||||
value: envKey.apiKey,
|
||||
});
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
process.env.OPENAI_API_KEY = envKey.apiKey;
|
||||
}
|
||||
await params.prompter.note(
|
||||
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||
"OpenAI API key",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
}
|
||||
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter OpenAI API key",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const trimmed = String(key).trim();
|
||||
const result = upsertSharedEnvVar({
|
||||
key: "OPENAI_API_KEY",
|
||||
value: trimmed,
|
||||
});
|
||||
process.env.OPENAI_API_KEY = trimmed;
|
||||
await params.prompter.note(
|
||||
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||
"OpenAI API key",
|
||||
);
|
||||
} else if (params.authChoice === "openai-codex") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
await params.prompter.note(
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
ensureAuthProfileStore,
|
||||
upsertAuthProfile,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { createCliProgress } from "../cli/progress.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
@@ -65,6 +64,8 @@ import {
|
||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
} from "./google-gemini-model-default.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import { upsertSharedEnvVar } from "../infra/env-file.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxConfig,
|
||||
@@ -351,6 +352,7 @@ async function promptAuthConfig(
|
||||
| "claude-cli"
|
||||
| "token"
|
||||
| "openai-codex"
|
||||
| "openai-api-key"
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
| "gemini-api-key"
|
||||
@@ -398,14 +400,6 @@ async function promptAuthConfig(
|
||||
mode: "token",
|
||||
});
|
||||
} else if (authChoice === "token" || authChoice === "oauth") {
|
||||
const profileNameRaw = guardCancel(
|
||||
await text({
|
||||
message: "Token name (blank = default)",
|
||||
placeholder: "default",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
|
||||
const provider = guardCancel(
|
||||
await select({
|
||||
message: "Token provider",
|
||||
@@ -419,32 +413,6 @@ async function promptAuthConfig(
|
||||
runtime,
|
||||
) as "anthropic";
|
||||
|
||||
const profileId = buildTokenProfileId({
|
||||
provider,
|
||||
name: String(profileNameRaw ?? ""),
|
||||
});
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const existing = store.profiles[profileId];
|
||||
if (existing?.type === "token") {
|
||||
const useExisting = guardCancel(
|
||||
await confirm({
|
||||
message: `Use existing token "${profileId}"?`,
|
||||
initialValue: true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (useExisting) {
|
||||
next = applyAuthProfileConfig(next, {
|
||||
profileId,
|
||||
provider,
|
||||
mode: "token",
|
||||
});
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
note(
|
||||
[
|
||||
"Run `claude setup-token` in your terminal.",
|
||||
@@ -462,34 +430,17 @@ async function promptAuthConfig(
|
||||
);
|
||||
const token = String(tokenRaw).trim();
|
||||
|
||||
const wantsExpiry = guardCancel(
|
||||
await confirm({
|
||||
message: "Does this token expire?",
|
||||
initialValue: false,
|
||||
const profileNameRaw = guardCancel(
|
||||
await text({
|
||||
message: "Token name (blank = default)",
|
||||
placeholder: "default",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
const expiresInRaw = wantsExpiry
|
||||
? guardCancel(
|
||||
await text({
|
||||
message: "Expires in (duration)",
|
||||
initialValue: "365d",
|
||||
validate: (value) => {
|
||||
try {
|
||||
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Invalid duration (e.g. 365d, 12h, 30m)";
|
||||
}
|
||||
},
|
||||
}),
|
||||
runtime,
|
||||
)
|
||||
: "";
|
||||
const expiresIn = String(expiresInRaw).trim();
|
||||
const expires = expiresIn
|
||||
? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
|
||||
: undefined;
|
||||
const profileId = buildTokenProfileId({
|
||||
provider,
|
||||
name: String(profileNameRaw ?? ""),
|
||||
});
|
||||
|
||||
upsertAuthProfile({
|
||||
profileId,
|
||||
@@ -497,11 +448,52 @@ async function promptAuthConfig(
|
||||
type: "token",
|
||||
provider,
|
||||
token,
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
next = applyAuthProfileConfig(next, { profileId, provider, mode: "token" });
|
||||
} else if (authChoice === "openai-api-key") {
|
||||
const envKey = resolveEnvApiKey("openai");
|
||||
if (envKey) {
|
||||
const useExisting = guardCancel(
|
||||
await confirm({
|
||||
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
|
||||
initialValue: true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (useExisting) {
|
||||
const result = upsertSharedEnvVar({
|
||||
key: "OPENAI_API_KEY",
|
||||
value: envKey.apiKey,
|
||||
});
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
process.env.OPENAI_API_KEY = envKey.apiKey;
|
||||
}
|
||||
note(
|
||||
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||
"OpenAI API key",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const key = guardCancel(
|
||||
await text({
|
||||
message: "Enter OpenAI API key",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
const trimmed = String(key).trim();
|
||||
const result = upsertSharedEnvVar({
|
||||
key: "OPENAI_API_KEY",
|
||||
value: trimmed,
|
||||
});
|
||||
process.env.OPENAI_API_KEY = trimmed;
|
||||
note(
|
||||
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||
"OpenAI API key",
|
||||
);
|
||||
} else if (authChoice === "openai-codex") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
note(
|
||||
@@ -703,13 +695,24 @@ async function promptAuthConfig(
|
||||
next = applyMinimaxConfig(next);
|
||||
}
|
||||
|
||||
const currentModel =
|
||||
typeof next.agent?.model === "string"
|
||||
? next.agent?.model
|
||||
: (next.agent?.model?.primary ?? "");
|
||||
const preferAnthropic =
|
||||
authChoice === "claude-cli" ||
|
||||
authChoice === "token" ||
|
||||
authChoice === "oauth" ||
|
||||
authChoice === "apiKey";
|
||||
const modelInitialValue =
|
||||
preferAnthropic && !currentModel.startsWith("anthropic/")
|
||||
? "anthropic/claude-opus-4-5"
|
||||
: currentModel;
|
||||
|
||||
const modelInput = guardCancel(
|
||||
await text({
|
||||
message: "Default model (blank to keep)",
|
||||
initialValue:
|
||||
typeof next.agent?.model === "string"
|
||||
? next.agent?.model
|
||||
: (next.agent?.model?.primary ?? ""),
|
||||
initialValue: modelInitialValue,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
@@ -1078,58 +1081,65 @@ export async function runConfigureWizard(
|
||||
runtime.error(controlUiAssets.message);
|
||||
}
|
||||
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
const gatewayProbe = await probeGatewayReachable({
|
||||
url: links.wsUrl,
|
||||
token:
|
||||
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
password:
|
||||
nextConfig.gateway?.auth?.password ??
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD,
|
||||
});
|
||||
const gatewayStatusLine = gatewayProbe.ok
|
||||
? "Gateway: reachable"
|
||||
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
|
||||
|
||||
note(
|
||||
(() => {
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
return [
|
||||
`Web UI: ${links.httpUrl}`,
|
||||
`Gateway WS: ${links.wsUrl}`,
|
||||
"Docs: https://docs.clawd.bot/web/control-ui",
|
||||
].join("\n");
|
||||
})(),
|
||||
[
|
||||
`Web UI: ${links.httpUrl}`,
|
||||
`Gateway WS: ${links.wsUrl}`,
|
||||
gatewayStatusLine,
|
||||
"Docs: https://docs.clawd.bot/web/control-ui",
|
||||
].join("\n"),
|
||||
"Control UI",
|
||||
);
|
||||
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (!browserSupport.ok) {
|
||||
note(
|
||||
formatControlUiSshHint({
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
token: gatewayToken,
|
||||
}),
|
||||
"Open Control UI",
|
||||
);
|
||||
} else {
|
||||
const wantsOpen = guardCancel(
|
||||
await confirm({
|
||||
message: "Open Control UI now?",
|
||||
initialValue: false,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (wantsOpen) {
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
const opened = await openUrl(links.httpUrl);
|
||||
if (!opened) {
|
||||
note(
|
||||
formatControlUiSshHint({
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
token: gatewayToken,
|
||||
}),
|
||||
"Open Control UI",
|
||||
);
|
||||
if (gatewayProbe.ok) {
|
||||
if (!browserSupport.ok) {
|
||||
note(
|
||||
formatControlUiSshHint({
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
token: gatewayToken,
|
||||
}),
|
||||
"Open Control UI",
|
||||
);
|
||||
} else {
|
||||
const wantsOpen = guardCancel(
|
||||
await confirm({
|
||||
message: "Open Control UI now?",
|
||||
initialValue: false,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (wantsOpen) {
|
||||
const opened = await openUrl(links.httpUrl);
|
||||
if (!opened) {
|
||||
note(
|
||||
formatControlUiSshHint({
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
token: gatewayToken,
|
||||
}),
|
||||
"Open Control UI",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import {
|
||||
type ClawdbotConfig,
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
@@ -16,6 +17,7 @@ import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { upsertSharedEnvVar } from "../infra/env-file.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
@@ -135,6 +137,19 @@ export async function runNonInteractiveOnboarding(
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applyGoogleGeminiModelDefault(nextConfig).next;
|
||||
} else if (authChoice === "openai-api-key") {
|
||||
const key = opts.openaiApiKey?.trim() || resolveEnvApiKey("openai")?.apiKey;
|
||||
if (!key) {
|
||||
runtime.error("Missing --openai-api-key (or OPENAI_API_KEY in env).");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const result = upsertSharedEnvVar({
|
||||
key: "OPENAI_API_KEY",
|
||||
value: key,
|
||||
});
|
||||
process.env.OPENAI_API_KEY = key;
|
||||
runtime.log(`Saved OPENAI_API_KEY to ${result.path}`);
|
||||
} else if (authChoice === "claude-cli") {
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AuthChoice =
|
||||
| "claude-cli"
|
||||
| "token"
|
||||
| "openai-codex"
|
||||
| "openai-api-key"
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
| "apiKey"
|
||||
@@ -26,6 +27,7 @@ export type OnboardOptions = {
|
||||
nonInteractive?: boolean;
|
||||
authChoice?: AuthChoice;
|
||||
anthropicApiKey?: string;
|
||||
openaiApiKey?: string;
|
||||
geminiApiKey?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayBind?: GatewayBind;
|
||||
|
||||
55
src/infra/env-file.ts
Normal file
55
src/infra/env-file.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveConfigDir } from "../utils.js";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function upsertSharedEnvVar(params: {
|
||||
key: string;
|
||||
value: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): { path: string; updated: boolean; created: boolean } {
|
||||
const env = params.env ?? process.env;
|
||||
const dir = resolveConfigDir(env);
|
||||
const filepath = path.join(dir, ".env");
|
||||
const key = params.key.trim();
|
||||
const value = params.value;
|
||||
|
||||
let raw = "";
|
||||
if (fs.existsSync(filepath)) {
|
||||
raw = fs.readFileSync(filepath, "utf8");
|
||||
}
|
||||
|
||||
const lines = raw.length ? raw.split(/\r?\n/) : [];
|
||||
const matcher = new RegExp(`^(\\s*(?:export\\s+)?)${escapeRegExp(key)}\\s*=`);
|
||||
let updated = false;
|
||||
let replaced = false;
|
||||
|
||||
const nextLines = lines.map((line) => {
|
||||
const match = line.match(matcher);
|
||||
if (!match) return line;
|
||||
replaced = true;
|
||||
const prefix = match[1] ?? "";
|
||||
const next = `${prefix}${key}=${value}`;
|
||||
if (next !== line) updated = true;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
nextLines.push(`${key}=${value}`);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const output = `${nextLines.join("\n")}\n`;
|
||||
fs.writeFileSync(filepath, output, "utf8");
|
||||
fs.chmodSync(filepath, 0o600);
|
||||
|
||||
return { path: filepath, updated, created: !raw };
|
||||
}
|
||||
@@ -548,65 +548,66 @@ export async function runOnboardingWizard(
|
||||
"Optional apps",
|
||||
);
|
||||
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port,
|
||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
const tokenParam =
|
||||
authMode === "token" && gatewayToken
|
||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||
: "";
|
||||
const authedUrl = `${links.httpUrl}${tokenParam}`;
|
||||
const gatewayProbe = await probeGatewayReachable({
|
||||
url: links.wsUrl,
|
||||
token: authMode === "token" ? gatewayToken : undefined,
|
||||
password: authMode === "password" ? baseConfig.gateway?.auth?.password : "",
|
||||
});
|
||||
const gatewayStatusLine = gatewayProbe.ok
|
||||
? "Gateway: reachable"
|
||||
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
|
||||
|
||||
await prompter.note(
|
||||
(() => {
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port,
|
||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
const tokenParam =
|
||||
authMode === "token" && gatewayToken
|
||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||
: "";
|
||||
const authedUrl = `${links.httpUrl}${tokenParam}`;
|
||||
return [
|
||||
`Web UI: ${links.httpUrl}`,
|
||||
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
|
||||
`Gateway WS: ${links.wsUrl}`,
|
||||
"Docs: https://docs.clawd.bot/web/control-ui",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
})(),
|
||||
[
|
||||
`Web UI: ${links.httpUrl}`,
|
||||
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
|
||||
`Gateway WS: ${links.wsUrl}`,
|
||||
gatewayStatusLine,
|
||||
"Docs: https://docs.clawd.bot/web/control-ui",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Control UI",
|
||||
);
|
||||
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (!browserSupport.ok) {
|
||||
await prompter.note(
|
||||
formatControlUiSshHint({
|
||||
port,
|
||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
||||
token: authMode === "token" ? gatewayToken : undefined,
|
||||
}),
|
||||
"Open Control UI",
|
||||
);
|
||||
} else {
|
||||
const wantsOpen = await prompter.confirm({
|
||||
message: "Open Control UI now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (wantsOpen) {
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port,
|
||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
||||
if (gatewayProbe.ok) {
|
||||
if (!browserSupport.ok) {
|
||||
await prompter.note(
|
||||
formatControlUiSshHint({
|
||||
port,
|
||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
||||
token: authMode === "token" ? gatewayToken : undefined,
|
||||
}),
|
||||
"Open Control UI",
|
||||
);
|
||||
} else {
|
||||
const wantsOpen = await prompter.confirm({
|
||||
message: "Open Control UI now?",
|
||||
initialValue: true,
|
||||
});
|
||||
const tokenParam =
|
||||
authMode === "token" && gatewayToken
|
||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||
: "";
|
||||
const opened = await openUrl(`${links.httpUrl}${tokenParam}`);
|
||||
if (!opened) {
|
||||
await prompter.note(
|
||||
formatControlUiSshHint({
|
||||
port,
|
||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
||||
token: authMode === "token" ? gatewayToken : undefined,
|
||||
}),
|
||||
"Open Control UI",
|
||||
);
|
||||
if (wantsOpen) {
|
||||
const opened = await openUrl(`${links.httpUrl}${tokenParam}`);
|
||||
if (!opened) {
|
||||
await prompter.note(
|
||||
formatControlUiSshHint({
|
||||
port,
|
||||
basePath: baseConfig.gateway?.controlUi?.basePath,
|
||||
token: authMode === "token" ? gatewayToken : undefined,
|
||||
}),
|
||||
"Open Control UI",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user