mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 11:59:29 +00:00
fix: navigate back to list after delete/remove errors (#2488)
* fix: navigate back to list after delete/remove errors instead of exiting
Previously, choosing "Delete this server" or "Remove from history" from
the action menu would always exit the picker — even if the operation
failed. Now handleRecordAction returns "back" for delete/remove actions,
and activeServerPicker refreshes the remaining list and loops back to
the picker. Cancel on the action menu also returns to the list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add ValueOf<T> type helper and GritQL enum ban rule
- Add shared ValueOf<T> type that extracts value unions from const objects
and readonly tuples
- Update RecordActionOutcome to use ValueOf<typeof RecordActionOutcome>
- Add lint/no-ts-enum.grit GritQL rule that bans TypeScript enum keyword
- Register new rule in biome.json plugins
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: sort type export before value exports in shared index
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add biome config for shared package, fix export sort order
Add biome.json to packages/shared so lint + format + import organization
is enforced on the shared library. Fix ValueOf export position to match
biome's organizeImports sort order (type specifiers after value exports).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: hoist type re-exports to top of shared index
Split inline `type Result` and `type ValueOf` out of mixed export
statements into separate `export type { ... }` re-exports, hoisted
to the top per biome's organizeImports group config.
biome's useExportType rule doesn't flag re-exports (only locally
defined types), so these must be manually separated.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: consolidate biome config to single root biome.json
Remove per-package biome.json files (packages/cli, packages/shared,
.claude/scripts, .claude/skills/setup-spa) and consolidate into a
single root config with includes glob covering packages/**/*.ts.
Update GritQL rule exclusions to also match shared/src/ paths now
that the shared package is covered by the root config. Fix build-clouds.ts
lint issues (node: protocol, block statements, import sort) that were
newly caught.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: replace grit filename exclusions with biome-ignore comments
Remove all $filename exclusion logic from GritQL rules and instead add
biome-ignore-all comments at the top of files that legitimately need
the banned patterns (result.ts, parse.ts, type-guards.ts).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5031d84e6c
commit
37fa334d78
13 changed files with 117 additions and 90 deletions
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"root": false,
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"extends": ["../../biome.json"],
|
||||
"vcs": {
|
||||
"enabled": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["*.ts"]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"root": false,
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"extends": ["../../../biome.json"],
|
||||
"vcs": {
|
||||
"enabled": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["*.ts"]
|
||||
}
|
||||
}
|
||||
35
biome.json
35
biome.json
|
|
@ -1,5 +1,15 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["packages/**/*.ts"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
|
|
@ -74,6 +84,31 @@
|
|||
"bracketSameLine": false
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["packages/cli/src/__tests__/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noAssignInExpressions": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "off",
|
||||
"noUnusedFunctionParameters": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
"./lint/no-type-assertion.grit",
|
||||
"./lint/no-typeof-string-number.grit",
|
||||
"./lint/no-try-catch.grit",
|
||||
"./lint/no-try-finally.grit",
|
||||
"./lint/no-ts-enum.grit"
|
||||
],
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
// $_ is an AST wildcard — it matches any subtree regardless of how many lines
|
||||
// it spans, so single-line and multiline try blocks are both caught.
|
||||
//
|
||||
// shared/result.ts and shared/parse.ts are excluded because that is where
|
||||
// tryCatch/asyncTryCatch are implemented, using actual try/catch internally.
|
||||
// Files that legitimately need try/catch use biome-ignore comments.
|
||||
language js(typescript)
|
||||
|
||||
or {
|
||||
|
|
@ -13,8 +12,6 @@ or {
|
|||
`try { $_ } catch ($err) { $_ } finally { $_ }`,
|
||||
`try { $_ } catch { $_ } finally { $_ }`
|
||||
} as $expr where {
|
||||
$filename <: not includes "shared/result.ts",
|
||||
$filename <: not includes "shared/parse.ts",
|
||||
register_diagnostic(
|
||||
span = $expr,
|
||||
message = "Avoid try/catch — use tryCatch / asyncTryCatch from @openrouter/spawn-shared. Sync: const r = tryCatch(() => expr); if (!r.ok) { ... }. Async: const r = await asyncTryCatch(() => fn()); if (!r.ok) { ... }.",
|
||||
|
|
|
|||
|
|
@ -6,13 +6,10 @@
|
|||
// Guidance: asyncTryCatch() never throws (it returns a Result), so cleanup
|
||||
// code can simply run sequentially on the next line — no nesting needed.
|
||||
//
|
||||
// shared/result.ts and shared/parse.ts are excluded because that is where
|
||||
// tryCatch/asyncTryCatch are implemented, using actual try/catch internally.
|
||||
// Files that legitimately need try/finally use biome-ignore comments.
|
||||
language js(typescript)
|
||||
|
||||
`try { $_ } finally { $_ }` as $expr where {
|
||||
$filename <: not includes "shared/result.ts",
|
||||
$filename <: not includes "shared/parse.ts",
|
||||
register_diagnostic(
|
||||
span = $expr,
|
||||
message = "Avoid try/finally — asyncTryCatch() from @openrouter/spawn-shared never throws, so cleanup just runs sequentially. Before: try { await fn(); } finally { cleanup(); }. After: await asyncTryCatch(() => fn()); cleanup();.",
|
||||
|
|
|
|||
9
lint/no-ts-enum.grit
Normal file
9
lint/no-ts-enum.grit
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
language js(typescript)
|
||||
|
||||
TsEnumDeclaration() as $decl where {
|
||||
register_diagnostic(
|
||||
span=$decl,
|
||||
message="TypeScript `enum` is banned. Use a `const` object with `as const` and a `ValueOf<typeof X>` type instead.",
|
||||
severity="error"
|
||||
)
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
"root": false,
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"extends": ["../../biome.json"],
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["src/**/*.ts"]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["src/__tests__/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noAssignInExpressions": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "off",
|
||||
"noUnusedFunctionParameters": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
"../../lint/no-type-assertion.grit",
|
||||
"../../lint/no-typeof-string-number.grit",
|
||||
"../../lint/no-try-catch.grit",
|
||||
"../../lint/no-try-finally.grit"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
// Build bundled JS files for cloud providers that use TypeScript.
|
||||
// Each cloud with a cli/src/{cloud}/main.ts gets bundled into {cloud}.js.
|
||||
// These bundles are uploaded to GitHub releases for curl|bash execution.
|
||||
|
|
@ -7,8 +8,8 @@
|
|||
// bun run cli/build-clouds.ts # build all clouds
|
||||
// bun run cli/build-clouds.ts aws # build specific cloud
|
||||
|
||||
import { readdirSync, existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const cliDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
const srcDir = path.join(cliDir, "src");
|
||||
|
|
@ -24,7 +25,9 @@ async function buildCloud(cloud: string): Promise<boolean> {
|
|||
|
||||
console.log(`build: src/${cloud}/main.ts -> ${cloud}.js`);
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entry],
|
||||
entrypoints: [
|
||||
entry,
|
||||
],
|
||||
outdir: cliDir,
|
||||
naming: `${cloud}.js`,
|
||||
target: "bun",
|
||||
|
|
@ -34,7 +37,9 @@ async function buildCloud(cloud: string): Promise<boolean> {
|
|||
|
||||
if (!result.success) {
|
||||
console.error(`FAIL: ${cloud}`);
|
||||
for (const log of result.logs) console.error(" ", log);
|
||||
for (const log of result.logs) {
|
||||
console.error(" ", log);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -51,13 +56,23 @@ if (filter) {
|
|||
(await buildCloud(filter)) ? built++ : failed++;
|
||||
} else {
|
||||
// Auto-discover: any directory under src/ with a main.ts
|
||||
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith("__")) continue;
|
||||
if (!existsSync(path.join(srcDir, entry.name, "main.ts"))) continue;
|
||||
for (const entry of readdirSync(srcDir, {
|
||||
withFileTypes: true,
|
||||
})) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name.startsWith("__")) {
|
||||
continue;
|
||||
}
|
||||
if (!existsSync(path.join(srcDir, entry.name, "main.ts"))) {
|
||||
continue;
|
||||
}
|
||||
(await buildCloud(entry.name)) ? built++ : failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${built} built, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ValueOf } from "@openrouter/spawn-shared";
|
||||
import type { SpawnRecord } from "../history.js";
|
||||
import type { Manifest } from "../manifest.js";
|
||||
|
||||
|
|
@ -242,8 +243,25 @@ export async function resolveListFilters(
|
|||
|
||||
// ── Record actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Handle reconnect or rerun action for a selected spawn record */
|
||||
export async function handleRecordAction(selected: SpawnRecord, manifest: Manifest | null): Promise<void> {
|
||||
/** Outcome of handleRecordAction — determines whether the picker loops or exits. */
|
||||
export const RecordActionOutcome = {
|
||||
/** Navigate back to the server list (delete/remove/cancel). */
|
||||
Back: 0,
|
||||
/** Exit the picker (enter/reconnect/rerun). */
|
||||
Exit: 1,
|
||||
} as const;
|
||||
|
||||
export type RecordActionOutcome = ValueOf<typeof RecordActionOutcome>;
|
||||
|
||||
/**
|
||||
* Handle reconnect or rerun action for a selected spawn record.
|
||||
* Returns Back if the picker should navigate back to the list (delete/remove),
|
||||
* or Exit for terminal actions (enter/reconnect/rerun) that exit the picker.
|
||||
*/
|
||||
export async function handleRecordAction(
|
||||
selected: SpawnRecord,
|
||||
manifest: Manifest | null,
|
||||
): Promise<RecordActionOutcome> {
|
||||
if (!selected.connection) {
|
||||
// No connection info -- just rerun, reusing the existing spawn name
|
||||
if (selected.name) {
|
||||
|
|
@ -251,7 +269,7 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife
|
|||
}
|
||||
p.log.step(`Spawning ${pc.bold(buildRecordLabel(selected, manifest))}`);
|
||||
await cmdRun(selected.agent, selected.cloud, selected.prompt);
|
||||
return;
|
||||
return RecordActionOutcome.Exit;
|
||||
}
|
||||
|
||||
const conn = selected.connection;
|
||||
|
|
@ -310,7 +328,7 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife
|
|||
});
|
||||
|
||||
if (p.isCancel(action)) {
|
||||
handleCancel();
|
||||
return RecordActionOutcome.Back;
|
||||
}
|
||||
|
||||
if (action === "enter") {
|
||||
|
|
@ -322,7 +340,7 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife
|
|||
`VM may no longer be running. Use ${pc.cyan(`spawn ${selected.agent} ${selected.cloud}`)} to start a new one.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
return RecordActionOutcome.Exit;
|
||||
}
|
||||
|
||||
if (action === "reconnect") {
|
||||
|
|
@ -334,12 +352,12 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife
|
|||
`VM may no longer be running. Use ${pc.cyan(`spawn ${selected.agent} ${selected.cloud}`)} to start a new one.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
return RecordActionOutcome.Exit;
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
await confirmAndDelete(selected, manifest);
|
||||
return;
|
||||
return RecordActionOutcome.Back;
|
||||
}
|
||||
|
||||
if (action === "remove") {
|
||||
|
|
@ -349,7 +367,7 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife
|
|||
} else {
|
||||
p.log.warn("Could not find record in history.");
|
||||
}
|
||||
return;
|
||||
return RecordActionOutcome.Back;
|
||||
}
|
||||
|
||||
// Rerun (create new spawn). Clear any pre-set name so the user is prompted for
|
||||
|
|
@ -360,6 +378,7 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife
|
|||
`Spawning ${pc.bold(buildRecordLabel(selected, manifest))} ${pc.dim(`(${buildRecordSubtitle(selected, manifest)})`)}`,
|
||||
);
|
||||
await cmdRun(selected.agent, selected.cloud, selected.prompt);
|
||||
return RecordActionOutcome.Exit;
|
||||
}
|
||||
|
||||
/** Interactive picker with inline delete support.
|
||||
|
|
@ -443,7 +462,18 @@ export async function activeServerPicker(records: SpawnRecord[], manifest: Manif
|
|||
}
|
||||
|
||||
// action === "select"
|
||||
await handleRecordAction(picked, manifest);
|
||||
const outcome = await handleRecordAction(picked, manifest);
|
||||
if (outcome === RecordActionOutcome.Back) {
|
||||
// Delete/remove completed (or errored) — refresh the remaining list and loop back
|
||||
const active = getActiveServers();
|
||||
const activeSet = new Set(active.map((r) => r.timestamp));
|
||||
for (let i = remaining.length - 1; i >= 0; i--) {
|
||||
if (!activeSet.has(remaining[i].timestamp)) {
|
||||
remaining.splice(i, 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
export type { Result } from "./result";
|
||||
export type { ValueOf } from "./type-guards";
|
||||
|
||||
export { parseJsonObj, parseJsonWith } from "./parse";
|
||||
export {
|
||||
asyncTryCatch,
|
||||
|
|
@ -8,7 +11,6 @@ export {
|
|||
isOperationalError,
|
||||
mapResult,
|
||||
Ok,
|
||||
type Result,
|
||||
tryCatch,
|
||||
tryCatchIf,
|
||||
unwrapOr,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// shared/parse.ts — Schema-validated JSON parsing (replaces unsafe `as` casts)
|
||||
// biome-ignore-all lint/plugin: parse implementations require raw try/catch around JSON.parse
|
||||
|
||||
import * as v from "valibot";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// shared/result.ts — Lightweight Result monad for retry-aware error handling.
|
||||
// biome-ignore-all lint/plugin: this file implements tryCatch/asyncTryCatch and error predicates that require raw try/catch, typeof, and `as`
|
||||
//
|
||||
// Returning Err() signals a retryable failure; throwing signals a non-retryable one.
|
||||
// Used with withRetry() so callers decide at the point of failure whether an error
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
// shared/type-guards.ts — Runtime type guards (replaces unsafe `as` casts on non-API values)
|
||||
// biome-ignore-all lint/plugin: type-guard implementations must use raw typeof
|
||||
|
||||
/** Extract union of all values from a const object or readonly tuple. */
|
||||
export type ValueOf<T> = T extends readonly (infer U)[] ? U : T[keyof T];
|
||||
|
||||
export function isString(val: unknown): val is string {
|
||||
return typeof val === "string";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue