mirror of
https://github.com/NoeFabris/opencode-antigravity-auth.git
synced 2026-05-22 13:24:56 +00:00
feat: implement auto-update checker for Antigravity plugin with installation instructions and model updates
This commit is contained in:
265
README.md
265
README.md
@@ -15,132 +15,225 @@ Enable Opencode to authenticate against **Antigravity** (Google's IDE) via OAuth
|
||||
- **Debug logging** for requests and responses
|
||||
- **Drop-in setup** Opencode auto-installs the plugin from config
|
||||
|
||||
## Quick start
|
||||
## Installation
|
||||
|
||||
### Step 1: Create your config file
|
||||
### For Humans
|
||||
|
||||
If this is your first time using Opencode, create the config directory first:
|
||||
**Option A: Let an LLM do it for you**
|
||||
|
||||
Paste this into any LLM agent (Claude Code, OpenCode, Cursor, etc.):
|
||||
|
||||
```
|
||||
Install the opencode-antigravity-auth plugin by following: https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/README.md
|
||||
```
|
||||
|
||||
**Option B: Do it yourself**
|
||||
|
||||
1. **Add the plugin to your config:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/opencode
|
||||
```
|
||||
|
||||
Create or edit `~/.config/opencode/opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-antigravity-auth@1.2.0"]
|
||||
}
|
||||
```
|
||||
|
||||
2. **Authenticate:**
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
```
|
||||
|
||||
Select **Google** → **OAuth with Google (Antigravity)** → Press Enter to skip Project ID → Sign in via browser.
|
||||
|
||||
3. **Add models to your config:**
|
||||
|
||||
Add the models you want to use to the same config file:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-antigravity-auth@1.2.0"],
|
||||
"provider": {
|
||||
"google": {
|
||||
"models": {
|
||||
"gemini-3-pro-high": {
|
||||
"name": "Gemini 3 Pro High (Antigravity)",
|
||||
"limit": { "context": 1048576, "output": 65535 },
|
||||
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
|
||||
},
|
||||
"claude-sonnet-4-5": {
|
||||
"name": "Claude Sonnet 4.5 (Antigravity)",
|
||||
"limit": { "context": 200000, "output": 64000 },
|
||||
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> See [Available models](#available-models) for the full list.
|
||||
|
||||
4. **Use it:**
|
||||
|
||||
```bash
|
||||
opencode run "Hello" --model=google/gemini-3-pro-high
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### For LLMs
|
||||
|
||||
<details>
|
||||
<summary><b>🤖 Detailed Installation Guide for LLM Agents</b> (click to expand)</summary>
|
||||
|
||||
If you're an LLM agent helping a user set up this plugin, follow these steps:
|
||||
|
||||
#### Step 1: Verify OpenCode is installed
|
||||
|
||||
```bash
|
||||
command -v opencode && opencode --version || echo "OpenCode not installed - see https://opencode.ai/docs"
|
||||
```
|
||||
|
||||
#### Step 2: Create or update the config
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/opencode
|
||||
|
||||
if [ -f ~/.config/opencode/opencode.json ]; then
|
||||
if command -v jq &> /dev/null; then
|
||||
jq '.plugin = ((.plugin // []) + ["opencode-antigravity-auth@1.2.0"] | unique)' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
else
|
||||
echo "Add \"opencode-antigravity-auth@1.2.0\" to the plugin array manually"
|
||||
fi
|
||||
else
|
||||
echo '{"plugin":["opencode-antigravity-auth@1.2.0"]}' > ~/.config/opencode/opencode.json
|
||||
fi
|
||||
```
|
||||
|
||||
Then create or edit the config file at `~/.config/opencode/opencode.json`:
|
||||
#### Step 3: Ask user about multi-account setup
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-antigravity-auth@1.2.0"]
|
||||
}
|
||||
```
|
||||
Before running `opencode auth login`, ask the user:
|
||||
|
||||
> **Note:** You can also use a project-local `.opencode.json` file in your project root instead. The global config at `~/.config/opencode/opencode.json` applies to all projects.
|
||||
> **Do you want to add multiple Google accounts for load balancing?**
|
||||
> - Adding 2-10 accounts maximizes rate limits and provides automatic failover
|
||||
> - You can always add more accounts later
|
||||
|
||||
### Step 2: Authenticate
|
||||
Based on their answer:
|
||||
- **Yes, multiple accounts** → After first login, prompt: "Sign in complete. Add another account? (up to 10 supported)"
|
||||
- **No, just one** → Single account is fine, they can add more later with `opencode auth login`
|
||||
|
||||
Run the authentication command:
|
||||
#### Step 4: Run authentication
|
||||
|
||||
Tell the user to run:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
```
|
||||
|
||||
Guide them through the prompts:
|
||||
1. Select **Google** as the provider
|
||||
2. Select **OAuth with Google (Antigravity)**
|
||||
3. **Project ID prompt:** You'll see this prompt:
|
||||
```
|
||||
Project ID (leave blank to use your default project):
|
||||
```
|
||||
**Just press Enter to skip this** — it's optional and only needed if you want to use a specific Google Cloud project. Most users can leave it blank.
|
||||
4. Sign in via the browser and return to Opencode. If the browser doesn't open, copy the displayed URL manually.
|
||||
5. After signing in, you can add more Google accounts (up to 10) for load balancing, or press Enter to finish.
|
||||
3. **Project ID prompt** → Tell user: "Press Enter to skip (most users don't need this)"
|
||||
4. Browser opens for Google sign-in
|
||||
5. If multi-account: repeat for additional accounts, or press Enter to finish
|
||||
|
||||
> **Alternative:** For a quick single-account setup without project ID options, open `opencode` and use the `/connect` command instead.
|
||||
#### Step 5: Add models to config
|
||||
|
||||
### Step 3: Add the models you want to use
|
||||
Merge model definitions into the user's config file (`~/.config/opencode/opencode.json`):
|
||||
|
||||
Open the **same config file** you created in Step 1 (`~/.config/opencode/opencode.json`) and add the models under `provider.google.models`:
|
||||
```bash
|
||||
# If jq is available, merge models programmatically
|
||||
if command -v jq &> /dev/null; then
|
||||
jq '.provider.google.models = {
|
||||
"gemini-3-pro-high": {"name": "Gemini 3 Pro High (Antigravity)", "limit": {"context": 1048576, "output": 65535}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
|
||||
"gemini-3-pro-low": {"name": "Gemini 3 Pro Low (Antigravity)", "limit": {"context": 1048576, "output": 65535}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
|
||||
"gemini-3-flash": {"name": "Gemini 3 Flash (Antigravity)", "limit": {"context": 1048576, "output": 65536}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
|
||||
"claude-sonnet-4-5": {"name": "Claude Sonnet 4.5 (Antigravity)", "limit": {"context": 200000, "output": 64000}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
|
||||
"claude-sonnet-4-5-thinking": {"name": "Claude Sonnet 4.5 Thinking (Antigravity)", "limit": {"context": 200000, "output": 64000}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
|
||||
"claude-opus-4-5-thinking": {"name": "Claude Opus 4.5 Thinking (Antigravity)", "limit": {"context": 200000, "output": 64000}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}},
|
||||
"gpt-oss-120b-medium": {"name": "GPT-OSS 120B Medium (Antigravity)", "limit": {"context": 131072, "output": 32768}, "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}}
|
||||
} + (.provider.google.models // {})' ~/.config/opencode/opencode.json > /tmp/oc.json && mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
fi
|
||||
```
|
||||
|
||||
Or edit the file manually and add the models from the [Available models](#available-models) section.
|
||||
|
||||
#### Step 6: Verify setup
|
||||
|
||||
```bash
|
||||
opencode run "Hello" --model=google/gemini-3-pro-high
|
||||
```
|
||||
|
||||
If successful, tell the user: **"Setup complete! You can now use Antigravity models in OpenCode."**
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Available models
|
||||
|
||||
Add these models to your `~/.config/opencode/opencode.json` under `provider.google.models`:
|
||||
|
||||
| Model ID | Description |
|
||||
|----------|-------------|
|
||||
| `gemini-3-pro-high` | Gemini 3 Pro High |
|
||||
| `gemini-3-pro-low` | Gemini 3 Pro Low |
|
||||
| `gemini-3-flash` | Gemini 3 Flash |
|
||||
| `claude-sonnet-4-5` | Claude Sonnet 4.5 |
|
||||
| `claude-sonnet-4-5-thinking` | Claude Sonnet 4.5 with thinking |
|
||||
| `claude-opus-4-5-thinking` | Claude Opus 4.5 with thinking |
|
||||
| `gpt-oss-120b-medium` | GPT-OSS 120B Medium |
|
||||
|
||||
<details>
|
||||
<summary><b>Full model configuration</b> (click to expand)</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-antigravity-auth@1.2.0"],
|
||||
"provider": {
|
||||
"google": {
|
||||
"models": {
|
||||
"gemini-3-pro-high": {
|
||||
"name": "Gemini 3 Pro High (Antigravity)",
|
||||
"limit": {
|
||||
"context": 1048576,
|
||||
"output": 65535
|
||||
},
|
||||
"modalities": {
|
||||
"input": ["text", "image", "pdf"],
|
||||
"output": ["text"]
|
||||
}
|
||||
"limit": { "context": 1048576, "output": 65535 },
|
||||
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
|
||||
},
|
||||
"gemini-3-pro-low": {
|
||||
"name": "Gemini 3 Pro Low (Antigravity)",
|
||||
"limit": {
|
||||
"context": 1048576,
|
||||
"output": 65535
|
||||
},
|
||||
"modalities": {
|
||||
"input": ["text", "image", "pdf"],
|
||||
"output": ["text"]
|
||||
}
|
||||
"limit": { "context": 1048576, "output": 65535 },
|
||||
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
|
||||
},
|
||||
"gemini-3-flash": {
|
||||
"name": "Gemini 3 Flash (Antigravity)",
|
||||
"limit": {
|
||||
"context": 1048576,
|
||||
"output": 65536
|
||||
},
|
||||
"modalities": {
|
||||
"input": ["text", "image", "pdf"],
|
||||
"output": ["text"]
|
||||
}
|
||||
"limit": { "context": 1048576, "output": 65536 },
|
||||
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
|
||||
},
|
||||
"claude-sonnet-4-5": {
|
||||
"name": "Claude Sonnet 4.5 (Antigravity)",
|
||||
"limit": {
|
||||
"context": 200000,
|
||||
"output": 64000
|
||||
},
|
||||
"modalities": {
|
||||
"input": ["text", "image", "pdf"],
|
||||
"output": ["text"]
|
||||
}
|
||||
"limit": { "context": 200000, "output": 64000 },
|
||||
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
|
||||
},
|
||||
"claude-sonnet-4-5-thinking": {
|
||||
"name": "Claude Sonnet 4.5 Thinking (Antigravity)",
|
||||
"limit": {
|
||||
"context": 200000,
|
||||
"output": 64000
|
||||
},
|
||||
"modalities": {
|
||||
"input": ["text", "image", "pdf"],
|
||||
"output": ["text"]
|
||||
}
|
||||
"limit": { "context": 200000, "output": 64000 },
|
||||
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
|
||||
},
|
||||
"claude-opus-4-5-thinking": {
|
||||
"name": "Claude Opus 4.5 Thinking (Antigravity)",
|
||||
"limit": {
|
||||
"context": 200000,
|
||||
"output": 64000
|
||||
},
|
||||
"modalities": {
|
||||
"input": ["text", "image", "pdf"],
|
||||
"output": ["text"]
|
||||
}
|
||||
"limit": { "context": 200000, "output": 64000 },
|
||||
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
|
||||
},
|
||||
"gpt-oss-120b-medium": {
|
||||
"name": "GPT-OSS 120B Medium (Antigravity)",
|
||||
"limit": {
|
||||
"context": 131072,
|
||||
"output": 32768
|
||||
},
|
||||
"modalities": {
|
||||
"input": ["text", "image", "pdf"],
|
||||
"output": ["text"]
|
||||
}
|
||||
"limit": { "context": 131072, "output": 32768 },
|
||||
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,19 +241,7 @@ Open the **same config file** you created in Step 1 (`~/.config/opencode/opencod
|
||||
}
|
||||
```
|
||||
|
||||
> **Tip:** You only need to add the models you plan to use. The example above includes all available models, but you can remove any you don't need. The `modalities` field enables image and PDF support in the TUI.
|
||||
|
||||
### Step 4: Use a model
|
||||
|
||||
```bash
|
||||
opencode run "Hello world" --model=google/gemini-3-pro-high
|
||||
```
|
||||
|
||||
Or start the interactive TUI and select a model from the model picker:
|
||||
|
||||
```bash
|
||||
opencode
|
||||
```
|
||||
</details>
|
||||
|
||||
## Multi-account load balancing
|
||||
|
||||
|
||||
91
src/hooks/auto-update-checker/cache.ts
Normal file
91
src/hooks/auto-update-checker/cache.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { CACHE_DIR, PACKAGE_NAME } from "./constants";
|
||||
|
||||
interface BunLockfile {
|
||||
workspaces?: {
|
||||
""?: {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
packages?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stripTrailingCommas(json: string): string {
|
||||
return json.replace(/,(\s*[}\]])/g, "$1");
|
||||
}
|
||||
|
||||
function removeFromBunLock(packageName: string): boolean {
|
||||
const lockPath = path.join(CACHE_DIR, "bun.lock");
|
||||
if (!fs.existsSync(lockPath)) return false;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(lockPath, "utf-8");
|
||||
const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile;
|
||||
let modified = false;
|
||||
|
||||
if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
|
||||
delete lock.workspaces[""].dependencies[packageName];
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (lock.packages?.[packageName]) {
|
||||
delete lock.packages[packageName];
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2));
|
||||
console.log(`[auto-update-checker] Removed from bun.lock: ${packageName}`);
|
||||
}
|
||||
|
||||
return modified;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
|
||||
try {
|
||||
const pkgDir = path.join(CACHE_DIR, "node_modules", packageName);
|
||||
const pkgJsonPath = path.join(CACHE_DIR, "package.json");
|
||||
|
||||
let packageRemoved = false;
|
||||
let dependencyRemoved = false;
|
||||
let lockRemoved = false;
|
||||
|
||||
if (fs.existsSync(pkgDir)) {
|
||||
fs.rmSync(pkgDir, { recursive: true, force: true });
|
||||
console.log(`[auto-update-checker] Package removed: ${pkgDir}`);
|
||||
packageRemoved = true;
|
||||
}
|
||||
|
||||
if (fs.existsSync(pkgJsonPath)) {
|
||||
const content = fs.readFileSync(pkgJsonPath, "utf-8");
|
||||
const pkgJson = JSON.parse(content);
|
||||
if (pkgJson.dependencies?.[packageName]) {
|
||||
delete pkgJson.dependencies[packageName];
|
||||
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
|
||||
console.log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`);
|
||||
dependencyRemoved = true;
|
||||
}
|
||||
}
|
||||
|
||||
lockRemoved = removeFromBunLock(packageName);
|
||||
|
||||
if (!packageRemoved && !dependencyRemoved && !lockRemoved) {
|
||||
console.log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[auto-update-checker] Failed to invalidate package:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateCache(): boolean {
|
||||
console.warn("[auto-update-checker] WARNING: invalidateCache is deprecated, use invalidatePackage");
|
||||
return invalidatePackage();
|
||||
}
|
||||
257
src/hooks/auto-update-checker/checker.ts
Normal file
257
src/hooks/auto-update-checker/checker.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types";
|
||||
import {
|
||||
PACKAGE_NAME,
|
||||
NPM_REGISTRY_URL,
|
||||
NPM_FETCH_TIMEOUT,
|
||||
INSTALLED_PACKAGE_JSON,
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
} from "./constants";
|
||||
|
||||
export function isLocalDevMode(directory: string): boolean {
|
||||
return getLocalDevPath(directory) !== null;
|
||||
}
|
||||
|
||||
function stripJsonComments(json: string): string {
|
||||
return json
|
||||
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m: string, g: string | undefined) => (g ? "" : m))
|
||||
.replace(/,(\s*[}\]])/g, "$1");
|
||||
}
|
||||
|
||||
function getConfigPaths(directory: string): string[] {
|
||||
return [
|
||||
path.join(directory, ".opencode", "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
path.join(directory, ".opencode.json"),
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
];
|
||||
}
|
||||
|
||||
export function getLocalDevPath(directory: string): string | null {
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue;
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
|
||||
const plugins = config.plugin ?? [];
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
||||
try {
|
||||
return fileURLToPath(entry);
|
||||
} catch {
|
||||
return entry.replace("file://", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findPackageJsonUp(startPath: string): string | null {
|
||||
try {
|
||||
const stat = fs.statSync(startPath);
|
||||
let dir = stat.isDirectory() ? startPath : path.dirname(startPath);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const pkgPath = path.join(dir, "package.json");
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8");
|
||||
const pkg = JSON.parse(content) as PackageJson;
|
||||
if (pkg.name === PACKAGE_NAME) return pkgPath;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLocalDevVersion(directory: string): string | null {
|
||||
const localPath = getLocalDevPath(directory);
|
||||
if (!localPath) return null;
|
||||
|
||||
try {
|
||||
const pkgPath = findPackageJsonUp(localPath);
|
||||
if (!pkgPath) return null;
|
||||
const content = fs.readFileSync(pkgPath, "utf-8");
|
||||
const pkg = JSON.parse(content) as PackageJson;
|
||||
return pkg.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginEntryInfo {
|
||||
entry: string;
|
||||
isPinned: boolean;
|
||||
pinnedVersion: string | null;
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue;
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
|
||||
const plugins = config.plugin ?? [];
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry === PACKAGE_NAME) {
|
||||
return { entry, isPinned: false, pinnedVersion: null, configPath };
|
||||
}
|
||||
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1);
|
||||
const isPinned = pinnedVersion !== "latest";
|
||||
return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCachedVersion(): string | null {
|
||||
try {
|
||||
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
|
||||
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8");
|
||||
const pkg = JSON.parse(content) as PackageJson;
|
||||
if (pkg.version) return pkg.version;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = findPackageJsonUp(currentDir);
|
||||
if (pkgPath) {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8");
|
||||
const pkg = JSON.parse(content) as PackageJson;
|
||||
if (pkg.version) return pkg.version;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[auto-update-checker] Failed to resolve version from current directory:", err);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const newEntry = `${PACKAGE_NAME}@${newVersion}`;
|
||||
|
||||
const pluginMatch = content.match(/"plugin"\s*:\s*\[/);
|
||||
if (!pluginMatch || pluginMatch.index === undefined) {
|
||||
console.log(`[auto-update-checker] No "plugin" array found in ${configPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const startIdx = pluginMatch.index + pluginMatch[0].length;
|
||||
let bracketCount = 1;
|
||||
let endIdx = startIdx;
|
||||
|
||||
for (let i = startIdx; i < content.length && bracketCount > 0; i++) {
|
||||
if (content[i] === "[") bracketCount++;
|
||||
else if (content[i] === "]") bracketCount--;
|
||||
endIdx = i;
|
||||
}
|
||||
|
||||
const before = content.slice(0, startIdx);
|
||||
const pluginArrayContent = content.slice(startIdx, endIdx);
|
||||
const after = content.slice(endIdx);
|
||||
|
||||
const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`["']${escapedOldEntry}["']`);
|
||||
|
||||
if (!regex.test(pluginArrayContent)) {
|
||||
console.log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`);
|
||||
const updatedContent = before + updatedPluginArray + after;
|
||||
|
||||
if (updatedContent === content) {
|
||||
console.log(`[auto-update-checker] No changes made to ${configPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, updatedContent, "utf-8");
|
||||
console.log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[auto-update-checker] Failed to update config file ${configPath}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(NPM_REGISTRY_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = (await response.json()) as NpmDistTags;
|
||||
return data.latest ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
|
||||
if (isLocalDevMode(directory)) {
|
||||
console.log("[auto-update-checker] Local dev mode detected, skipping update check");
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true, isPinned: false };
|
||||
}
|
||||
|
||||
const pluginInfo = findPluginEntry(directory);
|
||||
if (!pluginInfo) {
|
||||
console.log("[auto-update-checker] Plugin not found in config");
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false };
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion;
|
||||
if (!currentVersion) {
|
||||
console.log("[auto-update-checker] No version found (cached or pinned)");
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned };
|
||||
}
|
||||
|
||||
const latestVersion = await getLatestVersion();
|
||||
if (!latestVersion) {
|
||||
console.log("[auto-update-checker] Failed to fetch latest version");
|
||||
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned };
|
||||
}
|
||||
|
||||
const needsUpdate = currentVersion !== latestVersion;
|
||||
console.log(`[auto-update-checker] Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`);
|
||||
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: pluginInfo.isPinned };
|
||||
}
|
||||
32
src/hooks/auto-update-checker/constants.ts
Normal file
32
src/hooks/auto-update-checker/constants.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
export const PACKAGE_NAME = "opencode-antigravity-auth";
|
||||
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
|
||||
export const NPM_FETCH_TIMEOUT = 5000;
|
||||
|
||||
function getCacheDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode");
|
||||
}
|
||||
return path.join(os.homedir(), ".cache", "opencode");
|
||||
}
|
||||
|
||||
export const CACHE_DIR = getCacheDir();
|
||||
export const INSTALLED_PACKAGE_JSON = path.join(
|
||||
CACHE_DIR,
|
||||
"node_modules",
|
||||
PACKAGE_NAME,
|
||||
"package.json"
|
||||
);
|
||||
|
||||
function getUserConfigDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
||||
}
|
||||
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
||||
}
|
||||
|
||||
export const USER_CONFIG_DIR = getUserConfigDir();
|
||||
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json");
|
||||
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc");
|
||||
164
src/hooks/auto-update-checker/index.ts
Normal file
164
src/hooks/auto-update-checker/index.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { AutoUpdateCheckerOptions } from "./types";
|
||||
import { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion } from "./checker";
|
||||
import { invalidatePackage } from "./cache";
|
||||
import { PACKAGE_NAME } from "./constants";
|
||||
|
||||
interface PluginClient {
|
||||
tui: {
|
||||
showToast(options: {
|
||||
body: {
|
||||
title?: string;
|
||||
message: string;
|
||||
variant: "info" | "warning" | "success" | "error";
|
||||
duration?: number;
|
||||
};
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionCreatedEvent {
|
||||
type: "session.created";
|
||||
properties?: {
|
||||
info?: {
|
||||
parentID?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type PluginEvent = SessionCreatedEvent | { type: string; properties?: unknown };
|
||||
|
||||
export function createAutoUpdateCheckerHook(
|
||||
client: PluginClient,
|
||||
directory: string,
|
||||
options: AutoUpdateCheckerOptions = {}
|
||||
) {
|
||||
const { showStartupToast = true, autoUpdate = true } = options;
|
||||
|
||||
let hasChecked = false;
|
||||
|
||||
return {
|
||||
event: ({ event }: { event: PluginEvent }) => {
|
||||
if (event.type !== "session.created") return;
|
||||
if (hasChecked) return;
|
||||
|
||||
const props = event.properties as { info?: { parentID?: string } } | undefined;
|
||||
if (props?.info?.parentID) return;
|
||||
|
||||
hasChecked = true;
|
||||
|
||||
setTimeout(() => {
|
||||
const localDevVersion = getLocalDevVersion(directory);
|
||||
|
||||
if (localDevVersion) {
|
||||
if (showStartupToast) {
|
||||
showLocalDevToast(client, localDevVersion).catch(() => {});
|
||||
}
|
||||
console.log("[auto-update-checker] Local development mode");
|
||||
return;
|
||||
}
|
||||
|
||||
runBackgroundUpdateCheck(client, directory, autoUpdate).catch((err) => {
|
||||
console.log("[auto-update-checker] Background update check failed:", err);
|
||||
});
|
||||
}, 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runBackgroundUpdateCheck(
|
||||
client: PluginClient,
|
||||
directory: string,
|
||||
autoUpdate: boolean
|
||||
): Promise<void> {
|
||||
const pluginInfo = findPluginEntry(directory);
|
||||
if (!pluginInfo) {
|
||||
console.log("[auto-update-checker] Plugin not found in config");
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedVersion = getCachedVersion();
|
||||
const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion;
|
||||
if (!currentVersion) {
|
||||
console.log("[auto-update-checker] No version found (cached or pinned)");
|
||||
return;
|
||||
}
|
||||
|
||||
const latestVersion = await getLatestVersion();
|
||||
if (!latestVersion) {
|
||||
console.log("[auto-update-checker] Failed to fetch latest version");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentVersion === latestVersion) {
|
||||
console.log("[auto-update-checker] Already on latest version");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[auto-update-checker] Update available: ${currentVersion} → ${latestVersion}`);
|
||||
|
||||
if (!autoUpdate) {
|
||||
await showUpdateAvailableToast(client, latestVersion);
|
||||
console.log("[auto-update-checker] Auto-update disabled, notification only");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluginInfo.isPinned) {
|
||||
const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion);
|
||||
if (updated) {
|
||||
invalidatePackage(PACKAGE_NAME);
|
||||
await showAutoUpdatedToast(client, currentVersion, latestVersion);
|
||||
console.log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`);
|
||||
} else {
|
||||
await showUpdateAvailableToast(client, latestVersion);
|
||||
}
|
||||
} else {
|
||||
invalidatePackage(PACKAGE_NAME);
|
||||
await showUpdateAvailableToast(client, latestVersion);
|
||||
}
|
||||
}
|
||||
|
||||
async function showUpdateAvailableToast(client: PluginClient, latestVersion: string): Promise<void> {
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: `Antigravity Auth Update`,
|
||||
message: `v${latestVersion} available. Restart OpenCode to apply.`,
|
||||
variant: "info" as const,
|
||||
duration: 8000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
console.log(`[auto-update-checker] Update available toast shown: v${latestVersion}`);
|
||||
}
|
||||
|
||||
async function showAutoUpdatedToast(client: PluginClient, oldVersion: string, newVersion: string): Promise<void> {
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: `Antigravity Auth Updated!`,
|
||||
message: `v${oldVersion} → v${newVersion}\nRestart OpenCode to apply.`,
|
||||
variant: "success" as const,
|
||||
duration: 8000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
console.log(`[auto-update-checker] Auto-updated toast shown: v${oldVersion} → v${newVersion}`);
|
||||
}
|
||||
|
||||
async function showLocalDevToast(client: PluginClient, version: string): Promise<void> {
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: `Antigravity Auth ${version} (dev)`,
|
||||
message: "Running in local development mode.",
|
||||
variant: "warning" as const,
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
console.log(`[auto-update-checker] Local dev toast shown: v${version}`);
|
||||
}
|
||||
|
||||
export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types";
|
||||
export { checkForUpdate, getCachedVersion, getLatestVersion } from "./checker";
|
||||
export { invalidatePackage, invalidateCache } from "./cache";
|
||||
28
src/hooks/auto-update-checker/types.ts
Normal file
28
src/hooks/auto-update-checker/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface NpmDistTags {
|
||||
latest: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface OpencodeConfig {
|
||||
plugin?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PackageJson {
|
||||
version: string;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
needsUpdate: boolean;
|
||||
currentVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
isLocalDev: boolean;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
||||
export interface AutoUpdateCheckerOptions {
|
||||
showStartupToast?: boolean;
|
||||
autoUpdate?: boolean;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token
|
||||
import { startOAuthListener, type OAuthListener } from "./plugin/server";
|
||||
import { clearAccounts, loadAccounts, saveAccounts } from "./plugin/storage";
|
||||
import { AccountManager, type ModelFamily } from "./plugin/accounts";
|
||||
import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker";
|
||||
import type {
|
||||
GetAuth,
|
||||
LoaderResult,
|
||||
@@ -387,9 +388,16 @@ function sleep(ms: number, signal?: AbortSignal | null): Promise<void> {
|
||||
* Creates an Antigravity OAuth plugin for a specific provider ID.
|
||||
*/
|
||||
export const createAntigravityPlugin = (providerId: string) => async (
|
||||
{ client }: PluginContext,
|
||||
): Promise<PluginResult> => ({
|
||||
auth: {
|
||||
{ client, directory }: PluginContext,
|
||||
): Promise<PluginResult> => {
|
||||
const updateChecker = createAutoUpdateCheckerHook(client, directory, {
|
||||
showStartupToast: true,
|
||||
autoUpdate: true,
|
||||
});
|
||||
|
||||
return {
|
||||
event: updateChecker.event,
|
||||
auth: {
|
||||
provider: providerId,
|
||||
loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | Record<string, unknown>> => {
|
||||
const auth = await getAuth();
|
||||
@@ -1273,7 +1281,8 @@ export const createAntigravityPlugin = (providerId: string) => async (
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const AntigravityCLIOAuthPlugin = createAntigravityPlugin(ANTIGRAVITY_PROVIDER_ID);
|
||||
export const GoogleOAuthPlugin = AntigravityCLIOAuthPlugin;
|
||||
|
||||
@@ -606,7 +606,7 @@ export function prepareAntigravityRequest(
|
||||
|
||||
let upstreamModel = rawModel;
|
||||
if (upstreamModel === "gemini-2.5-flash-image") {
|
||||
upstreamModel = "gemini-3-flash";
|
||||
upstreamModel = "gemini-2.5-flash";
|
||||
}
|
||||
|
||||
const effectiveModel = upstreamModel;
|
||||
|
||||
@@ -43,6 +43,7 @@ export type PluginClient = PluginInput["client"];
|
||||
|
||||
export interface PluginContext {
|
||||
client: PluginClient;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export type AuthPrompt =
|
||||
@@ -81,12 +82,20 @@ export interface AuthMethod {
|
||||
authorize?: (inputs?: Record<string, string>) => Promise<OAuthAuthorizationResult>;
|
||||
}
|
||||
|
||||
export interface PluginEventPayload {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginResult {
|
||||
auth: {
|
||||
provider: string;
|
||||
loader: (getAuth: GetAuth, provider: Provider) => Promise<LoaderResult | Record<string, unknown>>;
|
||||
methods: AuthMethod[];
|
||||
};
|
||||
event?: (payload: PluginEventPayload) => void;
|
||||
}
|
||||
|
||||
export interface RefreshParts {
|
||||
|
||||
Reference in New Issue
Block a user