From fde4964500f07097b6a3d7673fbba6f0efcb1f24 Mon Sep 17 00:00:00 2001 From: Jonathan Mieloo Date: Fri, 9 Jan 2026 14:51:18 +0100 Subject: [PATCH 1/4] feat: add @array/core GitHub integration From d3ece55c542dc319aadc1aee7c683acd424ddd32 Mon Sep 17 00:00:00 2001 From: Jonathan Mieloo Date: Fri, 9 Jan 2026 14:51:18 +0100 Subject: [PATCH 2/4] feat: add @array/core stack management From 3579e2d424e53801ad4d36e7f4b7074e683b9fa8 Mon Sep 17 00:00:00 2001 From: Jonathan Mieloo Date: Fri, 9 Jan 2026 14:51:18 +0100 Subject: [PATCH 3/4] feat: add @array/core jj wrappers --- packages/core/src/jj/abandon.ts | 9 ++ packages/core/src/jj/bookmark-create.ts | 24 +++ packages/core/src/jj/bookmark-delete.ts | 9 ++ packages/core/src/jj/bookmark-tracking.ts | 102 +++++++++++++ packages/core/src/jj/describe.ts | 15 ++ packages/core/src/jj/diff.ts | 54 +++++++ packages/core/src/jj/edit.ts | 9 ++ packages/core/src/jj/find.ts | 86 +++++++++++ packages/core/src/jj/index.ts | 22 +++ packages/core/src/jj/list.ts | 24 +++ packages/core/src/jj/log.ts | 91 +++++++++++ packages/core/src/jj/new.ts | 29 ++++ packages/core/src/jj/push.ts | 26 ++++ packages/core/src/jj/rebase.ts | 29 ++++ packages/core/src/jj/runner.ts | 101 +++++++++++++ packages/core/src/jj/stack.ts | 19 +++ packages/core/src/jj/status.ts | 176 ++++++++++++++++++++++ packages/core/src/jj/sync.ts | 66 ++++++++ 18 files changed, 891 insertions(+) create mode 100644 packages/core/src/jj/abandon.ts create mode 100644 packages/core/src/jj/bookmark-create.ts create mode 100644 packages/core/src/jj/bookmark-delete.ts create mode 100644 packages/core/src/jj/bookmark-tracking.ts create mode 100644 packages/core/src/jj/describe.ts create mode 100644 packages/core/src/jj/diff.ts create mode 100644 packages/core/src/jj/edit.ts create mode 100644 packages/core/src/jj/find.ts create mode 100644 packages/core/src/jj/index.ts create mode 100644 packages/core/src/jj/list.ts create mode 100644 packages/core/src/jj/log.ts create mode 100644 packages/core/src/jj/new.ts create mode 100644 packages/core/src/jj/push.ts create mode 100644 packages/core/src/jj/rebase.ts create mode 100644 packages/core/src/jj/runner.ts create mode 100644 packages/core/src/jj/stack.ts create mode 100644 packages/core/src/jj/status.ts create mode 100644 packages/core/src/jj/sync.ts diff --git a/packages/core/src/jj/abandon.ts b/packages/core/src/jj/abandon.ts new file mode 100644 index 000000000..d822afd70 --- /dev/null +++ b/packages/core/src/jj/abandon.ts @@ -0,0 +1,9 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +export async function abandon( + changeId: string, + cwd = process.cwd(), +): Promise> { + return runJJVoid(["abandon", changeId], cwd); +} diff --git a/packages/core/src/jj/bookmark-create.ts b/packages/core/src/jj/bookmark-create.ts new file mode 100644 index 000000000..bafdfd6f4 --- /dev/null +++ b/packages/core/src/jj/bookmark-create.ts @@ -0,0 +1,24 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +async function createBookmark( + name: string, + revision?: string, + cwd = process.cwd(), +): Promise> { + const args = ["bookmark", "create", name]; + if (revision) { + args.push("-r", revision); + } + return runJJVoid(args, cwd); +} + +export async function ensureBookmark( + name: string, + changeId: string, + cwd = process.cwd(), +): Promise> { + const create = await createBookmark(name, changeId, cwd); + if (create.ok) return create; + return runJJVoid(["bookmark", "move", name, "-r", changeId], cwd); +} diff --git a/packages/core/src/jj/bookmark-delete.ts b/packages/core/src/jj/bookmark-delete.ts new file mode 100644 index 000000000..de8953b6e --- /dev/null +++ b/packages/core/src/jj/bookmark-delete.ts @@ -0,0 +1,9 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +export async function deleteBookmark( + name: string, + cwd = process.cwd(), +): Promise> { + return runJJVoid(["bookmark", "delete", name], cwd); +} diff --git a/packages/core/src/jj/bookmark-tracking.ts b/packages/core/src/jj/bookmark-tracking.ts new file mode 100644 index 000000000..2572a0d73 --- /dev/null +++ b/packages/core/src/jj/bookmark-tracking.ts @@ -0,0 +1,102 @@ +import { ok, type Result } from "../result"; +import type { BookmarkTrackingStatus } from "../types"; +import { runJJ } from "./runner"; + +export async function getBookmarkTracking( + cwd = process.cwd(), +): Promise> { + // Template to get bookmark name + tracking status from origin + const template = `if(remote == "origin", name ++ "\\t" ++ tracking_ahead_count.exact() ++ "/" ++ tracking_behind_count.exact() ++ "\\n")`; + const result = await runJJ(["bookmark", "list", "-T", template], cwd); + if (!result.ok) return result; + + const statuses: BookmarkTrackingStatus[] = []; + const lines = result.value.stdout.trim().split("\n").filter(Boolean); + + for (const line of lines) { + const parts = line.split("\t"); + if (parts.length !== 2) continue; + const [name, counts] = parts; + const [ahead, behind] = counts.split("/").map(Number); + if (!Number.isNaN(ahead) && !Number.isNaN(behind)) { + statuses.push({ name, aheadCount: ahead, behindCount: behind }); + } + } + + return ok(statuses); +} + +/** + * Clean up orphaned bookmarks: + * 1. Local bookmarks marked as deleted (no target) + * 2. Local bookmarks without origin pointing to empty changes + */ +export async function cleanupOrphanedBookmarks( + cwd = process.cwd(), +): Promise> { + // Get all bookmarks with their remote status and target info + // Format: name\tremote_or_local\thas_target\tis_empty + const template = + 'name ++ "\\t" ++ if(remote, remote, "local") ++ "\\t" ++ if(normal_target, "target", "no_target") ++ "\\t" ++ if(normal_target, normal_target.empty(), "") ++ "\\n"'; + const result = await runJJ( + ["bookmark", "list", "--all", "-T", template], + cwd, + ); + if (!result.ok) return result; + + // Parse bookmarks and group by name + const bookmarksByName = new Map< + string, + { hasOrigin: boolean; hasLocalTarget: boolean; isEmpty: boolean } + >(); + + for (const line of result.value.stdout.trim().split("\n")) { + if (!line) continue; + const [name, remote, hasTarget, isEmpty] = line.split("\t"); + if (!name) continue; + + const existing = bookmarksByName.get(name); + if (remote === "origin") { + if (existing) { + existing.hasOrigin = true; + } else { + bookmarksByName.set(name, { + hasOrigin: true, + hasLocalTarget: false, + isEmpty: false, + }); + } + } else if (remote === "local") { + const localHasTarget = hasTarget === "target"; + const localIsEmpty = isEmpty === "true"; + if (existing) { + existing.hasLocalTarget = localHasTarget; + existing.isEmpty = localIsEmpty; + } else { + bookmarksByName.set(name, { + hasOrigin: false, + hasLocalTarget: localHasTarget, + isEmpty: localIsEmpty, + }); + } + } + } + + // Find bookmarks to forget: + // 1. Deleted bookmarks (local has no target) - these show as "(deleted)" + // 2. Orphaned bookmarks (no origin AND empty change) + const forgotten: string[] = []; + for (const [name, info] of bookmarksByName) { + const isDeleted = !info.hasLocalTarget; + const isOrphaned = !info.hasOrigin && info.isEmpty; + + if (isDeleted || isOrphaned) { + const forgetResult = await runJJ(["bookmark", "forget", name], cwd); + if (forgetResult.ok) { + forgotten.push(name); + } + } + } + + return ok(forgotten); +} diff --git a/packages/core/src/jj/describe.ts b/packages/core/src/jj/describe.ts new file mode 100644 index 000000000..a364f3b6a --- /dev/null +++ b/packages/core/src/jj/describe.ts @@ -0,0 +1,15 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +/** + * Set the description of a change. + * @param description The new description + * @param revision The revision to describe (default: @) + */ +export async function describe( + description: string, + revision = "@", + cwd = process.cwd(), +): Promise> { + return runJJVoid(["describe", "-m", description, revision], cwd); +} diff --git a/packages/core/src/jj/diff.ts b/packages/core/src/jj/diff.ts new file mode 100644 index 000000000..04c973362 --- /dev/null +++ b/packages/core/src/jj/diff.ts @@ -0,0 +1,54 @@ +import { ok, type Result } from "../result"; +import type { DiffStats } from "../types"; +import { runJJ } from "./runner"; + +function parseDiffStats(stdout: string): DiffStats { + // Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)" + // or just "X file changed, ..." for single file + const summaryMatch = stdout.match( + /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/, + ); + + if (summaryMatch) { + return { + filesChanged: parseInt(summaryMatch[1], 10), + insertions: summaryMatch[2] ? parseInt(summaryMatch[2], 10) : 0, + deletions: summaryMatch[3] ? parseInt(summaryMatch[3], 10) : 0, + }; + } + + // No changes + return { filesChanged: 0, insertions: 0, deletions: 0 }; +} + +/** + * Get diff stats for a revision. + * If fromBookmark is provided, compares against the remote version of that bookmark. + */ +export async function getDiffStats( + revision: string, + options?: { fromBookmark?: string }, + cwd = process.cwd(), +): Promise> { + if (options?.fromBookmark) { + const result = await runJJ( + [ + "diff", + "--from", + `${options.fromBookmark}@origin`, + "--to", + revision, + "--stat", + ], + cwd, + ); + if (!result.ok) { + // If remote doesn't exist, fall back to total diff + return getDiffStats(revision, undefined, cwd); + } + return ok(parseDiffStats(result.value.stdout)); + } + const result = await runJJ(["diff", "-r", revision, "--stat"], cwd); + if (!result.ok) return result; + return ok(parseDiffStats(result.value.stdout)); +} diff --git a/packages/core/src/jj/edit.ts b/packages/core/src/jj/edit.ts new file mode 100644 index 000000000..570bdff7f --- /dev/null +++ b/packages/core/src/jj/edit.ts @@ -0,0 +1,9 @@ +import type { Result } from "../result"; +import { runJJWithMutableConfigVoid } from "./runner"; + +export async function edit( + revision: string, + cwd = process.cwd(), +): Promise> { + return runJJWithMutableConfigVoid(["edit", revision], cwd); +} diff --git a/packages/core/src/jj/find.ts b/packages/core/src/jj/find.ts new file mode 100644 index 000000000..62a77de57 --- /dev/null +++ b/packages/core/src/jj/find.ts @@ -0,0 +1,86 @@ +import type { Changeset } from "../parser"; +import { createError, err, ok, type Result } from "../result"; +import type { FindResult } from "../types"; +import { list } from "./list"; + +export async function findChange( + query: string, + options: { includeBookmarks?: boolean } = {}, + cwd = process.cwd(), +): Promise> { + // First, try direct revset lookup (handles change IDs, commit IDs, shortest prefixes, etc.) + // Change IDs: lowercase letters + digits (e.g., xnkxvwyk) + // Commit IDs: hex digits (e.g., 1af471ab) + const isChangeId = /^[a-z][a-z0-9]*$/.test(query); + const isCommitId = /^[0-9a-f]+$/.test(query); + + if (isChangeId || isCommitId) { + const idResult = await list({ revset: query, limit: 1 }, cwd); + if (idResult.ok && idResult.value.length === 1) { + return ok({ status: "found", change: idResult.value[0] }); + } + } + + // Search by description and bookmarks + // Escape backslashes first, then quotes + const escaped = query.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const revset = options.includeBookmarks + ? `description(substring-i:"${escaped}") | bookmarks(substring-i:"${escaped}")` + : `description(substring-i:"${escaped}")`; + + const listResult = await list({ revset }, cwd); + if (!listResult.ok) { + return ok({ status: "none" }); + } + + const matches = listResult.value.filter( + (cs) => !cs.changeId.startsWith("zzzzzzzz"), + ); + + if (matches.length === 0) { + return ok({ status: "none" }); + } + + // Check for exact bookmark match first + if (options.includeBookmarks) { + const exactBookmark = matches.find((cs) => + cs.bookmarks.some((b) => b.toLowerCase() === query.toLowerCase()), + ); + if (exactBookmark) { + return ok({ status: "found", change: exactBookmark }); + } + } + + if (matches.length === 1) { + return ok({ status: "found", change: matches[0] }); + } + + return ok({ status: "multiple", matches }); +} + +/** + * Resolve a target to a single Changeset, returning an error for not-found or ambiguous. + * This is a convenience wrapper around findChange that handles the common error patterns. + */ +export async function resolveChange( + target: string, + options: { includeBookmarks?: boolean } = {}, + cwd = process.cwd(), +): Promise> { + const findResult = await findChange(target, options, cwd); + if (!findResult.ok) return findResult; + + if (findResult.value.status === "none") { + return err(createError("INVALID_REVISION", `Change not found: ${target}`)); + } + if (findResult.value.status === "multiple") { + return err( + createError( + "AMBIGUOUS_REVISION", + `Multiple changes match "${target}". Use a more specific identifier.`, + ), + ); + } + + return ok(findResult.value.change); +} diff --git a/packages/core/src/jj/index.ts b/packages/core/src/jj/index.ts new file mode 100644 index 000000000..9ac5b5f2f --- /dev/null +++ b/packages/core/src/jj/index.ts @@ -0,0 +1,22 @@ +export { abandon } from "./abandon"; +export { ensureBookmark } from "./bookmark-create"; +export { deleteBookmark } from "./bookmark-delete"; +export { getBookmarkTracking } from "./bookmark-tracking"; +export { describe } from "./describe"; +export { getDiffStats } from "./diff"; +export { edit } from "./edit"; +export { findChange, resolveChange } from "./find"; +export { list } from "./list"; +export { getLog } from "./log"; +export { jjNew } from "./new"; +export { push } from "./push"; +export { rebase } from "./rebase"; +export { + getTrunk, + runJJ, + runJJWithMutableConfig, + runJJWithMutableConfigVoid, +} from "./runner"; +export { getStack } from "./stack"; +export { status } from "./status"; +export { sync } from "./sync"; diff --git a/packages/core/src/jj/list.ts b/packages/core/src/jj/list.ts new file mode 100644 index 000000000..9ebdb2382 --- /dev/null +++ b/packages/core/src/jj/list.ts @@ -0,0 +1,24 @@ +import { type Changeset, parseChangesets } from "../parser"; +import type { Result } from "../result"; +import { CHANGESET_JSON_TEMPLATE } from "../templates"; +import type { ListOptions } from "../types"; +import { runJJ } from "./runner"; + +export async function list( + options?: ListOptions, + cwd = process.cwd(), +): Promise> { + const args = ["log", "--no-graph", "-T", CHANGESET_JSON_TEMPLATE]; + + if (options?.revset) { + args.push("-r", options.revset); + } + if (options?.limit) { + args.push("-n", String(options.limit)); + } + + const result = await runJJ(args, cwd); + if (!result.ok) return result; + + return parseChangesets(result.value.stdout); +} diff --git a/packages/core/src/jj/log.ts b/packages/core/src/jj/log.ts new file mode 100644 index 000000000..f67030019 --- /dev/null +++ b/packages/core/src/jj/log.ts @@ -0,0 +1,91 @@ +import { buildTree, flattenTree, type LogResult } from "../log"; +import { ok, type Result } from "../result"; +import { getBookmarkTracking } from "./bookmark-tracking"; +import { getDiffStats } from "./diff"; +import { list } from "./list"; +import { getTrunk } from "./runner"; +import { status } from "./status"; + +export async function getLog(cwd = process.cwd()): Promise> { + // Fetch all mutable changes (all stacks) plus trunk + const result = await list({ revset: "mutable() | trunk()" }, cwd); + if (!result.ok) return result; + + // Get status for modified files info + const statusResult = await status(cwd); + const modifiedFiles = statusResult.ok ? statusResult.value.modifiedFiles : []; + const hasUncommittedWork = modifiedFiles.length > 0; + + const trunkBranch = await getTrunk(cwd); + const trunk = + result.value.find( + (c) => c.bookmarks.includes(trunkBranch) && c.isImmutable, + ) ?? null; + const workingCopy = result.value.find((c) => c.isWorkingCopy) ?? null; + const allChanges = result.value.filter((c) => !c.isImmutable); + const trunkId = trunk?.changeId ?? ""; + const wcChangeId = workingCopy?.changeId ?? null; + + // Current change is the parent of WC + const currentChangeId = workingCopy?.parents[0] ?? null; + const isOnTrunk = currentChangeId === trunkId; + + // Filter changes to display in the log - exclude the WC itself + const changes = allChanges.filter((c) => { + if (c.description.trim() !== "" || c.hasConflicts) { + return true; + } + if (c.changeId === wcChangeId) { + return false; + } + return !c.isEmpty; + }); + + // Get bookmark tracking to find modified (unpushed) bookmarks + const trackingResult = await getBookmarkTracking(cwd); + const modifiedBookmarks = new Set(); + if (trackingResult.ok) { + for (const statusItem of trackingResult.value) { + if (statusItem.aheadCount > 0) { + modifiedBookmarks.add(statusItem.name); + } + } + } + + const roots = buildTree(changes, trunkId); + const entries = flattenTree(roots, currentChangeId, modifiedBookmarks); + + // Fetch diff stats for uncommitted work if present + let uncommittedWork: LogResult["uncommittedWork"] = null; + if (hasUncommittedWork && workingCopy) { + const statsResult = await getDiffStats( + workingCopy.changeId, + undefined, + cwd, + ); + uncommittedWork = { + changeId: workingCopy.changeId, + changeIdPrefix: workingCopy.changeIdPrefix, + isOnTrunk, + diffStats: statsResult.ok ? statsResult.value : null, + }; + } + + return ok({ + entries, + trunk: { + name: trunkBranch, + commitId: trunk?.commitId ?? "", + commitIdPrefix: trunk?.commitIdPrefix ?? "", + description: trunk?.description ?? "", + timestamp: trunk?.timestamp ?? new Date(), + }, + currentChangeId, + currentChangeIdPrefix: + changes.find((c) => c.changeId === currentChangeId)?.changeIdPrefix ?? + null, + isOnTrunk, + hasEmptyWorkingCopy: false, // Always false now - WC is always empty on top + uncommittedWork, + }); +} diff --git a/packages/core/src/jj/new.ts b/packages/core/src/jj/new.ts new file mode 100644 index 000000000..2eb18dd56 --- /dev/null +++ b/packages/core/src/jj/new.ts @@ -0,0 +1,29 @@ +import { ok, type Result } from "../result"; +import type { NewOptions } from "../types"; +import { runJJ } from "./runner"; +import { status } from "./status"; + +export async function jjNew( + options?: NewOptions, + cwd = process.cwd(), +): Promise> { + const args = ["new"]; + + if (options?.parents && options.parents.length > 0) { + args.push(...options.parents); + } + if (options?.message) { + args.push("-m", options.message); + } + if (options?.noEdit) { + args.push("--no-edit"); + } + + const result = await runJJ(args, cwd); + if (!result.ok) return result; + + const statusResult = await status(cwd); + if (!statusResult.ok) return statusResult; + + return ok(statusResult.value.workingCopy.changeId); +} diff --git a/packages/core/src/jj/push.ts b/packages/core/src/jj/push.ts new file mode 100644 index 000000000..19afd0f52 --- /dev/null +++ b/packages/core/src/jj/push.ts @@ -0,0 +1,26 @@ +import type { Result } from "../result"; +import type { PushOptions } from "../types"; +import { runJJ, runJJVoid } from "./runner"; + +export async function push( + options?: PushOptions, + cwd = process.cwd(), +): Promise> { + const remote = options?.remote ?? "origin"; + + // Track the bookmark on the remote if specified (required for new bookmarks) + if (options?.bookmark) { + // Track ignores already-tracked bookmarks, so safe to call always + await runJJ(["bookmark", "track", `${options.bookmark}@${remote}`], cwd); + } + + const args = ["git", "push"]; + if (options?.remote) { + args.push("--remote", options.remote); + } + if (options?.bookmark) { + args.push("--bookmark", options.bookmark); + } + + return runJJVoid(args, cwd); +} diff --git a/packages/core/src/jj/rebase.ts b/packages/core/src/jj/rebase.ts new file mode 100644 index 000000000..d70405362 --- /dev/null +++ b/packages/core/src/jj/rebase.ts @@ -0,0 +1,29 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +interface RebaseOptions { + /** The bookmark or revision to rebase */ + source: string; + /** The destination to rebase onto */ + destination: string; + /** + * Rebase mode: + * - "branch" (-b): Rebase source and all ancestors not in destination (default) + * - "revision" (-r): Rebase only the source commit, not its ancestors + */ + mode?: "branch" | "revision"; +} + +/** + * Rebase a bookmark/revision onto a new destination. + */ +export async function rebase( + options: RebaseOptions, + cwd = process.cwd(), +): Promise> { + const flag = options.mode === "revision" ? "-r" : "-b"; + return runJJVoid( + ["rebase", flag, options.source, "-d", options.destination], + cwd, + ); +} diff --git a/packages/core/src/jj/runner.ts b/packages/core/src/jj/runner.ts new file mode 100644 index 000000000..c5503b11c --- /dev/null +++ b/packages/core/src/jj/runner.ts @@ -0,0 +1,101 @@ +import { type CommandResult, shellExecutor } from "../executor"; +import { detectError } from "../parser"; +import { createError, err, type JJErrorCode, ok, type Result } from "../result"; + +// Module-level trunk cache (per cwd) +const trunkCache = new Map(); + +export async function getTrunk(cwd = process.cwd()): Promise { + const cached = trunkCache.get(cwd); + if (cached) return cached; + + const result = await shellExecutor.execute( + "jj", + ["config", "get", 'revset-aliases."trunk()"'], + { cwd }, + ); + if (result.exitCode === 0 && result.stdout.trim()) { + const trunk = result.stdout.trim(); + trunkCache.set(cwd, trunk); + return trunk; + } + throw new Error("Trunk branch not configured. Run `arr init` first."); +} + +export async function runJJ( + args: string[], + cwd = process.cwd(), +): Promise> { + try { + const result = await shellExecutor.execute("jj", args, { cwd }); + + if (result.exitCode !== 0) { + const detected = detectError(result.stderr); + if (detected) { + return err( + createError(detected.code as JJErrorCode, detected.message, { + command: `jj ${args.join(" ")}`, + stderr: result.stderr, + }), + ); + } + return err( + createError("COMMAND_FAILED", `jj command failed: ${result.stderr}`, { + command: `jj ${args.join(" ")}`, + stderr: result.stderr, + }), + ); + } + + return ok(result); + } catch (e) { + return err( + createError("COMMAND_FAILED", `Failed to execute jj: ${e}`, { + command: `jj ${args.join(" ")}`, + }), + ); + } +} + +/** + * Run a jj command that returns no meaningful output. + */ +export async function runJJVoid( + args: string[], + cwd = process.cwd(), +): Promise> { + const result = await runJJ(args, cwd); + if (!result.ok) return result; + return ok(undefined); +} + +/** + * Config override to make remote bookmarks mutable. + * Only trunk and tags remain immutable. + */ +const MUTABLE_CONFIG = + 'revset-aliases."immutable_heads()"="present(trunk()) | tags()"'; + +/** + * Run a JJ command with immutability override via --config. + * Use when operating on commits that may have been pushed to remote. + * This is a fallback for repos that weren't initialized with arr init. + */ +export async function runJJWithMutableConfig( + args: string[], + cwd = process.cwd(), +): Promise> { + return runJJ(["--config", MUTABLE_CONFIG, ...args], cwd); +} + +/** + * Run a JJ command with immutability override, returning void. + */ +export async function runJJWithMutableConfigVoid( + args: string[], + cwd = process.cwd(), +): Promise> { + const result = await runJJWithMutableConfig(args, cwd); + if (!result.ok) return result; + return ok(undefined); +} diff --git a/packages/core/src/jj/stack.ts b/packages/core/src/jj/stack.ts new file mode 100644 index 000000000..848ff64a1 --- /dev/null +++ b/packages/core/src/jj/stack.ts @@ -0,0 +1,19 @@ +import type { Changeset } from "../parser"; +import { ok, type Result } from "../result"; +import { list } from "./list"; + +export async function getStack( + cwd = process.cwd(), +): Promise> { + // Get the current stack from trunk to the current head(s) + // This shows the linear path from trunk through current position to its descendants + const result = await list({ revset: "trunk()..heads(descendants(@))" }, cwd); + if (!result.ok) return result; + + // Filter out empty changes without descriptions, but always keep the working copy + const filtered = result.value.filter( + (cs) => cs.isWorkingCopy || cs.description.trim() !== "" || !cs.isEmpty, + ); + + return ok(filtered); +} diff --git a/packages/core/src/jj/status.ts b/packages/core/src/jj/status.ts new file mode 100644 index 000000000..9da541404 --- /dev/null +++ b/packages/core/src/jj/status.ts @@ -0,0 +1,176 @@ +import { parseConflicts } from "../parser"; +import { ok, type Result } from "../result"; +import type { ChangesetStatus, FileChange } from "../types"; +import { runJJ } from "./runner"; + +// Single template that gets all status info in one jj call +// Diff summary is multi-line, so we put markers around it: DIFF_START and END_CHANGE +const STATUS_TEMPLATE = [ + '"CHANGE:"', + "change_id.short()", + '"|"', + "change_id.shortest().prefix()", + '"|"', + 'if(current_working_copy, "wc", "")', + '"|"', + 'bookmarks.join(",")', + '"|"', + "description.first_line()", + '"|"', + 'if(conflict, "1", "0")', + '"|"', + 'if(empty, "1", "0")', + '"\\nDIFF_START\\n"', + "self.diff().summary()", + '"END_CHANGE\\n"', +].join(" ++ "); + +interface ParsedChange { + changeId: string; + changeIdPrefix: string; + isWorkingCopy: boolean; + bookmarks: string[]; + description: string; + hasConflicts: boolean; + isEmpty: boolean; + diffSummary: string; +} + +function parseModifiedFiles(diffSummary: string): FileChange[] { + if (!diffSummary.trim()) return []; + + return diffSummary + .split("\n") + .filter(Boolean) + .map((line) => { + const status = line[0]; + const path = line.slice(2).trim(); + const statusMap: Record = { + M: "modified", + A: "added", + D: "deleted", + R: "renamed", + C: "copied", + }; + return { path, status: statusMap[status] || "modified" }; + }); +} + +/** + * Get working copy status in a single jj call. + */ +export async function status( + cwd = process.cwd(), +): Promise> { + // Single jj call with template - gets WC, parent, and grandparent for stack path + const result = await runJJ( + ["log", "-r", "@ | @- | @--", "--no-graph", "-T", STATUS_TEMPLATE], + cwd, + ); + + if (!result.ok) return result; + + // Split by END_CHANGE marker to handle multi-line diff summaries + const blocks = result.value.stdout.split("END_CHANGE").filter(Boolean); + const changes = blocks + .map((block) => { + // Split block into metadata and diff parts using DIFF_START marker + const [metaPart, diffPart] = block.split("DIFF_START"); + if (!metaPart) return null; + + const changeLine = metaPart.trim(); + if (!changeLine.startsWith("CHANGE:")) return null; + + const data = changeLine.slice(7); + const parts = data.split("|"); + + return { + changeId: parts[0] || "", + changeIdPrefix: parts[1] || "", + isWorkingCopy: parts[2] === "wc", + bookmarks: (parts[3] || "").split(",").filter(Boolean), + description: parts[4] || "", + hasConflicts: parts[5] === "1", + isEmpty: parts[6] === "1", + diffSummary: diffPart?.trim() || "", + }; + }) + .filter(Boolean) as ParsedChange[]; + + const workingCopy = changes.find((c) => c.isWorkingCopy); + const parent = changes.find((c) => !c.isWorkingCopy); + + // For hasResolvedConflict, we still need jj status output + // But only if parent has conflicts - otherwise skip it + let hasResolvedConflict = false; + if (parent?.hasConflicts) { + const statusResult = await runJJ(["status"], cwd); + if (statusResult.ok) { + hasResolvedConflict = statusResult.value.stdout.includes( + "Conflict in parent commit has been resolved in working copy", + ); + } + } + + // Parse conflicts from jj status if there are any + let conflicts: { path: string; type: "content" | "delete" | "rename" }[] = []; + if (workingCopy?.hasConflicts || parent?.hasConflicts) { + const statusResult = await runJJ(["status"], cwd); + if (statusResult.ok) { + const parsed = parseConflicts(statusResult.value.stdout); + if (parsed.ok) conflicts = parsed.value; + } + } + + return ok({ + workingCopy: workingCopy + ? { + changeId: workingCopy.changeId, + changeIdPrefix: workingCopy.changeIdPrefix, + commitId: "", + commitIdPrefix: "", + description: workingCopy.description, + bookmarks: workingCopy.bookmarks, + parents: parent ? [parent.changeId] : [], + isWorkingCopy: true, + isImmutable: false, + isEmpty: workingCopy.isEmpty, + hasConflicts: workingCopy.hasConflicts, + } + : { + changeId: "", + changeIdPrefix: "", + commitId: "", + commitIdPrefix: "", + description: "", + bookmarks: [], + parents: [], + isWorkingCopy: true, + isImmutable: false, + isEmpty: true, + hasConflicts: false, + }, + parents: parent + ? [ + { + changeId: parent.changeId, + changeIdPrefix: parent.changeIdPrefix, + commitId: "", + commitIdPrefix: "", + description: parent.description, + bookmarks: parent.bookmarks, + parents: [], + isWorkingCopy: false, + isImmutable: false, + isEmpty: parent.isEmpty, + hasConflicts: parent.hasConflicts, + }, + ] + : [], + modifiedFiles: workingCopy + ? parseModifiedFiles(workingCopy.diffSummary) + : [], + conflicts, + hasResolvedConflict, + }); +} diff --git a/packages/core/src/jj/sync.ts b/packages/core/src/jj/sync.ts new file mode 100644 index 000000000..b21618b58 --- /dev/null +++ b/packages/core/src/jj/sync.ts @@ -0,0 +1,66 @@ +import { ok, type Result } from "../result"; +import type { SyncResult } from "../types"; +import { abandon } from "./abandon"; +import { cleanupOrphanedBookmarks } from "./bookmark-tracking"; +import { list } from "./list"; +import { getTrunk, runJJ, runJJVoid } from "./runner"; +import { status } from "./status"; + +async function rebaseOntoTrunk(cwd = process.cwd()): Promise> { + return runJJVoid(["rebase", "-s", "roots(trunk()..@)", "-d", "trunk()"], cwd); +} + +export async function sync(cwd = process.cwd()): Promise> { + const fetchResult = await runJJ(["git", "fetch"], cwd); + if (!fetchResult.ok) return fetchResult; + + // Update local trunk bookmark to match remote (so trunk() points to latest) + // Intentionally ignore errors - remote may not exist for new repos + const trunk = await getTrunk(cwd); + await runJJ(["bookmark", "set", trunk, "-r", `${trunk}@origin`], cwd); + + const rebaseResult = await rebaseOntoTrunk(cwd); + + // Check for conflicts - jj rebase succeeds even with conflicts, so check status + let hasConflicts = false; + if (rebaseResult.ok) { + const statusResult = await status(cwd); + if (statusResult.ok) { + hasConflicts = statusResult.value.workingCopy.hasConflicts; + } + } else { + hasConflicts = rebaseResult.error.message.includes("conflict"); + } + + // Find empty changes, but exclude the current working copy if it's empty + // (jj would just recreate it, and it's not really "cleaned up") + const emptyResult = await list( + { revset: "(trunk()..@) & empty() & ~@" }, + cwd, + ); + const abandoned: Array<{ changeId: string; reason: "empty" | "merged" }> = []; + + if (emptyResult.ok) { + for (const change of emptyResult.value) { + const abandonResult = await abandon(change.changeId, cwd); + if (abandonResult.ok) { + // Empty changes with descriptions are likely merged (content now in trunk) + // Empty changes without descriptions are just staging area WCs + const reason = change.description.trim() !== "" ? "merged" : "empty"; + abandoned.push({ changeId: change.changeId, reason }); + } + } + } + + // Clean up local bookmarks whose remote was deleted and change is empty + const cleanupResult = await cleanupOrphanedBookmarks(cwd); + const forgottenBookmarks = cleanupResult.ok ? cleanupResult.value : []; + + return ok({ + fetched: true, + rebased: rebaseResult.ok, + abandoned, + forgottenBookmarks, + hasConflicts, + }); +} From 3e08200e183ed885223ecba78e3c2335b9563388 Mon Sep 17 00:00:00 2001 From: Jonathan Mieloo Date: Fri, 9 Jan 2026 14:51:18 +0100 Subject: [PATCH 4/4] update squash --- CLAUDE.md | 21 +- knip.json | 11 + package.json | 4 +- packages/core/package.json | 34 ++ packages/core/src/auth.ts | 106 ++++ packages/core/src/background-refresh.ts | 63 +++ packages/core/src/bookmark-utils.ts | 106 ++++ packages/core/src/ci.ts | 407 ++++++++++++++ packages/core/src/config.ts | 106 ++++ packages/core/src/context.ts | 36 ++ packages/core/src/engine/context.ts | 31 ++ packages/core/src/engine/engine.ts | 364 ++++++++++++ packages/core/src/engine/index.ts | 3 + packages/core/src/engine/types.ts | 18 + packages/core/src/executor.ts | 136 +++++ packages/core/src/git/branch.ts | 32 ++ packages/core/src/git/metadata.ts | 256 +++++++++ packages/core/src/git/remote.ts | 65 +++ packages/core/src/git/repo.ts | 37 ++ packages/core/src/git/runner.ts | 45 ++ packages/core/src/git/status.ts | 13 + packages/core/src/git/trunk.ts | 57 ++ packages/core/src/init.ts | 122 ++++ packages/core/src/log.ts | 147 +++++ packages/core/src/parser.ts | 153 +++++ packages/core/src/resolve-state.ts | 63 +++ packages/core/src/result.ts | 55 ++ packages/core/src/slugify.ts | 52 ++ packages/core/src/stack-comment.ts | 49 ++ packages/core/src/templates.ts | 37 ++ packages/core/src/types.ts | 233 ++++++++ packages/core/tsconfig.build.json | 13 + packages/core/tsconfig.json | 25 + pnpm-lock.yaml | 712 ++++++++++++++++++++++-- 34 files changed, 3562 insertions(+), 50 deletions(-) create mode 100644 packages/core/package.json create mode 100644 packages/core/src/auth.ts create mode 100644 packages/core/src/background-refresh.ts create mode 100644 packages/core/src/bookmark-utils.ts create mode 100644 packages/core/src/ci.ts create mode 100644 packages/core/src/config.ts create mode 100644 packages/core/src/context.ts create mode 100644 packages/core/src/engine/context.ts create mode 100644 packages/core/src/engine/engine.ts create mode 100644 packages/core/src/engine/index.ts create mode 100644 packages/core/src/engine/types.ts create mode 100644 packages/core/src/executor.ts create mode 100644 packages/core/src/git/branch.ts create mode 100644 packages/core/src/git/metadata.ts create mode 100644 packages/core/src/git/remote.ts create mode 100644 packages/core/src/git/repo.ts create mode 100644 packages/core/src/git/runner.ts create mode 100644 packages/core/src/git/status.ts create mode 100644 packages/core/src/git/trunk.ts create mode 100644 packages/core/src/init.ts create mode 100644 packages/core/src/log.ts create mode 100644 packages/core/src/parser.ts create mode 100644 packages/core/src/resolve-state.ts create mode 100644 packages/core/src/result.ts create mode 100644 packages/core/src/slugify.ts create mode 100644 packages/core/src/stack-comment.ts create mode 100644 packages/core/src/templates.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/core/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index f1447c169..44f44b60e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,9 +46,12 @@ ### Avoid Barrel Files +- Do not make use of index.ts + Barrel files: + - Break tree-shaking -- Create circular dependency risks +- Create circular dependency risks - Hide the true source of imports - Make refactoring harder @@ -74,6 +77,17 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed patterns (DI, services, tR - PostHog API integration in `posthog-api.ts` - Task execution and session management +### CLI Package (packages/cli) + +- **Dumb shell, imperative core**: CLI commands should be thin wrappers that call `@array/core` +- All business logic belongs in `@array/core`, not in CLI command files +- CLI only handles: argument parsing, calling core, formatting output +- No data transformation, tree building, or complex logic in CLI + +### Core Package (packages/core) + +- Shared business logic for jj/GitHub operations + ## Key Libraries - React 18, Radix UI Themes, Tailwind CSS @@ -91,6 +105,5 @@ TODO: Update me ## Testing -- Tests use vitest with jsdom environment -- Test helpers in `src/test/` -- Run specific test: `pnpm --filter array test -- path/to/test` +- `pnpm test` - Run tests across all packages +- Array app: Vitest with jsdom, helpers in `apps/array/src/test/` diff --git a/knip.json b/knip.json index 3cceda007..0a9384495 100644 --- a/knip.json +++ b/knip.json @@ -26,11 +26,22 @@ "node-addon-api" ] }, + "apps/cli": { + "entry": ["src/cli.ts"], + "project": ["src/**/*.ts", "bin/**/*.ts"], + "includeEntryExports": true + }, "packages/agent": { "project": ["src/**/*.ts"], "ignore": ["src/templates/**"], "ignoreDependencies": ["minimatch"], "includeEntryExports": true + }, + "packages/core": { + "entry": ["src/*.ts"], + "project": ["src/**/*.ts"], + "ignore": ["tests/**"], + "includeEntryExports": true } } } diff --git a/package.json b/package.json index bb64e2f67..cdc118e49 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "typecheck": "turbo typecheck", "lint": "biome check --write --unsafe", "format": "biome format --write", - "test": "pnpm -r test", + "test": "turbo test", + "test:bun": "turbo test --filter=@array/core --filter=@array/cli", + "test:vitest": "pnpm --filter array --filter @posthog/electron-trpc test", "clean": "pnpm -r clean", "knip": "knip", "prepare": "husky" diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..5a0fb7444 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,34 @@ +{ + "name": "@array/core", + "version": "0.0.1", + "description": "Changeset management on top of jj (Jujutsu VCS)", + "type": "module", + "exports": { + "./commands/*": "./src/commands/*.ts", + "./engine": "./src/engine/index.ts", + "./git/*": "./src/git/*.ts", + "./jj": "./src/jj/index.ts", + "./jj/*": "./src/jj/*.ts", + "./stacks": "./src/stacks/index.ts", + "./stacks/*": "./src/stacks/*.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "build": "echo 'No build needed - using TypeScript sources directly'", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.5.0" + }, + "dependencies": { + "@octokit/graphql": "^9.0.3", + "@octokit/graphql-schema": "^15.26.1", + "@octokit/rest": "^22.0.1", + "zod": "^3.24.1" + }, + "files": [ + "dist/**/*", + "src/**/*" + ] +} diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts new file mode 100644 index 000000000..442bd7529 --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,106 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { type CommandExecutor, shellExecutor } from "./executor"; +import { createError, err, ok, type Result } from "./result"; + +const AuthStateSchema = z.object({ + version: z.literal(1), + ghAuthenticated: z.boolean(), + username: z.string().optional(), +}); + +type AuthState = z.infer; + +const AUTH_CONFIG_DIR = ".config/array"; +const AUTH_FILE = "auth.json"; + +function getAuthPath(): string { + return join(homedir(), AUTH_CONFIG_DIR, AUTH_FILE); +} + +export async function saveAuthState(state: AuthState): Promise { + const authDir = join(homedir(), AUTH_CONFIG_DIR); + const authPath = getAuthPath(); + + await ensureDir(authDir); + await Bun.write(authPath, JSON.stringify(state, null, 2)); +} + +interface GhAuthStatus { + authenticated: boolean; + username?: string; + error?: string; +} + +export async function checkGhAuth( + executor: CommandExecutor = shellExecutor, +): Promise { + try { + const result = await executor.execute("gh", ["auth", "status"], { + cwd: process.cwd(), + }); + + if (result.exitCode === 0) { + const usernameMatch = result.stdout.match( + /Logged in to github\.com account (\S+)/, + ); + const username = usernameMatch ? usernameMatch[1] : undefined; + return { authenticated: true, username }; + } + + return { authenticated: false, error: result.stderr }; + } catch (e) { + return { authenticated: false, error: `Failed to check gh auth: ${e}` }; + } +} + +export async function ghAuthLogin( + executor: CommandExecutor = shellExecutor, +): Promise> { + try { + const result = await executor.execute("gh", ["auth", "login", "--web"], { + cwd: process.cwd(), + }); + + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || "Failed to authenticate with GitHub", + ), + ); + } + + const status = await checkGhAuth(executor); + if (!status.authenticated) { + return err(createError("COMMAND_FAILED", "Authentication failed")); + } + + return ok(status.username || "unknown"); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to authenticate: ${e}`)); + } +} + +export async function isGhInstalled( + executor: CommandExecutor = shellExecutor, +): Promise { + try { + const result = await executor.execute("which", ["gh"], { + cwd: process.cwd(), + }); + return result.exitCode === 0; + } catch { + return false; + } +} + +async function ensureDir(dirPath: string): Promise { + try { + const { mkdir } = await import("node:fs/promises"); + await mkdir(dirPath, { recursive: true }); + } catch { + // Directory might already exist + } +} diff --git a/packages/core/src/background-refresh.ts b/packages/core/src/background-refresh.ts new file mode 100644 index 000000000..c28fbcb12 --- /dev/null +++ b/packages/core/src/background-refresh.ts @@ -0,0 +1,63 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const RATE_LIMIT_FILE = ".git/arr-last-pr-refresh"; +const RATE_LIMIT_MS = 60 * 1000; // 1 minute + +/** + * Get the path to the rate limit file. + */ +function getRateLimitPath(cwd: string): string { + return join(cwd, RATE_LIMIT_FILE); +} + +/** + * Check if we should refresh PR info (rate limited to once per minute). + */ +function shouldRefreshPRInfo(cwd: string): boolean { + const path = getRateLimitPath(cwd); + + if (!existsSync(path)) { + return true; + } + + try { + const content = readFileSync(path, "utf-8"); + const lastRefresh = parseInt(content, 10); + const now = Date.now(); + return now - lastRefresh > RATE_LIMIT_MS; + } catch { + return true; + } +} + +/** + * Mark that we're starting a PR refresh (update the timestamp). + */ +function markPRRefreshStarted(cwd: string): void { + const path = getRateLimitPath(cwd); + writeFileSync(path, String(Date.now())); +} + +/** + * Trigger background PR info refresh if rate limit allows. + * Spawns a detached process that runs `arr __refresh-pr-info`. + */ +export function triggerBackgroundRefresh(cwd: string): void { + if (!shouldRefreshPRInfo(cwd)) { + return; + } + + // Mark as started before spawning to prevent race conditions + markPRRefreshStarted(cwd); + + // Spawn detached process: arr __refresh-pr-info + const scriptPath = process.argv[1]; + const child = spawn(process.argv[0], [scriptPath, "__refresh-pr-info"], { + cwd, + detached: true, + stdio: "ignore", + }); + child.unref(); +} diff --git a/packages/core/src/bookmark-utils.ts b/packages/core/src/bookmark-utils.ts new file mode 100644 index 000000000..d9222b892 --- /dev/null +++ b/packages/core/src/bookmark-utils.ts @@ -0,0 +1,106 @@ +import { getPRForBranch, type PRInfo } from "./github/pr-status"; +import { createError, err, ok, type Result } from "./result"; + +/** Maximum number of suffix attempts before giving up on conflict resolution */ +const MAX_BOOKMARK_SUFFIX = 25; + +interface BookmarkConflictResult { + /** Original bookmark name before conflict resolution */ + originalName: string; + /** Final resolved bookmark name (may have -2, -3, etc. suffix) */ + resolvedName: string; + /** Whether the name was changed due to a conflict */ + hadConflict: boolean; +} + +/** + * Resolve bookmark name conflicts with existing closed/merged PRs on GitHub. + * + * When a bookmark name conflicts with a closed or merged PR, this function + * finds a unique name by appending -2, -3, etc. suffixes. + * + * @param bookmark - The bookmark name to check/resolve + * @param prCache - Optional pre-fetched PR cache to avoid redundant API calls + * @param assignedNames - Set of names already assigned in this batch (to avoid duplicates) + * @param cwd - Working directory (defaults to process.cwd()) + * @returns The resolved bookmark name, or error if too many conflicts + */ +export async function resolveBookmarkConflict( + bookmark: string, + prCache?: Map, + assignedNames?: Set, + cwd = process.cwd(), +): Promise> { + // Check cache first, otherwise fetch from GitHub + let existingPR: PRInfo | null = null; + if (prCache) { + existingPR = prCache.get(bookmark) ?? null; + } else { + const prResult = await getPRForBranch(bookmark, cwd); + if (!prResult.ok) return prResult; + existingPR = prResult.value; + } + + // No conflict if PR doesn't exist or is open + if (!existingPR || existingPR.state === "OPEN") { + return ok({ + originalName: bookmark, + resolvedName: bookmark, + hadConflict: false, + }); + } + + // PR exists and is closed/merged - find a unique suffix + const baseBookmark = bookmark; + let suffix = 2; + + while (suffix <= MAX_BOOKMARK_SUFFIX) { + const candidateName = `${baseBookmark}-${suffix}`; + + // Check if this candidate is already assigned in this batch + if (assignedNames?.has(candidateName)) { + suffix++; + continue; + } + + // Check if this candidate has an existing PR + let candidatePR: PRInfo | null = null; + if (prCache) { + candidatePR = prCache.get(candidateName) ?? null; + } else { + const checkResult = await getPRForBranch(candidateName, cwd); + if (checkResult.ok) { + candidatePR = checkResult.value; + } + } + + // Found an unused name + if (!candidatePR) { + return ok({ + originalName: bookmark, + resolvedName: candidateName, + hadConflict: true, + }); + } + + suffix++; + } + + // Exceeded max suffix attempts + return err( + createError( + "CONFLICT", + `Too many PR name conflicts for "${baseBookmark}". Clean up old PRs or use a different description.`, + ), + ); +} + +/** + * Check if a bookmark name is a remote-tracking bookmark (e.g., "feature@origin"). + * + * Remote-tracking bookmarks have a @remote suffix pattern and should be + * excluded from local operations. + */ +export function isTrackingBookmark(bookmark: string): boolean { + return /@[a-zA-Z0-9_-]+$/.test(bookmark); +} diff --git a/packages/core/src/ci.ts b/packages/core/src/ci.ts new file mode 100644 index 000000000..9cce11c94 --- /dev/null +++ b/packages/core/src/ci.ts @@ -0,0 +1,407 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createError, err, ok, type Result } from "./result"; + +const STACK_CHECK_WORKFLOW = `# Generated by Array CLI - https://github.com/posthog/array +# Blocks stacked PRs until their downstack dependencies are merged +# Only runs for PRs managed by Array (detected via stack comment marker) + +name: Stack Check + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + pull_request_target: + types: [closed] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + pull-requests: read + issues: read + steps: + - name: Check stack dependencies + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + // Check if this is an Array-managed PR by looking for stack comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }); + + const isArrayPR = comments.some(c => + c.body.includes('') + ); + + if (!isArrayPR) { + console.log('Not an Array PR, skipping'); + return; + } + + const baseBranch = pr.base.ref; + const trunk = ['main', 'master', 'develop']; + + if (trunk.includes(baseBranch)) { + console.log('Base is trunk, no dependencies'); + return; + } + + async function getBlockers(base, visited = new Set()) { + if (trunk.includes(base) || visited.has(base)) { + return []; + } + visited.add(base); + + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: \`\${context.repo.owner}:\${base}\` + }); + + if (prs.length === 0) { + return []; + } + + const blocker = prs[0]; + const upstream = await getBlockers(blocker.base.ref, visited); + return [{ number: blocker.number, title: blocker.title }, ...upstream]; + } + + const blockers = await getBlockers(baseBranch); + + if (blockers.length > 0) { + const list = blockers.map(b => \`#\${b.number} (\${b.title})\`).join('\\n - '); + core.setFailed(\`Blocked by:\\n - \${list}\\n\\nMerge these PRs first (bottom to top).\`); + } else { + console.log('All dependencies merged, ready to merge'); + } + + recheck-dependents: + runs-on: ubuntu-latest + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'closed' && + github.event.pull_request.merged == true + permissions: + pull-requests: write + issues: read + steps: + - name: Trigger recheck of dependent PRs + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + // Check if this is an Array-managed PR + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }); + + const isArrayPR = comments.some(c => + c.body.includes('') + ); + + if (!isArrayPR) { + console.log('Not an Array PR, skipping'); + return; + } + + const mergedBranch = pr.head.ref; + + const { data: dependentPRs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + base: mergedBranch, + state: 'open' + }); + + for (const dependentPR of dependentPRs) { + console.log(\`Retargeting PR #\${dependentPR.number} to \${pr.base.ref}\`); + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: dependentPR.number, + base: pr.base.ref + }); + } +`; + +export interface SetupCIResult { + created: boolean; + updated: boolean; + path: string; +} + +function getWorkflowPath(cwd: string): string { + return join(cwd, ".github", "workflows", "array-stack-check.yml"); +} + +export function setupCI(cwd: string): SetupCIResult { + const workflowPath = getWorkflowPath(cwd); + const existed = existsSync(workflowPath); + + const workflowDir = dirname(workflowPath); + mkdirSync(workflowDir, { recursive: true }); + writeFileSync(workflowPath, STACK_CHECK_WORKFLOW); + + return { + created: !existed, + updated: existed, + path: workflowPath, + }; +} + +export interface EnableProtectionResult { + success: boolean; + error?: string; + alreadyEnabled?: boolean; + updated?: boolean; +} + +/** + * Get repo info by reading git remote. + */ +export async function getRepoInfo( + cwd: string, + executor: { + execute: ( + cmd: string, + args: string[], + opts: { cwd: string }, + ) => Promise<{ exitCode: number; stdout: string; stderr: string }>; + }, +): Promise<{ owner: string; repo: string } | null> { + const remoteResult = await executor.execute( + "git", + ["config", "--get", "remote.origin.url"], + { cwd }, + ); + + if (remoteResult.exitCode !== 0) return null; + + const repoInfo = getRepoInfoFromRemote(remoteResult.stdout.trim()); + return repoInfo.ok ? repoInfo.value : null; +} + +export function getRepoInfoFromRemote( + remoteUrl: string, +): Result<{ owner: string; repo: string }> { + // Handle SSH format: git@github.com:owner/repo.git + // Use [\w-]+ to match valid GitHub usernames/org names (alphanumeric, underscore, hyphen) + const sshMatch = remoteUrl.match( + /^git@github\.com:([\w-]+)\/([\w.-]+?)(?:\.git)?$/, + ); + if (sshMatch) { + return ok({ owner: sshMatch[1], repo: sshMatch[2] }); + } + + // Handle HTTPS format: https://github.com/owner/repo.git + const httpsMatch = remoteUrl.match( + /^https:\/\/github\.com\/([\w-]+)\/([\w.-]+?)(?:\.git)?$/, + ); + if (httpsMatch) { + return ok({ owner: httpsMatch[1], repo: httpsMatch[2] }); + } + + return err( + createError("COMMAND_FAILED", "Could not parse GitHub remote URL"), + ); +} + +export function getBranchProtectionUrl(owner: string, repo: string): string { + // Use the newer rulesets UI with as many prefilled params as possible + const params = new URLSearchParams({ + target: "branch", + enforcement: "active", + name: "Array Stack Check", + // Try common param patterns for the check name + required_status_checks: "Stack Check", + }); + return `https://github.com/${owner}/${repo}/settings/rules/new?${params.toString()}`; +} + +export interface EnableProtectionOptions { + owner: string; + repo: string; + trunk: string; +} + +export async function checkRulesetExists( + owner: string, + repo: string, + executor: { + execute: ( + cmd: string, + args: string[], + opts: { cwd: string }, + ) => Promise<{ exitCode: number; stdout: string; stderr: string }>; + }, + cwd: string, +): Promise { + const listResult = await executor.execute( + "gh", + [ + "api", + `-H`, + `Accept: application/vnd.github+json`, + `/repos/${owner}/${repo}/rulesets`, + ], + { cwd }, + ); + + if (listResult.exitCode === 0) { + try { + const rulesets = JSON.parse(listResult.stdout); + return rulesets.some( + (r: { name: string }) => r.name === "Array Stack Check", + ); + } catch { + return false; + } + } + return false; +} + +export async function enableStackCheckProtection( + options: EnableProtectionOptions, + executor: { + execute: ( + cmd: string, + args: string[], + opts: { cwd: string }, + ) => Promise<{ exitCode: number; stdout: string; stderr: string }>; + }, + cwd: string, +): Promise { + const { owner, repo } = options; + + // Ruleset configuration + const rulesetBody = { + name: "Array Stack Check", + target: "branch", + enforcement: "active", + conditions: { + ref_name: { + include: ["~DEFAULT_BRANCH"], + exclude: [], + }, + }, + rules: [ + { + type: "required_status_checks", + parameters: { + required_status_checks: [ + { + context: "Stack Check", + integration_id: 15368, // GitHub Actions + }, + ], + strict_required_status_checks_policy: false, + }, + }, + ], + }; + + // Check if ruleset already exists + const listResult = await executor.execute( + "gh", + [ + "api", + `-H`, + `Accept: application/vnd.github+json`, + `/repos/${owner}/${repo}/rulesets`, + ], + { cwd }, + ); + + let existingId: number | null = null; + if (listResult.exitCode === 0) { + try { + const rulesets = JSON.parse(listResult.stdout); + const existing = rulesets.find( + (r: { name: string; id: number }) => r.name === "Array Stack Check", + ); + if (existing) { + existingId = existing.id; + } + } catch { + // Continue to create + } + } + + const body = JSON.stringify(rulesetBody); + + // Update existing or create new + if (existingId) { + const updateResult = await executor.execute( + "bash", + [ + "-c", + `echo '${body}' | gh api --method PUT -H "Accept: application/vnd.github+json" /repos/${owner}/${repo}/rulesets/${existingId} --input -`, + ], + { cwd }, + ); + + if (updateResult.exitCode === 0) { + return { success: true, updated: true }; + } + + const stderr = updateResult.stderr.toLowerCase(); + if ( + stderr.includes("403") || + stderr.includes("404") || + stderr.includes("must have admin") + ) { + return { + success: false, + error: "Admin access required. Ask a repo admin to run this command.", + }; + } + return { + success: false, + error: updateResult.stderr || "Failed to update ruleset", + }; + } + + const createResult = await executor.execute( + "bash", + [ + "-c", + `echo '${body}' | gh api --method POST -H "Accept: application/vnd.github+json" /repos/${owner}/${repo}/rulesets --input -`, + ], + { cwd }, + ); + + if (createResult.exitCode === 0) { + return { success: true }; + } + + const stderr = createResult.stderr.toLowerCase(); + if ( + stderr.includes("403") || + stderr.includes("404") || + stderr.includes("must have admin") + ) { + return { + success: false, + error: "Admin access required. Ask a repo admin to run this command.", + }; + } + + return { + success: false, + error: createResult.stderr || "Failed to create ruleset", + }; +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 000000000..e2e55d4d6 --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,106 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; + +const UserConfigSchema = z.object({ + version: z.literal(1), + tipsEnabled: z.boolean().default(true), + tipsSeen: z.array(z.string()).default([]), +}); + +type UserConfig = z.infer; + +const USER_CONFIG_DIR = ".config/array"; +const USER_CONFIG_FILE = "config.json"; + +function getUserConfigDir(): string { + return join(homedir(), USER_CONFIG_DIR); +} + +function getUserConfigPath(): string { + return join(getUserConfigDir(), USER_CONFIG_FILE); +} + +export async function loadUserConfig(): Promise { + const configPath = getUserConfigPath(); + + try { + const file = Bun.file(configPath); + if (!(await file.exists())) { + return createDefaultUserConfig(); + } + + const content = await file.text(); + const parsed = JSON.parse(content); + return UserConfigSchema.parse(parsed); + } catch { + return createDefaultUserConfig(); + } +} + +export async function saveUserConfig(config: UserConfig): Promise { + const configDir = getUserConfigDir(); + const configPath = getUserConfigPath(); + + await ensureDir(configDir); + await Bun.write(configPath, JSON.stringify(config, null, 2)); +} + +export function createDefaultUserConfig(): UserConfig { + return { + version: 1, + tipsEnabled: true, + tipsSeen: [], + }; +} + +export async function isRepoInitialized(cwd: string): Promise { + try { + const { stat } = await import("node:fs/promises"); + const [gitExists, jjExists] = await Promise.all([ + stat(join(cwd, ".git")) + .then(() => true) + .catch(() => false), + stat(join(cwd, ".jj")) + .then(() => true) + .catch(() => false), + ]); + return gitExists && jjExists; + } catch { + return false; + } +} + +export async function markTipSeen(tipId: string): Promise { + const config = await loadUserConfig(); + if (!config.tipsSeen.includes(tipId)) { + config.tipsSeen.push(tipId); + await saveUserConfig(config); + } +} + +export async function shouldShowTip(tipId: string): Promise { + const config = await loadUserConfig(); + return config.tipsEnabled && !config.tipsSeen.includes(tipId); +} + +const TIPS: Record = { + create: "Run `arr log` to see your stack.", + submit: "Run `arr sync` to pull latest changes.", + enable: "Run `arr status` to see the combined preview.", + log: "Use `arr up` and `arr down` to navigate.", + sync: "Run `arr submit --stack` to create linked PRs.", +}; + +export function getTip(command: string): string | null { + return TIPS[command] ?? null; +} + +async function ensureDir(dirPath: string): Promise { + try { + const { mkdir } = await import("node:fs/promises"); + await mkdir(dirPath, { recursive: true }); + } catch { + // Directory might already exist + } +} diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts new file mode 100644 index 000000000..5e0b1e594 --- /dev/null +++ b/packages/core/src/context.ts @@ -0,0 +1,36 @@ +import { isRepoInitialized } from "./config"; +import { isInGitRepo } from "./git/repo"; +import { checkPrerequisites, isJjInitialized } from "./init"; + +export type ContextLevel = "none" | "jj" | "array"; + +export interface Context { + inGitRepo: boolean; + jjInstalled: boolean; + jjInitialized: boolean; + arrayInitialized: boolean; +} + +export async function checkContext(cwd: string): Promise { + const prereqs = await checkPrerequisites(); + const inGitRepo = await isInGitRepo(cwd); + const jjInitialized = inGitRepo ? await isJjInitialized(cwd) : false; + const arrayInitialized = inGitRepo ? await isRepoInitialized(cwd) : false; + + return { + inGitRepo, + jjInstalled: prereqs.jj.found, + jjInitialized, + arrayInitialized, + }; +} + +export function isContextValid(context: Context, level: ContextLevel): boolean { + if (level === "none") return true; + + const jjReady = + context.jjInstalled && context.inGitRepo && context.jjInitialized; + if (level === "jj") return jjReady; + + return jjReady && context.arrayInitialized; +} diff --git a/packages/core/src/engine/context.ts b/packages/core/src/engine/context.ts new file mode 100644 index 000000000..b0aa9c172 --- /dev/null +++ b/packages/core/src/engine/context.ts @@ -0,0 +1,31 @@ +import { getTrunk } from "../jj"; +import { createEngine, type Engine } from "./engine"; + +/** + * Context passed to command handlers. + * Contains the engine and other shared state. + */ +export interface ArrContext { + engine: Engine; + trunk: string; + cwd: string; +} + +/** + * Initialize context for a command. + * Engine is loaded and ready to use. + */ +export async function initContext( + cwd: string = process.cwd(), +): Promise { + const engine = createEngine(cwd); + engine.load(); + + const trunk = await getTrunk(cwd); + + return { + engine, + trunk, + cwd, + }; +} diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts new file mode 100644 index 000000000..837f5420b --- /dev/null +++ b/packages/core/src/engine/engine.ts @@ -0,0 +1,364 @@ +import { + type BranchMeta, + deleteMetadata, + listTrackedBranches, + type PRInfo, + readMetadataBatch, + writeMetadata, +} from "../git/metadata"; +import { getTrunk, list } from "../jj"; +import { createError, ok, type Result } from "../result"; +import type { TreeNode } from "./types"; + +export type { PRInfo }; + +/** + * Engine manages tracked branches and cached data. + * + * - Load once at command start + * - All mutations go through update methods + * - Persist once at command end + */ +export interface Engine { + // Lifecycle + load(): void; + persist(): void; + + // Tracking + isTracked(bookmark: string): boolean; + getTrackedBookmarks(): string[]; + + // Metadata access + getMeta(bookmark: string): BranchMeta | null; + getParent(bookmark: string): string | null; + getChildren(bookmark: string): string[]; + + // Mutations + /** + * Set metadata directly for a bookmark. + * Use this when you already have the full metadata (e.g., from remote refs). + */ + setMeta(bookmark: string, meta: BranchMeta): void; + + /** + * Refresh a bookmark's changeId/commitId/parentBranchName from jj. + * Preserves existing prInfo if present. + * Returns error if bookmark not found in jj. + */ + refreshFromJJ(bookmark: string): Promise>; + + /** + * Track a new bookmark by looking up its info from jj. + * @deprecated Use setMeta() for explicit metadata or refreshFromJJ() for jj lookup + */ + track(bookmark: string, prInfo?: PRInfo): Promise; + + /** + * Untrack a bookmark (delete metadata). + */ + untrack(bookmark: string): void; + + /** + * Update PR info for a tracked bookmark. + */ + updatePRInfo(bookmark: string, prInfo: PRInfo): void; + + // Tree building + buildTree(trunk: string): TreeNode[]; +} + +/** + * Create a new Engine instance. + */ +export function createEngine(cwd: string = process.cwd()): Engine { + // In-memory state + const branches: Map = new Map(); + const dirty: Set = new Set(); + const deleted: Set = new Set(); + let loaded = false; + + return { + /** + * Load all tracked branches from disk. + */ + load(): void { + if (loaded) return; + + // Load metadata from git refs - single git call for all branches + const tracked = listTrackedBranches(cwd); + const metadataMap = readMetadataBatch(tracked, cwd); + for (const [bookmarkName, meta] of metadataMap) { + branches.set(bookmarkName, meta); + } + + loaded = true; + }, + + /** + * Persist all changes to disk. + */ + persist(): void { + // Write dirty branches to git refs + for (const bookmarkName of dirty) { + const meta = branches.get(bookmarkName); + if (meta) { + writeMetadata(bookmarkName, meta, cwd); + } + } + + // Delete removed branches from git refs + for (const bookmarkName of deleted) { + deleteMetadata(bookmarkName, cwd); + } + + // Clear dirty state + dirty.clear(); + deleted.clear(); + }, + + /** + * Check if a bookmark is tracked. + */ + isTracked(bookmark: string): boolean { + return branches.has(bookmark); + }, + + /** + * Get all tracked bookmark names. + */ + getTrackedBookmarks(): string[] { + return Array.from(branches.keys()); + }, + + /** + * Get metadata for a bookmark. + */ + getMeta(bookmark: string): BranchMeta | null { + return branches.get(bookmark) ?? null; + }, + + /** + * Get the parent branch name for a bookmark. + */ + getParent(bookmark: string): string | null { + return branches.get(bookmark)?.parentBranchName ?? null; + }, + + /** + * Get all children of a bookmark (derived from parent scan). + */ + getChildren(bookmark: string): string[] { + const children: string[] = []; + for (const [name, meta] of branches) { + if (meta.parentBranchName === bookmark) { + children.push(name); + } + } + return children; + }, + + /** + * Set metadata directly for a bookmark. + * Use this when you already have the full metadata (e.g., from remote refs). + */ + setMeta(bookmark: string, meta: BranchMeta): void { + branches.set(bookmark, meta); + dirty.add(bookmark); + deleted.delete(bookmark); + }, + + /** + * Refresh a bookmark's changeId/commitId/parentBranchName from jj. + * Preserves existing prInfo if present. + * Returns error if bookmark not found in jj. + */ + async refreshFromJJ(bookmark: string): Promise> { + const trunk = await getTrunk(cwd); + const existing = branches.get(bookmark); + + // Get the change for this bookmark + const changeResult = await list( + { revset: `bookmarks(exact:"${bookmark}")`, limit: 1 }, + cwd, + ); + if (!changeResult.ok) { + return { + ok: false, + error: createError("COMMAND_FAILED", changeResult.error.message), + }; + } + if (changeResult.value.length === 0) { + return { + ok: false, + error: createError( + "NOT_FOUND", + `Bookmark "${bookmark}" not found in jj`, + ), + }; + } + + const change = changeResult.value[0]; + const parentChangeId = change.parents[0]; + + // Determine parent branch name + let parentBranchName = trunk; + + if (parentChangeId) { + // Check if parent is trunk + const trunkResult = await list( + { revset: `bookmarks(exact:"${trunk}")`, limit: 1 }, + cwd, + ); + const isTrunkParent = + trunkResult.ok && + trunkResult.value.length > 0 && + trunkResult.value[0].changeId === parentChangeId; + + if (!isTrunkParent) { + // Find parent's bookmark + const parentResult = await list( + { revset: parentChangeId, limit: 1 }, + cwd, + ); + if (parentResult.ok && parentResult.value.length > 0) { + const parentBookmark = parentResult.value[0].bookmarks[0]; + if (parentBookmark) { + parentBranchName = parentBookmark; + } + } + } + } + + const meta: BranchMeta = { + changeId: change.changeId, + commitId: change.commitId, + parentBranchName, + prInfo: existing?.prInfo, // Preserve existing prInfo + }; + + branches.set(bookmark, meta); + dirty.add(bookmark); + deleted.delete(bookmark); + + return ok(undefined); + }, + + /** + * Track a bookmark. Derives changeId, commitId, parentBranchName from jj. + * If already tracked, updates the metadata (upsert behavior). + * @deprecated Use setMeta() for explicit metadata or refreshFromJJ() for jj lookup + */ + async track(bookmark: string, prInfo?: PRInfo): Promise { + const trunk = await getTrunk(cwd); + + // Get the change for this bookmark + const changeResult = await list( + { revset: `bookmarks(exact:"${bookmark}")`, limit: 1 }, + cwd, + ); + if (!changeResult.ok || changeResult.value.length === 0) { + return; // Bookmark not found, skip tracking + } + + const change = changeResult.value[0]; + const parentChangeId = change.parents[0]; + + // Determine parent branch name + let parentBranchName = trunk; + + if (parentChangeId) { + // Check if parent is trunk + const trunkResult = await list( + { revset: `bookmarks(exact:"${trunk}")`, limit: 1 }, + cwd, + ); + const isTrunkParent = + trunkResult.ok && + trunkResult.value.length > 0 && + trunkResult.value[0].changeId === parentChangeId; + + if (!isTrunkParent) { + // Find parent's bookmark + const parentResult = await list( + { revset: parentChangeId, limit: 1 }, + cwd, + ); + if (parentResult.ok && parentResult.value.length > 0) { + const parentBookmark = parentResult.value[0].bookmarks[0]; + if (parentBookmark) { + parentBranchName = parentBookmark; + } + } + } + } + + const meta: BranchMeta = { + changeId: change.changeId, + commitId: change.commitId, + parentBranchName, + prInfo, + }; + + branches.set(bookmark, meta); + dirty.add(bookmark); + deleted.delete(bookmark); + }, + + /** + * Untrack a bookmark (delete metadata). + */ + untrack(bookmark: string): void { + branches.delete(bookmark); + dirty.delete(bookmark); + deleted.add(bookmark); + }, + + /** + * Update PR info for a tracked bookmark. + */ + updatePRInfo(bookmark: string, prInfo: PRInfo): void { + const existing = branches.get(bookmark); + if (!existing) { + return; // Not tracked, skip + } + branches.set(bookmark, { ...existing, prInfo }); + dirty.add(bookmark); + }, + + /** + * Build a tree of tracked branches for rendering. + * Returns roots (branches whose parent is trunk). + */ + buildTree(trunk: string): TreeNode[] { + const nodeMap = new Map(); + + // Create nodes for all tracked branches + for (const [bookmarkName, meta] of branches) { + nodeMap.set(bookmarkName, { + bookmarkName, + meta, + children: [], + }); + } + + // Build parent-child relationships + const roots: TreeNode[] = []; + for (const [_bookmarkName, node] of nodeMap) { + const parentName = node.meta.parentBranchName; + if (parentName === trunk) { + roots.push(node); + } else { + const parentNode = nodeMap.get(parentName); + if (parentNode) { + parentNode.children.push(node); + } else { + // Parent not tracked - treat as root (orphaned) + roots.push(node); + } + } + } + + return roots; + }, + }; +} diff --git a/packages/core/src/engine/index.ts b/packages/core/src/engine/index.ts new file mode 100644 index 000000000..04231dc27 --- /dev/null +++ b/packages/core/src/engine/index.ts @@ -0,0 +1,3 @@ +export { type ArrContext, initContext } from "./context"; +export type { Engine } from "./engine"; +export type { BranchMeta, PRInfo, TreeNode } from "./types"; diff --git a/packages/core/src/engine/types.ts b/packages/core/src/engine/types.ts new file mode 100644 index 000000000..531c7fe06 --- /dev/null +++ b/packages/core/src/engine/types.ts @@ -0,0 +1,18 @@ +// Re-export types from the single source of truth +import type { BranchMeta } from "../git/metadata"; + +export type { + BranchMeta, + PRInfo, + PRState, + ReviewDecision, +} from "../git/metadata"; + +/** + * Tree node for rendering arr log. + */ +export interface TreeNode { + bookmarkName: string; + meta: BranchMeta; + children: TreeNode[]; +} diff --git a/packages/core/src/executor.ts b/packages/core/src/executor.ts new file mode 100644 index 000000000..b16be3e98 --- /dev/null +++ b/packages/core/src/executor.ts @@ -0,0 +1,136 @@ +export interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +interface ExecuteOptions { + cwd: string; + env?: Record; + timeout?: number; +} + +export interface CommandExecutor { + execute( + command: string, + args: string[], + options: ExecuteOptions, + ): Promise; +} + +function createShellExecutor(): CommandExecutor { + return { + async execute( + command: string, + args: string[], + options: ExecuteOptions, + ): Promise { + const proc = Bun.spawn([command, ...args], { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + stdout: "pipe", + stderr: "pipe", + }); + + const timeoutMs = options.timeout ?? 30000; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + proc.kill(); + reject(new Error(`Command timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + const resultPromise = (async () => { + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { stdout, stderr, exitCode }; + })(); + + return Promise.race([resultPromise, timeoutPromise]); + }, + }; +} + +export const shellExecutor = createShellExecutor(); + +interface SyncOptions { + cwd?: string; + input?: string; + onError?: "throw" | "ignore"; +} + +/** + * Run a command synchronously. + * Returns stdout on success, throws or returns empty string on failure. + */ +export function runSync( + command: string, + args: string[], + options?: SyncOptions, +): string { + const result = Bun.spawnSync([command, ...args], { + cwd: options?.cwd ?? process.cwd(), + stdin: options?.input ? Buffer.from(options.input) : undefined, + }); + + if (result.exitCode !== 0) { + if (options?.onError === "ignore") return ""; + const stderr = result.stderr.toString(); + throw new Error(`${command} ${args.join(" ")} failed: ${stderr}`); + } + + return result.stdout.toString().trim(); +} + +/** + * Run a command synchronously and split output into lines. + */ +export function runSyncLines( + command: string, + args: string[], + options?: SyncOptions, +): string[] { + return runSync(command, args, options) + .split("\n") + .filter((line) => line.length > 0); +} + +/** + * Run an async command and check if it succeeded. + */ +export async function cmdCheck( + command: string, + args: string[], + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + try { + const result = await executor.execute(command, args, { cwd }); + return result.exitCode === 0; + } catch { + return false; + } +} + +/** + * Run an async command and return stdout if successful, null otherwise. + */ +export async function cmdOutput( + command: string, + args: string[], + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + try { + const result = await executor.execute(command, args, { cwd }); + if (result.exitCode === 0) { + return result.stdout.trim(); + } + return null; + } catch { + return null; + } +} diff --git a/packages/core/src/git/branch.ts b/packages/core/src/git/branch.ts new file mode 100644 index 000000000..bd4ac59f1 --- /dev/null +++ b/packages/core/src/git/branch.ts @@ -0,0 +1,32 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; +import { gitCheck } from "./runner"; + +export async function hasBranch( + cwd: string, + branch: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return gitCheck( + ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + cwd, + executor, + ); +} + +/** + * Exit jj mode by checking out the trunk branch in git. + */ +export async function exitToGit( + cwd: string, + trunk: string, + executor: CommandExecutor = shellExecutor, +): Promise> { + const result = await executor.execute("git", ["checkout", trunk], { cwd }); + if (result.exitCode !== 0) { + return err( + createError("COMMAND_FAILED", result.stderr || "git checkout failed"), + ); + } + return ok({ trunk }); +} diff --git a/packages/core/src/git/metadata.ts b/packages/core/src/git/metadata.ts new file mode 100644 index 000000000..e87256b7e --- /dev/null +++ b/packages/core/src/git/metadata.ts @@ -0,0 +1,256 @@ +import { z } from "zod"; +import { REFS_PREFIX, runGitSync, runGitSyncLines } from "./runner"; + +/** + * PR state - matches GitHub GraphQL API (uppercase) + */ +export type PRState = "OPEN" | "CLOSED" | "MERGED"; + +/** + * Review decision - matches GitHub GraphQL API (uppercase) + */ +export type ReviewDecision = + | "APPROVED" + | "REVIEW_REQUIRED" + | "CHANGES_REQUESTED"; + +const prInfoSchema = z.object({ + // Required fields + number: z.number(), + url: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + base: z.string(), + title: z.string(), + + // Optional fields + head: z.string().optional(), + body: z.string().optional(), + reviewDecision: z + .enum(["APPROVED", "REVIEW_REQUIRED", "CHANGES_REQUESTED"]) + .nullable() + .optional(), + isDraft: z.boolean().optional(), + /** Number of times PR was submitted (1 = initial, 2+ = updated via force-push) */ + version: z.number().optional(), +}); + +const branchMetaSchema = z.object({ + // Identity + changeId: z.string(), + commitId: z.string(), + + // Stack relationship + parentBranchName: z.string(), + + // PR info (cached from GitHub) + prInfo: prInfoSchema.optional(), +}); + +export type PRInfo = z.infer; +export type BranchMeta = z.infer; + +/** + * Write metadata for a branch to refs/arr/ + */ +export function writeMetadata( + branchName: string, + meta: BranchMeta, + cwd?: string, +): void { + const json = JSON.stringify(meta); + const objectId = runGitSync(["hash-object", "-w", "--stdin"], { + input: json, + cwd, + }); + runGitSync(["update-ref", `${REFS_PREFIX}/${branchName}`, objectId], { cwd }); +} + +/** + * Read metadata for a branch from refs/arr/ + * Returns null if not tracked or metadata is invalid. + */ +export function readMetadata( + branchName: string, + cwd?: string, +): BranchMeta | null { + const json = runGitSync(["cat-file", "-p", `${REFS_PREFIX}/${branchName}`], { + cwd, + onError: "ignore", + }); + if (!json) return null; + + try { + const parsed = branchMetaSchema.safeParse(JSON.parse(json)); + if (!parsed.success) return null; + return parsed.data; + } catch { + return null; + } +} + +/** + * Batch read metadata for multiple branches in a single git call. + * Much faster than calling readMetadata() for each branch individually. + */ +export function readMetadataBatch( + branches: Map, + cwd?: string, +): Map { + const result = new Map(); + if (branches.size === 0) return result; + + // Build input: one object ID per line + const objectIds = Array.from(branches.values()); + const input = objectIds.join("\n"); + + // Run git cat-file --batch + const output = runGitSync(["cat-file", "--batch"], { + cwd, + input, + onError: "ignore", + }); + + if (!output) return result; + + // Parse batch output format: + // blob + // + // (blank line or next header) + const branchNames = Array.from(branches.keys()); + const lines = output.split("\n"); + let lineIdx = 0; + let branchIdx = 0; + + while (lineIdx < lines.length && branchIdx < branchNames.length) { + const headerLine = lines[lineIdx]; + if (!headerLine || headerLine.includes("missing")) { + // Object not found, skip this branch + lineIdx++; + branchIdx++; + continue; + } + + // Parse header: + const headerMatch = headerLine.match(/^([a-f0-9]+) (\w+) (\d+)$/); + if (!headerMatch) { + lineIdx++; + branchIdx++; + continue; + } + + const size = parseInt(headerMatch[3], 10); + lineIdx++; // Move past header + + // Read content (may span multiple lines) + let content = ""; + let remaining = size; + while (remaining > 0 && lineIdx < lines.length) { + const line = lines[lineIdx]; + content += line; + remaining -= line.length; + lineIdx++; + if (remaining > 0) { + content += "\n"; + remaining -= 1; // Account for newline + } + } + + // Parse JSON and validate + try { + const parsed = branchMetaSchema.safeParse(JSON.parse(content)); + if (parsed.success) { + result.set(branchNames[branchIdx], parsed.data); + } + } catch { + // Invalid JSON, skip + } + + branchIdx++; + } + + return result; +} + +/** + * Delete metadata for a branch. + */ +export function deleteMetadata(branchName: string, cwd?: string): void { + runGitSync(["update-ref", "-d", `${REFS_PREFIX}/${branchName}`], { + cwd, + onError: "ignore", + }); +} + +/** + * List all tracked branches with their metadata object IDs. + * Returns a map of branchName -> objectId + */ +export function listTrackedBranches(cwd?: string): Map { + const result = new Map(); + + const lines = runGitSyncLines( + [ + "for-each-ref", + "--format=%(refname:lstrip=2):%(objectname)", + `${REFS_PREFIX}/`, + ], + { cwd, onError: "ignore" }, + ); + + for (const line of lines) { + const [branchName, objectId] = line.split(":"); + if (branchName && objectId) { + result.set(branchName, objectId); + } + } + + return result; +} + +/** + * Get all tracked branch names. + */ +export function getTrackedBranchNames(cwd?: string): string[] { + return Array.from(listTrackedBranches(cwd).keys()); +} + +/** + * Get all tracked branches with their metadata. + * Used for debugging/inspection. + */ +export function getAllBranchMetadata( + cwd?: string, +): Map { + const branches = listTrackedBranches(cwd); + const result = new Map(); + + for (const [branchName] of branches) { + result.set(branchName, readMetadata(branchName, cwd)); + } + + return result; +} + +/** + * Check if a branch is tracked by arr. + */ +export function isTracked(branchName: string, cwd?: string): boolean { + const meta = readMetadata(branchName, cwd); + return meta !== null; +} + +/** + * Update PR info for a tracked branch. + * Preserves other metadata fields. + */ +export function updatePRInfo( + branchName: string, + prInfo: PRInfo, + cwd?: string, +): void { + const meta = readMetadata(branchName, cwd); + if (!meta) { + throw new Error(`Branch ${branchName} is not tracked by arr`); + } + writeMetadata(branchName, { ...meta, prInfo }, cwd); +} diff --git a/packages/core/src/git/remote.ts b/packages/core/src/git/remote.ts new file mode 100644 index 000000000..7ab2dea28 --- /dev/null +++ b/packages/core/src/git/remote.ts @@ -0,0 +1,65 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; +import { gitCheck, gitOutput } from "./runner"; + +export async function hasRemote( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + const output = await gitOutput(["remote"], cwd, executor); + return output !== null && output.length > 0; +} + +export async function isBranchPushed( + cwd: string, + branch: string, + remote = "origin", + executor: CommandExecutor = shellExecutor, +): Promise { + return gitCheck( + ["show-ref", "--verify", "--quiet", `refs/remotes/${remote}/${branch}`], + cwd, + executor, + ); +} + +export async function pushBranch( + cwd: string, + branch: string, + remote = "origin", + executor: CommandExecutor = shellExecutor, +): Promise> { + try { + const result = await executor.execute( + "git", + ["push", "-u", remote, branch], + { cwd }, + ); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || `Failed to push ${branch} to ${remote}`, + ), + ); + } + return ok(undefined); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to push branch: ${e}`)); + } +} + +/** + * Gets the default branch from the remote (origin). + * Returns null if unable to determine. + */ +export async function getRemoteDefaultBranch( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + const output = await gitOutput(["remote", "show", "origin"], cwd, executor); + if (!output) return null; + + const match = output.match(/HEAD branch:\s*(\S+)/); + return match?.[1] ?? null; +} diff --git a/packages/core/src/git/repo.ts b/packages/core/src/git/repo.ts new file mode 100644 index 000000000..c315a3897 --- /dev/null +++ b/packages/core/src/git/repo.ts @@ -0,0 +1,37 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; +import { gitCheck } from "./runner"; + +export async function isInGitRepo( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return gitCheck(["rev-parse", "--git-dir"], cwd, executor); +} + +export async function hasGitCommits( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return gitCheck(["rev-parse", "HEAD"], cwd, executor); +} + +export async function initGit( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise> { + try { + const result = await executor.execute("git", ["init"], { cwd }); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || "Failed to initialize git", + ), + ); + } + return ok(undefined); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to initialize git: ${e}`)); + } +} diff --git a/packages/core/src/git/runner.ts b/packages/core/src/git/runner.ts new file mode 100644 index 000000000..74c39cc1f --- /dev/null +++ b/packages/core/src/git/runner.ts @@ -0,0 +1,45 @@ +import { + type CommandExecutor, + cmdCheck, + cmdOutput, + runSync, + runSyncLines, + shellExecutor, +} from "../executor"; + +/** Namespace for arr metadata refs */ +export const REFS_PREFIX = "refs/arr"; + +/** Run an async git command and check if it succeeded. */ +export function gitCheck( + args: string[], + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return cmdCheck("git", args, cwd, executor); +} + +/** Run an async git command and return stdout if successful, null otherwise. */ +export function gitOutput( + args: string[], + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return cmdOutput("git", args, cwd, executor); +} + +/** Run a git command synchronously. */ +export function runGitSync( + args: string[], + options?: { cwd?: string; input?: string; onError?: "throw" | "ignore" }, +): string { + return runSync("git", args, options); +} + +/** Run a git command synchronously and split output into lines. */ +export function runGitSyncLines( + args: string[], + options?: { cwd?: string; onError?: "throw" | "ignore" }, +): string[] { + return runSyncLines("git", args, options); +} diff --git a/packages/core/src/git/status.ts b/packages/core/src/git/status.ts new file mode 100644 index 000000000..cfc8a6a64 --- /dev/null +++ b/packages/core/src/git/status.ts @@ -0,0 +1,13 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { gitOutput } from "./runner"; + +/** + * Get the current git branch name. + * Returns null if in detached HEAD state or not in a git repo. + */ +export async function getCurrentGitBranch( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return gitOutput(["symbolic-ref", "--short", "HEAD"], cwd, executor); +} diff --git a/packages/core/src/git/trunk.ts b/packages/core/src/git/trunk.ts new file mode 100644 index 000000000..b7a7f7b89 --- /dev/null +++ b/packages/core/src/git/trunk.ts @@ -0,0 +1,57 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { getRemoteDefaultBranch } from "./remote"; +import { gitCheck, gitOutput } from "./runner"; + +/** + * Detects the trunk branch for a repository. + * + * Strategy: + * 1. Query the remote's HEAD branch (most authoritative) + * 2. If that fails or branch doesn't exist locally, fall back to checking + * common branch names and return all matches for user selection + */ +export async function detectTrunkBranches( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + // First, try to get the remote's default branch + const remoteTrunk = await getRemoteDefaultBranch(cwd, executor); + if (remoteTrunk) { + // Verify the branch exists locally + const localExists = await gitCheck( + ["show-ref", "--verify", "--quiet", `refs/heads/${remoteTrunk}`], + cwd, + executor, + ); + if (localExists) { + return [remoteTrunk]; + } + // Branch exists on remote but not locally - still prefer it + const remoteExists = await gitCheck( + ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${remoteTrunk}`], + cwd, + executor, + ); + if (remoteExists) { + return [remoteTrunk]; + } + } + + // Fall back to checking common branch names + const candidates = ["main", "master", "develop", "trunk"]; + const found: string[] = []; + + const branchOutput = await gitOutput(["branch", "-a"], cwd, executor); + if (!branchOutput) { + return ["main", "master"]; + } + + const branches = branchOutput.toLowerCase(); + for (const candidate of candidates) { + if (branches.includes(candidate)) { + found.push(candidate); + } + } + + return found.length > 0 ? found : ["main"]; +} diff --git a/packages/core/src/init.ts b/packages/core/src/init.ts new file mode 100644 index 000000000..a24b3b3dd --- /dev/null +++ b/packages/core/src/init.ts @@ -0,0 +1,122 @@ +import { join } from "node:path"; +import { shellExecutor } from "./executor"; +import { createError, err, ok, type Result } from "./result"; + +export interface Prerequisites { + git: { found: boolean; version?: string; path?: string }; + jj: { found: boolean; version?: string; path?: string }; +} + +export async function checkPrerequisites(): Promise { + const [git, jj] = await Promise.all([checkBinary("git"), checkBinary("jj")]); + + return { git, jj }; +} + +async function checkBinary( + name: string, +): Promise<{ found: boolean; version?: string; path?: string }> { + try { + const whichResult = await shellExecutor.execute("which", [name], { + cwd: process.cwd(), + }); + if (whichResult.exitCode !== 0) { + return { found: false }; + } + + const path = whichResult.stdout.trim(); + const versionResult = await shellExecutor.execute(name, ["--version"], { + cwd: process.cwd(), + }); + const version = versionResult.stdout.trim().split("\n")[0]; + + return { found: true, version, path }; + } catch { + return { found: false }; + } +} + +export async function isJjInitialized(cwd: string): Promise { + const jjDir = join(cwd, ".jj"); + try { + const { stat } = await import("node:fs/promises"); + const stats = await stat(jjDir); + return stats.isDirectory(); + } catch { + return false; + } +} + +export async function initJj(cwd: string): Promise> { + try { + const result = await shellExecutor.execute( + "jj", + ["git", "init", "--colocate"], + { cwd }, + ); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || "Failed to initialize jj", + ), + ); + } + return ok(undefined); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to initialize jj: ${e}`)); + } +} + +export async function configureTrunk( + cwd: string, + trunk: string, +): Promise> { + try { + const result = await shellExecutor.execute( + "jj", + ["config", "set", "--repo", 'revset-aliases."trunk()"', trunk], + { cwd }, + ); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || "Failed to configure trunk", + ), + ); + } + return ok(undefined); + } catch (e) { + return err( + createError("COMMAND_FAILED", `Failed to configure trunk: ${e}`), + ); + } +} + +export async function installJj( + method: "brew" | "cargo", +): Promise> { + try { + const cmd = + method === "brew" + ? ["brew", ["install", "jj"]] + : ["cargo", ["install", "jj-cli"]]; + const result = await shellExecutor.execute( + cmd[0] as string, + cmd[1] as string[], + { cwd: process.cwd() }, + ); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || `Failed to install jj via ${method}`, + ), + ); + } + return ok(undefined); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to install jj: ${e}`)); + } +} diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts new file mode 100644 index 000000000..739a6fda5 --- /dev/null +++ b/packages/core/src/log.ts @@ -0,0 +1,147 @@ +import type { Changeset } from "./parser"; + +interface LogNode { + change: Changeset; + children: LogNode[]; +} + +interface LogEntry { + change: Changeset; + prefix: string; + isCurrent: boolean; + isLastInStack: boolean; + stackIndex: number; + /** True when the change's bookmark has local commits not yet pushed */ + isModified: boolean; +} + +/** Minimal PR info for log display */ +export interface LogPRInfo { + number: number; + state: "OPEN" | "MERGED" | "CLOSED"; + url: string; +} + +export interface EnrichedLogEntry extends LogEntry { + prInfo: LogPRInfo | null; + diffStats: { + filesChanged: number; + insertions: number; + deletions: number; + } | null; +} + +export interface EnrichedLogResult extends Omit { + entries: EnrichedLogEntry[]; + modifiedCount: number; +} + +export interface UncommittedWork { + changeId: string; + changeIdPrefix: string; + /** True when uncommitted work is directly on trunk, not in a stack */ + isOnTrunk: boolean; + diffStats: { + filesChanged: number; + insertions: number; + deletions: number; + } | null; +} + +export interface TrunkInfo { + name: string; + commitId: string; + commitIdPrefix: string; + description: string; + timestamp: Date; +} + +export interface LogResult { + entries: LogEntry[]; + trunk: TrunkInfo; + currentChangeId: string | null; + currentChangeIdPrefix: string | null; + isOnTrunk: boolean; + /** True when @ is an empty, undescribed change above the stack */ + hasEmptyWorkingCopy: boolean; + /** Present when @ has file changes but no description (uncommitted work) */ + uncommittedWork: UncommittedWork | null; +} + +export function buildTree(changes: Changeset[], trunkId: string): LogNode[] { + const nodeMap = new Map(); + const hasChild = new Set(); + + for (const change of changes) { + nodeMap.set(change.changeId, { change, children: [] }); + } + + // Build reverse tree: each node points to its parent as "child" + // This lets us traverse from heads down to roots + for (const change of changes) { + const parentId = change.parents[0]; + if (parentId && parentId !== trunkId && nodeMap.has(parentId)) { + // This node has a parent in our set, so parent is not a head + hasChild.add(parentId); + // Link: node -> parent (reversed direction for display) + const node = nodeMap.get(change.changeId)!; + const parent = nodeMap.get(parentId)!; + node.children.push(parent); + } + } + + // Heads are nodes that have no children pointing to them + const heads: LogNode[] = []; + for (const change of changes) { + if (!hasChild.has(change.changeId)) { + heads.push(nodeMap.get(change.changeId)!); + } + } + + return heads; +} + +export function flattenTree( + heads: LogNode[], + currentChangeId: string | null, + modifiedBookmarks: Set = new Set(), +): LogEntry[] { + const result: LogEntry[] = []; + + function visit(node: LogNode, prefix: string, stackIndex: number): void { + // isLastInStack = this node has no more ancestors (closest to trunk) + const isLastInStack = node.children.length === 0; + + // Check if any bookmark on this change is modified (has unpushed commits) + const isModified = node.change.bookmarks.some((b) => + modifiedBookmarks.has(b), + ); + + result.push({ + change: node.change, + prefix, + isCurrent: node.change.changeId === currentChangeId, + isLastInStack, + stackIndex, + isModified, + }); + + // children here are actually ancestors (going toward trunk) + for (const child of node.children) { + visit(child, prefix, stackIndex); + } + } + + // Sort heads by timestamp (newest first) + const sortedHeads = [...heads].sort( + (a, b) => b.change.timestamp.getTime() - a.change.timestamp.getTime(), + ); + + for (let i = 0; i < sortedHeads.length; i++) { + // First stack has no prefix, remaining stacks get │ prefix + const prefix = i === 0 ? "" : "│ "; + visit(sortedHeads[i], prefix, i); + } + + return result; +} diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts new file mode 100644 index 000000000..6fb3c8c45 --- /dev/null +++ b/packages/core/src/parser.ts @@ -0,0 +1,153 @@ +import { z } from "zod"; +import { createError, err, ok, type Result } from "./result"; +import type { ConflictInfo, FileChange } from "./types"; + +const BookmarkSchema = z.object({ + name: z.string(), + target: z.array(z.string().nullable()).optional(), +}); + +const DiffStatsSchema = z.object({ + filesChanged: z.number(), + insertions: z.number(), + deletions: z.number(), +}); + +const ChangesetSchema = z + .object({ + base: z.object({ + commit_id: z.string(), + change_id: z.string(), + description: z.string(), + author: z.object({ + name: z.string(), + email: z.string(), + timestamp: z.string(), + }), + }), + parentChangeIds: z.array(z.string()), + empty: z.boolean(), + conflict: z.boolean(), + immutable: z.boolean(), + workingCopy: z.boolean(), + bookmarks: z.array(BookmarkSchema), + changeIdPrefix: z.string(), + commitIdPrefix: z.string(), + diffStats: DiffStatsSchema.optional(), + }) + .transform((raw) => ({ + changeId: raw.base.change_id.slice(0, 12), + commitId: raw.base.commit_id.slice(0, 12), + changeIdPrefix: raw.changeIdPrefix, + commitIdPrefix: raw.commitIdPrefix, + description: raw.base.description.split("\n")[0], + author: { name: raw.base.author.name, email: raw.base.author.email }, + timestamp: new Date(raw.base.author.timestamp), + parents: raw.parentChangeIds.map((p) => p.slice(0, 12)), + isEmpty: raw.empty, + hasConflicts: raw.conflict, + isImmutable: raw.immutable, + isWorkingCopy: raw.workingCopy, + bookmarks: raw.bookmarks.map((b) => b.name.replace(/\*$/, "")), + diffStats: raw.diffStats ?? null, + })); + +export type Changeset = z.output; + +export function parseChangesets(stdout: string): Result { + try { + const lines = stdout.trim().split("\n").filter(Boolean); + return ok(lines.map((line) => ChangesetSchema.parse(JSON.parse(line)))); + } catch (e) { + return err(createError("PARSE_ERROR", `Failed to parse jj output: ${e}`)); + } +} + +export function parseFileChanges(stdout: string): Result { + try { + const lines = stdout.trim().split("\n").filter(Boolean); + const changes: FileChange[] = []; + + for (const line of lines) { + // Match status char followed by space(s) and path + // Use non-backtracking approach: check first char, then split + const firstChar = line[0]; + if (!"MADR".includes(firstChar)) continue; + if (line[1] !== " " && line[1] !== "\t") continue; + const path = line.slice(2).trim(); + if (!path) continue; + + const statusChar = firstChar; + const statusMap: Record = { + M: "modified", + A: "added", + D: "deleted", + R: "renamed", + }; + + changes.push({ + path, + status: statusMap[statusChar] ?? "modified", + }); + } + + return ok(changes); + } catch (e) { + return err( + createError("PARSE_ERROR", `Failed to parse file changes: ${e}`), + ); + } +} + +export function parseConflicts(stdout: string): Result { + try { + const lines = stdout.trim().split("\n").filter(Boolean); + const conflicts: ConflictInfo[] = []; + + for (const line of lines) { + // Old format: "C path/to/file" + if (line.startsWith("C ")) { + conflicts.push({ + path: line.slice(2).trim(), + type: "content", + }); + } + // New format: "path/to/file 2-sided conflict" or similar + // Must start with a path (non-space) and contain "-sided conflict" + // This excludes lines like "Working copy (@) : xyz (conflict) ..." + else if (line.includes("-sided conflict")) { + // Extract path: everything before first whitespace + const spaceIdx = line.search(/\s/); + if (spaceIdx > 0) { + const path = line.slice(0, spaceIdx); + // Verify the rest contains the conflict marker + if (line.slice(spaceIdx).includes("-sided conflict")) { + conflicts.push({ + path, + type: "content", + }); + } + } + } + } + + return ok(conflicts); + } catch (e) { + return err(createError("PARSE_ERROR", `Failed to parse conflicts: ${e}`)); + } +} + +export function detectError( + stderr: string, +): { code: string; message: string } | null { + if (stderr.includes("There is no jj repo in")) { + return { code: "NOT_IN_REPO", message: "Not in a jj repository" }; + } + if (stderr.includes("Revision") && stderr.includes("doesn't exist")) { + return { code: "INVALID_REVISION", message: "Invalid revision" }; + } + if (stderr.includes("Workspace") && stderr.includes("doesn't exist")) { + return { code: "WORKSPACE_NOT_FOUND", message: "Workspace not found" }; + } + return null; +} diff --git a/packages/core/src/resolve-state.ts b/packages/core/src/resolve-state.ts new file mode 100644 index 000000000..008659d2c --- /dev/null +++ b/packages/core/src/resolve-state.ts @@ -0,0 +1,63 @@ +import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runSync } from "./executor"; + +/** + * State persisted during conflict resolution. + * Stored in .jj/arr-resolve-state.json + */ +export interface ResolveState { + /** Original bookmark to return to when done */ + originalBookmark: string; + /** Original change ID for safety checks */ + originalChangeId: string; + /** Timestamp when resolution started */ + startedAt: string; +} + +function getStatePath(cwd: string): string { + // Find .jj directory + const jjRoot = runSync("jj", ["root"], { cwd, onError: "ignore" }); + if (!jjRoot) return join(cwd, ".jj", "arr-resolve-state.json"); + return join(jjRoot, ".jj", "arr-resolve-state.json"); +} + +/** + * Save resolve state to disk. + */ +export function saveResolveState(state: ResolveState, cwd: string): void { + const path = getStatePath(cwd); + writeFileSync(path, JSON.stringify(state, null, 2)); +} + +/** + * Load resolve state from disk, or null if not in resolution. + */ +export function loadResolveState(cwd: string): ResolveState | null { + const path = getStatePath(cwd); + if (!existsSync(path)) return null; + + try { + const content = readFileSync(path, "utf-8"); + return JSON.parse(content) as ResolveState; + } catch { + return null; + } +} + +/** + * Clear resolve state (resolution complete or aborted). + */ +export function clearResolveState(cwd: string): void { + const path = getStatePath(cwd); + if (existsSync(path)) { + unlinkSync(path); + } +} + +/** + * Check if we're currently in conflict resolution mode. + */ +export function isInResolveMode(cwd: string): boolean { + return loadResolveState(cwd) !== null; +} diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts new file mode 100644 index 000000000..66ce7c657 --- /dev/null +++ b/packages/core/src/result.ts @@ -0,0 +1,55 @@ +export type Result = + | { ok: true; value: T } + | { ok: false; error: E }; + +export function ok(value: T): Result { + return { ok: true, value }; +} + +export function err(error: E): Result { + return { ok: false, error }; +} + +interface JJError { + code: JJErrorCode; + message: string; + command?: string; + stderr?: string; +} + +export type JJErrorCode = + | "NOT_IN_REPO" + | "NOT_INITIALIZED" + | "COMMAND_FAILED" + | "CONFLICT" + | "INVALID_REVISION" + | "AMBIGUOUS_REVISION" + | "INVALID_STATE" + | "WORKSPACE_NOT_FOUND" + | "PARSE_ERROR" + | "DEPENDENCY_MISSING" + | "NAVIGATION_FAILED" + | "MERGE_BLOCKED" + | "ALREADY_MERGED" + | "NOT_FOUND" + | "EMPTY_CHANGE" + | "UNKNOWN"; + +export function createError( + code: JJErrorCode, + message: string, + details?: { command?: string; stderr?: string }, +): JJError { + return { + code, + message, + ...details, + }; +} + +export function unwrap(result: Result): T { + if (!result.ok) { + throw new Error(`unwrap called on error result: ${result.error.message}`); + } + return result.value; +} diff --git a/packages/core/src/slugify.ts b/packages/core/src/slugify.ts new file mode 100644 index 000000000..da78a220c --- /dev/null +++ b/packages/core/src/slugify.ts @@ -0,0 +1,52 @@ +/** + * Converts text to a branch-safe slug using hyphens. + * Used for git branch names where hyphens are conventional. + * + * - Lowercase all characters + * - Replace non-alphanumeric with hyphens + * - Collapse multiple hyphens + * - Trim leading/trailing hyphens + * - Limit to 50 characters + */ +function slugifyForBranch(text: string): string { + if (!text || !text.trim()) { + return "untitled"; + } + + // Replace non-alphanumeric with hyphens, then collapse via split/join + let result = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .split("-") + .filter(Boolean) + .join("-"); + + // Trim leading/trailing hyphens + while (result.startsWith("-")) result = result.slice(1); + while (result.endsWith("-")) result = result.slice(0, -1); + + return result.slice(0, 50) || "untitled"; +} + +/** + * Generate a display label for a change from its description and ID. + * Format: {slug}-{shortChangeId} e.g. "my-first-change-abc123" + * Used for CLI output and simple identification, not for actual git branch names. + */ +export function changeLabel(description: string, changeId: string): string { + const slug = slugifyForBranch(description); + const shortId = changeId.slice(0, 6); + return `${slug}-${shortId}`; +} + +/** + * Generate a date-prefixed display label for a change. + * Format: MM-DD-slug e.g. "01-15-my-feature" + * Used for log/timeline displays. + */ +export function datePrefixedLabel(description: string, date: Date): string { + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const slug = slugifyForBranch(description); + return `${month}-${day}-${slug}`; +} diff --git a/packages/core/src/stack-comment.ts b/packages/core/src/stack-comment.ts new file mode 100644 index 000000000..96ccacb53 --- /dev/null +++ b/packages/core/src/stack-comment.ts @@ -0,0 +1,49 @@ +export type StackEntryStatus = + | "this" + | "waiting" + | "approved" + | "merged" + | "closed"; + +export interface StackEntry { + prNumber: number; + title: string; + status: StackEntryStatus; +} + +export interface StackCommentOptions { + stack: StackEntry[]; +} + +export function generateStackComment(options: StackCommentOptions): string { + const { stack } = options; + + const lines: string[] = []; + + // Stack should be displayed top-to-bottom (newest first, closest to main last) + // Reverse the array since it comes in bottom-to-top order from submit + const topToBottom = [...stack].reverse(); + + for (const entry of topToBottom) { + const pointer = entry.status === "this" ? " 👈" : ""; + lines.push(`* **#${entry.prNumber}** ${entry.title}${pointer}`); + } + + lines.push("* `main`"); + lines.push(""); + lines.push("---"); + lines.push(""); + lines.push("Merge from bottom to top, or use `arr merge`"); + + return lines.join("\n"); +} + +export function mapReviewDecisionToStatus( + reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null, + state: "OPEN" | "CLOSED" | "MERGED", +): StackEntryStatus { + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + if (reviewDecision === "APPROVED") return "approved"; + return "waiting"; +} diff --git a/packages/core/src/templates.ts b/packages/core/src/templates.ts new file mode 100644 index 000000000..b798ec96e --- /dev/null +++ b/packages/core/src/templates.ts @@ -0,0 +1,37 @@ +export const CHANGESET_JSON_TEMPLATE = + '"{" ++' + + '"\\"base\\":" ++ json(self) ++ "," ++' + + '"\\"parentChangeIds\\":[" ++ parents.map(|p| "\\"" ++ p.change_id() ++ "\\"").join(",") ++ "]," ++' + + '"\\"empty\\":" ++ json(empty) ++ "," ++' + + '"\\"conflict\\":" ++ json(conflict) ++ "," ++' + + '"\\"immutable\\":" ++ json(immutable) ++ "," ++' + + '"\\"workingCopy\\":" ++ json(current_working_copy) ++ "," ++' + + '"\\"bookmarks\\":" ++ json(local_bookmarks) ++ "," ++' + + '"\\"changeIdPrefix\\":\\"" ++ change_id.shortest().prefix() ++ "\\"," ++' + + '"\\"commitIdPrefix\\":\\"" ++ commit_id.shortest().prefix() ++ "\\"" ++' + + '"}\\n"'; + +/** + * Template for log graph output with placeholders. + * jj handles graph rendering (markers like @, ○, ◆ and │ prefixes). + * We replace jj's markers with styled versions in post-processing. + * + * Output format per change: + * {{LABEL:changeId|prefix|timestamp|description|conflict|wc|empty|immutable|localBookmarks|remoteBookmarks}} + * + * The CLI handles: + * - Hiding empty undescribed WC (abstracted away) + * - Showing green marker on "current" branch (parent of empty WC) + * - Showing "(uncommitted)" badge when WC has changes + */ +export const LOG_GRAPH_TEMPLATE = ` +"{{LABEL:" ++ change_id.short(8) ++ "|" ++ change_id.shortest().prefix() ++ "|" ++ committer.timestamp().format("%s") ++ "|" ++ if(description, description.first_line(), "") ++ "|" ++ if(conflict, "1", "0") ++ "|" ++ if(current_working_copy, "1", "0") ++ "|" ++ if(empty, "1", "0") ++ "|" ++ if(immutable, "1", "0") ++ "|" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ remote_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n" ++ +"{{TIME:" ++ committer.timestamp().format("%s") ++ "}}\\n" ++ +if(current_working_copy && empty && !description, "{{HINT_EMPTY}}\\n", "") ++ +if(current_working_copy && !empty && !description, "{{HINT_UNCOMMITTED}}\\n", "") ++ +if(current_working_copy && description && local_bookmarks, "{{HINT_SUBMIT}}\\n", "") ++ +"\\n" ++ +if(local_bookmarks, "{{PR:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n{{PRURL:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n", "") ++ +"{{COMMIT:" ++ commit_id.short(8) ++ "|" ++ commit_id.shortest().prefix() ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n" ++ +"\\n" +`.trim(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 000000000..80c971f92 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,233 @@ +import type { Changeset } from "./parser"; + +export interface Author { + name: string; + email: string; +} + +export interface ConflictInfo { + path: string; + type: "content" | "delete" | "rename"; +} + +/** Lightweight changeset info for status display */ +export interface StatusChangeset { + changeId: string; + changeIdPrefix: string; + commitId: string; + commitIdPrefix: string; + description: string; + bookmarks: string[]; + parents: string[]; + isWorkingCopy: boolean; + isImmutable: boolean; + isEmpty: boolean; + hasConflicts: boolean; +} + +export interface ChangesetStatus { + workingCopy: StatusChangeset; + parents: StatusChangeset[]; + modifiedFiles: FileChange[]; + conflicts: ConflictInfo[]; + hasResolvedConflict: boolean; +} + +export interface FileChange { + path: string; + status: "modified" | "added" | "deleted" | "renamed" | "copied"; + originalPath?: string; +} + +export interface DiffStats { + filesChanged: number; + insertions: number; + deletions: number; +} + +export interface Bookmark { + name: string; + changeId: string; + isTracking: boolean; + remote?: string; +} + +export interface BookmarkTrackingStatus { + name: string; + aheadCount: number; + behindCount: number; +} + +export interface WorkspaceInfo { + name: string; + path: string; + isCurrent: boolean; +} + +export interface ListOptions { + revset?: string; + limit?: number; +} + +export interface NewOptions { + parents?: string[]; + message?: string; + noEdit?: boolean; +} + +export interface BookmarkOptions { + revision?: string; + create?: boolean; + move?: boolean; +} + +export interface PushOptions { + remote?: string; + bookmark?: string; +} + +export interface PROptions { + title?: string; + body?: string; + base?: string; + draft?: boolean; +} + +export interface PRResult { + url: string; + number: number; +} + +export interface CreateOptions { + message: string; + all?: boolean; + /** Optional bookmark name. If not provided, one will be generated from the message. */ + bookmarkName?: string; +} + +export type PRSubmitStatus = "created" | "updated" | "synced" | "untracked"; + +export interface StackPR { + changeId: string; + bookmarkName: string; + prNumber: number; + prUrl: string; + base: string; + title: string; + position: number; + status: PRSubmitStatus; +} + +export interface SubmitOptions { + stack?: boolean; + draft?: boolean; + /** Tracked bookmarks - only these will be shown in stack comments */ + trackedBookmarks?: string[]; + /** Dry run - show what would be done without making changes */ + dryRun?: boolean; +} + +export interface SubmitResult { + prs: StackPR[]; + created: number; + updated: number; + synced: number; + /** True if this was a dry run (no changes made) */ + dryRun?: boolean; +} + +export interface AbandonedChange { + changeId: string; + reason: "empty" | "merged"; +} + +export interface SyncResult { + fetched: boolean; + rebased: boolean; + abandoned: AbandonedChange[]; + forgottenBookmarks: string[]; + hasConflicts: boolean; +} + +export type NavigationPosition = + | "editing" // On a branch, editing it directly + | "on-top" // At top of stack, ready for new work + | "on-trunk"; // On trunk, starting fresh + +export interface NavigationResult { + changeId: string; + changeIdPrefix: string; + description: string; + /** The bookmark/branch name if on a tracked branch */ + bookmark?: string; + /** Where we ended up */ + position: NavigationPosition; +} + +export interface FindOptions { + query: string; + includeBookmarks?: boolean; +} + +export type FindResult = + | { status: "found"; change: Changeset } + | { status: "multiple"; matches: Changeset[] } + | { status: "none" }; + +export type ModifyResult = + | { status: "squashed" } + | { status: "already_editing"; description: string } + | { status: "no_parent" }; + +export type NextAction = + | { action: "create"; reason: "unsaved" | "empty" | "on_trunk" } + | { action: "submit"; reason: "create_pr" | "update_pr" } + | { action: "continue"; reason: "conflicts" } + | { action: "up"; reason: "start_new" }; + +export interface StatusInfo { + changeId: string; + changeIdPrefix: string; + name: string; + isUndescribed: boolean; + hasChanges: boolean; + hasConflicts: boolean; + /** True if the current stack is behind trunk and needs restacking */ + isBehindTrunk: boolean; + stackPath: string[]; + modifiedFiles: FileChange[]; + conflicts: ConflictInfo[]; + nextAction: NextAction; +} + +export interface PRToMerge { + prNumber: number; + prTitle: string; + prUrl: string; + bookmarkName: string; + changeId: string | null; + baseRefName: string; +} + +export interface MergeOptions { + method?: "merge" | "squash" | "rebase"; +} + +export interface MergeResult { + merged: PRToMerge[]; + synced: boolean; +} + +/** Transaction state for tracking resources created during submitStack */ +export interface SubmitTransaction { + createdPRs: Array<{ number: number; bookmark: string }>; + createdBookmarks: string[]; + pushedBookmarks: string[]; +} + +/** Result of rolling back a failed submission */ +export interface RollbackResult { + closedPRs: number[]; + deletedBookmarks: string[]; + failures: string[]; +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 000000000..b442a6da9 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 000000000..9e7aa6df9 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ES2022", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "declaration": true, + "declarationMap": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b9ee8e17..772c4d2bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 9.1.7 knip: specifier: ^5.66.3 - version: 5.70.1(@types/node@22.19.1)(typescript@5.9.3) + version: 5.70.1(@types/node@25.0.3)(typescript@5.9.3) lint-staged: specifier: ^15.5.2 version: 15.5.2 @@ -374,11 +374,30 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1)) vitest: specifier: ^4.0.10 - version: 4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + version: 4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) yaml: specifier: ^2.8.1 version: 2.8.1 + apps/cli: + dependencies: + '@array/core': + specifier: workspace:* + version: link:../../packages/core + devDependencies: + '@types/bun': + specifier: latest + version: 1.3.5 + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vitest: + specifier: ^4.0.16 + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + packages/agent: dependencies: '@agentclientprotocol/sdk': @@ -414,7 +433,7 @@ importers: devDependencies: '@changesets/cli': specifier: ^2.27.8 - version: 2.29.7(@types/node@22.19.1) + version: 2.29.7(@types/node@25.0.3) '@types/bun': specifier: latest version: 1.3.5 @@ -431,6 +450,28 @@ importers: specifier: ^5.5.0 version: 5.9.3 + packages/core: + dependencies: + '@octokit/graphql': + specifier: ^9.0.3 + version: 9.0.3 + '@octokit/graphql-schema': + specifier: ^15.26.1 + version: 15.26.1 + '@octokit/rest': + specifier: ^22.0.1 + version: 22.0.1 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + '@types/bun': + specifier: latest + version: 1.3.5 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + packages/electron-trpc: devDependencies: '@trpc/client': @@ -444,7 +485,7 @@ importers: version: 20.19.25 '@vitest/coverage-v8': specifier: ^0.34.0 - version: 0.34.6(vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1)) + version: 0.34.6(vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1)) builtin-modules: specifier: ^3.3.0 version: 3.3.0 @@ -454,9 +495,6 @@ importers: electron: specifier: ^35.2.1 version: 35.7.5 - superjson: - specifier: ^2.2.2 - version: 2.2.6 typescript: specifier: ^5.8.3 version: 5.9.3 @@ -468,10 +506,7 @@ importers: version: 0.1.4(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1) - zod: - specifier: ^3.24.1 - version: 3.25.76 + version: 2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1) packages: @@ -1565,6 +1600,10 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + '@inquirer/checkbox@3.0.1': resolution: {integrity: sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ==} engines: {node: '>=18'} @@ -1573,6 +1612,24 @@ packages: resolution: {integrity: sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w==} engines: {node: '>=18'} + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/core@9.2.1': resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} engines: {node: '>=18'} @@ -1634,6 +1691,15 @@ packages: resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} engines: {node: '>=18'} + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inversifyjs/common@1.5.2': resolution: {integrity: sha512-WlzR9xGadABS9gtgZQ+luoZ8V6qm4Ii6RQfcfC9Ho2SOlE6ZuemFo7PKJvKI0ikm8cmKbU8hw5UK6E4qovH21w==} @@ -1778,6 +1844,10 @@ packages: '@cfworker/json-schema': optional: true + '@mswjs/interceptors@0.40.0': + resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} + engines: {node: '>=18'} + '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} @@ -1814,42 +1884,82 @@ packages: resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} engines: {node: '>= 18'} + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + '@octokit/core@5.2.2': resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} engines: {node: '>= 18'} + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.2': + resolution: {integrity: sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==} + engines: {node: '>= 20'} + '@octokit/endpoint@9.0.6': resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} engines: {node: '>= 18'} + '@octokit/graphql-schema@15.26.1': + resolution: {integrity: sha512-RFDC2MpRBd4AxSRvUeBIVeBU7ojN/SxDfALUd7iVYOSeEK3gZaqR2MGOysj4Zh2xj2RY5fQAUT+Oqq7hWTraMA==} + '@octokit/graphql@7.1.1': resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} engines: {node: '>= 18'} + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + '@octokit/openapi-types@12.11.0': resolution: {integrity: sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==} '@octokit/openapi-types@24.2.0': resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + '@octokit/plugin-paginate-rest@11.4.4-cjs.2': resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==} engines: {node: '>= 18'} peerDependencies: '@octokit/core': '5' + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + '@octokit/plugin-request-log@4.0.1': resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} engines: {node: '>= 18'} peerDependencies: '@octokit/core': '5' + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1': resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==} engines: {node: '>= 18'} peerDependencies: '@octokit/core': ^5 + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + '@octokit/plugin-retry@6.1.0': resolution: {integrity: sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig==} engines: {node: '>= 18'} @@ -1860,6 +1970,14 @@ packages: resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} engines: {node: '>= 18'} + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.7': + resolution: {integrity: sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==} + engines: {node: '>= 20'} + '@octokit/request@8.4.1': resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} engines: {node: '>= 18'} @@ -1868,9 +1986,16 @@ packages: resolution: {integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==} engines: {node: '>= 18'} + '@octokit/rest@22.0.1': + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} + '@octokit/types@13.10.0': resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@octokit/types@6.41.0': resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==} @@ -1878,6 +2003,15 @@ packages: resolution: {integrity: sha512-w7FhUXfqpzw9igTZFfKS7cUNW1FK+tT426ZkClG2X8vufW0jyGqfgPd6Uq8+gJgSTLxayF9I802FDW2KjYcfYQ==} engines: {node: '>=18'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -3304,6 +3438,9 @@ packages: '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/node@25.0.3': + resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -3318,6 +3455,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3360,6 +3500,9 @@ packages: '@vitest/expect@4.0.12': resolution: {integrity: sha512-is+g0w8V3/ZhRNrRizrJNr8PFQKwYmctWlU4qg8zy5r9aIV5w8IxXLlfbbxJCwSpsVl2PXPTm2/zruqTqz3QSg==} + '@vitest/expect@4.0.16': + resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + '@vitest/mocker@2.1.9': resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: @@ -3382,30 +3525,53 @@ packages: vite: optional: true + '@vitest/mocker@4.0.16': + resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} '@vitest/pretty-format@4.0.12': resolution: {integrity: sha512-R7nMAcnienG17MvRN8TPMJiCG8rrZJblV9mhT7oMFdBXvS0x+QD6S1G4DxFusR2E0QIS73f7DqSR1n87rrmE+g==} + '@vitest/pretty-format@4.0.16': + resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} '@vitest/runner@4.0.12': resolution: {integrity: sha512-hDlCIJWuwlcLumfukPsNfPDOJokTv79hnOlf11V+n7E14rHNPz0Sp/BO6h8sh9qw4/UjZiKyYpVxK2ZNi+3ceQ==} + '@vitest/runner@4.0.16': + resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + '@vitest/snapshot@2.1.9': resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} '@vitest/snapshot@4.0.12': resolution: {integrity: sha512-2jz9zAuBDUSbnfyixnyOd1S2YDBrZO23rt1bicAb6MA/ya5rHdKFRikPIDpBj/Dwvh6cbImDmudegnDAkHvmRQ==} + '@vitest/snapshot@4.0.16': + resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + '@vitest/spy@2.1.9': resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} '@vitest/spy@4.0.12': resolution: {integrity: sha512-GZjI9PPhiOYNX8Nsyqdw7JQB+u0BptL5fSnXiottAUBHlcMzgADV58A7SLTXXQwcN1yZ6gfd1DH+2bqjuUlCzw==} + '@vitest/spy@4.0.16': + resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + '@vitest/ui@4.0.12': resolution: {integrity: sha512-RCqeApCnbwd5IFvxk6OeKMXTvzHU/cVqY8HAW0gWk0yAO6wXwQJMKhDfDtk2ss7JCy9u7RNC3kyazwiaDhBA/g==} peerDependencies: @@ -3417,6 +3583,9 @@ packages: '@vitest/utils@4.0.12': resolution: {integrity: sha512-DVS/TLkLdvGvj1avRy0LSmKfrcI9MNFvNGN6ECjTUHWJdlcgPDOXhjMis5Dh7rBH62nAmSXnkPbE+DZ5YD75Rw==} + '@vitest/utils@4.0.16': + resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + '@vscode/sudo-prompt@9.3.1': resolution: {integrity: sha512-9ORTwwS74VaTn38tNbQhsA5U44zkJfcb0BdTSyyG6frP4e8KMtHuTXYmwefe5dpL8XB1aGSIVTaLjD3BbWb5iA==} @@ -3700,6 +3869,9 @@ packages: before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -4008,8 +4180,8 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - copy-anything@4.0.5: - resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} core-js@3.47.0: @@ -4419,6 +4591,9 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4693,6 +4868,16 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql-tag@2.12.6: + resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + graphql@16.12.0: + resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4718,6 +4903,9 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -4904,6 +5092,9 @@ packages: is-my-json-valid@2.20.6: resolution: {integrity: sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw==} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4937,10 +5128,6 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-what@5.5.0: - resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} - engines: {node: '>=18'} - is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -5494,6 +5681,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.12.4: + resolution: {integrity: sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + murmur-32@0.2.0: resolution: {integrity: sha512-ZkcWZudylwF+ir3Ld1n7gL6bI2mQAzXvSobPwVtu8aYi2sbXeipeSkdcanRLzIofLcM5F53lGaKm2dk7orBi7Q==} @@ -5501,6 +5698,10 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -5619,6 +5820,9 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -5658,6 +5862,9 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + oxc-resolver@11.13.2: resolution: {integrity: sha512-1SXVyYQ9bqMX3uZo8Px81EG7jhZkO9PvvR5X9roY5TLYVm4ZA7pbPDNlYaDBBeF9U+YO3OeMNoHde52hrcCu8w==} @@ -5795,6 +6002,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -6268,6 +6478,9 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + rettime@0.7.0: + resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6498,6 +6711,9 @@ packages: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -6576,10 +6792,6 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} - superjson@2.2.6: - resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} - engines: {node: '>=16'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -6658,6 +6870,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -6684,10 +6900,17 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -6715,6 +6938,10 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -6870,6 +7097,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -6907,6 +7137,9 @@ packages: universal-user-agent@6.0.1: resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -6923,6 +7156,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + update-browserslist-db@1.1.4: resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} hasBin: true @@ -7177,6 +7413,40 @@ packages: jsdom: optional: true + vitest@4.0.16: + resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.16 + '@vitest/browser-preview': 4.0.16 + '@vitest/browser-webdriverio': 4.0.16 + '@vitest/ui': 4.0.16 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-icons-js@11.6.1: resolution: {integrity: sha512-rht18IFYv117UlqBn6o9j258SOtwhDBmtVrGwdoLPpSj6Z5LKQIzarQDd/tCRWneU68KEX25+nsh48tAoknKNw==} @@ -7225,6 +7495,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -7642,7 +7913,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.7(@types/node@22.19.1)': + '@changesets/cli@2.29.7(@types/node@25.0.3)': dependencies: '@changesets/apply-release-plan': 7.0.13 '@changesets/assemble-release-plan': 6.0.9 @@ -7658,7 +7929,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@22.19.1) + '@inquirer/external-editor': 1.0.3(@types/node@25.0.3) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -8725,6 +8996,9 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@inquirer/ansi@1.0.2': + optional: true + '@inquirer/checkbox@3.0.1': dependencies: '@inquirer/core': 9.2.1 @@ -8738,6 +9012,50 @@ snapshots: '@inquirer/core': 9.2.1 '@inquirer/type': 2.0.0 + '@inquirer/confirm@5.1.21(@types/node@20.19.25)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.25) + '@inquirer/type': 3.0.10(@types/node@20.19.25) + optionalDependencies: + '@types/node': 20.19.25 + optional: true + + '@inquirer/confirm@5.1.21(@types/node@25.0.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) + optionalDependencies: + '@types/node': 25.0.3 + optional: true + + '@inquirer/core@10.3.2(@types/node@20.19.25)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.25) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.25 + optional: true + + '@inquirer/core@10.3.2(@types/node@25.0.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.0.3) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.0.3 + optional: true + '@inquirer/core@9.2.1': dependencies: '@inquirer/figures': 1.0.15 @@ -8765,12 +9083,12 @@ snapshots: '@inquirer/type': 2.0.0 yoctocolors-cjs: 2.1.3 - '@inquirer/external-editor@1.0.3(@types/node@22.19.1)': + '@inquirer/external-editor@1.0.3(@types/node@25.0.3)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.0 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 '@inquirer/figures@1.0.15': {} @@ -8832,6 +9150,16 @@ snapshots: dependencies: mute-stream: 1.0.0 + '@inquirer/type@3.0.10(@types/node@20.19.25)': + optionalDependencies: + '@types/node': 20.19.25 + optional: true + + '@inquirer/type@3.0.10(@types/node@25.0.3)': + optionalDependencies: + '@types/node': 25.0.3 + optional: true + '@inversifyjs/common@1.5.2': {} '@inversifyjs/container@1.14.3(reflect-metadata@0.2.2)': @@ -9045,6 +9373,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@mswjs/interceptors@0.40.0': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + optional: true + '@napi-rs/wasm-runtime@1.0.7': dependencies: '@emnapi/core': 1.7.1 @@ -9090,6 +9428,8 @@ snapshots: '@octokit/auth-token@4.0.0': {} + '@octokit/auth-token@6.0.0': {} + '@octokit/core@5.2.2': dependencies: '@octokit/auth-token': 4.0.0 @@ -9100,35 +9440,77 @@ snapshots: before-after-hook: 2.2.3 universal-user-agent: 6.0.1 + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.7 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.2': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + '@octokit/endpoint@9.0.6': dependencies: '@octokit/types': 13.10.0 universal-user-agent: 6.0.1 + '@octokit/graphql-schema@15.26.1': + dependencies: + graphql: 16.12.0 + graphql-tag: 2.12.6(graphql@16.12.0) + '@octokit/graphql@7.1.1': dependencies: '@octokit/request': 8.4.1 '@octokit/types': 13.10.0 universal-user-agent: 6.0.1 + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.7 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + '@octokit/openapi-types@12.11.0': {} '@octokit/openapi-types@24.2.0': {} + '@octokit/openapi-types@27.0.0': {} + '@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 '@octokit/types': 13.10.0 + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + '@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 '@octokit/types': 13.10.0 + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + '@octokit/plugin-retry@6.1.0(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 @@ -9142,6 +9524,18 @@ snapshots: deprecation: 2.3.1 once: 1.4.0 + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.7': + dependencies: + '@octokit/endpoint': 11.0.2 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + fast-content-type-parse: 3.0.0 + universal-user-agent: 7.0.3 + '@octokit/request@8.4.1': dependencies: '@octokit/endpoint': 9.0.6 @@ -9156,16 +9550,39 @@ snapshots: '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.2) '@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2) + '@octokit/rest@22.0.1': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + '@octokit/types@13.10.0': dependencies: '@octokit/openapi-types': 24.2.0 + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + '@octokit/types@6.41.0': dependencies: '@octokit/openapi-types': 12.11.0 '@openai/codex-sdk@0.60.1': {} + '@open-draft/deferred-promise@2.2.0': + optional: true + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + optional: true + + '@open-draft/until@2.1.0': + optional: true + '@opentelemetry/api@1.9.0': {} '@oxc-resolver/binding-android-arm-eabi@11.13.2': @@ -10549,6 +10966,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@25.0.3': + dependencies: + undici-types: 7.16.0 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.27)': @@ -10564,6 +10985,9 @@ snapshots: dependencies: '@types/node': 20.19.25 + '@types/statuses@2.0.6': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -10595,7 +11019,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@0.34.6(vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1))': + '@vitest/coverage-v8@0.34.6(vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -10608,7 +11032,7 @@ snapshots: std-env: 3.10.0 test-exclude: 6.0.0 v8-to-istanbul: 9.3.0 - vitest: 2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1) + vitest: 2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1) transitivePeerDependencies: - supports-color @@ -10628,22 +11052,42 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1))': + '@vitest/expect@4.0.16': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + chai: 6.2.1 + tinyrainbow: 3.0.3 + + '@vitest/mocker@2.1.9(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 2.12.4(@types/node@20.19.25)(typescript@5.9.3) vite: 5.4.21(@types/node@20.19.25)(terser@5.44.1) - '@vitest/mocker@4.0.12(vite@7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@4.0.12(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(vite@7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 2.12.4(@types/node@20.19.25)(typescript@5.9.3) vite: 7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@4.0.16(msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3))(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.16 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.4(@types/node@25.0.3)(typescript@5.9.3) + vite: 6.4.1(@types/node@25.0.3)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -10652,6 +11096,10 @@ snapshots: dependencies: tinyrainbow: 3.0.3 + '@vitest/pretty-format@4.0.16': + dependencies: + tinyrainbow: 3.0.3 + '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 @@ -10662,6 +11110,11 @@ snapshots: '@vitest/utils': 4.0.12 pathe: 2.0.3 + '@vitest/runner@4.0.16': + dependencies: + '@vitest/utils': 4.0.16 + pathe: 2.0.3 + '@vitest/snapshot@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 @@ -10674,12 +11127,20 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@2.1.9': dependencies: tinyspy: 3.0.2 '@vitest/spy@4.0.12': {} + '@vitest/spy@4.0.16': {} + '@vitest/ui@4.0.12(vitest@4.0.12)': dependencies: '@vitest/utils': 4.0.12 @@ -10689,7 +11150,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vitest: 4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) '@vitest/utils@2.1.9': dependencies: @@ -10702,6 +11163,11 @@ snapshots: '@vitest/pretty-format': 4.0.12 tinyrainbow: 3.0.3 + '@vitest/utils@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + tinyrainbow: 3.0.3 + '@vscode/sudo-prompt@9.3.1': {} '@webassemblyjs/ast@1.14.1': @@ -10990,6 +11456,8 @@ snapshots: before-after-hook@2.2.3: {} + before-after-hook@4.0.0: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -11313,9 +11781,8 @@ snapshots: cookie@0.7.2: {} - copy-anything@4.0.5: - dependencies: - is-what: 5.5.0 + cookie@1.1.1: + optional: true core-js@3.47.0: {} @@ -11787,6 +12254,8 @@ snapshots: transitivePeerDependencies: - supports-color + fast-content-type-parse@3.0.0: {} + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -12115,6 +12584,13 @@ snapshots: graceful-fs@4.2.11: {} + graphql-tag@2.12.6(graphql@16.12.0): + dependencies: + graphql: 16.12.0 + tslib: 2.8.1 + + graphql@16.12.0: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -12156,6 +12632,9 @@ snapshots: dependencies: '@types/hast': 3.0.4 + headers-polyfill@4.0.3: + optional: true + hosted-git-info@2.8.9: {} html-encoding-sniffer@4.0.0: @@ -12328,6 +12807,9 @@ snapshots: xtend: 4.0.2 optional: true + is-node-process@1.2.0: + optional: true + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -12349,8 +12831,6 @@ snapshots: is-unicode-supported@0.1.0: {} - is-what@5.5.0: {} - is-windows@1.0.2: {} isbinaryfile@4.0.10: {} @@ -12477,10 +12957,10 @@ snapshots: dependencies: json-buffer: 3.0.1 - knip@5.70.1(@types/node@22.19.1)(typescript@5.9.3): + knip@5.70.1(@types/node@25.0.3)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 - '@types/node': 22.19.1 + '@types/node': 25.0.3 fast-glob: 3.3.3 formatly: 0.3.0 jiti: 2.6.1 @@ -13160,6 +13640,58 @@ snapshots: ms@2.1.3: {} + msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@20.19.25) + '@mswjs/interceptors': 0.40.0 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.7.0 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.2.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + + msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@25.0.3) + '@mswjs/interceptors': 0.40.0 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.7.0 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.2.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + murmur-32@0.2.0: dependencies: encode-utf8: 1.0.3 @@ -13169,6 +13701,9 @@ snapshots: mute-stream@1.0.0: {} + mute-stream@2.0.0: + optional: true + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -13273,6 +13808,8 @@ snapshots: object-keys@1.1.1: optional: true + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -13317,6 +13854,9 @@ snapshots: outdent@0.5.0: {} + outvariant@1.4.3: + optional: true + oxc-resolver@11.13.2: optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.13.2 @@ -13451,6 +13991,9 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@6.3.0: + optional: true + path-to-regexp@8.3.0: {} path-type@2.0.0: @@ -14009,6 +14552,9 @@ snapshots: retry@0.12.0: {} + rettime@0.7.0: + optional: true + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -14285,6 +14831,9 @@ snapshots: stream-buffers@2.2.0: optional: true + strict-event-emitter@0.5.1: + optional: true + string-argv@0.3.2: {} string-width@4.2.3: @@ -14370,10 +14919,6 @@ snapshots: transitivePeerDependencies: - supports-color - superjson@2.2.6: - dependencies: - copy-anything: 4.0.5 - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -14471,6 +15016,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -14490,10 +15037,18 @@ snapshots: tldts-core@6.1.86: {} + tldts-core@7.0.19: + optional: true + tldts@6.1.86: dependencies: tldts-core: 6.1.86 + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + optional: true + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -14518,6 +15073,11 @@ snapshots: dependencies: tldts: 6.1.86 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + optional: true + tr46@0.0.3: {} tr46@5.1.1: @@ -14658,6 +15218,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.16.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -14709,6 +15271,8 @@ snapshots: universal-user-agent@6.0.1: {} + universal-user-agent@7.0.3: {} + universalify@0.1.2: {} universalify@2.0.1: {} @@ -14718,6 +15282,9 @@ snapshots: unpipe@1.0.0: {} + until-async@3.0.2: + optional: true + update-browserslist-db@1.1.4(browserslist@4.28.0): dependencies: browserslist: 4.28.0 @@ -14839,6 +15406,22 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 + vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.3 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.44.1 + tsx: 4.20.6 + yaml: 2.8.1 + vite@7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.12 @@ -14855,10 +15438,10 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1): + vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1)) + '@vitest/mocker': 2.1.9(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -14891,10 +15474,10 @@ snapshots: - supports-color - terser - vitest@4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + vitest@4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.12 - '@vitest/mocker': 4.0.12(vite@7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.12(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(vite@7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.12 '@vitest/runner': 4.0.12 '@vitest/snapshot': 4.0.12 @@ -14933,6 +15516,45 @@ snapshots: - tsx - yaml + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + '@vitest/expect': 4.0.16 + '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3))(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/pretty-format': 4.0.16 + '@vitest/runner': 4.0.16 + '@vitest/snapshot': 4.0.16 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@25.0.3)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.0.3 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vscode-icons-js@11.6.1: dependencies: '@types/jasmine': 3.10.18