Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 19 additions & 4 deletions src/cli/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[] = [
Expand All @@ -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);
}
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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<string | null> {
try {
Expand Down Expand Up @@ -540,7 +554,8 @@ function printSetupResult(
games: Awaited<ReturnType<typeof configureSummerGamesMcp>>,
skills: SkillSetupResult,
doctor: DoctorResult,
projectFiles: ProjectFiles | null
projectFiles: ProjectFiles | null,
shadow: string | null = null
): void {
if (config.print) {
console.log(config.snippet.trimEnd());
Expand Down Expand Up @@ -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)}`);
}

Expand Down
60 changes: 60 additions & 0 deletions src/installer/path-shadow.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, links: Record<string, string> = {}): 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");
});
});
106 changes: 106 additions & 0 deletions src/installer/path-shadow.ts
Original file line number Diff line number Diff line change
@@ -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<PathShadowDeps>): { 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<PathShadowDeps> = {
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<PathShadowDeps>): 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`;
}
Loading