From 7ce8d1dc405f0f11a742f534c939ca9a8f32f155 Mon Sep 17 00:00:00 2001 From: mathias-heide Date: Sun, 25 Oct 2026 10:19:13 -0700 Subject: [PATCH 1/2] setup: name an older summer on PATH and how to update it After npx -y summer-engine@latest setup, a stale global summer (3.1.0) first on PATH runs on the next summer login. Setup now finds the first summer on PATH that is not its own npx copy, reads its summer-engine version, and when it is older prints which one it is and the command that updates it (npm install -g summer-engine@latest, or brew upgrade for a Homebrew install), plus the npx login to use until then. --json adds it as warnings; the --cli setup lists it with its other warnings. Found in Velizar's RC1 agent test (SUM-1278). Theme: cli-setup Co-Authored-By: Claude Opus 5.5 --- src/cli/commands/setup.ts | 23 +++++-- src/installer/path-shadow.test.ts | 60 +++++++++++++++++ src/installer/path-shadow.ts | 106 ++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 src/installer/path-shadow.test.ts create mode 100644 src/installer/path-shadow.ts diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 4e8e7d0c..aaa19ecd 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -22,6 +22,7 @@ import { brandLine, c, sym, tildeify } from "../../core/format.js"; import { TOOLKIT_VERSION as cliVersion } from "../../core/version.js"; import { listsSummerEngine, resolveDefaultChannel } from "../../installer/version-check.js"; +import { findShadowingSummer, shadowWarning } from "../../installer/path-shadow.js"; import { agentLabel, agentSpec, cliSetupRefusal, defaultPathContext } from "../../installer/agent-table.js"; import { addAllowRules, allowRulesPath, removeAllowRules, supportsAllowRules, type AllowRulesResult } from "../../installer/cli-permissions.js"; import { PROFILES, parseProfile, type Profile } from "../../core/tool-profiles.js"; @@ -271,6 +272,7 @@ async function setupAgent(agentArg: string | undefined, opts: SetupCommandOption const projectFiles = projectStep.value; const doctor = await runDoctor({ quiet: true }); + const shadow = opts.print ? null : staleSummerOnPath(); if (json) { const steps: SetupStep[] = [ @@ -281,12 +283,12 @@ async function setupAgent(agentArg: string | undefined, opts: SetupCommandOption doctorStep(doctor), ]; const ok = steps.every((step) => step.ok); - console.log(JSON.stringify({ ok, agent, scope, steps, config, games, skills, projectFiles, doctor }, null, 2)); + console.log(JSON.stringify({ ok, agent, scope, steps, config, games, skills, projectFiles, doctor, ...(shadow ? { warnings: [shadow] } : {}) }, null, 2)); if (!ok) process.exit(1); return; } - printSetupResult(config!, games, skills, doctor, projectFiles); + printSetupResult(config!, games, skills, doctor, projectFiles, shadow); if (!doctor.ok || skills.status === "failed") { process.exit(1); } @@ -390,6 +392,8 @@ async function setupCliOnly(agent: SupportedAgent, scope: ConfigScope, opts: Set if (existing) { warnings.push(`${tildeify(existing)} still lists the summer-engine MCP server from an earlier setup. Remove that entry to use the CLI only.`); } + const shadow = staleSummerOnPath(); + if (shadow) warnings.push(shadow); const dryRun = Boolean(opts.dryRun); const skills = setupSkills(agent, { @@ -511,6 +515,16 @@ function printProjectFiles(project: ProjectFiles | null): void { } } +/** An older summer-engine first on PATH would run instead of this one after setup (`summer login`). */ +function staleSummerOnPath(): string | null { + try { + const found = findShadowingSummer(cliVersion); + return found ? shadowWarning(found, cliVersion) : null; + } catch { + return null; + } +} + /** The config file that already lists summer-engine for this agent and scope, or null. */ async function recordedMcpEntry(agent: SupportedAgent, scope: ConfigScope): Promise { try { @@ -540,7 +554,8 @@ function printSetupResult( games: Awaited>, skills: SkillSetupResult, doctor: DoctorResult, - projectFiles: ProjectFiles | null + projectFiles: ProjectFiles | null, + shadow: string | null = null ): void { if (config.print) { console.log(config.snippet.trimEnd()); @@ -593,7 +608,7 @@ function printSetupResult( printSkillsLine(skills, config.dryRun); printProjectFiles(projectFiles); - for (const warning of config.warnings) { + for (const warning of shadow ? [...config.warnings, shadow] : config.warnings) { console.log(` ${sym.warn()} ${c.yellow(warning)}`); } diff --git a/src/installer/path-shadow.test.ts b/src/installer/path-shadow.test.ts new file mode 100644 index 00000000..289d9150 --- /dev/null +++ b/src/installer/path-shadow.test.ts @@ -0,0 +1,60 @@ +import { delimiter, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { findShadowingSummer, shadowWarning, type PathShadowDeps } from "./path-shadow.js"; + +/** A fake disk: files with contents, and links (path -> target). */ +function disk(files: Record, links: Record = {}): PathShadowDeps { + return { + exists: (path) => path in files || path in links, + realpath: (path) => links[path] ?? path, + readFile: (path) => { + if (!(path in files)) throw new Error(`ENOENT ${path}`); + return files[path]; + }, + platform: "darwin", + packageRoot: "/npx/abc/node_modules/summer-engine", + }; +} + +const pkg = (version: string) => JSON.stringify({ name: "summer-engine", version }); +const npx = { + "/npx/abc/node_modules/summer-engine/package.json": pkg("4.8.0"), + "/npx/abc/node_modules/summer-engine/dist/bin/summer.js": "", +}; +const npxLink = { "/npx/abc/node_modules/.bin/summer": "/npx/abc/node_modules/summer-engine/dist/bin/summer.js" }; +const globalOld = { + "/usr/local/lib/node_modules/summer-engine/package.json": pkg("3.1.0"), + "/usr/local/lib/node_modules/summer-engine/dist/bin/summer.js": "", +}; +const globalLink = { "/usr/local/bin/summer": "/usr/local/lib/node_modules/summer-engine/dist/bin/summer.js" }; +const path = (...dirs: string[]) => ({ PATH: dirs.join(delimiter) }); + +describe("findShadowingSummer", () => { + it("names an older global summer behind npx's own copy", () => { + const found = findShadowingSummer("4.8.0", path("/npx/abc/node_modules/.bin", "/usr/local/bin"), disk({ ...npx, ...globalOld }, { ...npxLink, ...globalLink })); + expect(found).toEqual({ path: "/usr/local/bin/summer", version: "3.1.0", homebrew: false }); + expect(shadowWarning(found!, "4.8.0")).toContain("npm install -g summer-engine@latest"); + }); + + it("says nothing when the global summer is as new, or there is none", () => { + const current = { ...globalOld, "/usr/local/lib/node_modules/summer-engine/package.json": pkg("4.8.0") }; + expect(findShadowingSummer("4.8.0", path("/npx/abc/node_modules/.bin", "/usr/local/bin"), disk({ ...npx, ...current }, { ...npxLink, ...globalLink }))).toBeNull(); + expect(findShadowingSummer("4.8.0", path("/npx/abc/node_modules/.bin", "/usr/bin"), disk(npx, npxLink))).toBeNull(); + }); + + it("says nothing when the first summer is another program", () => { + const other = { "/opt/bin/summer": "#!/bin/sh" }; + expect(findShadowingSummer("4.8.0", path("/opt/bin", "/usr/local/bin"), disk({ ...other, ...globalOld }, globalLink))).toBeNull(); + }); + + it("tells a Homebrew install to upgrade with brew", () => { + const cellar = "/opt/homebrew/Cellar/summer/3.1.0/libexec/lib/node_modules/summer-engine"; + const found = findShadowingSummer( + "4.8.0", + path("/opt/homebrew/bin"), + disk({ [join(cellar, "package.json")]: pkg("3.1.0"), [join(cellar, "dist/bin/summer.js")]: "" }, { "/opt/homebrew/bin/summer": join(cellar, "dist/bin/summer.js") }) + ); + expect(found?.homebrew).toBe(true); + expect(shadowWarning(found!, "4.8.0")).toContain("brew upgrade summerengine/tap/summer"); + }); +}); diff --git a/src/installer/path-shadow.ts b/src/installer/path-shadow.ts new file mode 100644 index 00000000..0251d424 --- /dev/null +++ b/src/installer/path-shadow.ts @@ -0,0 +1,106 @@ +/** + * An older `summer` first on PATH. After `npx -y summer-engine@latest setup`, + * the person's next `summer login` runs whatever `summer` their shell finds + * first, which can be a stale global install (3.1.0 while setup ran 4.8.0). + * Setup names it and says how to update it. + */ + +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join, sep } from "node:path"; +import { PACKAGE_ROOT } from "../core/package-root.js"; +import { compareSemver, parseSemver } from "./version-check.js"; + +export interface ShadowingSummer { + /** The `summer` the shell runs, as found on PATH. */ + path: string; + version: string; + /** Installed by Homebrew (its Cellar), so brew updates it. */ + homebrew: boolean; +} + +export interface PathShadowDeps { + exists?: (path: string) => boolean; + realpath?: (path: string) => string; + readFile?: (path: string) => string; + platform?: NodeJS.Platform; + packageRoot?: string; +} + +/** The summer-engine package a `summer` launcher belongs to: up from where its link points, or the + * npm prefix beside a Windows `summer.cmd`. Null for any other program named summer. */ +function owningPackage(bin: string, deps: Required): { root: string; version: string } | null { + const candidates: string[] = []; + let dir = dirname(deps.realpath(bin)); + for (let i = 0; i < 6; i += 1) { + candidates.push(dir); + const up = dirname(dir); + if (up === dir) break; + dir = up; + } + candidates.push(join(dirname(bin), "node_modules", "summer-engine")); + for (const root of candidates) { + const manifest = join(root, "package.json"); + if (!deps.exists(manifest)) continue; + try { + const pkg = JSON.parse(deps.readFile(manifest)) as { name?: string; version?: string }; + if (pkg.name === "summer-engine" && pkg.version) return { root, version: pkg.version }; + } catch { + // not a package we can read: keep looking + } + } + return null; +} + +/** + * The first `summer` on PATH that is not this package, when it is an older summer-engine. + * npx puts its own copy (this package) first while setup runs; that copy is gone afterwards, + * so the next one is what the person's shell runs. + */ +export function findShadowingSummer( + runningVersion: string, + env: NodeJS.ProcessEnv = process.env, + overrides: PathShadowDeps = {} +): ShadowingSummer | null { + const deps: Required = { + exists: overrides.exists ?? existsSync, + realpath: overrides.realpath ?? ((path) => realpathSync(path)), + readFile: overrides.readFile ?? ((path) => readFileSync(path, "utf-8")), + platform: overrides.platform ?? process.platform, + packageRoot: overrides.packageRoot ?? PACKAGE_ROOT, + }; + const running = parseSemver(runningVersion); + if (!running) return null; + const names = deps.platform === "win32" ? ["summer.cmd", "summer.exe", "summer"] : ["summer"]; + const ours = safeRealpath(deps.packageRoot, deps); + for (const dir of (env.PATH ?? env.Path ?? "").split(delimiter)) { + if (!dir) continue; + const bin = names.map((name) => join(dir, name)).find((path) => deps.exists(path)); + if (!bin) continue; + let owner: { root: string; version: string } | null; + try { + owner = owningPackage(bin, deps); + } catch { + continue; + } + if (owner && safeRealpath(owner.root, deps) === ours) continue; + if (!owner) return null; + const found = parseSemver(owner.version); + if (!found || compareSemver(found, running) >= 0) return null; + return { path: bin, version: owner.version, homebrew: safeRealpath(bin, deps).includes(`${sep}Cellar${sep}`) }; + } + return null; +} + +function safeRealpath(path: string, deps: Required): string { + try { + return deps.realpath(path); + } catch { + return path; + } +} + +/** The one warning line: which `summer` the shell runs, and the command that updates it. */ +export function shadowWarning(found: ShadowingSummer, runningVersion: string): string { + const update = found.homebrew ? "brew upgrade summerengine/tap/summer" : "npm install -g summer-engine@latest"; + return `The summer command in your shell is ${found.version} (${found.path}), older than this setup (${runningVersion}), so summer login would run the old CLI. Update it: ${update}. Until then: npx -y summer-engine@latest login`; +} From a2fff01d0bdf054b2610d0013ad5ef5cdcf3b7b9 Mon Sep 17 00:00:00 2001 From: mathias-heide Date: Sun, 25 Oct 2026 10:23:09 -0700 Subject: [PATCH 2/2] CHANGELOG: setup names an older summer on PATH Theme: cli-setup Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3180a8f6..9fad61b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Games go live on Summer's automatic checks, with Summer's review afterwards: `su - A sent-back page's decision comes from the review's `latestReview`; while the platform lacks it, from `/approval` when it answers; else the page still reports `needs_changes` and points at `store.status`. On a platform without `/review`, a first-launch hold reports as Summer's, as before. The waiting answer no longer carries `approvalState`. - **Platforms a hosted release left out: `notIncluded` and `notUsed`.** `summer_go_live` reads `droppedTargets` from the release, answers `notIncluded` (one entry per platform: the reason, `checkErrorCode` and the line to tell the creator, such as "iOS isn't included: this build couldn't be checked for iPhone.") and goes live on the platforms the release kept instead of waiting for the others. `notUsed` names a newer client the release did not use (that platform stays live with its current client). The live answer's `next` names both. - **`playable_release_required` answers `blocked`** with the platform's own message, or "Upload a build that includes your game's apps so they can be checked against the new server." +- **Setup names an older `summer` on PATH.** After `npx -y summer-engine@latest setup`, a stale global `summer` (3.1.0) first on PATH ran on the next `summer login`. Setup now says which one it is and how to update it (`npm install -g summer-engine@latest`, or `brew upgrade summerengine/tap/summer`), and to use `npx -y summer-engine@latest login` until then; `--json` lists it in `warnings`. - **"Summer is still checking this; nothing to do on your side."** When a publish's checks have run over an hour, `summer_go_live`'s waiting answer and `summer_publish_build`'s hold say so, with the time (`stillCheckingSince`). - **`summer_publish_build`'s hold** reads the review too: on hold (with the changes to make), taken down, or Summer's own hold. `approval_denied` now reads "This game was taken down, so it can't take new builds." - **Copy:** the README, `AGENTS.md`, the project `AGENTS.md` block and the `publish-your-game` skill say a game is live once the automatic checks pass, with staff review afterwards, and tell an automatic-check rejection from a staff hold and a take-down.