diff --git a/.github/workflows/prepare-release-metadata.yml b/.github/workflows/prepare-release-metadata.yml index 5219481..fc3cb15 100644 --- a/.github/workflows/prepare-release-metadata.yml +++ b/.github/workflows/prepare-release-metadata.yml @@ -41,12 +41,18 @@ jobs: import fs from "node:fs" const version = process.env.RELEASE_VERSION - for (const file of ["package.json", "package-lock.json"]) { - const data = JSON.parse(fs.readFileSync(file, "utf8")) - data.version = version - if (file === "package-lock.json") data.packages[""].version = version - fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n") - } + const packagePath = "package.json" + const packageJSON = JSON.parse(fs.readFileSync(packagePath, "utf8")) + packageJSON.version = version + fs.writeFileSync(packagePath, JSON.stringify(packageJSON, null, 2) + "\n") + + const lockPath = "package-lock.json" + const packageLock = JSON.parse(fs.readFileSync(lockPath, "utf8")) + packageLock.version = version + if (!packageLock.packages?.[""]) throw new Error("package-lock root package metadata not found") + packageLock.packages[""].version = version + packageLock.packages[""].bin = packageJSON.bin + fs.writeFileSync(lockPath, JSON.stringify(packageLock, null, 2) + "\n") const readmePath = "README.md" const readme = fs.readFileSync(readmePath, "utf8") diff --git a/README.md b/README.md index 42ed921..bf86075 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,21 @@ Choose either installation method below. npx -y @bybrawe/opencode-loop@latest ``` -Run the same command again whenever you want to update. +Run the same command again whenever you want to update. If **OpenCode Goals is already installed**, a normal Loop install/update also makes a best-effort call to the official `@bybrawe/opencode-goal@latest` installer so the companion Goal plugin does not silently remain on an older release. + +Install Loop and Goals together from scratch with: + +```bash +npx -y @bybrawe/opencode-loop@latest --with-goals +``` + +Update/install only Loop and skip all Goals companion network work with: + +```bash +npx -y @bybrawe/opencode-loop@latest --loop-only +``` + +`--with-goals` is explicit and fails if the requested Goals install/update fails. The automatic refresh of an already-installed Goals companion is best-effort: Loop still finishes its own update and prints a retry command if the companion registry/install step is temporarily unavailable. ### Option 2 — install with npm @@ -34,6 +48,12 @@ npm install -g @bybrawe/opencode-loop@latest opencode-loop ``` +If you want the global Loop installer to install Goals too, run: + +```bash +opencode-loop --with-goals +``` + `npm install @bybrawe/opencode-loop` by itself only adds the Node package to the current project. For a normal OpenCode installation, use the global npm method above or the recommended `npx` installer. The installer: @@ -42,7 +62,9 @@ The installer: - installs the `/loop-*` slash-command definitions; - installs the tool-denied `opencode-loop-local` command agent; - keeps an existing npm plugin entry pinned to the installer’s exact version to avoid stale OpenCode package-cache resolution; -- removes duplicate old local Loop plugin copies when the package entry is authoritative. +- removes duplicate old local Loop plugin copies when the package entry is authoritative; +- refreshes OpenCode Goals through **Goals' own official installer** when an existing managed Goals installation is detected; +- can explicitly install both packages with `--with-goals`, or skip companion network work with `--loop-only`. Then **fully restart OpenCode** and verify: @@ -51,6 +73,12 @@ Then **fully restart OpenCode** and verify: /loop-doctor ``` +If Goals is installed too, also verify: + +```text +/goal status +``` + ### Uninstall If you use the `npx` installer: @@ -68,7 +96,7 @@ npm uninstall -g @bybrawe/opencode-loop Run `opencode-loop --uninstall` before removing the global npm package so it can clean its OpenCode registrations and managed command files. -Uninstall removes known OpenCode Loop package registrations, local plugin files, `/loop-*` command markdown files, and the Loop local command agent while preserving unrelated OpenCode configuration. +Uninstall removes known OpenCode Loop package registrations, local plugin files, `/loop-*` command markdown files, and the Loop local command agent while preserving unrelated OpenCode configuration. It **does not uninstall OpenCode Goals**; Goals remains separately managed by its own installer. Project Loop state is intentionally preserved under: @@ -142,7 +170,13 @@ OpenCode Loop still includes the older **experimental** `/loop-goal` workflow. I For durable Goal Contracts, host-owned evidence, semantic verification, native Todo coordination, revision isolation, false-completion protection, restart recovery, Goal audit, budgets, and ordered Goals, install **OpenCode Goals**. -Recommended one-command installer: +Convenient combined installer from Loop: + +```bash +npx -y @bybrawe/opencode-loop@latest --with-goals +``` + +Or install Goals directly with its standalone installer: ```bash npx -y @bybrawe/opencode-goal@latest @@ -177,21 +211,21 @@ Do **not** run Loop’s `/loop-goal` and OpenCode Goals `/goal` against the same Likewise, avoid leaving a prompt-producing `/loop ...` job continuously injecting agent turns into a session while an OpenCode Goal is actively continuing. Use separate sessions or pause/remove that prompt loop until the Goal is done. Scheduled shell/command jobs should also be chosen carefully so they do not race files or verification. -Install both when needed with `npx`: +Install both with one `npx` command: ```bash -npx -y @bybrawe/opencode-loop@latest -npx -y @bybrawe/opencode-goal@latest +npx -y @bybrawe/opencode-loop@latest --with-goals ``` -Or install both globally with npm, then run both installers: +Or install both globally with npm, then run the Loop installer with the companion flag: ```bash npm install -g @bybrawe/opencode-loop@latest @bybrawe/opencode-goal@latest -opencode-loop -opencode-goal +opencode-loop --with-goals ``` +Once Goals is managed in the OpenCode config, future normal `opencode-loop` / `npx ...opencode-loop@latest` updates also attempt to refresh Goals to its latest stable release. Use `--loop-only` when you intentionally do not want that companion refresh. + ## Core commands | Command | Purpose | diff --git a/package.json b/package.json index e3e79b6..9438a6a 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,10 @@ "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool", "build:plugin:npm": "npm run build:plugin", "prepack": "node --check src/index.js", - "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs", - "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", + "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs", + "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs", "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs", - "install:global": "node scripts/install-node.mjs", + "install:global": "node scripts/install-with-goals.mjs", "pack:zip": "node scripts/make-zip.mjs" }, "keywords": [ @@ -64,7 +64,7 @@ "@opencode-ai/plugin": "^1.18.15" }, "bin": { - "opencode-loop": "scripts/install-node.mjs", + "opencode-loop": "scripts/install-with-goals.mjs", "opencode-loopd": "scripts/loopd.mjs" }, "publishConfig": { diff --git a/scripts/goal-companion-test.mjs b/scripts/goal-companion-test.mjs new file mode 100644 index 0000000..2bbce29 --- /dev/null +++ b/scripts/goal-companion-test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { promises as fs } from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const installer = path.join(root, "scripts", "install-with-goals.mjs") +const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-goal-companion-")) +const fakeNpm = path.join(temporaryRoot, "fake-npm.mjs") +const expectedGoalExec = [ + "exec", + "--yes", + "--package=@bybrawe/opencode-goal@latest", + "--", + "opencode-goal", +] + +await fs.writeFile(fakeNpm, ` +import { appendFileSync } from "node:fs" +const log = process.env.FAKE_NPM_LOG +if (log) appendFileSync(log, JSON.stringify(process.argv.slice(2)) + "\\n", "utf8") +process.exit(Number(process.env.FAKE_NPM_EXIT || 0)) +`, "utf8") + +async function runInstaller(config, cliArgs = [], env = {}) { + return await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [installer, ...cliArgs], { + cwd: root, + env: { + ...process.env, + OPENCODE_CONFIG_DIR: config, + npm_execpath: fakeNpm, + ...env, + }, + windowsHide: true, + }) + const stdout = [] + const stderr = [] + child.stdout.on("data", (data) => stdout.push(Buffer.from(data))) + child.stderr.on("data", (data) => stderr.push(Buffer.from(data))) + child.on("error", reject) + child.on("close", (code) => resolve({ + code, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + })) + }) +} + +async function calls(log) { + try { + return (await fs.readFile(log, "utf8")) + .trim() + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + } catch (error) { + if (error?.code === "ENOENT") return [] + throw error + } +} + +async function writeConfig(config, plugins, comment = "") { + await fs.mkdir(config, { recursive: true }) + await fs.writeFile( + path.join(config, "opencode.jsonc"), + `{\n ${comment ? `// ${comment}\n ` : ""}\"plugin\": ${JSON.stringify(plugins, null, 2).replaceAll("\n", "\n ")},\n}\n`, + "utf8", + ) +} + +try { + const absentConfig = path.join(temporaryRoot, "absent") + const absentLog = path.join(temporaryRoot, "absent.log") + const absent = await runInstaller(absentConfig, [], { FAKE_NPM_LOG: absentLog }) + assert.equal(absent.code, 0, absent.stderr) + assert.deepEqual(await calls(absentLog), [], "Loop-only installations must not silently add Goals") + + const existingConfig = path.join(temporaryRoot, "existing") + const existingLog = path.join(temporaryRoot, "existing.log") + await writeConfig(existingConfig, ["other-plugin", "@bybrawe/opencode-goal@1.3.16"]) + const existing = await runInstaller(existingConfig, [], { FAKE_NPM_LOG: existingLog }) + assert.equal(existing.code, 0, existing.stderr) + assert.deepEqual(await calls(existingLog), [expectedGoalExec], "an installed Goals package must auto-refresh through its own latest installer") + assert.match(existing.stdout, /Installed\/updated OpenCode Goals via @bybrawe\/opencode-goal@latest/) + + const localConfig = path.join(temporaryRoot, "local-goal") + const localLog = path.join(temporaryRoot, "local-goal.log") + await writeConfig(localConfig, ["./plugins/opencode-goal.ts"]) + const local = await runInstaller(localConfig, [], { FAKE_NPM_LOG: localLog }) + assert.equal(local.code, 0, local.stderr) + assert.deepEqual(await calls(localLog), [expectedGoalExec], "legacy local Goals installs must be refreshed/migrated by the companion installer") + + const managedCommandConfig = path.join(temporaryRoot, "managed-command") + const managedCommandLog = path.join(temporaryRoot, "managed-command.log") + await fs.mkdir(path.join(managedCommandConfig, "commands"), { recursive: true }) + await fs.writeFile(path.join(managedCommandConfig, "commands", "goal.md"), "\n", "utf8") + const managedCommand = await runInstaller(managedCommandConfig, [], { FAKE_NPM_LOG: managedCommandLog }) + assert.equal(managedCommand.code, 0, managedCommand.stderr) + assert.deepEqual(await calls(managedCommandLog), [expectedGoalExec], "a managed /goal command is sufficient evidence that Goals should be refreshed") + + const commentedConfig = path.join(temporaryRoot, "comment-only") + const commentedLog = path.join(temporaryRoot, "comment-only.log") + await writeConfig(commentedConfig, ["other-plugin"], 'old note: "@bybrawe/opencode-goal@1.3.16"') + const commented = await runInstaller(commentedConfig, [], { FAKE_NPM_LOG: commentedLog }) + assert.equal(commented.code, 0, commented.stderr) + assert.deepEqual(await calls(commentedLog), [], "a package name mentioned only in JSONC comments must not trigger a network update") + + const explicitConfig = path.join(temporaryRoot, "explicit") + const explicitLog = path.join(temporaryRoot, "explicit.log") + const explicit = await runInstaller(explicitConfig, ["--with-goals"], { FAKE_NPM_LOG: explicitLog }) + assert.equal(explicit.code, 0, explicit.stderr) + assert.deepEqual(await calls(explicitLog), [expectedGoalExec], "--with-goals must install the companion even when it is absent") + + const optOutConfig = path.join(temporaryRoot, "opt-out") + const optOutLog = path.join(temporaryRoot, "opt-out.log") + await writeConfig(optOutConfig, ["@bybrawe/opencode-goal@1.3.16"]) + const optOut = await runInstaller(optOutConfig, ["--loop-only"], { FAKE_NPM_LOG: optOutLog }) + assert.equal(optOut.code, 0, optOut.stderr) + assert.deepEqual(await calls(optOutLog), [], "--loop-only must suppress companion network work") + assert.match(optOut.stdout, /Skipped OpenCode Goals companion update/) + + const autoFailureConfig = path.join(temporaryRoot, "auto-failure") + const autoFailureLog = path.join(temporaryRoot, "auto-failure.log") + await writeConfig(autoFailureConfig, ["@bybrawe/opencode-goal@1.3.16"]) + const autoFailure = await runInstaller(autoFailureConfig, [], { FAKE_NPM_LOG: autoFailureLog, FAKE_NPM_EXIT: "7" }) + assert.equal(autoFailure.code, 0, "a best-effort refresh failure must not break an otherwise successful Loop update") + assert.match(autoFailure.stderr, /could not be refreshed/) + assert.match(autoFailure.stderr, /npx -y @bybrawe\/opencode-goal@latest/) + + const explicitFailureConfig = path.join(temporaryRoot, "explicit-failure") + const explicitFailureLog = path.join(temporaryRoot, "explicit-failure.log") + const explicitFailure = await runInstaller(explicitFailureConfig, ["--with-goals"], { FAKE_NPM_LOG: explicitFailureLog, FAKE_NPM_EXIT: "9" }) + assert.equal(explicitFailure.code, 9, "an explicitly requested companion install must fail closed") + assert.match(explicitFailure.stderr, /companion install\/update failed/) + + const conflicting = await runInstaller(path.join(temporaryRoot, "conflicting"), ["--with-goals", "--loop-only"]) + assert.equal(conflicting.code, 2) + assert.match(conflicting.stderr, /either --with-goals or --loop-only/) + + const helpLog = path.join(temporaryRoot, "help.log") + const help = await runInstaller(path.join(temporaryRoot, "help"), ["--with-goals", "--help"], { FAKE_NPM_LOG: helpLog }) + assert.equal(help.code, 0, help.stderr) + assert.match(help.stdout, /OpenCode Loop installer\/updater/) + assert.match(help.stdout, /--with-goals/) + assert.match(help.stdout, /--loop-only/) + assert.match(help.stdout, /Loop uninstall never removes Goals/) + assert.deepEqual(await calls(helpLog), [], "help/version flows must never install companion packages") + + console.log("OpenCode Loop Goals companion installer test passed") +} finally { + await fs.rm(temporaryRoot, { recursive: true, force: true }) +} diff --git a/scripts/install-with-goals.mjs b/scripts/install-with-goals.mjs new file mode 100644 index 0000000..37a299e --- /dev/null +++ b/scripts/install-with-goals.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process" +import { readFile } from "node:fs/promises" +import { homedir } from "node:os" +import { dirname, join } from "node:path" +import process from "node:process" +import { fileURLToPath } from "node:url" + +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const loopInstaller = join(root, "scripts", "install-node.mjs") +const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode") +const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"] +const goalPackageName = "@bybrawe/opencode-goal" +const goalPackageSpec = `${goalPackageName}@latest` +const managedGoalCommandMarker = "" +const rawArgs = process.argv.slice(2) +const withGoals = rawArgs.includes("--with-goals") +const loopOnly = rawArgs.includes("--loop-only") +const loopArgs = rawArgs.filter((arg) => arg !== "--with-goals" && arg !== "--loop-only") +const uninstallRequested = loopArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(loopArgs[0] || "") +const helpRequested = loopArgs.some((arg) => ["--help", "-h"].includes(arg)) +const versionRequested = loopArgs.some((arg) => ["--version", "-v"].includes(arg)) +const informational = helpRequested || versionRequested + +function stripJsonComments(input) { + let output = "" + let quote = "" + let escaped = false + let lineComment = false + let blockComment = false + for (let index = 0; index < input.length; index++) { + const char = input[index] + const next = input[index + 1] + if (lineComment) { + if (char === "\n" || char === "\r") { lineComment = false; output += char } + continue + } + if (blockComment) { + if (char === "*" && next === "/") { blockComment = false; index++ } + else if (char === "\n" || char === "\r") output += char + continue + } + if (quote) { + output += char + if (escaped) escaped = false + else if (char === "\\") escaped = true + else if (char === quote) quote = "" + continue + } + if (char === '"') { quote = char; output += char; continue } + if (char === "/" && next === "/") { lineComment = true; index++; continue } + if (char === "/" && next === "*") { blockComment = true; index++; continue } + output += char + } + return output +} + +function stripTrailingCommas(input) { + let output = "" + let quote = "" + let escaped = false + for (let index = 0; index < input.length; index++) { + const char = input[index] + if (quote) { + output += char + if (escaped) escaped = false + else if (char === "\\") escaped = true + else if (char === quote) quote = "" + continue + } + if (char === '"') { quote = char; output += char; continue } + if (char === ",") { + let lookahead = index + 1 + while (/\s/.test(input[lookahead] || "")) lookahead++ + if (input[lookahead] === "]" || input[lookahead] === "}") continue + } + output += char + } + return output +} + +function parseJsonc(input) { + const parsed = JSON.parse(stripTrailingCommas(stripJsonComments(input))) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("OpenCode config root must be a JSON object") + return parsed +} + +function isGoalPluginSpec(value) { + if (typeof value !== "string") return false + const spec = value.trim() + if (spec === goalPackageName || spec.startsWith(`${goalPackageName}@`)) return true + const normalized = spec.replaceAll("\\", "/") + return normalized === "./plugins/opencode-goal.ts" + || normalized === "./plugins/opencode-goal.js" + || normalized.endsWith("/plugins/opencode-goal.ts") + || normalized.endsWith("/plugins/opencode-goal.js") +} + +async function goalsAlreadyInstalled() { + for (const name of configCandidates) { + try { + const parsed = parseJsonc(await readFile(join(config, name), "utf8")) + if (Array.isArray(parsed.plugin) && parsed.plugin.some(isGoalPluginSpec)) return true + } catch (error) { + if (error?.code !== "ENOENT") { + console.warn(`Could not inspect ${join(config, name)} for OpenCode Goals: ${error.message}`) + } + } + } + + try { + const command = await readFile(join(config, "commands", "goal.md"), "utf8") + return command.includes(managedGoalCommandMarker) + } catch (error) { + if (error?.code !== "ENOENT") console.warn(`Could not inspect managed /goal command: ${error.message}`) + } + return false +} + +function runLoopInstaller() { + return spawnSync(process.execPath, [loopInstaller, ...loopArgs], { + cwd: root, + env: process.env, + stdio: "inherit", + windowsHide: true, + }) +} + +function runGoalInstaller() { + const npmExecPath = process.env.npm_execpath + const command = npmExecPath ? process.execPath : (process.platform === "win32" ? "npm.cmd" : "npm") + const args = npmExecPath + ? [npmExecPath, "exec", "--yes", `--package=${goalPackageSpec}`, "--", "opencode-goal"] + : ["exec", "--yes", `--package=${goalPackageSpec}`, "--", "opencode-goal"] + + return spawnSync(command, args, { + cwd: root, + env: { ...process.env, OPENCODE_CONFIG_DIR: config }, + stdio: "inherit", + windowsHide: true, + timeout: 180_000, + }) +} + +function statusCode(result) { + if (Number.isInteger(result?.status)) return result.status + return result?.error ? 1 : 0 +} + +function printCompanionHelp() { + console.log(`\nOpenCode Goals companion options:\n --with-goals Install/update @bybrawe/opencode-goal@latest even when it is not already installed.\n --loop-only Skip all OpenCode Goals companion detection and network update work.\n\nNormal Loop install/update refreshes an already-managed OpenCode Goals installation on a best-effort basis. Loop uninstall never removes Goals.`) +} + +async function main() { + if (withGoals && loopOnly) { + console.error("Use either --with-goals or --loop-only, not both.") + process.exitCode = 2 + return + } + + const loopResult = runLoopInstaller() + if (loopResult.error) { + console.error(`OpenCode Loop installer failed to start: ${loopResult.error.message}`) + process.exitCode = 1 + return + } + const loopCode = statusCode(loopResult) + if (loopCode !== 0) { + process.exitCode = loopCode + return + } + + if (informational) { + if (helpRequested) printCompanionHelp() + return + } + if (uninstallRequested) return + + if (loopOnly) { + console.log("Skipped OpenCode Goals companion update (--loop-only).") + return + } + + const alreadyInstalled = await goalsAlreadyInstalled() + if (!alreadyInstalled && !withGoals) return + + const goalResult = runGoalInstaller() + const goalCode = statusCode(goalResult) + if (goalCode === 0) { + console.log(`Installed/updated OpenCode Goals via ${goalPackageSpec}.`) + return + } + + const detail = goalResult.error?.message || `exit code ${goalCode}` + if (withGoals) { + console.error(`OpenCode Goals companion install/update failed (${detail}).`) + process.exitCode = goalCode || 1 + return + } + + console.warn(`OpenCode Loop updated, but the existing OpenCode Goals companion could not be refreshed (${detail}).`) + console.warn(`Retry later with: npx -y ${goalPackageSpec}`) +} + +main().catch((error) => { + console.error(error?.stack || error) + process.exitCode = 1 +})