Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
274 lines
8.7 KiB
TypeScript
274 lines
8.7 KiB
TypeScript
import type {
|
|
AgentProviderStatus,
|
|
WorkspaceAgentProvider
|
|
} from "@tutti-os/client-tuttid-ts";
|
|
import {
|
|
deriveAgentSetupStages,
|
|
projectRevealedStages,
|
|
reasonCodeIndicatesCliVersionUnsupported,
|
|
type AgentSetupStage,
|
|
type AgentSetupStageId,
|
|
type AgentSetupStageLabels,
|
|
type StageDetailToken
|
|
} from "./agentEnvWizardFlow.ts";
|
|
import type {
|
|
CodexSetupActiveAction,
|
|
CodexSetupActiveActionError,
|
|
CodexSetupPhase
|
|
} from "./codexSetupContract.ts";
|
|
|
|
export interface NetworkCheck {
|
|
kind: "registry" | "api" | "proxy";
|
|
reachable: boolean;
|
|
host: string | null;
|
|
configured?: boolean;
|
|
}
|
|
|
|
const MANUAL_INSTALL_COMMANDS: Partial<Record<WorkspaceAgentProvider, string>> =
|
|
{
|
|
codex: "npm install -g @openai/codex --include=optional",
|
|
"claude-code": "curl -fsSL https://claude.ai/install.sh | bash",
|
|
cursor: "curl https://cursor.com/install -fsS | bash",
|
|
opencode: "curl -fsSL https://opencode.ai/install | bash"
|
|
};
|
|
|
|
function endpointHost(endpoint: string | null | undefined): string | null {
|
|
if (!endpoint) {
|
|
return null;
|
|
}
|
|
return endpoint.replace(/^https?:\/\//, "").replace(/\/.*$/, "") || null;
|
|
}
|
|
|
|
// A proxy URL may carry credentials (http://user:pass@host:port). Strip the
|
|
// userinfo before it reaches the renderer/UI so proxy passwords are never
|
|
// surfaced; the scheme + host:port are kept for display.
|
|
function scrubProxyUrl(url: string | null | undefined): string | null {
|
|
if (!url) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = new URL(url);
|
|
parsed.username = "";
|
|
parsed.password = "";
|
|
return `${parsed.protocol}//${parsed.host}`;
|
|
} catch {
|
|
// Not a parseable URL — still strip a leading userinfo segment defensively.
|
|
return url.replace(/\/\/[^/@]*@/, "//") || null;
|
|
}
|
|
}
|
|
|
|
function textToken(text: string | null): StageDetailToken | null {
|
|
return text ? { kind: "text", text } : null;
|
|
}
|
|
|
|
function joinDetail(parts: Array<string | null | undefined>): string | null {
|
|
const joined = parts.filter((p): p is string => Boolean(p)).join(" · ");
|
|
return joined || null;
|
|
}
|
|
|
|
export function deriveHasAnomaly(
|
|
stages: AgentSetupStage[],
|
|
activeActionError: CodexSetupActiveActionError | null
|
|
): boolean {
|
|
return stages.some((s) => s.status === "error") || Boolean(activeActionError);
|
|
}
|
|
|
|
export interface AgentEnvWizardViewModelInput {
|
|
provider: WorkspaceAgentProvider;
|
|
status: AgentProviderStatus | null;
|
|
isLoading: boolean;
|
|
activeAction: CodexSetupActiveAction | null;
|
|
installActionPending: boolean;
|
|
updateActionPending: boolean;
|
|
loginPending: boolean;
|
|
revealIndex: number;
|
|
stageLabels: AgentSetupStageLabels;
|
|
}
|
|
|
|
export interface AgentEnvWizardViewModel {
|
|
ready: boolean;
|
|
busy: boolean;
|
|
redetecting: boolean;
|
|
displayStages: AgentSetupStage[];
|
|
blockingStageId: AgentSetupStageId | null;
|
|
networkChecks: NetworkCheck[];
|
|
hasAnomaly: boolean;
|
|
activePhase: CodexSetupPhase | null;
|
|
log: string[];
|
|
registry: string | null;
|
|
error: CodexSetupActiveActionError | null;
|
|
manualCommand: string | null;
|
|
installPending: boolean;
|
|
updateAvailable: boolean;
|
|
updating: boolean;
|
|
loginPending: boolean;
|
|
}
|
|
|
|
export function buildAgentEnvWizardViewModel(
|
|
input: AgentEnvWizardViewModelInput
|
|
): AgentEnvWizardViewModel {
|
|
const { status, activeAction, provider } = input;
|
|
const ready = status?.availability.status === "ready";
|
|
const installPending = input.installActionPending;
|
|
const updatePending = input.updateActionPending;
|
|
const loginPending = input.loginPending;
|
|
const updating = updatePending || activeAction?.phase === "update";
|
|
const busy =
|
|
installPending ||
|
|
updating ||
|
|
activeAction?.phase === "install" ||
|
|
activeAction?.phase === "repair" ||
|
|
activeAction?.phase === "verify";
|
|
|
|
const reasonCode = (status?.availability.reasonCode ?? "").toLowerCase();
|
|
const versionTooOld = reasonCodeIndicatesCliVersionUnsupported(reasonCode);
|
|
const cliBelowFloor = versionTooOld;
|
|
// The codex launcher is present but its platform subpackage is missing — the
|
|
// CLI spawns ENOENT. The daemon repairs this in place via the install action;
|
|
// the wizard must NOT mark the install stage ok just because the launcher
|
|
// resolved.
|
|
const platformPackageIncomplete = reasonCode.includes(
|
|
"codex_platform_pkg_incomplete"
|
|
);
|
|
const adapterVersionMismatch = reasonCode.includes(
|
|
"acp_adapter_version_mismatch"
|
|
);
|
|
|
|
const cliDetail: StageDetailToken | null =
|
|
cliBelowFloor && status?.cli.version && status.cli.minVersion
|
|
? {
|
|
kind: "version-floor",
|
|
current: status.cli.version,
|
|
required: status.cli.minVersion
|
|
}
|
|
: status?.cli.installed
|
|
? textToken(joinDetail([status?.cli.version, status?.cli.binaryPath]))
|
|
: textToken(status?.cli.version ?? null);
|
|
|
|
const adapterDetail: StageDetailToken | null =
|
|
adapterVersionMismatch &&
|
|
status?.adapter.version &&
|
|
status?.adapter.requiredVersion
|
|
? {
|
|
kind: "version-mismatch",
|
|
current: status.adapter.version,
|
|
required: status.adapter.requiredVersion
|
|
}
|
|
: status?.adapter.installed
|
|
? textToken(
|
|
joinDetail([status?.adapter.version, status?.adapter.binaryPath])
|
|
)
|
|
: textToken(
|
|
status?.adapter.binaryPath ??
|
|
(status?.adapter.command?.length
|
|
? status.adapter.command.join(" ")
|
|
: null)
|
|
);
|
|
|
|
const networkChecks: NetworkCheck[] = status?.network
|
|
? [
|
|
{
|
|
kind: "registry",
|
|
reachable: status.network.registry.reachable,
|
|
host: endpointHost(status.network.registry.endpoint)
|
|
},
|
|
...(status.network.providerApi
|
|
? [
|
|
{
|
|
kind: "api" as const,
|
|
reachable: status.network.providerApi.reachable,
|
|
host: endpointHost(status.network.providerApi.endpoint)
|
|
}
|
|
]
|
|
: []),
|
|
...(status.network.proxy
|
|
? [
|
|
{
|
|
kind: "proxy" as const,
|
|
reachable:
|
|
!status.network.proxy.configured ||
|
|
status.network.proxy.reachable,
|
|
host: scrubProxyUrl(status.network.proxy.url),
|
|
configured: status.network.proxy.configured
|
|
}
|
|
]
|
|
: [])
|
|
]
|
|
: [];
|
|
const networkReachable =
|
|
networkChecks.length === 0 ? null : networkChecks.every((c) => c.reachable);
|
|
|
|
// The completed stage label already conveys the sign-in state. Render a
|
|
// detail only when the provider supplies an actual user-facing account label.
|
|
const accountDetail: StageDetailToken | null = status?.auth.accountLabel
|
|
? { kind: "text", text: status.auth.accountLabel }
|
|
: null;
|
|
const authMethod: string | null = status?.auth.authMethod ?? null;
|
|
|
|
const stages = deriveAgentSetupStages({
|
|
detected: status !== null,
|
|
cliInstalled: status?.cli.installed ?? false,
|
|
versionTooOld,
|
|
platformPackageIncomplete,
|
|
adapterInstalled: status?.adapter.installed ?? false,
|
|
adapterVersionMismatch,
|
|
authenticated: status?.auth.status === "authenticated",
|
|
credentialsConfigured: status?.auth.status === "configured",
|
|
authRequired: status?.auth.status === "required",
|
|
ready: Boolean(ready),
|
|
activePhase: activeAction?.phase ?? null,
|
|
installActionPending: installPending,
|
|
updateActionPending: updatePending,
|
|
loginPending,
|
|
networkReachable,
|
|
cliVersionDetail: cliDetail,
|
|
adapterDetail,
|
|
accountDetail,
|
|
authMethod,
|
|
networkDetail: null,
|
|
labels: input.stageLabels
|
|
});
|
|
|
|
const registry = activeAction?.registry ?? null;
|
|
const stagesWithDetail = registry
|
|
? stages.map((s) =>
|
|
s.id === "ready"
|
|
? { ...s, detail: { kind: "text" as const, text: registry } }
|
|
: s
|
|
)
|
|
: stages;
|
|
const displayStages = projectRevealedStages(
|
|
stagesWithDetail,
|
|
input.revealIndex
|
|
);
|
|
|
|
const blockingIndex = stages.findIndex((s) => s.status !== "ok");
|
|
const blockingStage = blockingIndex >= 0 ? stages[blockingIndex] : undefined;
|
|
const blockingStageId: AgentSetupStageId | null =
|
|
blockingStage && input.revealIndex >= blockingIndex
|
|
? blockingStage.id
|
|
: null;
|
|
|
|
return {
|
|
ready: Boolean(ready),
|
|
busy: Boolean(busy),
|
|
redetecting: input.isLoading,
|
|
displayStages,
|
|
blockingStageId,
|
|
networkChecks,
|
|
hasAnomaly: deriveHasAnomaly(stages, activeAction?.error ?? null),
|
|
activePhase: activeAction?.phase ?? null,
|
|
log: activeAction?.log ?? [],
|
|
registry,
|
|
error: activeAction?.error ?? null,
|
|
manualCommand: MANUAL_INSTALL_COMMANDS[provider] ?? null,
|
|
installPending,
|
|
updateAvailable:
|
|
updatePending ||
|
|
(status?.update?.updateAvailable === true &&
|
|
status.actions.some((action) => action.id === "update")),
|
|
updating,
|
|
loginPending
|
|
};
|
|
}
|