diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e7ec8aec2..f6188d7af 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,6 +22,11 @@ on: required: false type: boolean default: false + publish_lazycodex: + description: "Publish lazycodex npm alias and sync Codex marketplace" + required: false + type: boolean + default: false concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -72,10 +77,11 @@ jobs: id-token: write contents: read steps: - - name: Verify trusted publisher for all 25 packages + - name: Verify trusted publisher for release packages env: REPO: code-yeongyu/oh-my-openagent WORKFLOW_FILE: publish.yml + PUBLISH_LAZYCODEX: ${{ inputs.publish_lazycodex }} run: | OIDC_TOKEN=$(curl -sH "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=npm:registry.npmjs.org" \ @@ -87,7 +93,10 @@ jobs: fi PLATFORMS=(darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline) - ALL_PACKAGES=(oh-my-opencode oh-my-openagent lazycodex) + ALL_PACKAGES=(oh-my-opencode oh-my-openagent) + if [ "${PUBLISH_LAZYCODEX}" = "true" ]; then + ALL_PACKAGES+=(lazycodex) + fi for plat in "${PLATFORMS[@]}"; do ALL_PACKAGES+=("oh-my-opencode-${plat}") ALL_PACKAGES+=("oh-my-openagent-${plat}") @@ -327,6 +336,7 @@ jobs: git checkout -- package.json - name: Check if lazycodex already published + if: inputs.publish_lazycodex == true id: check-lazycodex env: VERSION: ${{ steps.version.outputs.version }} @@ -340,7 +350,7 @@ jobs: fi - name: Publish lazycodex - if: steps.check-lazycodex.outputs.skip != 'true' + if: inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true' continue-on-error: true env: VERSION: ${{ steps.version.outputs.version }} @@ -368,7 +378,7 @@ jobs: fi - name: Restore package.json after lazycodex publish attempt - if: always() && steps.check-lazycodex.outputs.skip != 'true' + if: always() && inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true' run: | git checkout -- package.json @@ -452,12 +462,13 @@ jobs: git push origin "v${VERSION}" - name: Require LazyCodex sync token - if: ${{ secrets.LAZYCODEX_SYNC_TOKEN == '' }} + if: inputs.publish_lazycodex == true && secrets.LAZYCODEX_SYNC_TOKEN == '' run: | echo "::error::LAZYCODEX_SYNC_TOKEN is required to push the Codex marketplace bundle to code-yeongyu/lazycodex." exit 1 - name: Checkout LazyCodex marketplace + if: inputs.publish_lazycodex == true uses: actions/checkout@v4 with: repository: code-yeongyu/lazycodex @@ -466,6 +477,7 @@ jobs: fetch-depth: 0 - name: Sync LazyCodex Codex marketplace + if: inputs.publish_lazycodex == true env: VERSION: ${{ needs.publish-main.outputs.version }} run: | diff --git a/script/publish-workflow.test.ts b/script/publish-workflow.test.ts index ad691d1ff..312250ae2 100644 --- a/script/publish-workflow.test.ts +++ b/script/publish-workflow.test.ts @@ -59,21 +59,31 @@ describe("test workflows", () => { expect(buildNeedsCodexMatrix, "Build must wait for Codex compatibility checks").toBe(true) }) - test("syncs the LazyCodex Codex marketplace bundle during release", () => { + test("keeps LazyCodex deployment behind an explicit publish flag", () => { // #given const workflow = readFileSync(new URL("../.github/workflows/publish.yml", import.meta.url), "utf8") // #when const appliesCodexPluginVersion = workflow.includes("packages/omo-codex/plugin/.codex-plugin/plugin.json") + const flagDefaultsOff = workflow.includes("publish_lazycodex:") && + workflow.includes('description: "Publish lazycodex npm alias and sync Codex marketplace"') && + workflow.includes("default: false") const syncsLazycodexMarketplace = workflow.includes("bun run script/sync-lazycodex-marketplace.ts") const pushesLazycodexMarketplace = workflow.includes("code-yeongyu/lazycodex") + const gatesLazycodexNpmPublish = workflow.includes("name: Publish lazycodex") && + workflow.includes("if: inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true'") + const gatesLazycodexMarketplaceSync = workflow.includes("name: Sync LazyCodex Codex marketplace") && + workflow.includes("if: inputs.publish_lazycodex == true") const requiresLazycodexSyncToken = workflow.includes("secrets.LAZYCODEX_SYNC_TOKEN == ''") && workflow.includes("token: ${{ secrets.LAZYCODEX_SYNC_TOKEN }}") // #then expect(appliesCodexPluginVersion, "release must version the Codex plugin manifest before marketplace sync").toBe(true) + expect(flagDefaultsOff, "LazyCodex deployment must default to disabled").toBe(true) expect(syncsLazycodexMarketplace, "release must sync the LazyCodex marketplace bundle").toBe(true) expect(pushesLazycodexMarketplace, "release must target the LazyCodex repository").toBe(true) + expect(gatesLazycodexNpmPublish, "lazycodex npm publish must require publish_lazycodex=true").toBe(true) + expect(gatesLazycodexMarketplaceSync, "LazyCodex marketplace push must require publish_lazycodex=true").toBe(true) expect(requiresLazycodexSyncToken, "release must require a cross-repo token for LazyCodex push").toBe(true) }) }) diff --git a/src/cli/cli-installer.platform.test.ts b/src/cli/cli-installer.platform.test.ts index 5228b80ac..126ed268d 100644 --- a/src/cli/cli-installer.platform.test.ts +++ b/src/cli/cli-installer.platform.test.ts @@ -60,13 +60,20 @@ function stubOpenCodeSuccess(): void { describe("runCliInstaller platform branching", () => { const consoleLog = console.log + const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX beforeEach(() => { console.log = mock(() => {}) + process.env.OMO_PUBLISH_LAZYCODEX = "true" }) afterEach(() => { console.log = consoleLog + if (originalPublishLazycodex === undefined) { + delete process.env.OMO_PUBLISH_LAZYCODEX + } else { + process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex + } mock.restore() }) diff --git a/src/cli/cli-installer.test.ts b/src/cli/cli-installer.test.ts index d2458fc21..6f71f3ca0 100644 --- a/src/cli/cli-installer.test.ts +++ b/src/cli/cli-installer.test.ts @@ -9,6 +9,7 @@ describe("runCliInstaller", () => { const mockConsoleError = mock(() => {}) const originalConsoleLog = console.log const originalConsoleError = console.error + const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX beforeEach(() => { console.log = mockConsoleLog @@ -20,6 +21,11 @@ describe("runCliInstaller", () => { afterEach(() => { console.log = originalConsoleLog console.error = originalConsoleError + if (originalPublishLazycodex === undefined) { + delete process.env.OMO_PUBLISH_LAZYCODEX + } else { + process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex + } mock.restore() }) @@ -128,6 +134,7 @@ describe("runCliInstaller", () => { it("skips OpenCode checks and writes for platform=codex", async () => { // given + process.env.OMO_PUBLISH_LAZYCODEX = "true" const detectSpy = spyOn(configManager, "detectCurrentConfig") const installedSpy = spyOn(configManager, "isOpenCodeInstalled") const versionSpy = spyOn(configManager, "getOpenCodeVersion") diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts index 0fade9ff6..61ae60d7f 100644 --- a/src/cli/cli-program.ts +++ b/src/cli/cli-program.ts @@ -6,6 +6,7 @@ import { doctor } from "./doctor" import { refreshModelCapabilities } from "./refresh-model-capabilities" import { createMcpOAuthCommand } from "./mcp-oauth" import { boulder } from "./boulder" +import { isLazycodexPublishingEnabled } from "./lazycodex-feature-flag" import type { InstallArgs } from "./types" import type { RunOptions } from "./run" import type { GetLocalVersionOptions } from "./get-local-version/types" @@ -31,17 +32,22 @@ type InstallCommandOptions = { readonly skipAuth?: boolean } +type Environment = Readonly> + export function resolveInstallArgs( options: InstallCommandOptions, invocationName: string | undefined = process.env.OMO_INVOCATION_NAME, + env: Environment = process.env, ): InstallArgs { + const defaultPlatform = invocationName === "lazycodex" && isLazycodexPublishingEnabled(env) ? "codex" : undefined + return { tui: options.tui !== false, claude: options.claude, openai: options.openai, gemini: options.gemini, copilot: options.copilot, - platform: options.platform ?? (invocationName === "lazycodex" ? "codex" : undefined), + platform: options.platform ?? defaultPlatform, opencodeZen: options.opencodeZen, zaiCodingPlan: options.zaiCodingPlan, kimiForCoding: options.kimiForCoding, diff --git a/src/cli/install-codex/lazycodex-routing.test.ts b/src/cli/install-codex/lazycodex-routing.test.ts index 4d3a62666..221dc8224 100644 --- a/src/cli/install-codex/lazycodex-routing.test.ts +++ b/src/cli/install-codex/lazycodex-routing.test.ts @@ -7,18 +7,46 @@ import { argsToConfig } from "../install-validators" describe("lazycodex install routing", () => { const originalInvocationName = process.env.OMO_INVOCATION_NAME + const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX afterEach(() => { if (originalInvocationName === undefined) { delete process.env.OMO_INVOCATION_NAME - return + } else { + process.env.OMO_INVOCATION_NAME = originalInvocationName + } + + if (originalPublishLazycodex === undefined) { + delete process.env.OMO_PUBLISH_LAZYCODEX + } else { + process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex } - process.env.OMO_INVOCATION_NAME = originalInvocationName }) - test("defaults platform to codex when invoked as lazycodex without --platform", () => { + test("leaves lazycodex invocation unresolved when lazycodex publishing is disabled", () => { // given process.env.OMO_INVOCATION_NAME = "lazycodex" + delete process.env.OMO_PUBLISH_LAZYCODEX + + // when + const args = resolveInstallArgs({ + tui: false, + claude: "no", + gemini: "no", + copilot: "no", + }) + const config = argsToConfig(args) + + // then + expect(args.platform).toBeUndefined() + expect(config.hasCodex).toBe(false) + expect(config.hasOpenCode).toBe(true) + }) + + test("defaults platform to codex when invoked as lazycodex with lazycodex publishing enabled", () => { + // given + process.env.OMO_INVOCATION_NAME = "lazycodex" + process.env.OMO_PUBLISH_LAZYCODEX = "true" // when const args = resolveInstallArgs({ @@ -35,9 +63,10 @@ describe("lazycodex install routing", () => { expect(config.hasOpenCode).toBe(false) }) - test("respects explicit --platform=both when invoked as lazycodex", () => { + test("respects explicit --platform=both when lazycodex publishing is enabled", () => { // given process.env.OMO_INVOCATION_NAME = "lazycodex" + process.env.OMO_PUBLISH_LAZYCODEX = "true" // when const args = resolveInstallArgs({ diff --git a/src/cli/install-platform-resolution.test.ts b/src/cli/install-platform-resolution.test.ts index b740f3796..cad0125ba 100644 --- a/src/cli/install-platform-resolution.test.ts +++ b/src/cli/install-platform-resolution.test.ts @@ -48,13 +48,24 @@ describe("install platform resolution", () => { expect(args.platform).toBe("opencode") }) - test("defaults lazycodex install to codex platform", () => { + test("leaves lazycodex install unresolved when lazycodex publishing is disabled", () => { // given const invocationName = "lazycodex" // when const args = resolveInstallArgs({ tui: true }, invocationName) + // then + expect(args.platform).toBeUndefined() + }) + + test("defaults lazycodex install to codex platform when lazycodex publishing is enabled", () => { + // given + const invocationName = "lazycodex" + + // when + const args = resolveInstallArgs({ tui: true }, invocationName, { OMO_PUBLISH_LAZYCODEX: "true" }) + // then expect(args.platform).toBe("codex") }) diff --git a/src/cli/install-validators.test.ts b/src/cli/install-validators.test.ts index 562efc216..a3eda0c96 100644 --- a/src/cli/install-validators.test.ts +++ b/src/cli/install-validators.test.ts @@ -117,24 +117,52 @@ describe("validateNonTuiArgs", () => { expect(result.errors).toContain("--copilot is required (values: no, yes)") }) - test("allows codex-only non-TUI installs without OpenCode provider flags", () => { + test("rejects codex-only non-TUI installs when lazycodex publishing is disabled", () => { // #given const args: InstallArgs = { tui: false, platform: "codex" } // #when const result = validateNonTuiArgs(args) + // #then + expect(result.valid).toBe(false) + expect(result.errors).toContain( + "Codex platform install is disabled. Set OMO_PUBLISH_LAZYCODEX=true to enable LazyCodex publish/install.", + ) + }) + + test("allows codex-only non-TUI installs with lazycodex publishing enabled", () => { + // #given + const args: InstallArgs = { tui: false, platform: "codex" } + + // #when + const result = validateNonTuiArgs(args, { OMO_PUBLISH_LAZYCODEX: "true" }) + // #then expect(result.valid).toBe(true) expect(result.errors).toEqual([]) }) + test("rejects platform=both when lazycodex publishing is disabled", () => { + // #given + const args = createArgs({ platform: "both" }) + + // #when + const result = validateNonTuiArgs(args) + + // #then + expect(result.valid).toBe(false) + expect(result.errors).toContain( + "Codex platform install is disabled. Set OMO_PUBLISH_LAZYCODEX=true to enable LazyCodex publish/install.", + ) + }) + test("rejects OpenCode flags for codex-only non-TUI installs", () => { // #given const args = createArgs({ platform: "codex", claude: "yes" }) // #when - const result = validateNonTuiArgs(args) + const result = validateNonTuiArgs(args, { OMO_PUBLISH_LAZYCODEX: "true" }) // #then expect(result.valid).toBe(false) diff --git a/src/cli/install-validators.ts b/src/cli/install-validators.ts index 473b3a1c1..f82179bf2 100644 --- a/src/cli/install-validators.ts +++ b/src/cli/install-validators.ts @@ -7,6 +7,11 @@ import type { InstallConfig, InstallPlatform, } from "./types" +import { + LAZYCODEX_DISABLED_MESSAGE, + isLazycodexPublishingEnabled, + platformRequiresLazycodex, +} from "./lazycodex-feature-flag" export const SYMBOLS = { check: color.green("[OK]"), @@ -19,6 +24,7 @@ export const SYMBOLS = { } const ANSI_COLOR_PATTERN = new RegExp("\u001b\\[[0-9;]*m", "g") +type Environment = Readonly> function formatProvider(name: string, enabled: boolean, detail?: string): string { const status = enabled ? SYMBOLS.check : color.dim("○") @@ -116,12 +122,19 @@ export function printBox(content: string, title?: string): void { console.log() } -export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } { +export function validateNonTuiArgs( + args: InstallArgs, + env: Environment = process.env, +): { valid: boolean; errors: string[] } { const errors: string[] = [] const platform = resolvePlatform(args) const hasOpenCode = platform === "opencode" || platform === "both" const hasCodexOnly = platform === "codex" + if (platformRequiresLazycodex(platform) && !isLazycodexPublishingEnabled(env)) { + errors.push(LAZYCODEX_DISABLED_MESSAGE) + } + if (hasOpenCode && args.claude === undefined) { errors.push("--claude is required (values: no, yes, max20)") } else if (args.claude !== undefined && !["no", "yes", "max20"].includes(args.claude)) { diff --git a/src/cli/lazycodex-feature-flag.ts b/src/cli/lazycodex-feature-flag.ts new file mode 100644 index 000000000..24545b859 --- /dev/null +++ b/src/cli/lazycodex-feature-flag.ts @@ -0,0 +1,15 @@ +import type { InstallPlatform } from "./types" + +export const LAZYCODEX_PUBLISH_FLAG = "OMO_PUBLISH_LAZYCODEX" +export const LAZYCODEX_DISABLED_MESSAGE = + "Codex platform install is disabled. Set OMO_PUBLISH_LAZYCODEX=true to enable LazyCodex publish/install." + +type Environment = Readonly> + +export function isLazycodexPublishingEnabled(env: Environment = process.env): boolean { + return env[LAZYCODEX_PUBLISH_FLAG] === "true" +} + +export function platformRequiresLazycodex(platform: InstallPlatform | undefined): boolean { + return platform === "codex" || platform === "both" +} diff --git a/src/cli/tui-install-prompts.test.ts b/src/cli/tui-install-prompts.test.ts index 6a903187d..d0a7db62f 100644 --- a/src/cli/tui-install-prompts.test.ts +++ b/src/cli/tui-install-prompts.test.ts @@ -46,12 +46,12 @@ describe("promptInstallPlatform", () => { mock.restore() }) - test("offers OpenCode, Codex, and Both choices with OpenCode as the default", async () => { + test("offers OpenCode, Codex, and Both choices when lazycodex publishing is enabled", async () => { // given const selectSpy = spyOn(p, "select").mockResolvedValue("opencode") // when - const value = await prompts.promptInstallPlatform() + const value = await prompts.promptInstallPlatform("opencode", true) // then expect(value).toBe("opencode") @@ -65,6 +65,22 @@ describe("promptInstallPlatform", () => { ], }) }) + + test("hides Codex platform choices when lazycodex publishing is disabled", async () => { + // given + const selectSpy = spyOn(p, "select").mockResolvedValue("opencode") + + // when + const value = await prompts.promptInstallPlatform("codex", false) + + // then + expect(value).toBe("opencode") + expect(selectSpy).toHaveBeenCalledTimes(1) + expect(selectSpy.mock.calls[0]?.[0]).toMatchObject({ + initialValue: "opencode", + options: [{ value: "opencode" }], + }) + }) }) describe("promptInstallConfig platform branching", () => { diff --git a/src/cli/tui-install-prompts.ts b/src/cli/tui-install-prompts.ts index 646ec7b91..84104bc29 100644 --- a/src/cli/tui-install-prompts.ts +++ b/src/cli/tui-install-prompts.ts @@ -7,6 +7,7 @@ import type { InstallPlatform, } from "./types" import { detectedToInitialValues } from "./install-validators" +import { isLazycodexPublishingEnabled } from "./lazycodex-feature-flag" async function selectOrCancel>(params: { message: string @@ -29,15 +30,24 @@ async function selectOrCancel export async function promptInstallPlatform( initialValue: InstallPlatform = "opencode", + lazycodexEnabled = isLazycodexPublishingEnabled(), ): Promise { - return selectOrCancel({ - message: "Which platform do you want to install?", - options: [ - { value: "opencode", label: "OpenCode", hint: "Install OpenCode plugin only" }, + const options: Option[] = [ + { value: "opencode", label: "OpenCode", hint: "Install OpenCode plugin only" }, + ] + if (lazycodexEnabled) { + options.push( { value: "codex", label: "Codex", hint: "Install Codex harness adapter only" }, { value: "both", label: "Both", hint: "Install OpenCode plugin and Codex adapter" }, - ], - initialValue, + ) + } + + const safeInitialValue = lazycodexEnabled || initialValue === "opencode" ? initialValue : "opencode" + + return selectOrCancel({ + message: "Which platform do you want to install?", + options, + initialValue: safeInitialValue, }) } diff --git a/src/cli/tui-installer.test.ts b/src/cli/tui-installer.test.ts index ed4210d1f..9a0323eeb 100644 --- a/src/cli/tui-installer.test.ts +++ b/src/cli/tui-installer.test.ts @@ -19,6 +19,7 @@ function createMockSpinner(): ReturnType { describe("runTuiInstaller", () => { const originalIsStdinTty = process.stdin.isTTY const originalIsStdoutTty = process.stdout.isTTY + const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX beforeEach(() => { Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }) @@ -28,6 +29,11 @@ describe("runTuiInstaller", () => { afterEach(() => { Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty }) Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty }) + if (originalPublishLazycodex === undefined) { + delete process.env.OMO_PUBLISH_LAZYCODEX + } else { + process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex + } }) it("blocks installation when OpenCode is below the minimum version", async () => { @@ -76,6 +82,29 @@ describe("runTuiInstaller", () => { outroSpy.mockRestore() }) + it("blocks codex platform when lazycodex publishing is disabled", async () => { + // given + delete process.env.OMO_PUBLISH_LAZYCODEX + const platformSpy = spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("codex") + const promptConfigSpy = spyOn(tuiInstallPrompts, "promptInstallConfig") + const logErrorSpy = spyOn(p.log, "error").mockImplementation(() => undefined) + const outroSpy = spyOn(p, "outro").mockImplementation(() => undefined) + + // when + const result = await runTuiInstaller({ tui: true, platform: "codex" }, "3.16.0") + + // then + expect(result).toBe(1) + expect(platformSpy).toHaveBeenCalled() + expect(promptConfigSpy).not.toHaveBeenCalled() + expect(logErrorSpy).toHaveBeenCalled() + + platformSpy.mockRestore() + promptConfigSpy.mockRestore() + logErrorSpy.mockRestore() + outroSpy.mockRestore() + }) + it("proceeds when OpenCode meets the minimum version", async () => { // given const restoreSpies = [ @@ -143,6 +172,7 @@ describe("runTuiInstaller", () => { it("skips OpenCode checks and writes when platform is codex", async () => { // given + process.env.OMO_PUBLISH_LAZYCODEX = "true" const restoreSpies = [ spyOn(p, "spinner").mockReturnValue(createMockSpinner()), spyOn(p, "intro").mockImplementation(() => undefined), diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts index 1cd56c001..d3c37d6a7 100644 --- a/src/cli/tui-installer.ts +++ b/src/cli/tui-installer.ts @@ -13,6 +13,11 @@ import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" import { promptInstallConfig, promptInstallPlatform } from "./tui-install-prompts" import { runCodexInstaller } from "./install-codex" +import { + LAZYCODEX_DISABLED_MESSAGE, + isLazycodexPublishingEnabled, + platformRequiresLazycodex, +} from "./lazycodex-feature-flag" export async function runTuiInstaller(args: InstallArgs, version: string): Promise { if (!process.stdin.isTTY || !process.stdout.isTTY) { @@ -22,6 +27,11 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi const selectedPlatform = await promptInstallPlatform(args.platform ?? "opencode") if (!selectedPlatform) return 1 + if (platformRequiresLazycodex(selectedPlatform) && !isLazycodexPublishingEnabled()) { + p.log.error(LAZYCODEX_DISABLED_MESSAGE) + p.outro(color.red("Installation blocked.")) + return 1 + } const hasOpenCode = selectedPlatform === "opencode" || selectedPlatform === "both" const detected = hasOpenCode