@uniqbit/mate-core 0.18.1-canary.1 → 0.18.1-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/claude-plugin/hooks/session-banner.mjs +1 -1
  2. package/opencode-plugin/server.ts +5 -0
  3. package/opencode-plugin/src/add-dir.ts +118 -0
  4. package/opencode-plugin/src/companion.ts +180 -0
  5. package/opencode-plugin/src/server.ts +26 -0
  6. package/opencode-plugin/src/tui.tsx +1 -0
  7. package/opencode-plugin/tui.tsx +2 -0
  8. package/package.json +8 -6
  9. package/src/cli/commands/artifact/finish/engine.ts +1 -1
  10. package/src/cli/commands/artifact/finish/git.ts +74 -59
  11. package/src/cli/commands/companion/hub.ts +1 -1
  12. package/src/cli/commands/companion/link.ts +13 -43
  13. package/src/cli/commands/doctor.ts +30 -4
  14. package/src/cli/commands/plugin/plugin.ts +0 -4
  15. package/src/cli/commands/report/formatter.ts +0 -4
  16. package/src/cli/commands/studio/selection.ts +3 -0
  17. package/src/cli/commands/studio/vault.ts +5 -3
  18. package/src/cli/commands/studio/views/workflow/steps.ts +5 -4
  19. package/src/cli/commands/update.ts +4 -20
  20. package/src/cli/commands/working/cleanup.ts +4 -1
  21. package/src/cli/launch-selector.tsx +0 -318
  22. package/src/cli/main.ts +0 -29
  23. package/src/cli/setup-selector.tsx +1 -1
  24. package/src/cli/usage.ts +0 -1
  25. package/src/hooks/session-banner.ts +7 -5
  26. package/src/hooks/validate-artifact-path.ts +17 -6
  27. package/src/lib/opencode-plugin-package.ts +7 -28
  28. package/src/lib/orchestrator/adapters/opencode.ts +28 -67
  29. package/src/lib/orchestrator/companion-git-sync.ts +22 -104
  30. package/src/lib/orchestrator/companion-hub.ts +70 -119
  31. package/src/lib/orchestrator/repo-local-registry.ts +0 -21
  32. package/src/lib/orchestrator/working-repo-projection.ts +13 -9
  33. package/src/lib/package-paths.ts +32 -0
  34. package/src/lib/prebuilt-workspace.ts +0 -3
  35. package/src/opencode/companion-hooks.ts +15 -10
  36. package/src/runtime/companion-git.ts +279 -102
  37. package/src/runtime/companion-sync.ts +34 -44
  38. package/src/runtime/index.ts +7 -5
  39. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +2 -2
  40. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +25 -0
  41. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +9 -7
  42. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +34 -4
  43. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +2 -2
  44. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +25 -0
  45. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +9 -7
  46. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +34 -4
  47. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +68 -77
  48. package/src/tools/setup/capabilities/graphify.ts +0 -4
  49. package/src/tools/setup/dynamic-plugins/staging.ts +3 -1
  50. package/src/tools/setup/providers/opencode-format.ts +4 -0
  51. package/src/tools/setup/providers/opencode.ts +9 -65
  52. package/src/tools/setup/runtime-documents.ts +8 -9
  53. package/src/cli/commands/plugin/verify.ts +0 -25
@@ -1,6 +1,5 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import { spawnSync } from "node:child_process";
4
3
  import fs from "node:fs/promises";
5
4
 
6
5
  import { FRAMEWORK_NAME } from "../../../framework";
@@ -19,17 +18,16 @@ import { runSetupFlowAtPath } from "../setup";
19
18
  import { runInstallCommand } from "../install";
20
19
  import type { CompanionSource, LinkedRepository } from "../../../lib/orchestrator/types";
21
20
  import { invalidateInstallState } from "../../../lib/install";
22
- import { toSshUrl } from "../../../runtime/companion-git";
21
+ import { companionGit, describeGitFailure, type GitResult } from "../../../runtime/companion-git";
23
22
 
24
23
  interface CompanionLinkCommandDeps {
25
24
  selectCompanionLinkInputs?: (
26
25
  options: CompanionLinkWizardOptions,
27
26
  ) => Promise<CompanionLinkInputs | null>;
28
27
  extractRepoName?: (url: string) => string | null;
29
- spawnSync?: typeof spawnSync;
28
+ cloneCompanion?: (url: string, destination: string) => Promise<GitResult>;
30
29
  mkdir?: typeof fs.mkdir;
31
30
  stat?: typeof fs.stat;
32
- rm?: typeof fs.rm;
33
31
  listCompanions?: () => Promise<string[]>;
34
32
  registerCompanion?: (companionPath: string) => Promise<void>;
35
33
  resolveCompanion?: (cwd: string) => ReturnType<CompanionResolver["resolve"]>;
@@ -80,10 +78,12 @@ export async function runCompanionLinkCommandWithDeps(
80
78
 
81
79
  const selectInputs = deps.selectCompanionLinkInputs ?? selectCompanionLinkInputs;
82
80
  const extractName = deps.extractRepoName ?? extractRepoName;
83
- const spawn = deps.spawnSync ?? spawnSync;
81
+ const cloneCompanion =
82
+ deps.cloneCompanion ??
83
+ ((url: string, destination: string) =>
84
+ companionGit(path.dirname(destination), { prompt: "if-terminal" }).clone(url, destination));
84
85
  const mkdir = deps.mkdir ?? fs.mkdir;
85
86
  const stat = deps.stat ?? fs.stat;
86
- const rm = deps.rm ?? fs.rm;
87
87
  const listCompanions =
88
88
  deps.listCompanions ??
89
89
  (() => {
@@ -149,44 +149,14 @@ export async function runCompanionLinkCommandWithDeps(
149
149
  const existingTarget = await stat(companionPath).catch(() => null);
150
150
  if (!existingTarget?.isDirectory()) {
151
151
  await mkdir(companionsDir, { recursive: true });
152
- const sshUrl = toSshUrl(gitUrl);
153
- const cloneUrls = sshUrl && sshUrl !== gitUrl ? [sshUrl, gitUrl] : [gitUrl];
154
- let cloneResult:
155
- | {
156
- status: number | null;
157
- stderr?: string | Buffer;
158
- stdout?: string | Buffer;
159
- }
160
- | undefined;
161
- const cloneErrors: string[] = [];
162
-
163
- for (const cloneUrl of cloneUrls) {
164
- cloneResult = spawn("git", ["clone", cloneUrl, companionPath], {
165
- encoding: "utf8",
166
- stdio: ["ignore", "pipe", "pipe"],
167
- });
168
- if (cloneResult.status === 0) {
169
- break;
170
- }
171
-
172
- cloneErrors.push(
173
- String(cloneResult.stderr || cloneResult.stdout || `Clone failed for ${cloneUrl}`),
152
+ const clone = await cloneCompanion(gitUrl, companionPath);
153
+ if (clone.status !== 0) {
154
+ const detail = describeGitFailure(clone);
155
+ exitWithError(
156
+ `mate: failed to clone companion repository:\n${
157
+ detail === "unknown Git error" ? "review the Git or SSH diagnostics above" : detail
158
+ }`,
174
159
  );
175
- try {
176
- await rm(companionPath, { recursive: true, force: true });
177
- } catch {
178
- // Ignore cleanup errors after a failed clone.
179
- }
180
- }
181
-
182
- if (!cloneResult || cloneResult.status !== 0) {
183
- try {
184
- await rm(companionPath, { recursive: true, force: true });
185
- } catch {
186
- // Ignore cleanup errors after a failed clone.
187
- }
188
- const output = cloneErrors.join("\n\n");
189
- exitWithError(`mate: failed to clone companion repository:\n${output}`);
190
160
  }
191
161
  }
192
162
  companionPaths = [companionPath];
@@ -2,12 +2,17 @@ import { execFileSync } from "node:child_process";
2
2
  import path from "node:path";
3
3
 
4
4
  import { FRAMEWORK_NAME } from "../../framework";
5
+ import { gitEnvironment } from "../../runtime/companion-git";
5
6
  import { getActiveDistribution } from "../../distribution";
6
7
  import { checkEngineRequirement } from "../../lib/orchestrator/engine-guard";
7
8
  import type { GlobalConfigStore } from "../../lib/orchestrator/global-config-store";
8
9
  import { pathIsDirectory } from "../../lib/orchestrator/repo-local-registry";
9
10
  import { resolveRootContext, type RootContext } from "../../lib/orchestrator/root-context";
10
11
  import type { CapabilityConfig, FrameworkConfig, HubMember } from "../../lib/orchestrator/types";
12
+ import {
13
+ verifyDeclaredPlugins,
14
+ type PluginVerificationFailure,
15
+ } from "../../tools/setup/dynamic-plugins/verify";
11
16
  import { getRequiredPluginDrift } from "../../tools/setup/policy";
12
17
  import { resolveCommandOnPath } from "../../tools/setup/utils";
13
18
  import { writeJsonStdout } from "../write-json-stdout";
@@ -19,6 +24,7 @@ interface DoctorDeps {
19
24
  pathValue?: string;
20
25
  /** Returns a member checkout's HEAD commit, or null when unreadable. */
21
26
  gitHead?: (memberPath: string) => string | null;
27
+ verifyPlugins?: (companionPath: string) => Promise<PluginVerificationFailure[]>;
22
28
  }
23
29
 
24
30
  export type DoctorKind = "core" | "working" | "companion" | "hub";
@@ -51,6 +57,7 @@ export interface DoctorReport {
51
57
  capabilities?: string[];
52
58
  toolInstallations?: DoctorToolInstallation[];
53
59
  requiredPluginDrift?: Array<{ pluginId: string; kind: string; reason: string }>;
60
+ pluginFailures?: PluginVerificationFailure[];
54
61
  engineRequirement?: { range: string; ok: boolean; detail: string };
55
62
  hub?: { members: DoctorHubMember[] };
56
63
  resolutionFailures: Array<{ companionPath: string; message: string }>;
@@ -74,6 +81,7 @@ function defaultGitHead(memberPath: string): string | null {
74
81
  try {
75
82
  return execFileSync("git", ["-C", memberPath, "rev-parse", "HEAD"], {
76
83
  stdio: ["ignore", "pipe", "ignore"],
84
+ env: gitEnvironment(),
77
85
  })
78
86
  .toString()
79
87
  .trim();
@@ -237,6 +245,10 @@ export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<Doctor
237
245
  report.engineRequirement = collectEngineRequirement(root.config);
238
246
  }
239
247
 
248
+ if (root.rootPath && root.config) {
249
+ report.pluginFailures = await (deps.verifyPlugins ?? verifyDeclaredPlugins)(root.rootPath);
250
+ }
251
+
240
252
  return report;
241
253
  }
242
254
 
@@ -329,6 +341,15 @@ function renderHumanReport(report: DoctorReport): void {
329
341
  ),
330
342
  );
331
343
  }
344
+ if (report.pluginFailures && report.pluginFailures.length > 0) {
345
+ printSection(
346
+ "Declared Plugins",
347
+ renderTable(
348
+ ["Plugin", "Issue"],
349
+ report.pluginFailures.map((failure) => [failure.package, failure.reason]),
350
+ ),
351
+ );
352
+ }
332
353
  if (report.engineRequirement) {
333
354
  printSection("Version Requirement", report.engineRequirement.detail);
334
355
  }
@@ -350,13 +371,18 @@ function renderHumanReport(report: DoctorReport): void {
350
371
  * working repository), `companion`, `hub`, or `core` (not linked) — with
351
372
  * kind-specific diagnostics: policy and capabilities for working repos,
352
373
  * capabilities and tool checks for companions, member health for hubs.
374
+ * Declared plugins are verified strictly (allowed by `MATE_ALLOWED_PLUGINS`,
375
+ * installed, loadable); any failure is named on stderr and exits non-zero.
353
376
  * `--json` emits the same report as one JSON document.
354
377
  */
355
378
  export async function runDoctorCommand(argv: string[] = [], deps: DoctorDeps = {}): Promise<void> {
356
379
  const report = await collectDoctorReport(deps);
357
- if (argv.includes("--json")) {
358
- await writeJsonStdout(report);
359
- return;
380
+ if (argv.includes("--json")) await writeJsonStdout(report);
381
+ else renderHumanReport(report);
382
+
383
+ const failures = report.pluginFailures ?? [];
384
+ for (const failure of failures) {
385
+ process.stderr.write(`${FRAMEWORK_NAME}: plugin ${failure.package}: ${failure.reason}\n`);
360
386
  }
361
- renderHumanReport(report);
387
+ if (failures.length > 0) process.exitCode = 1;
362
388
  }
@@ -1,6 +1,5 @@
1
1
  import { usage } from "../../usage";
2
2
  import { runPluginInstallCommand } from "./install";
3
- import { runPluginVerifyCommand } from "./verify";
4
3
 
5
4
  export async function runPluginCommand(
6
5
  subcommand: string | undefined,
@@ -10,9 +9,6 @@ export async function runPluginCommand(
10
9
  case "install":
11
10
  await runPluginInstallCommand(argv);
12
11
  return;
13
- case "verify":
14
- await runPluginVerifyCommand();
15
- return;
16
12
  default:
17
13
  console.error(`Unknown plugin command: ${subcommand ?? ""}`);
18
14
  console.error(usage());
@@ -11,7 +11,3 @@ export function formatTokens(count: number): string {
11
11
  export function formatCost(amount: number): string {
12
12
  return `$${amount.toFixed(2)}`;
13
13
  }
14
-
15
- export function formatPercentage(value: number): string {
16
- return `${value.toFixed(1)}%`;
17
- }
@@ -30,6 +30,9 @@ export const STUDIO_PARAMS: readonly string[] = [
30
30
  /** Filled into a prompt when no change is named, so a prompt is never half-written. */
31
31
  export const CHANGE_PLACEHOLDER = "<change-name>";
32
32
 
33
+ /** Filled into pre-explore prompts, which run before any change exists. */
34
+ export const IDEA_PLACEHOLDER = "<idea>";
35
+
33
36
  const DIGEST_LENGTH = 10;
34
37
 
35
38
  interface SelectionBase {
@@ -4,6 +4,8 @@ import * as fsp from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
 
7
+ import { gitEnvironment } from "../../../runtime/companion-git";
8
+
7
9
  export interface VaultTreeNode {
8
10
  name: string;
9
11
  path: string;
@@ -108,7 +110,7 @@ function inside(root: string, candidate: string): boolean {
108
110
  );
109
111
  }
110
112
 
111
- async function realpathContaining(root: string, candidate: string): Promise<string> {
113
+ async function realpathContaining(candidate: string): Promise<string> {
112
114
  let current = candidate;
113
115
  while (true) {
114
116
  try {
@@ -146,7 +148,7 @@ export async function resolveVaultPath(
146
148
  absolute = await fsp.realpath(lexical);
147
149
  } catch (error) {
148
150
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
149
- const containing = await realpathContaining(root, path.dirname(lexical));
151
+ const containing = await realpathContaining(path.dirname(lexical));
150
152
  if (!inside(root, containing))
151
153
  throw new VaultPathError("the requested path leaves the companion root");
152
154
  absolute = lexical;
@@ -167,7 +169,7 @@ export async function resolveVaultPath(
167
169
 
168
170
  function runGit(args: string[], input?: string): Promise<VaultGitResult> {
169
171
  return new Promise((resolve, reject) => {
170
- const child = spawn("git", args, { stdio: ["pipe", "pipe", "pipe"] });
172
+ const child = spawn("git", args, { stdio: ["pipe", "pipe", "pipe"], env: gitEnvironment() });
171
173
  const stdout: Buffer[] = [];
172
174
  const stderr: Buffer[] = [];
173
175
  child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
@@ -1,4 +1,4 @@
1
- import { CHANGE_PLACEHOLDER } from "../../selection";
1
+ import { CHANGE_PLACEHOLDER, IDEA_PLACEHOLDER } from "../../selection";
2
2
 
3
3
  export type WorkflowStepKind = "skill" | "review" | "completion" | "optional";
4
4
  export type WorkflowStepBadge = "optional" | "skill" | "openspec" | "human-in-loop";
@@ -56,11 +56,12 @@ function stepPrompt(
56
56
  includeChange = true,
57
57
  ): string {
58
58
  const changeNote = includeChange ? ` for ${CHANGE_PLACEHOLDER}` : " for";
59
+ const ideaNote = includeChange ? ` about ${IDEA_PLACEHOLDER}` : " about";
59
60
  const schemaNote = profile && profile !== "mate-v1" ? ` (use schema: ${profile})` : "";
60
61
 
61
62
  switch (step.id) {
62
63
  case "pre-explore":
63
- return `/mate-interview-me or /mate-grill-me${changeNote}`;
64
+ return `/mate-interview-me or /mate-grill-me${ideaNote}`;
64
65
  case "explore":
65
66
  return `/openspec-explore${schemaNote}${changeNote}`;
66
67
  case "proposal":
@@ -172,8 +173,8 @@ export function workflowPlan(
172
173
  name === "mate-interview-me"
173
174
  ? "Difference: guided, one-question-at-a-time clarification. Best when: the request is ambiguous and intent, constraints, or scope still need confirmation."
174
175
  : "Difference: adversarial, round-based review of the full design-tree frontier. Best when: the direction is mostly known but hidden assumptions, risks, and downstream impacts need surfacing.",
175
- prompt: `/${name} for ${CHANGE_PLACEHOLDER}`,
176
- copyPrompt: `/${name} for`,
176
+ prompt: `/${name} about ${IDEA_PLACEHOLDER}`,
177
+ copyPrompt: `/${name} about`,
177
178
  });
178
179
  }
179
180
  const start = optionalStep(
@@ -1,10 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
 
3
3
  import { FRAMEWORK_NAME } from "../../framework";
4
- import {
5
- OPENCODE_PLUGIN_PACKAGE_NAME,
6
- warmOpenCodePluginCache,
7
- } from "../../lib/opencode-plugin-package";
8
4
  import { installPublicPackageSync, isPackageInstalledAtNpmGlobalRoot } from "../../lib/public-npm";
9
5
  import {
10
6
  fetchLatestVersion,
@@ -66,8 +62,6 @@ export const updateCommandDeps = {
66
62
  },
67
63
  spawnPostInstall: (executable: string, args: string[]): InstallResult =>
68
64
  spawnSync(executable, args, { stdio: "inherit" }) as InstallResult,
69
- warmOpenCodePluginCache: (latest: string) =>
70
- warmOpenCodePluginCache(latest, process.env, getUpdateConfig().registry),
71
65
  };
72
66
 
73
67
  /**
@@ -157,20 +151,6 @@ export async function runUpdateCommand(argv: string[]): Promise<void> {
157
151
 
158
152
  await updateCommandDeps.saveUpdateState(latest);
159
153
 
160
- // The plugin package is released in lockstep with the CLI; pre-fetch the
161
- // newly coordinated version into OpenCode's plugin environment so the next
162
- // managed launch does not stall on a registry download. Best effort only.
163
- const warmed = await updateCommandDeps.warmOpenCodePluginCache(latest);
164
- if (!warmed.ok) {
165
- process.stderr.write(
166
- [
167
- `${FRAMEWORK_NAME}: could not pre-fetch ${OPENCODE_PLUGIN_PACKAGE_NAME}@${latest} for OpenCode.`,
168
- "The next managed OpenCode launch will download it (requires registry access).",
169
- ...(warmed.detail ? [`Details: ${warmed.detail}`] : []),
170
- ].join("\n") + "\n",
171
- );
172
- }
173
-
174
154
  const postInstall = updateCommandDeps.runPostInstall(skipConfirm);
175
155
  if (postInstall.status !== 0 || postInstall.error) {
176
156
  process.stderr.write(
@@ -182,4 +162,8 @@ export async function runUpdateCommand(argv: string[]): Promise<void> {
182
162
 
183
163
  console.log(`\nUpgraded to ${latest}.`);
184
164
  console.log("Post-update installation complete.");
165
+ /** Managed launches run standalone; only a long-lived service keeps the replaced plugin loaded. */
166
+ console.log(
167
+ "Restart any running OpenCode service to load the new plugin: `opencode service restart`.",
168
+ );
185
169
  }
@@ -6,6 +6,7 @@ import { promisify } from "node:util";
6
6
  import { parse } from "yaml";
7
7
 
8
8
  import { FRAMEWORK_NAME } from "../../../framework";
9
+ import { gitEnvironment } from "../../../runtime/companion-git";
9
10
  import type { RootKind } from "../../../lib/orchestrator/root-context";
10
11
  import {
11
12
  collectWorkspaceInventory,
@@ -39,7 +40,9 @@ export interface WorkingCleanupCommandDeps {
39
40
 
40
41
  async function resolveGitRoot(cwd: string): Promise<string | null> {
41
42
  try {
42
- const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
43
+ const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
44
+ env: gitEnvironment(),
45
+ });
43
46
  return canonicalPath(stdout.trim());
44
47
  } catch {
45
48
  return null;
@@ -1,320 +1,2 @@
1
- import { Box, Text, render as inkRender } from "ink";
2
- import React, { useEffect } from "react";
3
- import { SelectMenu, type SelectMenuItem } from "../lib/components/select-menu";
4
- import { WizardFooter } from "../lib/components/wizard-footer";
5
- import { WizardHeader } from "../lib/components/wizard-header";
6
-
7
1
  export const LAUNCH_TARGETS = ["claude", "opencode"] as const;
8
2
  export type LaunchTarget = (typeof LAUNCH_TARGETS)[number];
9
-
10
- export interface LaunchPreviewData {
11
- repositoryId: string;
12
- repositoryPath: string;
13
- companionPath: string;
14
- }
15
-
16
- export interface LaunchWizardResult {
17
- target: LaunchTarget;
18
- preview: LaunchPreviewData;
19
- }
20
-
21
- type WizardStep = "target" | "review";
22
- type PreviewState =
23
- | { status: "idle" }
24
- | { status: "loading" }
25
- | { status: "ready"; data: LaunchPreviewData }
26
- | { status: "error"; message: string };
27
-
28
- interface LaunchWizardAppProps {
29
- activeIndex: number;
30
- hasPreselectedTarget: boolean;
31
- items: SelectMenuItem[];
32
- previewState: PreviewState;
33
- selectedTarget: LaunchTarget | null;
34
- step: WizardStep;
35
- totalSteps: number;
36
- onMounted?: () => void;
37
- }
38
-
39
- export interface LaunchWizardOptions {
40
- stdin?: NodeJS.ReadStream;
41
- stdout?: NodeJS.WriteStream;
42
- stderr?: NodeJS.WriteStream;
43
- allowedTargets?: Set<string>;
44
- preselectedTarget?: LaunchTarget;
45
- resolvePreview(target: LaunchTarget): Promise<LaunchPreviewData>;
46
- }
47
-
48
- const launchSelectorDeps = {
49
- render: inkRender,
50
- };
51
-
52
- const TARGET_ITEMS: SelectMenuItem[] = [
53
- { key: "claude", label: "claude" },
54
- { key: "opencode", label: "opencode" },
55
- ];
56
-
57
- function parseLaunchSelectorInput(
58
- input: string,
59
- ): "submit" | "cancel" | "up" | "down" | "back" | null {
60
- if (input === "\r" || input === "\n") return "submit";
61
- if (input === "\x1b[A" || input === "[A") return "up";
62
- if (input === "\x1b[B" || input === "[B") return "down";
63
- if (input === "\x7f" || input === "\b") return "back";
64
- if (input === "\x1b" || input.toLowerCase() === "q") return "cancel";
65
- return null;
66
- }
67
-
68
- function ReviewPanel({
69
- previewState,
70
- selectedTarget,
71
- }: {
72
- previewState: PreviewState;
73
- selectedTarget: LaunchTarget | null;
74
- }) {
75
- if (previewState.status === "loading" || previewState.status === "idle") {
76
- return <Text dimColor>Resolving launch preview…</Text>;
77
- }
78
-
79
- if (previewState.status === "error") {
80
- return <Text color="red">{previewState.message}</Text>;
81
- }
82
-
83
- return (
84
- <Box flexDirection="column">
85
- <Text>Target: {selectedTarget}</Text>
86
- <Text>Repository: {previewState.data.repositoryId}</Text>
87
- <Text dimColor>{previewState.data.repositoryPath}</Text>
88
- <Text>Companion:</Text>
89
- <Text dimColor>{previewState.data.companionPath}</Text>
90
- </Box>
91
- );
92
- }
93
-
94
- function LaunchWizardApp({
95
- activeIndex,
96
- hasPreselectedTarget,
97
- items,
98
- previewState,
99
- selectedTarget,
100
- step,
101
- totalSteps,
102
- onMounted,
103
- }: LaunchWizardAppProps): React.JSX.Element {
104
- useEffect(() => {
105
- onMounted?.();
106
- }, [onMounted]);
107
-
108
- const stepNumber = hasPreselectedTarget ? 0 : step === "target" ? 0 : totalSteps - 1;
109
- const title = step === "target" ? "Select launch target" : "Review launch";
110
- const hint =
111
- step === "target"
112
- ? "↑↓ navigate · enter continue · q cancel"
113
- : previewState.status === "error"
114
- ? "enter retry · backspace previous · q cancel"
115
- : previewState.status === "ready"
116
- ? "enter launch · backspace previous · q cancel"
117
- : "backspace previous · q cancel";
118
-
119
- return (
120
- <Box flexDirection="column" width="100%">
121
- <WizardHeader title={title} step={stepNumber} totalSteps={totalSteps} />
122
- <Box marginTop={1} flexDirection="column">
123
- {step === "target" ? <SelectMenu items={items} activeIndex={activeIndex} /> : null}
124
- {step === "review" ? (
125
- <ReviewPanel previewState={previewState} selectedTarget={selectedTarget} />
126
- ) : null}
127
- </Box>
128
- <WizardFooter hint={hint} />
129
- </Box>
130
- );
131
- }
132
-
133
- export async function runLaunchWizard(
134
- options: LaunchWizardOptions,
135
- ): Promise<LaunchWizardResult | null> {
136
- const stdin = options.stdin ?? process.stdin;
137
- const stdout = options.stdout ?? process.stdout;
138
- const stderr = options.stderr ?? process.stderr;
139
- if (!stdin.isTTY || !stdout.isTTY) {
140
- throw new Error("Launch wizard requires a TTY.");
141
- }
142
-
143
- const allowedTargets = options.allowedTargets;
144
- const preselectedTarget = options.preselectedTarget ?? null;
145
- const items: SelectMenuItem[] = TARGET_ITEMS.map((item) => ({
146
- ...item,
147
- dimColor: allowedTargets ? !allowedTargets.has(item.key) : false,
148
- }));
149
- const firstEnabledIndex = items.findIndex((item) => !item.dimColor);
150
- let activeIndex = preselectedTarget ? 0 : firstEnabledIndex < 0 ? 0 : firstEnabledIndex;
151
- let step: WizardStep = preselectedTarget ? "review" : "target";
152
- let selectedTarget: LaunchTarget | null = preselectedTarget;
153
- let previewState: PreviewState = { status: "idle" };
154
- let previewRequestId = 0;
155
- let finalResult: LaunchWizardResult | null = null;
156
- let resolved = false;
157
- let rendered: ReturnType<typeof launchSelectorDeps.render> | undefined;
158
-
159
- const totalSteps = preselectedTarget ? 1 : 2;
160
-
161
- stdin.resume();
162
- (stdin as NodeJS.ReadStream & { ref?: () => void }).ref?.();
163
- const keepAlive = setInterval(() => {}, 1 << 30);
164
-
165
- const cleanup = () => {
166
- clearInterval(keepAlive);
167
- stdin.off("data", onData);
168
- stdin.pause();
169
- stdin.setRawMode?.(false);
170
- (stdin as NodeJS.ReadStream & { unref?: () => void }).unref?.();
171
- };
172
-
173
- const currentTarget = () => LAUNCH_TARGETS[activeIndex];
174
- const isEnabled = (index: number) => !items[index]?.dimColor;
175
- const nextEnabledIndex = (start: number, direction: 1 | -1): number => {
176
- if (!items.some((item) => !item.dimColor)) return start;
177
- let index = start;
178
- for (let i = 0; i < items.length; i += 1) {
179
- index = (index + direction + items.length) % items.length;
180
- if (isEnabled(index)) return index;
181
- }
182
- return start;
183
- };
184
-
185
- const rerender = () => {
186
- rendered?.rerender(
187
- <LaunchWizardApp
188
- activeIndex={activeIndex}
189
- hasPreselectedTarget={!!preselectedTarget}
190
- items={items}
191
- previewState={previewState}
192
- selectedTarget={selectedTarget}
193
- step={step}
194
- totalSteps={totalSteps}
195
- onMounted={onMounted}
196
- />,
197
- );
198
- };
199
-
200
- const settle = (
201
- value: LaunchWizardResult | null,
202
- resolve: (value: LaunchWizardResult | null) => void,
203
- ) => {
204
- if (resolved) return;
205
- resolved = true;
206
- cleanup();
207
- resolve(value);
208
- };
209
-
210
- const loadPreview = () => {
211
- if (!selectedTarget) return;
212
- const requestId = ++previewRequestId;
213
- previewState = { status: "loading" };
214
- rerender();
215
-
216
- options
217
- .resolvePreview(selectedTarget)
218
- .then((preview) => {
219
- if (requestId !== previewRequestId || resolved) return;
220
- previewState = { status: "ready", data: preview };
221
- rerender();
222
- })
223
- .catch((error: unknown) => {
224
- if (requestId !== previewRequestId || resolved) return;
225
- previewState = {
226
- status: "error",
227
- message: error instanceof Error ? error.message : String(error),
228
- };
229
- rerender();
230
- });
231
- };
232
-
233
- const enterReview = () => {
234
- step = "review";
235
- loadPreview();
236
- };
237
-
238
- const onMounted = () => {
239
- const buffered = stdin.read();
240
- if (buffered) {
241
- // Discard buffered input before the wizard becomes interactive.
242
- }
243
- stdin.on("data", onData);
244
- if (step === "review") {
245
- loadPreview();
246
- }
247
- };
248
-
249
- const onData = (chunk: string) => {
250
- const action = parseLaunchSelectorInput(chunk);
251
- if (!action) return;
252
- if (action === "cancel") {
253
- finalResult = null;
254
- rendered?.unmount();
255
- return;
256
- }
257
- if (action === "back") {
258
- if (step === "review") {
259
- if (preselectedTarget) {
260
- finalResult = null;
261
- rendered?.unmount();
262
- } else {
263
- step = "target";
264
- previewState = { status: "idle" };
265
- rerender();
266
- }
267
- return;
268
- }
269
- return;
270
- }
271
- if (step === "review") {
272
- if (action !== "submit") return;
273
- if (previewState.status === "error") {
274
- loadPreview();
275
- return;
276
- }
277
- if (previewState.status !== "ready" || !selectedTarget) return;
278
- finalResult = {
279
- target: selectedTarget,
280
- preview: previewState.data,
281
- };
282
- rendered?.unmount();
283
- return;
284
- }
285
- if (action === "up" || action === "down") {
286
- activeIndex = nextEnabledIndex(activeIndex, action === "up" ? -1 : 1);
287
- rerender();
288
- return;
289
- }
290
- if (action !== "submit") return;
291
- if (!isEnabled(activeIndex)) return;
292
- selectedTarget = currentTarget();
293
- enterReview();
294
- };
295
-
296
- stdin.setEncoding("utf8");
297
- stdin.setRawMode?.(true);
298
-
299
- return new Promise<LaunchWizardResult | null>((resolve) => {
300
- rendered = launchSelectorDeps.render(
301
- <LaunchWizardApp
302
- activeIndex={activeIndex}
303
- hasPreselectedTarget={!!preselectedTarget}
304
- items={items}
305
- previewState={previewState}
306
- selectedTarget={selectedTarget}
307
- step={step}
308
- totalSteps={totalSteps}
309
- onMounted={onMounted}
310
- />,
311
- { stdin, stdout, stderr },
312
- );
313
-
314
- const finish = (value: LaunchWizardResult | null) => settle(value, resolve);
315
-
316
- rendered.waitUntilExit().then(() => {
317
- finish(finalResult);
318
- });
319
- });
320
- }