mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
feat: unified arrow-key selection + setup checkboxes (#2459)
* feat: unified arrow-key selection + setup checkboxes Replace p.autocomplete (type-ahead) with p.select (arrow-key navigation) for agent and cloud selection. Add p.multiselect checkboxes for optional post-provision setup steps (GitHub CLI, Chrome browser), all ON by default. Three fast prompts: agent → cloud → setup options. Defaults: OpenClaw, first cloud with credentials, all steps enabled. Key changes: - interactive.ts: p.autocomplete → p.select with initialValue defaults - interactive.ts: promptSetupOptions() with p.multiselect, exported for reuse - run.ts: wire setup options into cmdRun direct path - agents.ts: OptionalStep type, getAgentOptionalSteps() static metadata - orchestrate.ts: read SPAWN_ENABLED_STEPS env var, gate GitHub auth + configure - agent-setup.ts: gate Chrome install with enabledSteps in setupOpenclawConfig - Version bump 0.15.40 → 0.16.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: mirror tarball files to $HOME for non-root SSH users (GCP, AWS) Tarballs are built with absolute /root/ paths, but GCP and AWS Lightsail SSH as a regular user whose $HOME is /home/<user>/. After extraction, binaries like `claude` end up at /root/.claude/local/bin/ but the launchCmd looks in $HOME/.claude/local/bin/ — causing "command not found". Add a post-extraction step that copies /root/ dotfiles to $HOME/ when the SSH user isn't root. This fixes `spawn claude gcp` failing with exit code 127 after tarball install. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
This commit is contained in:
parent
dc3e4650bb
commit
d82dea811d
9 changed files with 179 additions and 23 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.15.40",
|
||||
"version": "0.16.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -84,11 +84,14 @@ describe("tryTarballInstall", () => {
|
|||
const result = await tryTarballInstall(runner, "openclaw", fetchFn);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(runner.runServer).toHaveBeenCalledTimes(1);
|
||||
// 2 calls: download+extract, then mirror files for non-root users
|
||||
expect(runner.runServer).toHaveBeenCalledTimes(2);
|
||||
const cmd = String(runner.runServer.mock.calls[0][0]);
|
||||
expect(cmd).toContain("curl -fsSL");
|
||||
expect(cmd).toContain("tar xz -C /");
|
||||
expect(cmd).toContain(".spawn-tarball");
|
||||
const mirrorCmd = String(runner.runServer.mock.calls[1][0]);
|
||||
expect(mirrorCmd).toContain("cp -a");
|
||||
});
|
||||
|
||||
it("returns false when release does not exist (404)", async () => {
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ describe("runOrchestration", () => {
|
|||
process.env.SPAWN_HOME = testDir;
|
||||
// Skip GitHub auth prompts during tests
|
||||
process.env.SPAWN_SKIP_GITHUB_AUTH = "1";
|
||||
// Ensure no stale SPAWN_ENABLED_STEPS leaks between tests
|
||||
delete process.env.SPAWN_ENABLED_STEPS;
|
||||
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
exitSpy = spyOn(process, "exit").mockImplementation((code) => {
|
||||
capturedExitCode = isNumber(code) ? code : 0;
|
||||
|
|
@ -326,7 +328,7 @@ describe("runOrchestration", () => {
|
|||
|
||||
await runOrchestrationSafe(cloud, agent, "testagent");
|
||||
|
||||
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "anthropic/claude-3");
|
||||
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "anthropic/claude-3", undefined);
|
||||
stderrSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
|
@ -342,7 +344,7 @@ describe("runOrchestration", () => {
|
|||
|
||||
await runOrchestrationSafe(cloud, agent, "testagent");
|
||||
|
||||
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "google/gemini-pro");
|
||||
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "google/gemini-pro", undefined);
|
||||
process.env.MODEL_ID = originalModelId;
|
||||
stderrSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
|
|
@ -359,7 +361,7 @@ describe("runOrchestration", () => {
|
|||
|
||||
await runOrchestrationSafe(cloud, agent, "testagent");
|
||||
|
||||
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", undefined);
|
||||
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", undefined, undefined);
|
||||
process.env.MODEL_ID = originalModelId;
|
||||
stderrSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import * as p from "@clack/prompts";
|
|||
import pc from "picocolors";
|
||||
import { getActiveServers } from "../history.js";
|
||||
import { agentKeys } from "../manifest.js";
|
||||
import { getAgentOptionalSteps } from "../shared/agents.js";
|
||||
import { activeServerPicker } from "./list.js";
|
||||
import { execScript, showDryRunPreview } from "./run.js";
|
||||
import {
|
||||
|
|
@ -20,14 +21,14 @@ import {
|
|||
VERSION,
|
||||
} from "./shared.js";
|
||||
|
||||
// Prompt user to select an agent with hints and type-ahead filtering
|
||||
// Prompt user to select an agent with arrow-key navigation
|
||||
async function selectAgent(manifest: Manifest): Promise<string> {
|
||||
const agents = agentKeys(manifest);
|
||||
const agentHints = buildAgentPickerHints(manifest);
|
||||
const agentChoice = await p.autocomplete({
|
||||
message: "Select an agent (type to filter)",
|
||||
const agentChoice = await p.select({
|
||||
message: "Select an agent",
|
||||
options: mapToSelectOptions(agents, manifest.agents, agentHints),
|
||||
placeholder: "Start typing to search...",
|
||||
initialValue: agents.includes("openclaw") ? "openclaw" : agents[0],
|
||||
});
|
||||
if (p.isCancel(agentChoice)) {
|
||||
handleCancel();
|
||||
|
|
@ -73,16 +74,16 @@ function getAndValidateCloudChoices(
|
|||
};
|
||||
}
|
||||
|
||||
// Prompt user to select a cloud from the sorted list with type-ahead filtering
|
||||
// Prompt user to select a cloud with arrow-key navigation
|
||||
async function selectCloud(
|
||||
manifest: Manifest,
|
||||
cloudList: string[],
|
||||
hintOverrides: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const cloudChoice = await p.autocomplete({
|
||||
message: "Select a cloud (type to filter)",
|
||||
const cloudChoice = await p.select({
|
||||
message: "Select a cloud",
|
||||
options: mapToSelectOptions(cloudList, manifest.clouds, hintOverrides),
|
||||
placeholder: "Start typing to search...",
|
||||
initialValue: cloudList[0],
|
||||
});
|
||||
if (p.isCancel(cloudChoice)) {
|
||||
handleCancel();
|
||||
|
|
@ -121,7 +122,66 @@ async function promptSpawnName(): Promise<string | undefined> {
|
|||
return spawnName || undefined;
|
||||
}
|
||||
|
||||
export { promptSpawnName, getAndValidateCloudChoices, selectCloud };
|
||||
/** Check whether the local host has a GitHub token (env or `gh auth`). */
|
||||
function hasLocalGithubToken(): boolean {
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const result = Bun.spawnSync(
|
||||
[
|
||||
"gh",
|
||||
"auth",
|
||||
"token",
|
||||
],
|
||||
{
|
||||
stdio: [
|
||||
"ignore",
|
||||
"pipe",
|
||||
"ignore",
|
||||
],
|
||||
},
|
||||
);
|
||||
return result.exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a multiselect prompt for optional post-provision setup steps.
|
||||
* Returns a Set of enabled step values, or undefined if there are no steps.
|
||||
* On cancel, returns all steps enabled (safe default).
|
||||
*/
|
||||
async function promptSetupOptions(agentName: string): Promise<Set<string> | undefined> {
|
||||
const steps = getAgentOptionalSteps(agentName);
|
||||
|
||||
// Filter GitHub option if no local token detected
|
||||
const filteredSteps = hasLocalGithubToken() ? steps : steps.filter((s) => s.value !== "github");
|
||||
|
||||
if (filteredSteps.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allValues = filteredSteps.map((s) => s.value);
|
||||
const selected = await p.multiselect({
|
||||
message: "Setup options",
|
||||
options: filteredSteps.map((s) => ({
|
||||
value: s.value,
|
||||
label: s.label,
|
||||
hint: s.hint,
|
||||
})),
|
||||
initialValues: allValues,
|
||||
required: false,
|
||||
});
|
||||
|
||||
if (p.isCancel(selected)) {
|
||||
return new Set(allValues);
|
||||
}
|
||||
return new Set(selected);
|
||||
}
|
||||
|
||||
export { promptSpawnName, promptSetupOptions, getAndValidateCloudChoices, selectCloud };
|
||||
|
||||
export async function cmdInteractive(): Promise<void> {
|
||||
p.intro(pc.inverse(` spawn v${VERSION} `));
|
||||
|
|
@ -166,6 +226,13 @@ export async function cmdInteractive(): Promise<void> {
|
|||
|
||||
await preflightCredentialCheck(manifest, cloudChoice);
|
||||
|
||||
const enabledSteps = await promptSetupOptions(agentChoice);
|
||||
if (enabledSteps) {
|
||||
process.env.SPAWN_ENABLED_STEPS = [
|
||||
...enabledSteps,
|
||||
].join(",");
|
||||
}
|
||||
|
||||
const spawnName = await promptSpawnName();
|
||||
|
||||
const agentName = manifest.agents[agentChoice].name;
|
||||
|
|
@ -212,6 +279,13 @@ export async function cmdAgentInteractive(agent: string, prompt?: string, dryRun
|
|||
|
||||
await preflightCredentialCheck(manifest, cloudChoice);
|
||||
|
||||
const enabledSteps = await promptSetupOptions(resolvedAgent);
|
||||
if (enabledSteps) {
|
||||
process.env.SPAWN_ENABLED_STEPS = [
|
||||
...enabledSteps,
|
||||
].join(",");
|
||||
}
|
||||
|
||||
const spawnName = await promptSpawnName();
|
||||
|
||||
const agentName = manifest.agents[resolvedAgent].name;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { generateSpawnId, getActiveServers, saveSpawnRecord } from "../history.j
|
|||
import { loadManifest, RAW_BASE, REPO, SPAWN_CDN } from "../manifest.js";
|
||||
import { validateIdentifier, validatePrompt, validateScriptContent } from "../security.js";
|
||||
import { prepareStdinForHandoff, toKebabCase } from "../shared/ui.js";
|
||||
import { promptSpawnName } from "./interactive.js";
|
||||
import { promptSetupOptions, promptSpawnName } from "./interactive.js";
|
||||
import { handleRecordAction } from "./list.js";
|
||||
import {
|
||||
buildRetryCommand,
|
||||
|
|
@ -935,6 +935,13 @@ export async function cmdRun(
|
|||
|
||||
await preflightCredentialCheck(manifest, cloud);
|
||||
|
||||
const enabledSteps = await promptSetupOptions(agent);
|
||||
if (enabledSteps) {
|
||||
process.env.SPAWN_ENABLED_STEPS = [
|
||||
...enabledSteps,
|
||||
].join(",");
|
||||
}
|
||||
|
||||
const spawnName = await promptSpawnName();
|
||||
|
||||
// If a name was given, check whether an active instance with that name already
|
||||
|
|
|
|||
|
|
@ -339,13 +339,17 @@ async function setupOpenclawConfig(
|
|||
apiKey: string,
|
||||
modelId: string,
|
||||
token?: string,
|
||||
enabledSteps?: Set<string>,
|
||||
): Promise<void> {
|
||||
logStep("Configuring openclaw...");
|
||||
await runner.runServer("mkdir -p ~/.openclaw");
|
||||
|
||||
// Chrome must be installed before config is written (config references its path).
|
||||
// This runs in configure() — not install() — so it works even with tarball installs.
|
||||
await installChromeBrowser(runner);
|
||||
// Gate with enabledSteps — user can skip ~400 MB download via setup checkboxes.
|
||||
if (!enabledSteps || enabledSteps.has("browser")) {
|
||||
await installChromeBrowser(runner);
|
||||
}
|
||||
|
||||
const gatewayToken = token ?? crypto.randomUUID().replace(/-/g, "");
|
||||
const escapedKey = jsonEscape(apiKey);
|
||||
|
|
@ -655,8 +659,8 @@ function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
|
|||
`ANTHROPIC_API_KEY=${apiKey}`,
|
||||
"ANTHROPIC_BASE_URL=https://openrouter.ai/api",
|
||||
],
|
||||
configure: (apiKey: string, modelId?: string) =>
|
||||
setupOpenclawConfig(runner, apiKey, modelId || "moonshotai/kimi-k2.5", dashboardToken),
|
||||
configure: (apiKey: string, modelId?: string, enabledSteps?: Set<string>) =>
|
||||
setupOpenclawConfig(runner, apiKey, modelId || "moonshotai/kimi-k2.5", dashboardToken, enabledSteps),
|
||||
preLaunch: () => startGateway(runner),
|
||||
preLaunchMsg: "Your web dashboard will open automatically. If it doesn't, check the terminal for the URL.",
|
||||
launchCmd: () =>
|
||||
|
|
|
|||
|
|
@ -113,6 +113,27 @@ export async function tryTarballInstall(
|
|||
return false;
|
||||
}
|
||||
|
||||
// Phase 4: Mirror /root/ files to $HOME/ for non-root SSH users (e.g. GCP, AWS Lightsail).
|
||||
// Tarballs are built with absolute /root/ paths, but some clouds SSH as a regular user
|
||||
// whose $HOME is /home/<user>/, not /root/. Without this, binaries are unreachable.
|
||||
const mirrorCmd = [
|
||||
'if [ "$(id -u)" != "0" ]; then',
|
||||
" for _d in .claude .local .npm-global .cargo .opencode .hermes .bun; do",
|
||||
' if [ -d "/root/$_d" ]; then',
|
||||
' mkdir -p "$HOME/$_d"',
|
||||
' cp -a "/root/$_d/." "$HOME/$_d/" 2>/dev/null || true',
|
||||
" fi",
|
||||
" done",
|
||||
" # Copy marker file",
|
||||
' cp /root/.spawn-tarball "$HOME/.spawn-tarball" 2>/dev/null || true',
|
||||
"fi",
|
||||
].join("\n");
|
||||
try {
|
||||
await runner.runServer(mirrorCmd, 30);
|
||||
} catch {
|
||||
logWarn("Tarball file mirroring failed (non-fatal)");
|
||||
}
|
||||
|
||||
logInfo("Agent installed from pre-built tarball");
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ import { logError } from "./ui";
|
|||
/** Cloud-init dependency tier: what packages to pre-install on the VM. */
|
||||
export type CloudInitTier = "minimal" | "node" | "bun" | "full";
|
||||
|
||||
/** An optional post-provision setup step the user can toggle on/off. */
|
||||
export interface OptionalStep {
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
/** Default model ID passed to configure() (no interactive prompt — override via MODEL_ID env var). */
|
||||
|
|
@ -18,7 +25,7 @@ export interface AgentConfig {
|
|||
/** Return env var pairs for .spawnrc. */
|
||||
envVars: (apiKey: string) => string[];
|
||||
/** Agent-specific configuration (settings files, etc.). */
|
||||
configure?: (apiKey: string, modelId?: string) => Promise<void>;
|
||||
configure?: (apiKey: string, modelId?: string, enabledSteps?: Set<string>) => Promise<void>;
|
||||
/** Pre-launch hook (e.g., start gateway daemon). */
|
||||
preLaunch?: () => Promise<void>;
|
||||
/** Optional tip or warning shown to the user just before the agent launches. */
|
||||
|
|
@ -39,6 +46,35 @@ export interface TunnelConfig {
|
|||
browserUrl?: (localPort: number) => string | undefined;
|
||||
}
|
||||
|
||||
// ─── Agent Optional Steps (static metadata — no CloudRunner needed) ─────────
|
||||
|
||||
/** Optional setup steps for each agent, keyed by agent name. */
|
||||
const AGENT_OPTIONAL_STEPS: Record<string, OptionalStep[]> = {
|
||||
openclaw: [
|
||||
{
|
||||
value: "github",
|
||||
label: "GitHub CLI",
|
||||
},
|
||||
{
|
||||
value: "browser",
|
||||
label: "Chrome browser",
|
||||
hint: "~400 MB — enables web tools",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONAL_STEPS: OptionalStep[] = [
|
||||
{
|
||||
value: "github",
|
||||
label: "GitHub CLI",
|
||||
},
|
||||
];
|
||||
|
||||
/** Get the optional setup steps for a given agent (no CloudRunner required). */
|
||||
export function getAgentOptionalSteps(agentName: string): OptionalStep[] {
|
||||
return AGENT_OPTIONAL_STEPS[agentName] ?? DEFAULT_OPTIONAL_STEPS;
|
||||
}
|
||||
|
||||
// ─── Shared Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -170,17 +170,26 @@ export async function runOrchestration(
|
|||
logWarn("Environment setup had errors");
|
||||
}
|
||||
|
||||
// 10. Agent-specific configuration
|
||||
// 10. Parse enabled setup steps from env (set by interactive/run prompts)
|
||||
let enabledSteps: Set<string> | undefined;
|
||||
const stepsEnv = process.env.SPAWN_ENABLED_STEPS;
|
||||
if (stepsEnv !== undefined) {
|
||||
enabledSteps = new Set(stepsEnv.split(",").filter(Boolean));
|
||||
}
|
||||
|
||||
// 10b. Agent-specific configuration
|
||||
if (agent.configure) {
|
||||
try {
|
||||
await withRetry("agent config", () => wrapSshCall(agent.configure!(apiKey, modelId)), 2, 5);
|
||||
await withRetry("agent config", () => wrapSshCall(agent.configure!(apiKey, modelId, enabledSteps)), 2, 5);
|
||||
} catch {
|
||||
logWarn("Agent configuration failed (continuing with defaults)");
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub CLI setup
|
||||
await offerGithubAuth(cloud.runner);
|
||||
// GitHub CLI setup (skip if user unchecked in setup options)
|
||||
if (!enabledSteps || enabledSteps.has("github")) {
|
||||
await offerGithubAuth(cloud.runner);
|
||||
}
|
||||
|
||||
// 11. Pre-launch hooks (e.g. OpenClaw gateway)
|
||||
if (agent.preLaunch) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue