From c61547e1e876c7ef61f5d1a9705b3c5f18a76dfe Mon Sep 17 00:00:00 2001 From: duongductrong Date: Sun, 4 Jan 2026 15:24:32 +0700 Subject: [PATCH 1/4] docs: Add original author and license information to oauth, oauth-server, and quota utility files. --- src/utils/oauth-server.ts | 4 ++++ src/utils/oauth.ts | 5 +++++ src/utils/quota.ts | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/src/utils/oauth-server.ts b/src/utils/oauth-server.ts index 3f9883e..733f0ac 100644 --- a/src/utils/oauth-server.ts +++ b/src/utils/oauth-server.ts @@ -2,6 +2,10 @@ * OAuth Server Utilities * * Local HTTP server for OAuth callback and browser opening utilities. + * + * Original Author: lbjlaq (https://github.com/lbjlaq) + * License: CC-BY-NC-SA-4.0 (https://creativecommons.org/licenses/by-nc-sa/4.0/) + * Note: This version contains modifications based on the original source code. */ import * as http from "node:http"; diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index cba3a5d..7e1d62b 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -1,7 +1,12 @@ /** + * * OAuth Authentication Utilities * * Handles Google OAuth token management for Antigravity accounts. + * + * Original Author: lbjlaq (https://github.com/lbjlaq) + * License: CC-BY-NC-SA-4.0 (https://creativecommons.org/licenses/by-nc-sa/4.0/) + * Note: This version contains modifications based on the original source code. */ // ============================================ diff --git a/src/utils/quota.ts b/src/utils/quota.ts index 57df6d2..71ddb61 100644 --- a/src/utils/quota.ts +++ b/src/utils/quota.ts @@ -2,6 +2,10 @@ * Quota Fetching Utilities * * Handles fetching quota information from Google Cloud Code API. + * + * Original Author: lbjlaq (https://github.com/lbjlaq) + * License: CC-BY-NC-SA-4.0 (https://creativecommons.org/licenses/by-nc-sa/4.0/) + * Note: This version contains modifications based on the original source code. */ import { refreshAccessToken } from "./oauth.js"; From c998265f9b24dff4aa40f2082253f36e20a893e6 Mon Sep 17 00:00:00 2001 From: duongductrong Date: Sun, 4 Jan 2026 15:33:12 +0700 Subject: [PATCH 2/4] refactor: standardize code formatting and improve readability across utility modules - Updated formatting in symlink-manager.ts, token-storage.ts, version-checker.ts, workspace-storage.ts, tsconfig.json, and tsup.config.ts for consistency. - Removed unnecessary whitespace and adjusted indentation. - Enhanced error handling and streamlined logic in various functions. - Ensured consistent use of semicolons and improved import statements. --- .prettierignore | 17 + .prettierrc | 10 + CHANGELOG.md | 41 +- LICENSE.md | 1 - README.md | 55 +- package.json | 3 +- src/cli.ts | 84 ++-- src/commands/auth/add.ts | 632 +++++++++++------------ src/commands/auth/index.ts | 113 +++-- src/commands/auth/list.ts | 811 +++++++++++++++--------------- src/commands/auth/quota.ts | 722 +++++++++++++------------- src/commands/auth/remove.ts | 236 +++++---- src/commands/auth/switch.ts | 236 +++++---- src/commands/upgrade.ts | 472 ++++++++--------- src/index.ts | 110 ++-- src/types/auth.ts | 68 +-- src/utils/antigravity-launcher.ts | 290 +++++------ src/utils/branding.ts | 112 ++--- src/utils/google-api.ts | 22 +- src/utils/oauth-server.ts | 126 ++--- src/utils/oauth.ts | 52 +- src/utils/profile-manager.ts | 668 ++++++++++++------------ src/utils/quota.ts | 104 ++-- src/utils/sqlite-reader.ts | 316 ++++++------ src/utils/symlink-manager.ts | 116 ++--- src/utils/token-storage.ts | 187 +++---- src/utils/version-checker.ts | 349 +++++++------ src/utils/workspace-storage.ts | 224 ++++----- tsconfig.json | 72 +-- tsup.config.ts | 48 +- 30 files changed, 3205 insertions(+), 3092 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..33df9c3 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,17 @@ +dist +coverage +storybook-static +.prettierignore +yarn.lock +pnpm-lock.yaml +*.snap +.npmrc +.husky +.gitignore +.editorconfig +.env.template +node_modules +.cursor +.github +.pnpm-store +.assets \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f9ac405 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 80, + "useTabs": false, + "tabWidth": 2, + "semi": false, + "bracketSpacing": true, + "singleQuote": false, + "trailingComma": "es5", + "arrowParens": "always" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d1091..5e9865c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,64 +2,57 @@ ## [0.0.4](https://github.com/duongductrong/antigravity-kit/compare/v0.0.3...v0.0.4) (2026-01-04) - ### ๐Ÿš€ Features -* feat: Add project logo, update README, and introduce a git push helper script. ([247287a](https://github.com/duongductrong/antigravity-kit/commit/247287aa4ae940b096a39c934a127cc3eccc6d1d)) -* feat: Add CLI upgrade command with interactive version selection and installation. ([744eccd](https://github.com/duongductrong/antigravity-kit/commit/744eccd0329f3e6e90debb7331a38bf3b7c5a032)) +- feat: Add project logo, update README, and introduce a git push helper script. ([247287a](https://github.com/duongductrong/antigravity-kit/commit/247287aa4ae940b096a39c934a127cc3eccc6d1d)) +- feat: Add CLI upgrade command with interactive version selection and installation. ([744eccd](https://github.com/duongductrong/antigravity-kit/commit/744eccd0329f3e6e90debb7331a38bf3b7c5a032)) ### ๐Ÿ› Bug Fixes -* fix: Adjust package.json lookup path for bundled builds. ([3523789](https://github.com/duongductrong/antigravity-kit/commit/35237893f270459d95ab753fb70508222e87cd7c)) +- fix: Adjust package.json lookup path for bundled builds. ([3523789](https://github.com/duongductrong/antigravity-kit/commit/35237893f270459d95ab753fb70508222e87cd7c)) ### ๐Ÿ  Chores -* chore(master): release 0.0.4-beta.3 ([921bcd2](https://github.com/duongductrong/antigravity-kit/commit/921bcd2c03835b732c2c0cb27632302db26e3c86)) -* chore: Update logo image. ([424a20c](https://github.com/duongductrong/antigravity-kit/commit/424a20c8529e81f4ccf297f40d9b371302b3fd73)) -* chore(master): release 0.0.4-beta.2 ([3042513](https://github.com/duongductrong/antigravity-kit/commit/3042513575715964d1557ca9b5aa08470a58bdbc)) -* chore(master): release 0.0.4-beta.1 ([b28695e](https://github.com/duongductrong/antigravity-kit/commit/b28695edd944824bc74ce6e53297f6099c201938)) -* chore(master): release 0.0.4-beta ([ad8bc3b](https://github.com/duongductrong/antigravity-kit/commit/ad8bc3ba86894dee7ee77ffaaa1dd31191d36eea)) - +- chore(master): release 0.0.4-beta.3 ([921bcd2](https://github.com/duongductrong/antigravity-kit/commit/921bcd2c03835b732c2c0cb27632302db26e3c86)) +- chore: Update logo image. ([424a20c](https://github.com/duongductrong/antigravity-kit/commit/424a20c8529e81f4ccf297f40d9b371302b3fd73)) +- chore(master): release 0.0.4-beta.2 ([3042513](https://github.com/duongductrong/antigravity-kit/commit/3042513575715964d1557ca9b5aa08470a58bdbc)) +- chore(master): release 0.0.4-beta.1 ([b28695e](https://github.com/duongductrong/antigravity-kit/commit/b28695edd944824bc74ce6e53297f6099c201938)) +- chore(master): release 0.0.4-beta ([ad8bc3b](https://github.com/duongductrong/antigravity-kit/commit/ad8bc3ba86894dee7ee77ffaaa1dd31191d36eea)) ## [0.0.3](https://github.com/duongductrong/antigravity-kit/compare/v0.0.2...v0.0.3) (2026-01-04) - ### ๐Ÿš€ Features -* feat: add interactive account selection for auth quota and live quota display for auth list ([318e103](https://github.com/duongductrong/antigravity-kit/commit/318e103928904eb541ac1a01fb7724c1739244a8)) +- feat: add interactive account selection for auth quota and live quota display for auth list ([318e103](https://github.com/duongductrong/antigravity-kit/commit/318e103928904eb541ac1a01fb7724c1739244a8)) ### ๐Ÿ  Chores -* chore(master): release 0.0.3-beta ([bc4ef7a](https://github.com/duongductrong/antigravity-kit/commit/bc4ef7ac75263b591c23fc3a393dc97da2b111c2)) - +- chore(master): release 0.0.3-beta ([bc4ef7a](https://github.com/duongductrong/antigravity-kit/commit/bc4ef7ac75263b591c23fc3a393dc97da2b111c2)) ## [0.0.2](https://github.com/duongductrong/antigravity-kit/compare/v0.0.1...v0.0.2) (2026-01-04) - ### ๐Ÿš€ Features -* feat: introduce Google Cloud Code quota client and async token storage ([93d15a2](https://github.com/duongductrong/antigravity-kit/commit/93d15a2cb8b217d21268ba99b321429bbe701bd8)) -* feat: Add Google OAuth authentication, token storage, and quota checking, enhancing auth commands with status and options. ([769ed54](https://github.com/duongductrong/antigravity-kit/commit/769ed54de38141896276724b3ba93c8c495adb9b)) +- feat: introduce Google Cloud Code quota client and async token storage ([93d15a2](https://github.com/duongductrong/antigravity-kit/commit/93d15a2cb8b217d21268ba99b321429bbe701bd8)) +- feat: Add Google OAuth authentication, token storage, and quota checking, enhancing auth commands with status and options. ([769ed54](https://github.com/duongductrong/antigravity-kit/commit/769ed54de38141896276724b3ba93c8c495adb9b)) ### ๐Ÿ› Bug Fixes -* fix(auth): ensure process exits successfully after adding an account ([d264165](https://github.com/duongductrong/antigravity-kit/commit/d26416523a6a9f69003a4fcda28fe6e739c526c8)) +- fix(auth): ensure process exits successfully after adding an account ([d264165](https://github.com/duongductrong/antigravity-kit/commit/d26416523a6a9f69003a4fcda28fe6e739c526c8)) ### ๐Ÿ“š Documentation -* docs: update README ([39b2418](https://github.com/duongductrong/antigravity-kit/commit/39b2418f5b00cd3f76a7a2c6daa9e5a8bc549098)) +- docs: update README ([39b2418](https://github.com/duongductrong/antigravity-kit/commit/39b2418f5b00cd3f76a7a2c6daa9e5a8bc549098)) ### โ™ป๏ธ Code Refactoring -* refactor: remove Google Cloud Code quota client implementation ([cd680c6](https://github.com/duongductrong/antigravity-kit/commit/cd680c69b51d181657a2277c2d1edf8e8c76570f)) +- refactor: remove Google Cloud Code quota client implementation ([cd680c6](https://github.com/duongductrong/antigravity-kit/commit/cd680c69b51d181657a2277c2d1edf8e8c76570f)) ### ๐Ÿ  Chores -* chore(master): release 0.0.2-beta.1 ([b9d8866](https://github.com/duongductrong/antigravity-kit/commit/b9d8866b0f915476581b3189b032a36e2f1aef6a)) -* chore(master): release 0.0.2-beta ([c40dc2e](https://github.com/duongductrong/antigravity-kit/commit/c40dc2ebcc844824dc03eab7a897a9760a8dab14)) - +- chore(master): release 0.0.2-beta.1 ([b9d8866](https://github.com/duongductrong/antigravity-kit/commit/b9d8866b0f915476581b3189b032a36e2f1aef6a)) +- chore(master): release 0.0.2-beta ([c40dc2e](https://github.com/duongductrong/antigravity-kit/commit/c40dc2ebcc844824dc03eab7a897a9760a8dab14)) ## [0.0.1](https://github.com/duongductrong/antigravity-kit/compare/...v0.0.1) (2026-01-02) - ### Initial Release diff --git a/LICENSE.md b/LICENSE.md index 6ee1181..7e8148d 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/README.md b/README.md index ce04476..da5cc58 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ agk upgrade # Upgrade to latest version ## ๐Ÿ“– Commands The CLI can be invoked using any of these aliases: + - `antigravity-kit` - `antigravikit` - `agk` (recommended - shortest) @@ -80,20 +81,22 @@ agk auth add --insecure **Options:** -| Flag | Description | -|------|-------------| -| `--manual` | Use manual IDE login instead of OAuth (opens Antigravity IDE) | -| `--insecure` | Store tokens in local file instead of macOS Keychain | +| Flag | Description | +| ------------ | ------------------------------------------------------------- | +| `--manual` | Use manual IDE login instead of OAuth (opens Antigravity IDE) | +| `--insecure` | Store tokens in local file instead of macOS Keychain | **How it works:** **OAuth Flow (default):** + 1. Opens your browser for Google sign-in 2. Captures authentication and creates a profile 3. Stores refresh token securely (Keychain on macOS) 4. Shows available quota for the account **Manual Flow (`--manual`):** + 1. If an existing Antigravity login is detected, you'll be prompted to add it as a profile 2. If no login exists, Antigravity IDE will open for you to sign in 3. The CLI watches for authentication and saves your profile automatically @@ -155,6 +158,7 @@ agk auth list ``` **Legend:** + - `โ—` (green) - Active profile - `โ—‹` (dim) - Inactive profile - `โœ“` - OAuth token stored (quota checking enabled) @@ -179,9 +183,9 @@ agk auth switch -w my-project # Open specific workspace by name **Options:** -| Flag | Alias | Description | -|------|-------|-------------| -| `--workspace ` | `-w` | Specify a workspace to open. Use `select` to choose interactively. | +| Flag | Alias | Description | +| -------------------- | ----- | ------------------------------------------------------------------ | +| `--workspace ` | `-w` | Specify a workspace to open. Use `select` to choose interactively. | **How it works:** @@ -272,12 +276,13 @@ agk auth quota --interval 60 **Options:** -| Flag | Alias | Description | -|------|-------|-------------| -| `--account ` | `-a` | Email of the account to check (defaults to active profile) | -| `--interval ` | `-i` | Auto-refresh interval in seconds (default: 30, min: 10) | +| Flag | Alias | Description | +| ---------------------- | ----- | ---------------------------------------------------------- | +| `--account ` | `-a` | Email of the account to check (defaults to active profile) | +| `--interval ` | `-i` | Auto-refresh interval in seconds (default: 30, min: 10) | **Interactive Controls:** + - Press `r` to manually reload quota data - Press `q` to exit the monitor @@ -304,6 +309,7 @@ agk auth quota --interval 60 ``` **Requirements:** + - OAuth token must be stored for the account - Use `agk auth add` (without `--manual`) to enable quota checking @@ -329,11 +335,11 @@ agk upgrade --check **Options:** -| Flag | Description | -|------|-------------| +| Flag | Description | +| ---------- | ------------------------------------ | | `--latest` | Upgrade to the latest stable version | -| `--beta` | Upgrade to the latest beta version | -| `--check` | Check for updates without installing | +| `--beta` | Upgrade to the latest beta version | +| `--check` | Check for updates without installing | **How it works:** @@ -348,13 +354,14 @@ agk upgrade --check Antigravity Kit supports secure token storage for OAuth authentication: -| Platform | Storage Method | Notes | -|----------|---------------|-------| -| macOS | Keychain | Default. Uses macOS Keychain for secure storage | -| Linux | File | Stored in `~/.antigravity-kit/tokens/` | -| Windows | File | Stored in `~/.antigravity-kit/tokens/` | +| Platform | Storage Method | Notes | +| -------- | -------------- | ----------------------------------------------- | +| macOS | Keychain | Default. Uses macOS Keychain for secure storage | +| Linux | File | Stored in `~/.antigravity-kit/tokens/` | +| Windows | File | Stored in `~/.antigravity-kit/tokens/` | **Flags:** + - Use `--insecure` with `auth add` to force file-based storage on macOS - Keychain storage may require user permission on first use @@ -362,11 +369,11 @@ Antigravity Kit supports secure token storage for OAuth authentication: ## ๐Ÿ–ฅ๏ธ Platform Support -| Platform | Status | Notes | -|----------|--------|-------| -| macOS | โœ… | Looks for Antigravity.app in `/Applications` or `~/Applications` | +| Platform | Status | Notes | +| -------- | ------ | ----------------------------------------------------------------- | +| macOS | โœ… | Looks for Antigravity.app in `/Applications` or `~/Applications` | | Linux | โœ… | Uses `antigravity` command or checks `/usr/bin`, `/usr/local/bin` | -| Windows | โœ… | Supports Git Bash, MSYS, Cygwin. Looks in Program Files | +| Windows | โœ… | Supports Git Bash, MSYS, Cygwin. Looks in Program Files | ## ๐Ÿ“ Profile Storage diff --git a/package.json b/package.json index fcf6b4f..ad78b76 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "start": "node dist/cli.mjs", "typecheck": "tsc --noEmit", "lint": "biome check --write", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "format": "prettier --write ." }, "keywords": [ "antigravity", diff --git a/src/cli.ts b/src/cli.ts index 6d46ebb..6dcf26c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,61 +1,61 @@ #!/usr/bin/env node -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { defineCommand, runMain } from "citty"; -import authCommand from "./commands/auth/index.js"; -import upgradeCommand from "./commands/upgrade.js"; +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { defineCommand, runMain } from "citty" +import authCommand from "./commands/auth/index.js" +import upgradeCommand from "./commands/upgrade.js" import { - checkForUpdates, - printUpdateNotification, -} from "./utils/version-checker.js"; + checkForUpdates, + printUpdateNotification, +} from "./utils/version-checker.js" -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = dirname(fileURLToPath(import.meta.url)) interface PackageJson { - version: string; - name: string; - description: string; + version: string + name: string + description: string } function getPackageJson(): PackageJson { - try { - const pkgPath = join(__dirname, "..", "package.json"); - const content = readFileSync(pkgPath, "utf-8"); - return JSON.parse(content) as PackageJson; - } catch { - return { - version: "0.0.0", - name: "antigravity-kit", - description: "CLI toolkit to manage antigravity IDE rules and commands", - }; - } + try { + const pkgPath = join(__dirname, "..", "package.json") + const content = readFileSync(pkgPath, "utf-8") + return JSON.parse(content) as PackageJson + } catch { + return { + version: "0.0.0", + name: "antigravity-kit", + description: "CLI toolkit to manage antigravity IDE rules and commands", + } + } } -const pkg = getPackageJson(); +const pkg = getPackageJson() const main = defineCommand({ - meta: { - name: pkg.name, - version: pkg.version, - description: pkg.description, - }, - subCommands: { - auth: authCommand, - upgrade: upgradeCommand, - }, -}); + meta: { + name: pkg.name, + version: pkg.version, + description: pkg.description, + }, + subCommands: { + auth: authCommand, + upgrade: upgradeCommand, + }, +}) // Run the main command and check for updates after completion async function run() { - await runMain(main); + await runMain(main) - // Check for updates in background (non-blocking, silent on errors) - const updateInfo = await checkForUpdates(); - if (updateInfo) { - printUpdateNotification(updateInfo); - } + // Check for updates in background (non-blocking, silent on errors) + const updateInfo = await checkForUpdates() + if (updateInfo) { + printUpdateNotification(updateInfo) + } } -run(); +run() diff --git a/src/commands/auth/add.ts b/src/commands/auth/add.ts index 0df2deb..67df9d9 100644 --- a/src/commands/auth/add.ts +++ b/src/commands/auth/add.ts @@ -1,320 +1,324 @@ -import * as p from "@clack/prompts"; -import { defineCommand } from "citty"; -import pc from "picocolors"; -import type { AuthSessionData } from "../../types/auth.js"; -import { openAntigravity } from "../../utils/antigravity-launcher.js"; -import { printHeader } from "../../utils/branding.js"; -import { startOAuthFlow } from "../../utils/oauth-server.js"; -import { saveRefreshToken, isKeychainAvailable } from "../../utils/token-storage.js"; +import * as p from "@clack/prompts" +import { defineCommand } from "citty" +import pc from "picocolors" +import type { AuthSessionData } from "../../types/auth.js" +import { openAntigravity } from "../../utils/antigravity-launcher.js" +import { printHeader } from "../../utils/branding.js" +import { startOAuthFlow } from "../../utils/oauth-server.js" import { - addProfileAndSetActive, - copyDefaultProfileToStorage, - getDefaultAntigravityDataDir, - getDefaultStateDbPath, - getProfileByEmail, - watchForAuth, -} from "../../utils/profile-manager.js"; -import { getFirstAuthSession } from "../../utils/sqlite-reader.js"; + saveRefreshToken, + isKeychainAvailable, +} from "../../utils/token-storage.js" +import { + addProfileAndSetActive, + copyDefaultProfileToStorage, + getDefaultAntigravityDataDir, + getDefaultStateDbPath, + getProfileByEmail, + watchForAuth, +} from "../../utils/profile-manager.js" +import { getFirstAuthSession } from "../../utils/sqlite-reader.js" -type WaitResultType = "auto" | "manual"; +type WaitResultType = "auto" | "manual" interface WaitResult { - type: WaitResultType; - session?: AuthSessionData; + type: WaitResultType + session?: AuthSessionData } function waitForEnterKey(): Promise { - return new Promise((resolve) => { - const onData = (data: Buffer) => { - if (data.toString().includes("\n") || data.toString().includes("\r")) { - process.stdin.removeListener("data", onData); - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - } - resolve(); - } - }; - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } - process.stdin.resume(); - process.stdin.on("data", onData); - }); + return new Promise((resolve) => { + const onData = (data: Buffer) => { + if (data.toString().includes("\n") || data.toString().includes("\r")) { + process.stdin.removeListener("data", onData) + if (process.stdin.isTTY) { + process.stdin.setRawMode(false) + } + resolve() + } + } + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + process.stdin.on("data", onData) + }) } function cleanupStdin(): void { - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - } - process.stdin.pause(); - process.stdin.removeAllListeners("data"); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false) + } + process.stdin.pause() + process.stdin.removeAllListeners("data") } async function waitForIdeLogin(): Promise { - const spinner = p.spinner(); + const spinner = p.spinner() - console.log(); - p.note( - `${pc.dim("Opening Antigravity...")} + console.log() + p.note( + `${pc.dim("Opening Antigravity...")} ${pc.dim("Please sign in with your Google account.")} ${pc.yellow("โš ")} ${pc.dim("Do not close Antigravity until the login is complete.")}`, - "Action Required", - ); - - spinner.start("Opening Antigravity..."); - - const defaultDataDir = getDefaultAntigravityDataDir(); - await openAntigravity(defaultDataDir); - - spinner.stop("Antigravity opened"); - - spinner.start( - `Waiting for login... ${pc.dim("(Press Enter to check manually)")}`, - ); - - const dbPath = getDefaultStateDbPath(); - - let session: AuthSessionData | undefined; - let loginDetected = false; - - while (!loginDetected) { - const watchPromise = watchForAuth(300000).then( - (res): WaitResult => ({ type: "auto", session: res.session }), - ); - watchPromise.catch(() => { }); - - const result = await Promise.race([ - watchPromise, - waitForEnterKey().then((): WaitResult => ({ type: "manual" })), - ]); - - if (result.type === "auto" && result.session) { - session = result.session; - loginDetected = true; - cleanupStdin(); - } else if (result.type === "manual") { - spinner.stop("Checking login status..."); - - const currentSession = getFirstAuthSession(dbPath); - - if (currentSession) { - session = currentSession; - loginDetected = true; - } else { - console.log(); - p.log.warning( - pc.yellow( - "No login detected. Please sign in with your Google account in Antigravity.", - ), - ); - console.log(); - spinner.start( - `Waiting for login... ${pc.dim("(Press Enter to check manually)")}`, - ); - } - } - } - - spinner.stop("Login detected!"); - - if (!session) { - throw new Error("No session detected after login"); - } - - return session; + "Action Required" + ) + + spinner.start("Opening Antigravity...") + + const defaultDataDir = getDefaultAntigravityDataDir() + await openAntigravity(defaultDataDir) + + spinner.stop("Antigravity opened") + + spinner.start( + `Waiting for login... ${pc.dim("(Press Enter to check manually)")}` + ) + + const dbPath = getDefaultStateDbPath() + + let session: AuthSessionData | undefined + let loginDetected = false + + while (!loginDetected) { + const watchPromise = watchForAuth(300000).then( + (res): WaitResult => ({ type: "auto", session: res.session }) + ) + watchPromise.catch(() => {}) + + const result = await Promise.race([ + watchPromise, + waitForEnterKey().then((): WaitResult => ({ type: "manual" })), + ]) + + if (result.type === "auto" && result.session) { + session = result.session + loginDetected = true + cleanupStdin() + } else if (result.type === "manual") { + spinner.stop("Checking login status...") + + const currentSession = getFirstAuthSession(dbPath) + + if (currentSession) { + session = currentSession + loginDetected = true + } else { + console.log() + p.log.warning( + pc.yellow( + "No login detected. Please sign in with your Google account in Antigravity." + ) + ) + console.log() + spinner.start( + `Waiting for login... ${pc.dim("(Press Enter to check manually)")}` + ) + } + } + } + + spinner.stop("Login detected!") + + if (!session) { + throw new Error("No session detected after login") + } + + return session } export default defineCommand({ - meta: { - name: "add", - description: "Add a new Google AntiGravity account", - }, - args: { - manual: { - type: "boolean", - description: "Use manual IDE login instead of OAuth", - default: false, - }, - insecure: { - type: "boolean", - description: "Store tokens in local file instead of Keychain", - default: false, - }, - }, - async run({ args }) { - printHeader("antigravity-kit auth add"); - - p.intro(`${pc.cyan("โ—†")} ${pc.bold("Add a new account")}`); - - const spinner = p.spinner(); - - // Use OAuth flow by default, manual flow with --manual flag - if (args.manual) { - // Manual flow: open Antigravity IDE and watch for login - await runManualFlow(spinner); - } else { - // OAuth flow: open browser for Google sign-in - await runOAuthFlow(spinner, args.insecure); - } - }, -}); - -async function runOAuthFlow(spinner: ReturnType, insecure: boolean): Promise { - console.log(); - p.note( - `${pc.cyan("This will:")} + meta: { + name: "add", + description: "Add a new Google AntiGravity account", + }, + args: { + manual: { + type: "boolean", + description: "Use manual IDE login instead of OAuth", + default: false, + }, + insecure: { + type: "boolean", + description: "Store tokens in local file instead of Keychain", + default: false, + }, + }, + async run({ args }) { + printHeader("antigravity-kit auth add") + + p.intro(`${pc.cyan("โ—†")} ${pc.bold("Add a new account")}`) + + const spinner = p.spinner() + + // Use OAuth flow by default, manual flow with --manual flag + if (args.manual) { + // Manual flow: open Antigravity IDE and watch for login + await runManualFlow(spinner) + } else { + // OAuth flow: open browser for Google sign-in + await runOAuthFlow(spinner, args.insecure) + } + }, +}) + +async function runOAuthFlow( + spinner: ReturnType, + insecure: boolean +): Promise { + console.log() + p.note( + `${pc.cyan("This will:")} 1. Open your browser for Google sign-in 2. Capture your login and create a profile 3. Store tokens for quota checking ${(await isKeychainAvailable()) && !insecure ? pc.green("๐Ÿ” Tokens will be stored in macOS Keychain") : pc.dim("๐Ÿ’พ Tokens will be stored locally")}`, - "OAuth Sign-in", - ); - - const shouldContinue = await p.confirm({ - message: "Ready to continue?", - initialValue: true, - }); - - if (p.isCancel(shouldContinue) || !shouldContinue) { - p.cancel("Operation cancelled"); - process.exit(0); - } - - try { - const result = await startOAuthFlow(); - const email = result.userInfo.email; - - // Check if profile already exists - const existingProfile = getProfileByEmail(email); - if (existingProfile) { - console.log(); - console.log(pc.dim("Account already exists: ") + pc.green(email)); - - const shouldReplace = await p.confirm({ - message: "Replace the existing profile with fresh data?", - initialValue: false, - }); - - if (p.isCancel(shouldReplace) || !shouldReplace) { - p.cancel("Operation cancelled"); - process.exit(0); - } - } - - console.log(); - console.log(pc.dim("Account to add: ") + pc.green(email)); - if (result.userInfo.name) { - console.log(pc.dim("Name: ") + pc.cyan(result.userInfo.name)); - } - console.log(); - - spinner.start("Saving profile..."); - - // Copy current IDE settings/profile to preserve extensions, themes, etc. - const profilePath = copyDefaultProfileToStorage(email); - const profile = addProfileAndSetActive(email, profilePath); - - // Save refresh token (Keychain by default on macOS unless --insecure) - await saveRefreshToken(email, result.tokens.refresh_token, insecure); - - spinner.stop("Profile saved"); - - console.log(); - p.note( - `${pc.green("Email:")} ${profile.email} + "OAuth Sign-in" + ) + + const shouldContinue = await p.confirm({ + message: "Ready to continue?", + initialValue: true, + }) + + if (p.isCancel(shouldContinue) || !shouldContinue) { + p.cancel("Operation cancelled") + process.exit(0) + } + + try { + const result = await startOAuthFlow() + const email = result.userInfo.email + + // Check if profile already exists + const existingProfile = getProfileByEmail(email) + if (existingProfile) { + console.log() + console.log(pc.dim("Account already exists: ") + pc.green(email)) + + const shouldReplace = await p.confirm({ + message: "Replace the existing profile with fresh data?", + initialValue: false, + }) + + if (p.isCancel(shouldReplace) || !shouldReplace) { + p.cancel("Operation cancelled") + process.exit(0) + } + } + + console.log() + console.log(pc.dim("Account to add: ") + pc.green(email)) + if (result.userInfo.name) { + console.log(pc.dim("Name: ") + pc.cyan(result.userInfo.name)) + } + console.log() + + spinner.start("Saving profile...") + + // Copy current IDE settings/profile to preserve extensions, themes, etc. + const profilePath = copyDefaultProfileToStorage(email) + const profile = addProfileAndSetActive(email, profilePath) + + // Save refresh token (Keychain by default on macOS unless --insecure) + await saveRefreshToken(email, result.tokens.refresh_token, insecure) + + spinner.stop("Profile saved") + + console.log() + p.note( + `${pc.green("Email:")} ${profile.email} ${pc.green("Profile:")} ${profilePath} ${pc.green("Status:")} ${pc.cyan("Active")} ${pc.green("Quota:")} ${result.quota.models.length} models available`, - "Profile Created", - ); - - p.outro( - `${pc.green("โœ”")} Account ${pc.cyan(email)} added successfully!`, - ); - process.exit(0); - } catch (error) { - spinner.stop("Failed"); - - const errorMessage = - error instanceof Error ? error.message : "Unknown error occurred"; - p.cancel(`Failed to add account: ${errorMessage}`); - process.exit(1); - } + "Profile Created" + ) + + p.outro(`${pc.green("โœ”")} Account ${pc.cyan(email)} added successfully!`) + process.exit(0) + } catch (error) { + spinner.stop("Failed") + + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred" + p.cancel(`Failed to add account: ${errorMessage}`) + process.exit(1) + } } -async function runManualFlow(spinner: ReturnType): Promise { - const dbPath = getDefaultStateDbPath(); +async function runManualFlow( + spinner: ReturnType +): Promise { + const dbPath = getDefaultStateDbPath() - // Check if there's existing auth in the default Antigravity data directory - let session = getFirstAuthSession(dbPath); + // Check if there's existing auth in the default Antigravity data directory + let session = getFirstAuthSession(dbPath) - if (session) { - // Found existing auth - ask if user wants to use it or login fresh - console.log(); - console.log( - pc.dim("Found existing login: ") + pc.green(session.email), - ); - if (session.name) { - console.log(pc.dim("Name: ") + pc.cyan(session.name)); - } - console.log(); + if (session) { + // Found existing auth - ask if user wants to use it or login fresh + console.log() + console.log(pc.dim("Found existing login: ") + pc.green(session.email)) + if (session.name) { + console.log(pc.dim("Name: ") + pc.cyan(session.name)) + } + console.log() - const existingProfile = getProfileByEmail(session.email); + const existingProfile = getProfileByEmail(session.email) - if (existingProfile) { - // Profile already exists for this account - p.note( - `A profile for ${pc.cyan(session.email)} already exists. + if (existingProfile) { + // Profile already exists for this account + p.note( + `A profile for ${pc.cyan(session.email)} already exists. ${pc.cyan("To add a different account:")} 1. Open Antigravity IDE 2. Sign out from your current account 3. Sign in with the new account 4. Run this command again`, - "Account Already Added", - ); - - const shouldReplace = await p.confirm({ - message: "Replace the existing profile with fresh data?", - initialValue: false, - }); - - if (p.isCancel(shouldReplace) || !shouldReplace) { - p.cancel("Operation cancelled"); - process.exit(0); - } - } else { - // New account detected - ask to add it - const shouldAdd = await p.confirm({ - message: `Add ${pc.cyan(session.email)} as a new profile?`, - initialValue: true, - }); - - if (p.isCancel(shouldAdd)) { - p.cancel("Operation cancelled"); - process.exit(0); - } - - if (!shouldAdd) { - // User wants to login with different account - p.note( - `${pc.cyan("To add a different account:")} + "Account Already Added" + ) + + const shouldReplace = await p.confirm({ + message: "Replace the existing profile with fresh data?", + initialValue: false, + }) + + if (p.isCancel(shouldReplace) || !shouldReplace) { + p.cancel("Operation cancelled") + process.exit(0) + } + } else { + // New account detected - ask to add it + const shouldAdd = await p.confirm({ + message: `Add ${pc.cyan(session.email)} as a new profile?`, + initialValue: true, + }) + + if (p.isCancel(shouldAdd)) { + p.cancel("Operation cancelled") + process.exit(0) + } + + if (!shouldAdd) { + // User wants to login with different account + p.note( + `${pc.cyan("To add a different account:")} 1. Open Antigravity IDE 2. Sign out from your current account 3. Sign in with the new account 4. Run this command again`, - "Add Different Account", - ); - p.cancel("Operation cancelled"); - process.exit(0); - } - } - } else { - // No existing auth - need to login via IDE - p.note( - `No existing Antigravity login found. + "Add Different Account" + ) + p.cancel("Operation cancelled") + process.exit(0) + } + } + } else { + // No existing auth - need to login via IDE + p.note( + `No existing Antigravity login found. ${pc.cyan("This will:")} 1. Open Antigravity IDE @@ -322,55 +326,55 @@ ${pc.cyan("This will:")} 3. CLI will capture your login and create a profile ${pc.yellow("โš ")} ${pc.dim("Antigravity IDE must be installed.")}`, - "How it works", - ); + "How it works" + ) - const shouldContinue = await p.confirm({ - message: "Ready to continue?", - initialValue: true, - }); + const shouldContinue = await p.confirm({ + message: "Ready to continue?", + initialValue: true, + }) - if (p.isCancel(shouldContinue) || !shouldContinue) { - p.cancel("Operation cancelled"); - process.exit(0); - } + if (p.isCancel(shouldContinue) || !shouldContinue) { + p.cancel("Operation cancelled") + process.exit(0) + } - session = await waitForIdeLogin(); - } + session = await waitForIdeLogin() + } - try { - console.log(); - console.log(pc.dim("Account to add: ") + pc.green(session.email)); - if (session.name) { - console.log(pc.dim("Name: ") + pc.cyan(session.name)); - } - console.log(); + try { + console.log() + console.log(pc.dim("Account to add: ") + pc.green(session.email)) + if (session.name) { + console.log(pc.dim("Name: ") + pc.cyan(session.name)) + } + console.log() - spinner.start("Saving profile..."); + spinner.start("Saving profile...") - // Copy the current default Antigravity data to our profiles storage - const profilePath = copyDefaultProfileToStorage(session.email); - const profile = addProfileAndSetActive(session.email, profilePath); + // Copy the current default Antigravity data to our profiles storage + const profilePath = copyDefaultProfileToStorage(session.email) + const profile = addProfileAndSetActive(session.email, profilePath) - spinner.stop("Profile saved"); + spinner.stop("Profile saved") - console.log(); - p.note( - `${pc.green("Email:")} ${profile.email} + console.log() + p.note( + `${pc.green("Email:")} ${profile.email} ${pc.green("Profile:")} ${profilePath} ${pc.green("Status:")} ${pc.cyan("Active")}`, - "Profile Created", - ); - - p.outro( - `${pc.green("โœ”")} Account ${pc.cyan(session.email)} added successfully!`, - ); - } catch (error) { - spinner.stop("Failed"); - - const errorMessage = - error instanceof Error ? error.message : "Unknown error occurred"; - p.cancel(`Failed to add account: ${errorMessage}`); - process.exit(1); - } + "Profile Created" + ) + + p.outro( + `${pc.green("โœ”")} Account ${pc.cyan(session.email)} added successfully!` + ) + } catch (error) { + spinner.stop("Failed") + + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred" + p.cancel(`Failed to add account: ${errorMessage}`) + process.exit(1) + } } diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index c4b1d01..31756a2 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -1,74 +1,73 @@ -import * as p from "@clack/prompts"; -import { defineCommand, runCommand } from "citty"; -import pc from "picocolors"; -import { printHeader } from "../../utils/branding.js"; -import addCommand from "./add.js"; -import listCommand from "./list.js"; -import quotaCommand from "./quota.js"; -import removeCommand from "./remove.js"; -import switchCommand from "./switch.js"; +import * as p from "@clack/prompts" +import { defineCommand, runCommand } from "citty" +import pc from "picocolors" +import { printHeader } from "../../utils/branding.js" +import addCommand from "./add.js" +import listCommand from "./list.js" +import quotaCommand from "./quota.js" +import removeCommand from "./remove.js" +import switchCommand from "./switch.js" const subCommands = { - add: addCommand, - list: listCommand, - switch: switchCommand, - remove: removeCommand, - quota: quotaCommand, -}; + add: addCommand, + list: listCommand, + switch: switchCommand, + remove: removeCommand, + quota: quotaCommand, +} -type SubCommandKey = keyof typeof subCommands; +type SubCommandKey = keyof typeof subCommands interface SubCommandOption { - value: SubCommandKey; - label: string; - hint: string; + value: SubCommandKey + label: string + hint: string } const subCommandOptions: SubCommandOption[] = [ - { value: "list", label: "list", hint: "List all saved profiles" }, - { value: "add", label: "add", hint: "Add a new account" }, - { value: "switch", label: "switch", hint: "Switch to a different profile" }, - { value: "remove", label: "remove", hint: "Remove a saved profile" }, - { value: "quota", label: "quota", hint: "Check account quota" }, -]; + { value: "list", label: "list", hint: "List all saved profiles" }, + { value: "add", label: "add", hint: "Add a new account" }, + { value: "switch", label: "switch", hint: "Switch to a different profile" }, + { value: "remove", label: "remove", hint: "Remove a saved profile" }, + { value: "quota", label: "quota", hint: "Check account quota" }, +] -const subCommandNames = Object.keys(subCommands); +const subCommandNames = Object.keys(subCommands) export default defineCommand({ - meta: { - name: "auth", - description: "Manage Google AntiGravity authentication", - }, - subCommands, - async run({ rawArgs }) { - // Only show interactive menu when no subcommand was explicitly provided - // If a subcommand was provided, citty already executed it - const hasSubCommand = rawArgs?.some((arg) => subCommandNames.includes(arg)); - if (hasSubCommand) { - return; // Subcommand was already executed by citty - } - - // Show interactive menu when no subcommand is provided - printHeader("antigravity-kit auth"); + meta: { + name: "auth", + description: "Manage Google AntiGravity authentication", + }, + subCommands, + async run({ rawArgs }) { + // Only show interactive menu when no subcommand was explicitly provided + // If a subcommand was provided, citty already executed it + const hasSubCommand = rawArgs?.some((arg) => subCommandNames.includes(arg)) + if (hasSubCommand) { + return // Subcommand was already executed by citty + } - p.intro(`${pc.cyan("โ—†")} ${pc.bold("Authentication Manager")}`); + // Show interactive menu when no subcommand is provided + printHeader("antigravity-kit auth") - const selected = await p.select({ - message: "Select an action:", - options: subCommandOptions, - }); + p.intro(`${pc.cyan("โ—†")} ${pc.bold("Authentication Manager")}`) - if (p.isCancel(selected)) { - p.cancel("Operation cancelled"); - process.exit(0); - } + const selected = await p.select({ + message: "Select an action:", + options: subCommandOptions, + }) - console.log(); + if (p.isCancel(selected)) { + p.cancel("Operation cancelled") + process.exit(0) + } - // Run the selected subcommand - const commandKey = selected as SubCommandKey; - // biome-ignore lint/suspicious/noExplicitAny: runCommand has strict typing that conflicts with dynamic command selection - await runCommand(subCommands[commandKey] as any, { rawArgs: [] }); - }, -}); + console.log() + // Run the selected subcommand + const commandKey = selected as SubCommandKey + // biome-ignore lint/suspicious/noExplicitAny: runCommand has strict typing that conflicts with dynamic command selection + await runCommand(subCommands[commandKey] as any, { rawArgs: [] }) + }, +}) diff --git a/src/commands/auth/list.ts b/src/commands/auth/list.ts index e880e52..0f60194 100644 --- a/src/commands/auth/list.ts +++ b/src/commands/auth/list.ts @@ -1,45 +1,49 @@ -import * as p from "@clack/prompts"; -import { defineCommand } from "citty"; -import pc from "picocolors"; -import Table from "cli-table3"; -import { printHeader } from "../../utils/branding.js"; +import * as p from "@clack/prompts" +import { defineCommand } from "citty" +import pc from "picocolors" +import Table from "cli-table3" +import { printHeader } from "../../utils/branding.js" import { - formatSize, - getProfileSize, - listProfiles, -} from "../../utils/profile-manager.js"; -import { isActiveProfile } from "../../utils/symlink-manager.js"; -import { hasStoredToken, isTokenInKeychain, getRefreshToken } from "../../utils/token-storage.js"; -import { getQuotaWithRefresh, type ModelQuota } from "../../utils/quota.js"; + formatSize, + getProfileSize, + listProfiles, +} from "../../utils/profile-manager.js" +import { isActiveProfile } from "../../utils/symlink-manager.js" +import { + hasStoredToken, + isTokenInKeychain, + getRefreshToken, +} from "../../utils/token-storage.js" +import { getQuotaWithRefresh, type ModelQuota } from "../../utils/quota.js" // ============================================ // CONFIGURATION // ============================================ -const TARGET_MODEL_CLAUDE = "claude-opus-4-5-thinking"; -const TARGET_MODEL_GEMINI = "gemini-3-pro-high"; -const REFRESH_INTERVAL = 30; // seconds +const TARGET_MODEL_CLAUDE = "claude-opus-4-5-thinking" +const TARGET_MODEL_GEMINI = "gemini-3-pro-high" +const REFRESH_INTERVAL = 30 // seconds // ============================================ // TYPES // ============================================ interface QuotaInfo { - percentage: number; - resetTime: string; + percentage: number + resetTime: string } interface ProfileWithQuota { - email: string; - profilePath: string; - createdAt: number; - isActive: boolean; - hasOAuth: boolean; - isKeychain: boolean; - size: number; - quotas: Map; // model name -> quota info or null - isLoading: boolean; - error?: string; + email: string + profilePath: string + createdAt: number + isActive: boolean + hasOAuth: boolean + isKeychain: boolean + size: number + quotas: Map // model name -> quota info or null + isLoading: boolean + error?: string } // ============================================ @@ -47,125 +51,129 @@ interface ProfileWithQuota { // ============================================ function formatDate(timestamp: number): string { - const date = new Date(timestamp); - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); + const date = new Date(timestamp) + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }) } function truncate(str: string, maxLength: number): string { - if (str.length <= maxLength) return str; - return `${str.slice(0, maxLength - 3)}...`; + if (str.length <= maxLength) return str + return `${str.slice(0, maxLength - 3)}...` } function clearLines(count: number): void { - for (let i = 0; i < count; i++) { - process.stdout.write("\x1b[1A\x1b[2K"); - } + for (let i = 0; i < count; i++) { + process.stdout.write("\x1b[1A\x1b[2K") + } } function formatResetTime(resetTimeStr: string): string { - if (!resetTimeStr) return ""; + if (!resetTimeStr) return "" - try { - const resetTime = new Date(resetTimeStr); - const now = new Date(); - const diffMs = resetTime.getTime() - now.getTime(); + try { + const resetTime = new Date(resetTimeStr) + const now = new Date() + const diffMs = resetTime.getTime() - now.getTime() - if (diffMs <= 0) return "now"; + if (diffMs <= 0) return "now" - const hours = Math.floor(diffMs / (1000 * 60 * 60)); - const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + const hours = Math.floor(diffMs / (1000 * 60 * 60)) + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) - if (hours > 0) { - return `${hours}h${minutes}m`; - } - return `${minutes}m`; - } catch { - return ""; - } + if (hours > 0) { + return `${hours}h${minutes}m` + } + return `${minutes}m` + } catch { + return "" + } } -function createQuotaDisplay(info: QuotaInfo | null, isLoading: boolean): string { - if (isLoading) return pc.dim("..."); - if (info === null) return pc.dim("โ€”"); - - const percentage = info.percentage; - const resetStr = formatResetTime(info.resetTime); - - // Color based on percentage - let color: (s: string) => string; - if (percentage >= 70) { - color = pc.green; - } else if (percentage >= 30) { - color = pc.yellow; - } else { - color = pc.red; - } - - // Format: "52% (1h30m)" or "52%" if no reset time - const pctStr = `${percentage}%`; - const display = resetStr ? `${pctStr} (${resetStr})` : pctStr; - return color(display); +function createQuotaDisplay( + info: QuotaInfo | null, + isLoading: boolean +): string { + if (isLoading) return pc.dim("...") + if (info === null) return pc.dim("โ€”") + + const percentage = info.percentage + const resetStr = formatResetTime(info.resetTime) + + // Color based on percentage + let color: (s: string) => string + if (percentage >= 70) { + color = pc.green + } else if (percentage >= 30) { + color = pc.yellow + } else { + color = pc.red + } + + // Format: "52% (1h30m)" or "52%" if no reset time + const pctStr = `${percentage}%` + const display = resetStr ? `${pctStr} (${resetStr})` : pctStr + return color(display) } function getStorageBadge(email: string): string { - if (!isTokenInKeychain(email)) { - return pc.dim("โ€”"); - } - return "๐Ÿ”"; + if (!isTokenInKeychain(email)) { + return pc.dim("โ€”") + } + return "๐Ÿ”" } -function findModelQuota(models: ModelQuota[], targetModel: string): QuotaInfo | null { - // Find exact match or partial match - const model = models.find(m => - m.name === targetModel || - m.name.includes(targetModel) || - targetModel.includes(m.name.split("/").pop() || "") - ); - if (!model) return null; - return { - percentage: model.percentage, - resetTime: model.resetTime, - }; +function findModelQuota( + models: ModelQuota[], + targetModel: string +): QuotaInfo | null { + // Find exact match or partial match + const model = models.find( + (m) => + m.name === targetModel || + m.name.includes(targetModel) || + targetModel.includes(m.name.split("/").pop() || "") + ) + if (!model) return null + return { + percentage: model.percentage, + resetTime: model.resetTime, + } } // ============================================ // KEY INPUT HANDLING // ============================================ -function setupKeyHandler( - onReload: () => void, - onExit: () => void -): () => void { - const stdin = process.stdin; - - if (stdin.isTTY) { - stdin.setRawMode(true); - } - stdin.resume(); - stdin.setEncoding("utf8"); - - const handler = (key: string) => { - if (key === "q" || key === "\x03") { - // 'q' or Ctrl+C - onExit(); - } else if (key === "r") { - onReload(); - } - }; - - stdin.on("data", handler); - - return () => { - stdin.removeListener("data", handler); - if (stdin.isTTY) { - stdin.setRawMode(false); - } - stdin.pause(); - }; +function setupKeyHandler(onReload: () => void, onExit: () => void): () => void { + const stdin = process.stdin + + if (stdin.isTTY) { + stdin.setRawMode(true) + } + stdin.resume() + stdin.setEncoding("utf8") + + const handler = (key: string) => { + if (key === "q" || key === "\x03") { + // 'q' or Ctrl+C + onExit() + } else if (key === "r") { + onReload() + } + } + + stdin.on("data", handler) + + return () => { + stdin.removeListener("data", handler) + if (stdin.isTTY) { + stdin.setRawMode(false) + } + stdin.pause() + } } // ============================================ @@ -173,153 +181,163 @@ function setupKeyHandler( // ============================================ function renderTable( - profiles: ProfileWithQuota[], - countdown: number, - isLoading: boolean + profiles: ProfileWithQuota[], + countdown: number, + isLoading: boolean ): number { - const lines: string[] = []; - - // Create beautiful table using cli-table3 - const table = new Table({ - head: [ - pc.white(""), - pc.white("Email"), - pc.white("OAuth"), - pc.white("Storage"), - pc.white("Claude"), - pc.white("Gemini"), - pc.white("Size"), - pc.white("Created"), - ], - style: { - head: [], - border: ["gray"], - }, - chars: { - "top": "โ”€", - "top-mid": "โ”ฌ", - "top-left": "โ”Œ", - "top-right": "โ”", - "bottom": "โ”€", - "bottom-mid": "โ”ด", - "bottom-left": "โ””", - "bottom-right": "โ”˜", - "left": "โ”‚", - "left-mid": "โ”œ", - "mid": "โ”€", - "mid-mid": "โ”ผ", - "right": "โ”‚", - "right-mid": "โ”ค", - "middle": "โ”‚", - }, - colWidths: [3, 30, 7, 9, 16, 16, 12, 14], - }); - - for (const profile of profiles) { - const indicator = profile.isActive ? pc.green("โ—") : pc.dim("โ—‹"); - const emailDisplay = truncate(profile.email, 28); - const email = profile.isActive ? pc.green(emailDisplay) : emailDisplay; - - const oauth = profile.hasOAuth ? pc.green("โœ“") : pc.dim("โœ—"); - const storage = getStorageBadge(profile.email); - - // Get quota values for target models - const claudeQuota = profile.quotas.get(TARGET_MODEL_CLAUDE) ?? null; - const geminiQuota = profile.quotas.get(TARGET_MODEL_GEMINI) ?? null; - - table.push([ - indicator, - email, - oauth, - storage, - createQuotaDisplay(claudeQuota, profile.isLoading), - createQuotaDisplay(geminiQuota, profile.isLoading), - formatSize(profile.size), - pc.dim(formatDate(profile.createdAt)), - ]); - } - - lines.push(""); - lines.push(pc.bold(" ๐Ÿ“Š Account Profiles")); - lines.push(""); - - // Split table into lines - const tableString = table.toString(); - const tableLines = tableString.split("\n"); - for (const line of tableLines) { - lines.push(` ${line}`); - } - - lines.push(""); - - // Active profile info - const activeProfile = profiles.find(p => p.isActive); - if (activeProfile) { - lines.push(pc.dim(" Active: ") + pc.green(activeProfile.email)); - } - - // Legend - lines.push(""); - lines.push( - pc.dim(" Legend: ") + - pc.green("โ—") + pc.dim(" Active ") + - pc.green("โœ“") + pc.dim(" OAuth ") + - "๐Ÿ”" + pc.dim(" Keychain ") + - pc.green("N%") + pc.dim(" Quota (reset)") - ); - - lines.push( - pc.dim( - ` Total: ${profiles.length} profile${profiles.length === 1 ? "" : "s"}`, - ), - ); - - // Footer with controls - lines.push(""); - if (isLoading) { - lines.push(pc.dim(" โŸณ Loading quotas...")); - } else { - lines.push(pc.dim(` โŸณ Auto-refresh in ${countdown}s | Press 'r' to reload | Press 'q' to exit`)); - } - lines.push(""); - - for (const line of lines) { - console.log(line); - } - - return lines.length; + const lines: string[] = [] + + // Create beautiful table using cli-table3 + const table = new Table({ + head: [ + pc.white(""), + pc.white("Email"), + pc.white("OAuth"), + pc.white("Storage"), + pc.white("Claude"), + pc.white("Gemini"), + pc.white("Size"), + pc.white("Created"), + ], + style: { + head: [], + border: ["gray"], + }, + chars: { + top: "โ”€", + "top-mid": "โ”ฌ", + "top-left": "โ”Œ", + "top-right": "โ”", + bottom: "โ”€", + "bottom-mid": "โ”ด", + "bottom-left": "โ””", + "bottom-right": "โ”˜", + left: "โ”‚", + "left-mid": "โ”œ", + mid: "โ”€", + "mid-mid": "โ”ผ", + right: "โ”‚", + "right-mid": "โ”ค", + middle: "โ”‚", + }, + colWidths: [3, 30, 7, 9, 16, 16, 12, 14], + }) + + for (const profile of profiles) { + const indicator = profile.isActive ? pc.green("โ—") : pc.dim("โ—‹") + const emailDisplay = truncate(profile.email, 28) + const email = profile.isActive ? pc.green(emailDisplay) : emailDisplay + + const oauth = profile.hasOAuth ? pc.green("โœ“") : pc.dim("โœ—") + const storage = getStorageBadge(profile.email) + + // Get quota values for target models + const claudeQuota = profile.quotas.get(TARGET_MODEL_CLAUDE) ?? null + const geminiQuota = profile.quotas.get(TARGET_MODEL_GEMINI) ?? null + + table.push([ + indicator, + email, + oauth, + storage, + createQuotaDisplay(claudeQuota, profile.isLoading), + createQuotaDisplay(geminiQuota, profile.isLoading), + formatSize(profile.size), + pc.dim(formatDate(profile.createdAt)), + ]) + } + + lines.push("") + lines.push(pc.bold(" ๐Ÿ“Š Account Profiles")) + lines.push("") + + // Split table into lines + const tableString = table.toString() + const tableLines = tableString.split("\n") + for (const line of tableLines) { + lines.push(` ${line}`) + } + + lines.push("") + + // Active profile info + const activeProfile = profiles.find((p) => p.isActive) + if (activeProfile) { + lines.push(pc.dim(" Active: ") + pc.green(activeProfile.email)) + } + + // Legend + lines.push("") + lines.push( + pc.dim(" Legend: ") + + pc.green("โ—") + + pc.dim(" Active ") + + pc.green("โœ“") + + pc.dim(" OAuth ") + + "๐Ÿ”" + + pc.dim(" Keychain ") + + pc.green("N%") + + pc.dim(" Quota (reset)") + ) + + lines.push( + pc.dim( + ` Total: ${profiles.length} profile${profiles.length === 1 ? "" : "s"}` + ) + ) + + // Footer with controls + lines.push("") + if (isLoading) { + lines.push(pc.dim(" โŸณ Loading quotas...")) + } else { + lines.push( + pc.dim( + ` โŸณ Auto-refresh in ${countdown}s | Press 'r' to reload | Press 'q' to exit` + ) + ) + } + lines.push("") + + for (const line of lines) { + console.log(line) + } + + return lines.length } // ============================================ // QUOTA FETCHING // ============================================ -async function fetchQuotasForProfiles(profiles: ProfileWithQuota[]): Promise { - const fetchPromises = profiles.map(async (profile) => { - if (!profile.hasOAuth) { - return; - } - - try { - const refreshToken = await getRefreshToken(profile.email); - if (!refreshToken) { - return; - } - - const result = await getQuotaWithRefresh(refreshToken); - - // Extract quotas for target models - const targetModels = [TARGET_MODEL_CLAUDE, TARGET_MODEL_GEMINI]; - for (const targetModel of targetModels) { - const quota = findModelQuota(result.models, targetModel); - profile.quotas.set(targetModel, quota); - } - } catch (error) { - profile.error = error instanceof Error ? error.message : "Unknown error"; - } - }); - - await Promise.all(fetchPromises); +async function fetchQuotasForProfiles( + profiles: ProfileWithQuota[] +): Promise { + const fetchPromises = profiles.map(async (profile) => { + if (!profile.hasOAuth) { + return + } + + try { + const refreshToken = await getRefreshToken(profile.email) + if (!refreshToken) { + return + } + + const result = await getQuotaWithRefresh(refreshToken) + + // Extract quotas for target models + const targetModels = [TARGET_MODEL_CLAUDE, TARGET_MODEL_GEMINI] + for (const targetModel of targetModels) { + const quota = findModelQuota(result.models, targetModel) + profile.quotas.set(targetModel, quota) + } + } catch (error) { + profile.error = error instanceof Error ? error.message : "Unknown error" + } + }) + + await Promise.all(fetchPromises) } // ============================================ @@ -327,144 +345,145 @@ async function fetchQuotasForProfiles(profiles: ProfileWithQuota[]): Promise ({ - email: profile.email, - profilePath: profile.profilePath, - createdAt: profile.createdAt, - isActive: isActiveProfile(profile.profilePath), - hasOAuth: await hasStoredToken(profile.email), - isKeychain: isTokenInKeychain(profile.email), - size: getProfileSize(profile.profilePath), - quotas: new Map(), - isLoading: true, - })) - ); - - let running = true; - let lastLineCount = 0; - let countdown = REFRESH_INTERVAL; - let isLoadingQuotas = false; - - // Render function - just renders current state - const render = (showLoadingIndicator: boolean) => { - if (lastLineCount > 0) { - clearLines(lastLineCount); - } - lastLineCount = renderTable(profiles, countdown, showLoadingIndicator); - }; - - // Fetch and update function - fetches then re-renders - const fetchAndRender = async () => { - if (isLoadingQuotas) return; // Prevent concurrent fetches - - isLoadingQuotas = true; - - // Mark all profiles as loading - for (const profile of profiles) { - profile.isLoading = true; - } - - // Render loading state - render(true); - - // Fetch quotas - await fetchQuotasForProfiles(profiles); - - // Mark all profiles as done loading - for (const profile of profiles) { - profile.isLoading = false; - } - - countdown = REFRESH_INTERVAL; - isLoadingQuotas = false; - - // Render final state - render(false); - }; - - // Initial render with loading state, then fetch - console.log(); - lastLineCount = renderTable(profiles, countdown, true); - - // Fetch quotas (will clear and re-render when done) - await fetchQuotasForProfiles(profiles); - - // Mark all profiles as done loading - for (const profile of profiles) { - profile.isLoading = false; - } - - // Render with actual data - render(false); - - // Setup key handler - const cleanup = setupKeyHandler( - () => { - // Manual reload - fetchAndRender(); - }, - () => { - // Exit - running = false; - } - ); - - // Auto-refresh loop - const intervalId = setInterval(() => { - if (!running) { - clearInterval(intervalId); - cleanup(); - process.exit(0); - } - - countdown--; - - // Update countdown display (only if not loading) - if (!isLoadingQuotas) { - render(false); - } - - if (countdown <= 0) { - fetchAndRender(); - } - }, 1000); - - // Keep process alive - await new Promise((resolve) => { - const checkInterval = setInterval(() => { - if (!running) { - clearInterval(checkInterval); - clearInterval(intervalId); - cleanup(); - console.log(); - p.outro(pc.dim("Profile list closed")); - resolve(); - } - }, 100); - }); - }, -}); + "Empty" + ) + p.outro(pc.dim("No profiles to display")) + return + } + + // Build profile data with quota placeholders + const profiles: ProfileWithQuota[] = await Promise.all( + rawProfiles.map(async (profile) => ({ + email: profile.email, + profilePath: profile.profilePath, + createdAt: profile.createdAt, + isActive: isActiveProfile(profile.profilePath), + hasOAuth: await hasStoredToken(profile.email), + isKeychain: isTokenInKeychain(profile.email), + size: getProfileSize(profile.profilePath), + quotas: new Map(), + isLoading: true, + })) + ) + + let running = true + let lastLineCount = 0 + let countdown = REFRESH_INTERVAL + let isLoadingQuotas = false + + // Render function - just renders current state + const render = (showLoadingIndicator: boolean) => { + if (lastLineCount > 0) { + clearLines(lastLineCount) + } + lastLineCount = renderTable(profiles, countdown, showLoadingIndicator) + } + + // Fetch and update function - fetches then re-renders + const fetchAndRender = async () => { + if (isLoadingQuotas) return // Prevent concurrent fetches + + isLoadingQuotas = true + + // Mark all profiles as loading + for (const profile of profiles) { + profile.isLoading = true + } + + // Render loading state + render(true) + + // Fetch quotas + await fetchQuotasForProfiles(profiles) + + // Mark all profiles as done loading + for (const profile of profiles) { + profile.isLoading = false + } + + countdown = REFRESH_INTERVAL + isLoadingQuotas = false + + // Render final state + render(false) + } + + // Initial render with loading state, then fetch + console.log() + lastLineCount = renderTable(profiles, countdown, true) + + // Fetch quotas (will clear and re-render when done) + await fetchQuotasForProfiles(profiles) + + // Mark all profiles as done loading + for (const profile of profiles) { + profile.isLoading = false + } + + // Render with actual data + render(false) + + // Setup key handler + const cleanup = setupKeyHandler( + () => { + // Manual reload + fetchAndRender() + }, + () => { + // Exit + running = false + } + ) + + // Auto-refresh loop + const intervalId = setInterval(() => { + if (!running) { + clearInterval(intervalId) + cleanup() + process.exit(0) + } + + countdown-- + + // Update countdown display (only if not loading) + if (!isLoadingQuotas) { + render(false) + } + + if (countdown <= 0) { + fetchAndRender() + } + }, 1000) + + // Keep process alive + await new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (!running) { + clearInterval(checkInterval) + clearInterval(intervalId) + cleanup() + console.log() + p.outro(pc.dim("Profile list closed")) + resolve() + } + }, 100) + }) + }, +}) diff --git a/src/commands/auth/quota.ts b/src/commands/auth/quota.ts index 011f15f..32a091d 100644 --- a/src/commands/auth/quota.ts +++ b/src/commands/auth/quota.ts @@ -1,175 +1,203 @@ -import * as p from "@clack/prompts"; -import { defineCommand } from "citty"; -import pc from "picocolors"; -import { printHeader } from "../../utils/branding.js"; -import { listProfiles } from "../../utils/profile-manager.js"; -import { isActiveProfile } from "../../utils/symlink-manager.js"; -import { getRefreshToken, hasStoredToken } from "../../utils/token-storage.js"; -import { getQuotaWithRefresh, type QuotaResult } from "../../utils/quota.js"; +import * as p from "@clack/prompts" +import { defineCommand } from "citty" +import pc from "picocolors" +import { printHeader } from "../../utils/branding.js" +import { listProfiles } from "../../utils/profile-manager.js" +import { isActiveProfile } from "../../utils/symlink-manager.js" +import { getRefreshToken, hasStoredToken } from "../../utils/token-storage.js" +import { getQuotaWithRefresh, type QuotaResult } from "../../utils/quota.js" // ============================================ // UI HELPERS // ============================================ function clearLines(count: number): void { - for (let i = 0; i < count; i++) { - process.stdout.write("\x1b[1A\x1b[2K"); - } + for (let i = 0; i < count; i++) { + process.stdout.write("\x1b[1A\x1b[2K") + } } function formatResetTime(resetTimeStr: string): string { - if (!resetTimeStr) return "โ€”"; + if (!resetTimeStr) return "โ€”" - try { - const resetTime = new Date(resetTimeStr); - const now = new Date(); - const diffMs = resetTime.getTime() - now.getTime(); + try { + const resetTime = new Date(resetTimeStr) + const now = new Date() + const diffMs = resetTime.getTime() - now.getTime() - if (diffMs <= 0) return "now"; + if (diffMs <= 0) return "now" - const hours = Math.floor(diffMs / (1000 * 60 * 60)); - const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + const hours = Math.floor(diffMs / (1000 * 60 * 60)) + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) - if (hours > 0) { - return `in ${hours}h ${minutes}m`; - } - return `in ${minutes}m`; - } catch { - return resetTimeStr.slice(0, 16); - } + if (hours > 0) { + return `in ${hours}h ${minutes}m` + } + return `in ${minutes}m` + } catch { + return resetTimeStr.slice(0, 16) + } } function createProgressBar(percentage: number, width = 10): string { - const filled = Math.round((percentage / 100) * width); - const empty = width - filled; - - // Color based on percentage - let color: (s: string) => string; - if (percentage >= 70) { - color = pc.green; - } else if (percentage >= 30) { - color = pc.yellow; - } else { - color = pc.red; - } - - return color("โ–ˆ".repeat(filled)) + pc.dim("โ–‘".repeat(empty)); + const filled = Math.round((percentage / 100) * width) + const empty = width - filled + + // Color based on percentage + let color: (s: string) => string + if (percentage >= 70) { + color = pc.green + } else if (percentage >= 30) { + color = pc.yellow + } else { + color = pc.red + } + + return color("โ–ˆ".repeat(filled)) + pc.dim("โ–‘".repeat(empty)) } function renderQuotaTable( - email: string, - quota: QuotaResult, - countdown: number, - isLoading = false + email: string, + quota: QuotaResult, + countdown: number, + isLoading = false ): number { - const lines: string[] = []; - - // Header - lines.push(""); - lines.push(pc.bold(` ๐Ÿ“Š Quota Status - ${pc.cyan(email)}`)); - - if (quota.subscriptionTier) { - lines.push(` ๐Ÿ’Ž Subscription: ${pc.magenta(quota.subscriptionTier)}`); - } - - if (quota.isForbidden) { - lines.push(` ${pc.red("๐Ÿšซ Status: FORBIDDEN (403)")}`); - lines.push(""); - return printLines(lines); - } - - lines.push(""); - - // Table header - const modelColWidth = 28; - const quotaColWidth = 14; - const resetColWidth = 15; - - lines.push( - pc.dim(" โ”Œ" + "โ”€".repeat(modelColWidth) + "โ”ฌ" + "โ”€".repeat(quotaColWidth) + "โ”ฌ" + "โ”€".repeat(resetColWidth) + "โ”") - ); - lines.push( - ` โ”‚${pc.bold(" Model".padEnd(modelColWidth - 1))}โ”‚${pc.bold(" Quota".padEnd(quotaColWidth - 1))}โ”‚${pc.bold(" Reset Time".padEnd(resetColWidth - 1))}โ”‚` - ); - lines.push( - pc.dim(" โ”œ" + "โ”€".repeat(modelColWidth) + "โ”ผ" + "โ”€".repeat(quotaColWidth) + "โ”ผ" + "โ”€".repeat(resetColWidth) + "โ”ค") - ); - - // Sort models by name - const sortedModels = [...quota.models].sort((a, b) => a.name.localeCompare(b.name)); - - for (const model of sortedModels) { - const displayName = model.name.length > modelColWidth - 3 - ? model.name.slice(0, modelColWidth - 4) + "โ€ฆ" - : model.name; - const bar = createProgressBar(model.percentage); - const pct = `${model.percentage}%`.padStart(4); - const reset = formatResetTime(model.resetTime); - - lines.push( - ` โ”‚ ${displayName.padEnd(modelColWidth - 2)}โ”‚ ${bar} ${pct} โ”‚ ${reset.padEnd(resetColWidth - 3)}โ”‚` - ); - } - - lines.push( - pc.dim(" โ””" + "โ”€".repeat(modelColWidth) + "โ”ด" + "โ”€".repeat(quotaColWidth) + "โ”ด" + "โ”€".repeat(resetColWidth) + "โ”˜") - ); - - // Footer - lines.push(""); - - if (isLoading) { - lines.push(pc.dim(` โŸณ Loading...`)); - } else { - lines.push(pc.dim(` โŸณ Auto-refresh in ${countdown}s | Press 'r' to reload | Press 'q' to exit`)); - } - - lines.push(""); - - return printLines(lines); + const lines: string[] = [] + + // Header + lines.push("") + lines.push(pc.bold(` ๐Ÿ“Š Quota Status - ${pc.cyan(email)}`)) + + if (quota.subscriptionTier) { + lines.push(` ๐Ÿ’Ž Subscription: ${pc.magenta(quota.subscriptionTier)}`) + } + + if (quota.isForbidden) { + lines.push(` ${pc.red("๐Ÿšซ Status: FORBIDDEN (403)")}`) + lines.push("") + return printLines(lines) + } + + lines.push("") + + // Table header + const modelColWidth = 28 + const quotaColWidth = 14 + const resetColWidth = 15 + + lines.push( + pc.dim( + " โ”Œ" + + "โ”€".repeat(modelColWidth) + + "โ”ฌ" + + "โ”€".repeat(quotaColWidth) + + "โ”ฌ" + + "โ”€".repeat(resetColWidth) + + "โ”" + ) + ) + lines.push( + ` โ”‚${pc.bold(" Model".padEnd(modelColWidth - 1))}โ”‚${pc.bold(" Quota".padEnd(quotaColWidth - 1))}โ”‚${pc.bold(" Reset Time".padEnd(resetColWidth - 1))}โ”‚` + ) + lines.push( + pc.dim( + " โ”œ" + + "โ”€".repeat(modelColWidth) + + "โ”ผ" + + "โ”€".repeat(quotaColWidth) + + "โ”ผ" + + "โ”€".repeat(resetColWidth) + + "โ”ค" + ) + ) + + // Sort models by name + const sortedModels = [...quota.models].sort((a, b) => + a.name.localeCompare(b.name) + ) + + for (const model of sortedModels) { + const displayName = + model.name.length > modelColWidth - 3 + ? model.name.slice(0, modelColWidth - 4) + "โ€ฆ" + : model.name + const bar = createProgressBar(model.percentage) + const pct = `${model.percentage}%`.padStart(4) + const reset = formatResetTime(model.resetTime) + + lines.push( + ` โ”‚ ${displayName.padEnd(modelColWidth - 2)}โ”‚ ${bar} ${pct} โ”‚ ${reset.padEnd(resetColWidth - 3)}โ”‚` + ) + } + + lines.push( + pc.dim( + " โ””" + + "โ”€".repeat(modelColWidth) + + "โ”ด" + + "โ”€".repeat(quotaColWidth) + + "โ”ด" + + "โ”€".repeat(resetColWidth) + + "โ”˜" + ) + ) + + // Footer + lines.push("") + + if (isLoading) { + lines.push(pc.dim(` โŸณ Loading...`)) + } else { + lines.push( + pc.dim( + ` โŸณ Auto-refresh in ${countdown}s | Press 'r' to reload | Press 'q' to exit` + ) + ) + } + + lines.push("") + + return printLines(lines) } function printLines(lines: string[]): number { - for (const line of lines) { - console.log(line); - } - return lines.length; + for (const line of lines) { + console.log(line) + } + return lines.length } // ============================================ // KEY INPUT HANDLING // ============================================ -function setupKeyHandler( - onReload: () => void, - onExit: () => void -): () => void { - const stdin = process.stdin; - - if (stdin.isTTY) { - stdin.setRawMode(true); - } - stdin.resume(); - stdin.setEncoding("utf8"); - - const handler = (key: string) => { - if (key === "q" || key === "\x03") { - // 'q' or Ctrl+C - onExit(); - } else if (key === "r") { - onReload(); - } - }; - - stdin.on("data", handler); - - return () => { - stdin.removeListener("data", handler); - if (stdin.isTTY) { - stdin.setRawMode(false); - } - stdin.pause(); - }; +function setupKeyHandler(onReload: () => void, onExit: () => void): () => void { + const stdin = process.stdin + + if (stdin.isTTY) { + stdin.setRawMode(true) + } + stdin.resume() + stdin.setEncoding("utf8") + + const handler = (key: string) => { + if (key === "q" || key === "\x03") { + // 'q' or Ctrl+C + onExit() + } else if (key === "r") { + onReload() + } + } + + stdin.on("data", handler) + + return () => { + stdin.removeListener("data", handler) + if (stdin.isTTY) { + stdin.setRawMode(false) + } + stdin.pause() + } } // ============================================ @@ -177,215 +205,221 @@ function setupKeyHandler( // ============================================ export default defineCommand({ - meta: { - name: "quota", - description: "Check quota for Google AntiGravity accounts", - }, - args: { - account: { - type: "string", - description: "Email of the account to check (defaults to active profile)", - alias: "a", - }, - interval: { - type: "string", - description: "Auto-refresh interval in seconds (default: 30)", - alias: "i", - default: "30", - }, - }, - async run({ args }) { - printHeader("antigravity-kit auth quota"); - - p.intro(`${pc.cyan("โ—†")} ${pc.bold("Quota Monitor")}`); - - // Find the account to check - let email: string | undefined; - - if (args.account) { - // Use specified account - email = args.account; - } else { - // Get all profiles with OAuth tokens - const profiles = listProfiles(); - const accountsWithOAuth: { email: string; isActive: boolean }[] = []; - - for (const profile of profiles) { - if (await hasStoredToken(profile.email)) { - accountsWithOAuth.push({ - email: profile.email, - isActive: isActiveProfile(profile.profilePath), - }); - } - } - - if (accountsWithOAuth.length === 0) { - // No accounts with OAuth - p.note( - `No accounts with OAuth tokens found. Add an account first: + meta: { + name: "quota", + description: "Check quota for Google AntiGravity accounts", + }, + args: { + account: { + type: "string", + description: "Email of the account to check (defaults to active profile)", + alias: "a", + }, + interval: { + type: "string", + description: "Auto-refresh interval in seconds (default: 30)", + alias: "i", + default: "30", + }, + }, + async run({ args }) { + printHeader("antigravity-kit auth quota") + + p.intro(`${pc.cyan("โ—†")} ${pc.bold("Quota Monitor")}`) + + // Find the account to check + let email: string | undefined + + if (args.account) { + // Use specified account + email = args.account + } else { + // Get all profiles with OAuth tokens + const profiles = listProfiles() + const accountsWithOAuth: { email: string; isActive: boolean }[] = [] + + for (const profile of profiles) { + if (await hasStoredToken(profile.email)) { + accountsWithOAuth.push({ + email: profile.email, + isActive: isActiveProfile(profile.profilePath), + }) + } + } + + if (accountsWithOAuth.length === 0) { + // No accounts with OAuth + p.note( + `No accounts with OAuth tokens found. Add an account first: ${pc.cyan("antigravity-kit auth add")}`, - "No Account", - ); - p.cancel("No account to check quota for"); - process.exit(1); - } else if (accountsWithOAuth.length === 1) { - // Only one account, use it directly - email = accountsWithOAuth[0]!.email; - } else { - // Multiple accounts - show interactive selector - const activeAccount = accountsWithOAuth.find((a) => a.isActive); - const selected = await p.select({ - message: "Select an account to monitor:", - options: accountsWithOAuth.map((acc) => { - const option: { value: string; label: string; hint?: string } = { - value: acc.email, - label: acc.email, - }; - if (acc.isActive) { - option.hint = "active"; - } - return option; - }), - initialValue: activeAccount ? activeAccount.email : accountsWithOAuth[0]!.email, - }); - - if (p.isCancel(selected)) { - p.cancel("Operation cancelled"); - process.exit(0); - } - - email = selected; - } - } - - if (!email) { - p.note( - `No active profile found. Add an account first: + "No Account" + ) + p.cancel("No account to check quota for") + process.exit(1) + } else if (accountsWithOAuth.length === 1) { + // Only one account, use it directly + email = accountsWithOAuth[0]!.email + } else { + // Multiple accounts - show interactive selector + const activeAccount = accountsWithOAuth.find((a) => a.isActive) + const selected = await p.select({ + message: "Select an account to monitor:", + options: accountsWithOAuth.map((acc) => { + const option: { value: string; label: string; hint?: string } = { + value: acc.email, + label: acc.email, + } + if (acc.isActive) { + option.hint = "active" + } + return option + }), + initialValue: activeAccount + ? activeAccount.email + : accountsWithOAuth[0]!.email, + }) + + if (p.isCancel(selected)) { + p.cancel("Operation cancelled") + process.exit(0) + } + + email = selected + } + } + + if (!email) { + p.note( + `No active profile found. Add an account first: ${pc.cyan("antigravity-kit auth add")}`, - "No Account", - ); - p.cancel("No account to check quota for"); - process.exit(1); - } + "No Account" + ) + p.cancel("No account to check quota for") + process.exit(1) + } - // Check if we have a refresh token for this account - if (!(await hasStoredToken(email))) { - p.note( - `No OAuth token found for ${pc.cyan(email)}. + // Check if we have a refresh token for this account + if (!(await hasStoredToken(email))) { + p.note( + `No OAuth token found for ${pc.cyan(email)}. To enable quota checking, re-add this account using OAuth: ${pc.cyan("antigravity-kit auth add")} ${pc.dim("(The account was likely added using --manual mode)")}`, - "Token Required", - ); - p.cancel("Cannot check quota without OAuth token"); - process.exit(1); - } - - const refreshToken = await getRefreshToken(email); - if (!refreshToken) { - p.cancel("Failed to retrieve refresh token"); - process.exit(1); - } - - const refreshInterval = Math.max(10, Number.parseInt(args.interval, 10) || 30); - - console.log(); - console.log(pc.dim(` Monitoring quota for ${pc.cyan(email)}`)); - console.log(pc.dim(` Refresh interval: ${refreshInterval}s`)); - - let running = true; - let lastLineCount = 0; - let countdown = refreshInterval; - let isLoading = true; - let currentQuota: QuotaResult | null = null; - - const fetchAndRender = async () => { - isLoading = true; - - // Clear previous output - if (lastLineCount > 0) { - clearLines(lastLineCount); - } - - // Show loading state - if (currentQuota) { - lastLineCount = renderQuotaTable(email!, currentQuota, countdown, true); - } - - try { - const result = await getQuotaWithRefresh(refreshToken); - currentQuota = result; - countdown = refreshInterval; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Unknown error"; - console.log(pc.red(` โŒ Failed to fetch quota: ${errorMsg}`)); - } - - isLoading = false; - - // Clear and re-render - if (lastLineCount > 0) { - clearLines(lastLineCount); - } - - if (currentQuota) { - lastLineCount = renderQuotaTable(email!, currentQuota, countdown, false); - } - }; - - // Initial fetch - await fetchAndRender(); - - // Setup key handler - const cleanup = setupKeyHandler( - () => { - // Manual reload - fetchAndRender(); - }, - () => { - // Exit - running = false; - } - ); - - // Auto-refresh loop - const intervalId = setInterval(() => { - if (!running) { - clearInterval(intervalId); - cleanup(); - process.exit(0); - } - - countdown--; - - // Update countdown display - if (lastLineCount > 0 && currentQuota && !isLoading) { - clearLines(lastLineCount); - lastLineCount = renderQuotaTable(email!, currentQuota, countdown, false); - } - - if (countdown <= 0) { - fetchAndRender(); - } - }, 1000); - - // Keep process alive - await new Promise((resolve) => { - const checkInterval = setInterval(() => { - if (!running) { - clearInterval(checkInterval); - clearInterval(intervalId); - cleanup(); - console.log(); - p.outro(pc.dim("Quota monitor stopped")); - resolve(); - } - }, 100); - }); - }, -}); + "Token Required" + ) + p.cancel("Cannot check quota without OAuth token") + process.exit(1) + } + + const refreshToken = await getRefreshToken(email) + if (!refreshToken) { + p.cancel("Failed to retrieve refresh token") + process.exit(1) + } + + const refreshInterval = Math.max( + 10, + Number.parseInt(args.interval, 10) || 30 + ) + + console.log() + console.log(pc.dim(` Monitoring quota for ${pc.cyan(email)}`)) + console.log(pc.dim(` Refresh interval: ${refreshInterval}s`)) + + let running = true + let lastLineCount = 0 + let countdown = refreshInterval + let isLoading = true + let currentQuota: QuotaResult | null = null + + const fetchAndRender = async () => { + isLoading = true + + // Clear previous output + if (lastLineCount > 0) { + clearLines(lastLineCount) + } + + // Show loading state + if (currentQuota) { + lastLineCount = renderQuotaTable(email!, currentQuota, countdown, true) + } + + try { + const result = await getQuotaWithRefresh(refreshToken) + currentQuota = result + countdown = refreshInterval + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : "Unknown error" + console.log(pc.red(` โŒ Failed to fetch quota: ${errorMsg}`)) + } + + isLoading = false + + // Clear and re-render + if (lastLineCount > 0) { + clearLines(lastLineCount) + } + + if (currentQuota) { + lastLineCount = renderQuotaTable(email!, currentQuota, countdown, false) + } + } + + // Initial fetch + await fetchAndRender() + + // Setup key handler + const cleanup = setupKeyHandler( + () => { + // Manual reload + fetchAndRender() + }, + () => { + // Exit + running = false + } + ) + + // Auto-refresh loop + const intervalId = setInterval(() => { + if (!running) { + clearInterval(intervalId) + cleanup() + process.exit(0) + } + + countdown-- + + // Update countdown display + if (lastLineCount > 0 && currentQuota && !isLoading) { + clearLines(lastLineCount) + lastLineCount = renderQuotaTable(email!, currentQuota, countdown, false) + } + + if (countdown <= 0) { + fetchAndRender() + } + }, 1000) + + // Keep process alive + await new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (!running) { + clearInterval(checkInterval) + clearInterval(intervalId) + cleanup() + console.log() + p.outro(pc.dim("Quota monitor stopped")) + resolve() + } + }, 100) + }) + }, +}) diff --git a/src/commands/auth/remove.ts b/src/commands/auth/remove.ts index e04d56d..00eadc9 100644 --- a/src/commands/auth/remove.ts +++ b/src/commands/auth/remove.ts @@ -1,128 +1,126 @@ -import * as p from "@clack/prompts"; -import { defineCommand } from "citty"; -import pc from "picocolors"; -import { printHeader } from "../../utils/branding.js"; +import * as p from "@clack/prompts" +import { defineCommand } from "citty" +import pc from "picocolors" +import { printHeader } from "../../utils/branding.js" import { - formatSize, - getProfileSize, - listProfiles, - removeProfile, -} from "../../utils/profile-manager.js"; + formatSize, + getProfileSize, + listProfiles, + removeProfile, +} from "../../utils/profile-manager.js" import { - isActiveProfile, - removeActiveSymlink, - setActiveProfile, -} from "../../utils/symlink-manager.js"; + isActiveProfile, + removeActiveSymlink, + setActiveProfile, +} from "../../utils/symlink-manager.js" export default defineCommand({ - meta: { - name: "remove", - description: "Remove a Google AntiGravity profile", - }, - async run() { - printHeader("antigravity-kit auth remove"); + meta: { + name: "remove", + description: "Remove a Google AntiGravity profile", + }, + async run() { + printHeader("antigravity-kit auth remove") - p.intro(pc.cyan("โ—†") + " " + pc.bold("Remove Profile")); + p.intro(pc.cyan("โ—†") + " " + pc.bold("Remove Profile")) - const profiles = listProfiles(); + const profiles = listProfiles() - if (profiles.length === 0) { - p.note( - `No profiles found. Add one with: + if (profiles.length === 0) { + p.note( + `No profiles found. Add one with: ${pc.cyan("antigravity-kit auth add")}`, - "No Profiles", - ); - p.cancel("No profiles to remove"); - process.exit(1); - } - - const options = profiles.map((profile) => { - const isActive = isActiveProfile(profile.profilePath); - const size = formatSize(getProfileSize(profile.profilePath)); - const label = isActive - ? `${profile.email} ${pc.green("(active)")}` - : profile.email; - - return { - value: profile.profilePath, - label, - hint: size, - }; - }); - - const selectedPath = await p.select({ - message: "Select a profile to remove:", - options, - }); - - if (p.isCancel(selectedPath)) { - p.cancel("Operation cancelled"); - process.exit(0); - } - - const selectedProfile = profiles.find( - (p) => p.profilePath === selectedPath, - ); - - if (!selectedProfile) { - p.cancel("Profile not found"); - process.exit(1); - } - - const isActive = isActiveProfile(selectedPath as string); - const profileSize = formatSize(getProfileSize(selectedPath as string)); - - let warningMessage = `Are you sure you want to remove ${pc.cyan(selectedProfile.email)}?`; - warningMessage += `\n${pc.dim(`This will free up ${profileSize} of disk space.`)}`; - - if (isActive) { - warningMessage += `\n\n${pc.yellow("โš ")} This is your ${pc.yellow("active")} profile.`; - } - - const confirmed = await p.confirm({ - message: warningMessage, - initialValue: false, - }); - - if (p.isCancel(confirmed) || !confirmed) { - p.cancel("Removal cancelled"); - process.exit(0); - } - - const spinner = p.spinner(); - spinner.start("Removing profile..."); - - const success = removeProfile(selectedPath as string); - - if (!success) { - spinner.stop("Failed"); - p.cancel("Failed to remove profile"); - process.exit(1); - } - - // If removed profile was active, update symlink - if (isActive) { - const remainingProfiles = listProfiles(); - if (remainingProfiles.length > 0 && remainingProfiles[0]) { - setActiveProfile(remainingProfiles[0].profilePath); - spinner.stop("Profile removed"); - console.log(); - p.note( - `Active profile changed to: ${pc.cyan(remainingProfiles[0].email)}`, - "New Active Profile", - ); - } else { - removeActiveSymlink(); - spinner.stop("Profile removed"); - } - } else { - spinner.stop("Profile removed"); - } - - console.log(); - p.outro( - `${pc.green("โœ”")} Profile ${pc.cyan(selectedProfile.email)} removed`, - ); - }, -}); + "No Profiles" + ) + p.cancel("No profiles to remove") + process.exit(1) + } + + const options = profiles.map((profile) => { + const isActive = isActiveProfile(profile.profilePath) + const size = formatSize(getProfileSize(profile.profilePath)) + const label = isActive + ? `${profile.email} ${pc.green("(active)")}` + : profile.email + + return { + value: profile.profilePath, + label, + hint: size, + } + }) + + const selectedPath = await p.select({ + message: "Select a profile to remove:", + options, + }) + + if (p.isCancel(selectedPath)) { + p.cancel("Operation cancelled") + process.exit(0) + } + + const selectedProfile = profiles.find((p) => p.profilePath === selectedPath) + + if (!selectedProfile) { + p.cancel("Profile not found") + process.exit(1) + } + + const isActive = isActiveProfile(selectedPath as string) + const profileSize = formatSize(getProfileSize(selectedPath as string)) + + let warningMessage = `Are you sure you want to remove ${pc.cyan(selectedProfile.email)}?` + warningMessage += `\n${pc.dim(`This will free up ${profileSize} of disk space.`)}` + + if (isActive) { + warningMessage += `\n\n${pc.yellow("โš ")} This is your ${pc.yellow("active")} profile.` + } + + const confirmed = await p.confirm({ + message: warningMessage, + initialValue: false, + }) + + if (p.isCancel(confirmed) || !confirmed) { + p.cancel("Removal cancelled") + process.exit(0) + } + + const spinner = p.spinner() + spinner.start("Removing profile...") + + const success = removeProfile(selectedPath as string) + + if (!success) { + spinner.stop("Failed") + p.cancel("Failed to remove profile") + process.exit(1) + } + + // If removed profile was active, update symlink + if (isActive) { + const remainingProfiles = listProfiles() + if (remainingProfiles.length > 0 && remainingProfiles[0]) { + setActiveProfile(remainingProfiles[0].profilePath) + spinner.stop("Profile removed") + console.log() + p.note( + `Active profile changed to: ${pc.cyan(remainingProfiles[0].email)}`, + "New Active Profile" + ) + } else { + removeActiveSymlink() + spinner.stop("Profile removed") + } + } else { + spinner.stop("Profile removed") + } + + console.log() + p.outro( + `${pc.green("โœ”")} Profile ${pc.cyan(selectedProfile.email)} removed` + ) + }, +}) diff --git a/src/commands/auth/switch.ts b/src/commands/auth/switch.ts index ec58797..63808f7 100644 --- a/src/commands/auth/switch.ts +++ b/src/commands/auth/switch.ts @@ -1,28 +1,28 @@ -import * as p from "@clack/prompts"; -import { defineCommand } from "citty"; -import pc from "picocolors"; +import * as p from "@clack/prompts" +import { defineCommand } from "citty" +import pc from "picocolors" import { isAntigravityRunning, openAntigravity, quitAntigravity, -} from "../../utils/antigravity-launcher.js"; -import { printHeader } from "../../utils/branding.js"; +} from "../../utils/antigravity-launcher.js" +import { printHeader } from "../../utils/branding.js" import { AntigravityRunningError, listProfiles, restoreProfileToDefault, -} from "../../utils/profile-manager.js"; +} from "../../utils/profile-manager.js" import { isActiveProfile, setActiveProfile, -} from "../../utils/symlink-manager.js"; +} from "../../utils/symlink-manager.js" import { findWorkspaceByName, getCurrentWorkspaceFromState, listWorkspaces, -} from "../../utils/workspace-storage.js"; -import { hasStoredToken, saveRefreshToken } from "../../utils/token-storage.js"; -import { startOAuthFlow } from "../../utils/oauth-server.js"; +} from "../../utils/workspace-storage.js" +import { hasStoredToken, saveRefreshToken } from "../../utils/token-storage.js" +import { startOAuthFlow } from "../../utils/oauth-server.js" export default defineCommand({ meta: { @@ -32,16 +32,17 @@ export default defineCommand({ args: { workspace: { type: "string", - description: "Specify a workspace to open (name or path). Use 'select' to choose interactively.", + description: + "Specify a workspace to open (name or path). Use 'select' to choose interactively.", alias: "w", }, }, async run({ args }) { - printHeader("antigravity-kit auth switch"); + printHeader("antigravity-kit auth switch") - p.intro(pc.cyan("โ—†") + " " + pc.bold("Switch Profile")); + p.intro(pc.cyan("โ—†") + " " + pc.bold("Switch Profile")) - const profiles = listProfiles(); + const profiles = listProfiles() if (profiles.length === 0) { p.note( @@ -49,9 +50,9 @@ export default defineCommand({ ${pc.cyan("antigravity-kit auth add")}`, "No Profiles" - ); - p.cancel("No profiles to switch between"); - process.exit(1); + ) + p.cancel("No profiles to switch between") + process.exit(1) } if (profiles.length === 1) { @@ -60,256 +61,275 @@ export default defineCommand({ ${pc.cyan("antigravity-kit auth add")}`, "Single Profile" - ); - p.outro(pc.dim("Nothing to switch")); - return; + ) + p.outro(pc.dim("Nothing to switch")) + return } const currentActiveProfile = profiles.find((p) => isActiveProfile(p.profilePath) - ); + ) if (currentActiveProfile) { - console.log(); + console.log() console.log( pc.dim("Current profile: ") + pc.cyan(currentActiveProfile.email) - ); - console.log(); + ) + console.log() } const options = profiles.map((profile) => { - const isActive = isActiveProfile(profile.profilePath); + const isActive = isActiveProfile(profile.profilePath) const label = isActive ? `${profile.email} ${pc.green("(active)")}` - : profile.email; - const hint = profile.name !== profile.email ? profile.name : undefined; + : profile.email + const hint = profile.name !== profile.email ? profile.name : undefined const option: { value: string; label: string; hint?: string } = { value: profile.profilePath, label, - }; + } if (hint) { - option.hint = hint; + option.hint = hint } - return option; - }); + return option + }) const selectedPath = await p.select({ message: "Select a profile to switch to:", options, initialValue: currentActiveProfile?.profilePath, - }); + }) if (p.isCancel(selectedPath)) { - p.cancel("Operation cancelled"); - process.exit(0); + p.cancel("Operation cancelled") + process.exit(0) } - const selectedProfile = profiles.find( - (p) => p.profilePath === selectedPath - ); + const selectedProfile = profiles.find((p) => p.profilePath === selectedPath) if (!selectedProfile) { - p.cancel("Profile not found"); - process.exit(1); + p.cancel("Profile not found") + process.exit(1) } if (isActiveProfile(selectedPath as string)) { - p.outro(pc.dim("Profile unchanged")); - return; + p.outro(pc.dim("Profile unchanged")) + return } // Check if selected profile has OAuth token if (!(await hasStoredToken(selectedProfile.email))) { - console.log(); + console.log() const action = await p.select({ message: `${pc.yellow(selectedProfile.email)} has no OAuth token. Quota checking won't work.`, options: [ - { value: "signin", label: "Sign in with OAuth first", hint: "Opens browser for Google sign-in" }, - { value: "continue", label: "Continue without OAuth", hint: "Quota checking disabled" }, + { + value: "signin", + label: "Sign in with OAuth first", + hint: "Opens browser for Google sign-in", + }, + { + value: "continue", + label: "Continue without OAuth", + hint: "Quota checking disabled", + }, { value: "cancel", label: "Cancel" }, ], - }); + }) if (p.isCancel(action) || action === "cancel") { - p.cancel("Operation cancelled"); - process.exit(0); + p.cancel("Operation cancelled") + process.exit(0) } if (action === "signin") { try { - const result = await startOAuthFlow(); - await saveRefreshToken(selectedProfile.email, result.tokens.refresh_token); - console.log(pc.green("โœ”") + " OAuth token saved for " + pc.cyan(selectedProfile.email)); + const result = await startOAuthFlow() + await saveRefreshToken( + selectedProfile.email, + result.tokens.refresh_token + ) + console.log( + pc.green("โœ”") + + " OAuth token saved for " + + pc.cyan(selectedProfile.email) + ) } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - p.log.warning(pc.yellow(`OAuth sign-in failed: ${errorMessage}`)); - p.log.info(pc.dim("Continuing without OAuth token...")); + const errorMessage = + error instanceof Error ? error.message : "Unknown error" + p.log.warning(pc.yellow(`OAuth sign-in failed: ${errorMessage}`)) + p.log.info(pc.dim("Continuing without OAuth token...")) } } } // Capture current workspace BEFORE closing Antigravity // This is the workspace the user was working on that should reopen after switch - const currentWorkspacePath = getCurrentWorkspaceFromState(); + const currentWorkspacePath = getCurrentWorkspaceFromState() - const isRunning = await isAntigravityRunning(); + const isRunning = await isAntigravityRunning() if (isRunning) { const shouldContinue = await p.confirm({ message: `${pc.yellow( "Antigravity is currently running." )} Close it to continue?`, initialValue: true, - }); + }) if (p.isCancel(shouldContinue) || !shouldContinue) { - p.cancel("Please close Antigravity manually and try again"); - process.exit(1); + p.cancel("Please close Antigravity manually and try again") + process.exit(1) } - const spinner = p.spinner(); - spinner.start("Closing Antigravity..."); + const spinner = p.spinner() + spinner.start("Closing Antigravity...") - const quitSuccess = await quitAntigravity(); + const quitSuccess = await quitAntigravity() if (!quitSuccess) { - spinner.message("Waiting for Antigravity to close..."); + spinner.message("Waiting for Antigravity to close...") } - let attempts = 0; - const maxAttempts = 30; + let attempts = 0 + const maxAttempts = 30 while (attempts < maxAttempts) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - const stillRunning = await isAntigravityRunning(); + await new Promise((resolve) => setTimeout(resolve, 1000)) + const stillRunning = await isAntigravityRunning() if (!stillRunning) { - break; + break } - attempts++; + attempts++ } - const stillRunning = await isAntigravityRunning(); + const stillRunning = await isAntigravityRunning() if (stillRunning) { - spinner.stop("Antigravity is still running"); - p.cancel("Please close Antigravity manually and try again"); - process.exit(1); + spinner.stop("Antigravity is still running") + p.cancel("Please close Antigravity manually and try again") + process.exit(1) } - spinner.stop("Antigravity closed"); + spinner.stop("Antigravity closed") } - const success = setActiveProfile(selectedPath as string); + const success = setActiveProfile(selectedPath as string) if (!success) { - p.cancel("Failed to switch profile"); - process.exit(1); + p.cancel("Failed to switch profile") + process.exit(1) } try { - const restored = restoreProfileToDefault(selectedPath as string); + const restored = restoreProfileToDefault(selectedPath as string) if (!restored) { - p.cancel("Failed to restore profile data"); - process.exit(1); + p.cancel("Failed to restore profile data") + process.exit(1) } } catch (error) { if (error instanceof AntigravityRunningError) { - p.cancel(error.message); - process.exit(1); + p.cancel(error.message) + process.exit(1) } - throw error; + throw error } - console.log(); + console.log() const shouldLaunch = await p.confirm({ message: "Launch Antigravity with the new profile?", initialValue: true, - }); + }) if (p.isCancel(shouldLaunch)) { - p.outro(`${pc.green("โœ”")} Switched to ${pc.cyan(selectedProfile.email)}`); - return; + p.outro(`${pc.green("โœ”")} Switched to ${pc.cyan(selectedProfile.email)}`) + return } if (shouldLaunch) { try { - const stillRunning = await isAntigravityRunning(); + const stillRunning = await isAntigravityRunning() if (stillRunning) { - await quitAntigravity(); - await new Promise((resolve) => setTimeout(resolve, 500)); + await quitAntigravity() + await new Promise((resolve) => setTimeout(resolve, 500)) } // Get workspace to open // Priority: 1) --workspace flag, 2) current workspace (captured before switch), 3) select interactively - let workspaceToOpen: string | null = null; + let workspaceToOpen: string | null = null if (args.workspace === "select") { // Interactive selection from available workspaces - const workspaces = listWorkspaces(); + const workspaces = listWorkspaces() if (workspaces.length > 0) { const workspaceOptions = workspaces.map((w) => ({ value: w.folderPath, label: w.folderName, hint: w.folderPath, - })); + })) workspaceOptions.push({ value: "", label: "Skip (open without workspace)", hint: "", - }); + }) const selected = await p.select({ message: "Select a workspace to open:", options: workspaceOptions, - }); + }) if (!p.isCancel(selected) && selected) { - workspaceToOpen = selected as string; + workspaceToOpen = selected as string } } } else if (args.workspace) { // Find workspace by name from new profile's workspaces - const found = findWorkspaceByName(args.workspace); + const found = findWorkspaceByName(args.workspace) if (found) { - workspaceToOpen = found.folderPath; + workspaceToOpen = found.folderPath } else { console.log( - pc.yellow(`Workspace "${args.workspace}" not found, using current workspace.`) - ); - workspaceToOpen = currentWorkspacePath; + pc.yellow( + `Workspace "${args.workspace}" not found, using current workspace.` + ) + ) + workspaceToOpen = currentWorkspacePath } } else { // Default: use the workspace that was open before switching - workspaceToOpen = currentWorkspacePath; + workspaceToOpen = currentWorkspacePath } - await openAntigravity(selectedPath as string, workspaceToOpen || undefined); + await openAntigravity( + selectedPath as string, + workspaceToOpen || undefined + ) if (workspaceToOpen) { - const folderName = workspaceToOpen.split("/").pop() || workspaceToOpen; + const folderName = workspaceToOpen.split("/").pop() || workspaceToOpen p.outro( `${pc.green("โœ”")} Switched to ${pc.cyan( selectedProfile.email )} and opened ${pc.cyan(folderName)}` - ); + ) } else { p.outro( `${pc.green("โœ”")} Switched to ${pc.cyan( selectedProfile.email )} and launched Antigravity` - ); + ) } } catch (error) { p.outro( `${pc.green("โœ”")} Switched to ${pc.cyan( selectedProfile.email )}\n${pc.yellow("โš ")} Failed to launch Antigravity` - ); + ) } } else { - p.outro(`${pc.green("โœ”")} Switched to ${pc.cyan(selectedProfile.email)}`); + p.outro(`${pc.green("โœ”")} Switched to ${pc.cyan(selectedProfile.email)}`) } }, -}); +}) diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 756f837..027f45c 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -1,252 +1,256 @@ -import { spawn } from "node:child_process"; -import * as p from "@clack/prompts"; -import { defineCommand } from "citty"; -import pc from "picocolors"; -import { printHeader } from "../utils/branding.js"; +import { spawn } from "node:child_process" +import * as p from "@clack/prompts" +import { defineCommand } from "citty" +import pc from "picocolors" +import { printHeader } from "../utils/branding.js" import { - type UpdateInfo, - checkForUpdates, - compareVersions, - getPackageJson, -} from "../utils/version-checker.js"; + type UpdateInfo, + checkForUpdates, + compareVersions, + getPackageJson, +} from "../utils/version-checker.js" interface UpgradeOption { - value: "latest" | "beta" | "cancel"; - label: string; - hint?: string; + value: "latest" | "beta" | "cancel" + label: string + hint?: string } /** * Execute npm install -g command to upgrade the package */ async function executeUpgrade(version: string): Promise { - return new Promise((resolve) => { - const packageSpec = `antigravity-kit@${version}`; - - const child = spawn("npm", ["install", "-g", packageSpec], { - stdio: "inherit", - shell: true, - }); - - child.on("close", (code) => { - resolve(code === 0); - }); - - child.on("error", () => { - resolve(false); - }); - }); + return new Promise((resolve) => { + const packageSpec = `antigravity-kit@${version}` + + const child = spawn("npm", ["install", "-g", packageSpec], { + stdio: "inherit", + shell: true, + }) + + child.on("close", (code) => { + resolve(code === 0) + }) + + child.on("error", () => { + resolve(false) + }) + }) } /** * Build upgrade options based on available versions */ function buildUpgradeOptions(updateInfo: UpdateInfo): UpgradeOption[] { - const options: UpgradeOption[] = []; - - if (updateInfo.hasLatestUpdate && updateInfo.latestVersion) { - options.push({ - value: "latest", - label: `Latest stable (${pc.green(updateInfo.latestVersion)})`, - hint: "Recommended", - }); - } - - if (updateInfo.hasBetaUpdate && updateInfo.betaVersion) { - // Only show beta if it's newer than both current and latest - const betaIsNewer = - !updateInfo.latestVersion || - compareVersions(updateInfo.betaVersion, updateInfo.latestVersion) > 0; - - if (betaIsNewer) { - options.push({ - value: "beta", - label: `Beta (${pc.yellow(updateInfo.betaVersion)})`, - hint: "May be unstable", - }); - } - } - - options.push({ - value: "cancel", - label: "Cancel", - }); - - return options; + const options: UpgradeOption[] = [] + + if (updateInfo.hasLatestUpdate && updateInfo.latestVersion) { + options.push({ + value: "latest", + label: `Latest stable (${pc.green(updateInfo.latestVersion)})`, + hint: "Recommended", + }) + } + + if (updateInfo.hasBetaUpdate && updateInfo.betaVersion) { + // Only show beta if it's newer than both current and latest + const betaIsNewer = + !updateInfo.latestVersion || + compareVersions(updateInfo.betaVersion, updateInfo.latestVersion) > 0 + + if (betaIsNewer) { + options.push({ + value: "beta", + label: `Beta (${pc.yellow(updateInfo.betaVersion)})`, + hint: "May be unstable", + }) + } + } + + options.push({ + value: "cancel", + label: "Cancel", + }) + + return options } export default defineCommand({ - meta: { - name: "upgrade", - description: "Upgrade antigravity-kit to the latest version", - }, - args: { - beta: { - type: "boolean", - description: "Upgrade to the latest beta version", - default: false, - }, - latest: { - type: "boolean", - description: "Upgrade to the latest stable version", - default: false, - }, - check: { - type: "boolean", - description: "Check for updates without upgrading", - default: false, - }, - }, - async run({ args }) { - printHeader("antigravity-kit upgrade"); - - const pkg = getPackageJson(); - p.intro(`${pc.cyan("โ—†")} ${pc.bold("Upgrade Manager")}`); - - const spinner = p.spinner(); - spinner.start("Checking for updates..."); - - const updateInfo = await checkForUpdates(); - - if (!updateInfo) { - spinner.stop("Check complete"); - console.log(); - p.note( - `Unable to check for updates.\nThis may be due to network issues.`, - "Error", - ); - p.outro(pc.dim("Please try again later")); - process.exit(1); - } - - spinner.stop("Check complete"); - - // Display current version info - console.log(); - console.log(` ${pc.dim("Current version:")} ${pc.cyan(pkg.version)}`); - - if (updateInfo.latestVersion) { - const isLatest = compareVersions(pkg.version, updateInfo.latestVersion) >= 0; - console.log( - ` ${pc.dim("Latest stable:")} ${isLatest ? pc.green(updateInfo.latestVersion) : pc.yellow(updateInfo.latestVersion)}`, - ); - } - - if (updateInfo.betaVersion) { - const isBeta = compareVersions(pkg.version, updateInfo.betaVersion) >= 0; - console.log( - ` ${pc.dim("Latest beta:")} ${isBeta ? pc.green(updateInfo.betaVersion) : pc.yellow(updateInfo.betaVersion)}`, - ); - } - console.log(); - - // Check-only mode - if (args.check) { - if (!updateInfo.hasLatestUpdate && !updateInfo.hasBetaUpdate) { - p.outro(`${pc.green("โœ”")} You're on the latest version!`); - } else { - if (updateInfo.hasLatestUpdate) { - console.log( - ` ${pc.yellow("โ†’")} Run ${pc.cyan("agk upgrade --latest")} to upgrade to stable`, - ); - } - if (updateInfo.hasBetaUpdate) { - console.log( - ` ${pc.yellow("โ†’")} Run ${pc.cyan("agk upgrade --beta")} to upgrade to beta`, - ); - } - console.log(); - p.outro(pc.dim("Run without --check to upgrade")); - } - return; - } - - // Already up to date - if (!updateInfo.hasLatestUpdate && !updateInfo.hasBetaUpdate) { - p.outro(`${pc.green("โœ”")} You're already on the latest version!`); - return; - } - - // Determine target version - let targetVersion: string | null = null; - let targetTag: "latest" | "beta" | null = null; - - if (args.beta) { - if (!updateInfo.hasBetaUpdate || !updateInfo.betaVersion) { - p.note("No beta version available or you're already on the latest beta.", "Info"); - p.outro(pc.dim("No upgrade needed")); - return; - } - targetVersion = updateInfo.betaVersion; - targetTag = "beta"; - } else if (args.latest) { - if (!updateInfo.hasLatestUpdate || !updateInfo.latestVersion) { - p.note("You're already on the latest stable version.", "Info"); - p.outro(pc.dim("No upgrade needed")); - return; - } - targetVersion = updateInfo.latestVersion; - targetTag = "latest"; - } else { - // Interactive mode - const options = buildUpgradeOptions(updateInfo); - - if (options.length === 1) { - // Only cancel option, no upgrades available - p.outro(`${pc.green("โœ”")} You're already on the latest version!`); - return; - } - - const selected = await p.select({ - message: "Select version to upgrade to:", - options, - }); - - if (p.isCancel(selected) || selected === "cancel") { - p.cancel("Upgrade cancelled"); - process.exit(0); - } - - targetTag = selected as "latest" | "beta"; - targetVersion = - targetTag === "latest" - ? updateInfo.latestVersion! - : updateInfo.betaVersion!; - } - - // Confirm upgrade - const confirmed = await p.confirm({ - message: `Upgrade from ${pc.dim(pkg.version)} to ${pc.cyan(targetVersion)}?`, - initialValue: true, - }); - - if (p.isCancel(confirmed) || !confirmed) { - p.cancel("Upgrade cancelled"); - process.exit(0); - } - - // Execute upgrade - console.log(); - const upgradeSpinner = p.spinner(); - upgradeSpinner.start(`Upgrading to ${targetVersion}...`); - - const success = await executeUpgrade(targetTag); - - if (success) { - upgradeSpinner.stop("Upgrade complete"); - console.log(); - p.outro( - `${pc.green("โœ”")} Successfully upgraded to ${pc.cyan(targetVersion)}`, - ); - } else { - upgradeSpinner.stop("Upgrade failed"); - console.log(); - p.note( - `Failed to upgrade automatically.\n\nTry running manually:\n ${pc.cyan(`npm install -g antigravity-kit@${targetTag}`)}`, - "Error", - ); - p.outro(pc.dim("Upgrade failed")); - process.exit(1); - } - }, -}); + meta: { + name: "upgrade", + description: "Upgrade antigravity-kit to the latest version", + }, + args: { + beta: { + type: "boolean", + description: "Upgrade to the latest beta version", + default: false, + }, + latest: { + type: "boolean", + description: "Upgrade to the latest stable version", + default: false, + }, + check: { + type: "boolean", + description: "Check for updates without upgrading", + default: false, + }, + }, + async run({ args }) { + printHeader("antigravity-kit upgrade") + + const pkg = getPackageJson() + p.intro(`${pc.cyan("โ—†")} ${pc.bold("Upgrade Manager")}`) + + const spinner = p.spinner() + spinner.start("Checking for updates...") + + const updateInfo = await checkForUpdates() + + if (!updateInfo) { + spinner.stop("Check complete") + console.log() + p.note( + `Unable to check for updates.\nThis may be due to network issues.`, + "Error" + ) + p.outro(pc.dim("Please try again later")) + process.exit(1) + } + + spinner.stop("Check complete") + + // Display current version info + console.log() + console.log(` ${pc.dim("Current version:")} ${pc.cyan(pkg.version)}`) + + if (updateInfo.latestVersion) { + const isLatest = + compareVersions(pkg.version, updateInfo.latestVersion) >= 0 + console.log( + ` ${pc.dim("Latest stable:")} ${isLatest ? pc.green(updateInfo.latestVersion) : pc.yellow(updateInfo.latestVersion)}` + ) + } + + if (updateInfo.betaVersion) { + const isBeta = compareVersions(pkg.version, updateInfo.betaVersion) >= 0 + console.log( + ` ${pc.dim("Latest beta:")} ${isBeta ? pc.green(updateInfo.betaVersion) : pc.yellow(updateInfo.betaVersion)}` + ) + } + console.log() + + // Check-only mode + if (args.check) { + if (!updateInfo.hasLatestUpdate && !updateInfo.hasBetaUpdate) { + p.outro(`${pc.green("โœ”")} You're on the latest version!`) + } else { + if (updateInfo.hasLatestUpdate) { + console.log( + ` ${pc.yellow("โ†’")} Run ${pc.cyan("agk upgrade --latest")} to upgrade to stable` + ) + } + if (updateInfo.hasBetaUpdate) { + console.log( + ` ${pc.yellow("โ†’")} Run ${pc.cyan("agk upgrade --beta")} to upgrade to beta` + ) + } + console.log() + p.outro(pc.dim("Run without --check to upgrade")) + } + return + } + + // Already up to date + if (!updateInfo.hasLatestUpdate && !updateInfo.hasBetaUpdate) { + p.outro(`${pc.green("โœ”")} You're already on the latest version!`) + return + } + + // Determine target version + let targetVersion: string | null = null + let targetTag: "latest" | "beta" | null = null + + if (args.beta) { + if (!updateInfo.hasBetaUpdate || !updateInfo.betaVersion) { + p.note( + "No beta version available or you're already on the latest beta.", + "Info" + ) + p.outro(pc.dim("No upgrade needed")) + return + } + targetVersion = updateInfo.betaVersion + targetTag = "beta" + } else if (args.latest) { + if (!updateInfo.hasLatestUpdate || !updateInfo.latestVersion) { + p.note("You're already on the latest stable version.", "Info") + p.outro(pc.dim("No upgrade needed")) + return + } + targetVersion = updateInfo.latestVersion + targetTag = "latest" + } else { + // Interactive mode + const options = buildUpgradeOptions(updateInfo) + + if (options.length === 1) { + // Only cancel option, no upgrades available + p.outro(`${pc.green("โœ”")} You're already on the latest version!`) + return + } + + const selected = await p.select({ + message: "Select version to upgrade to:", + options, + }) + + if (p.isCancel(selected) || selected === "cancel") { + p.cancel("Upgrade cancelled") + process.exit(0) + } + + targetTag = selected as "latest" | "beta" + targetVersion = + targetTag === "latest" + ? updateInfo.latestVersion! + : updateInfo.betaVersion! + } + + // Confirm upgrade + const confirmed = await p.confirm({ + message: `Upgrade from ${pc.dim(pkg.version)} to ${pc.cyan(targetVersion)}?`, + initialValue: true, + }) + + if (p.isCancel(confirmed) || !confirmed) { + p.cancel("Upgrade cancelled") + process.exit(0) + } + + // Execute upgrade + console.log() + const upgradeSpinner = p.spinner() + upgradeSpinner.start(`Upgrading to ${targetVersion}...`) + + const success = await executeUpgrade(targetTag) + + if (success) { + upgradeSpinner.stop("Upgrade complete") + console.log() + p.outro( + `${pc.green("โœ”")} Successfully upgraded to ${pc.cyan(targetVersion)}` + ) + } else { + upgradeSpinner.stop("Upgrade failed") + console.log() + p.note( + `Failed to upgrade automatically.\n\nTry running manually:\n ${pc.cyan(`npm install -g antigravity-kit@${targetTag}`)}`, + "Error" + ) + p.outro(pc.dim("Upgrade failed")) + process.exit(1) + } + }, +}) diff --git a/src/index.ts b/src/index.ts index 585f45e..c98aef4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,74 +1,74 @@ // Types export type { - AuthSession, - AuthSessionData, - Profile, - ProfileStore, -} from "./types/auth.js"; + AuthSession, + AuthSessionData, + Profile, + ProfileStore, +} from "./types/auth.js" export { - ACTIVE_SYMLINK_NAME, - CLEANUP_DIRECTORIES, - CONFIG_FILE_NAME, - PENDING_SESSION_NAME, - PROFILES_DIR_NAME, - STATE_DB_PATH, -} from "./types/auth.js"; + ACTIVE_SYMLINK_NAME, + CLEANUP_DIRECTORIES, + CONFIG_FILE_NAME, + PENDING_SESSION_NAME, + PROFILES_DIR_NAME, + STATE_DB_PATH, +} from "./types/auth.js" // Profile Manager utilities export { - addProfileAndSetActive, - cleanupProfile, - copyDefaultProfileToStorage, - createPendingProfile, - finalizeProfile, - formatSize, - generateProfileId, - getActiveProfileEmail, - getDefaultAntigravityDataDir, - getDefaultStateDbPath, - getPendingProfilePath, - getProfileByEmail, - getProfileSize, - getProfilesDir, - getStateDbPath, - listProfiles, - removeProfile, - watchForAuth, -} from "./utils/profile-manager.js"; + addProfileAndSetActive, + cleanupProfile, + copyDefaultProfileToStorage, + createPendingProfile, + finalizeProfile, + formatSize, + generateProfileId, + getActiveProfileEmail, + getDefaultAntigravityDataDir, + getDefaultStateDbPath, + getPendingProfilePath, + getProfileByEmail, + getProfileSize, + getProfilesDir, + getStateDbPath, + listProfiles, + removeProfile, + watchForAuth, +} from "./utils/profile-manager.js" // SQLite Reader utilities export { - getFirstAuthSession, - hasAuthSessions, - parseAuthSession, - readAuthSessions, -} from "./utils/sqlite-reader.js"; + getFirstAuthSession, + hasAuthSessions, + parseAuthSession, + readAuthSessions, +} from "./utils/sqlite-reader.js" // Symlink Manager utilities export { - getActiveProfileName, - getActiveProfilePath, - getActiveSymlinkPath, - isActiveProfile, - removeActiveSymlink, - setActiveProfile, -} from "./utils/symlink-manager.js"; + getActiveProfileName, + getActiveProfilePath, + getActiveSymlinkPath, + isActiveProfile, + removeActiveSymlink, + setActiveProfile, +} from "./utils/symlink-manager.js" // Antigravity Launcher utilities export { - isAntigravityInstalled, - openAntigravity, -} from "./utils/antigravity-launcher.js"; + isAntigravityInstalled, + openAntigravity, +} from "./utils/antigravity-launcher.js" // Branding utilities export { - printBanner, - printCommand, - printError, - printHeader, - printInfo, - printSuccess, - printVersion, - printWarning, -} from "./utils/branding.js"; + printBanner, + printCommand, + printError, + printHeader, + printInfo, + printSuccess, + printVersion, + printWarning, +} from "./utils/branding.js" diff --git a/src/types/auth.ts b/src/types/auth.ts index 454c698..a8751b6 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,48 +1,48 @@ export interface Profile { - id: string; - email: string; - name: string; - profilePath: string; - createdAt: number; - updatedAt: number; + id: string + email: string + name: string + profilePath: string + createdAt: number + updatedAt: number } export interface ProfileStore { - profiles: Profile[]; - activeProfileId: string | null; - version: number; + profiles: Profile[] + activeProfileId: string | null + version: number } export interface AuthSession { - id: string; - account: { - label: string; - id: string; - }; - scopes?: string[]; + id: string + account: { + label: string + id: string + } + scopes?: string[] } export interface AuthSessionData { - email: string; - accountId: string; - sessionId: string; - name?: string; + email: string + accountId: string + sessionId: string + name?: string } -export const PROFILES_DIR_NAME = "profiles"; -export const PENDING_SESSION_NAME = "pending_session"; -export const ACTIVE_SYMLINK_NAME = "active"; -export const CONFIG_FILE_NAME = "config.json"; -export const STATE_DB_PATH = "User/globalStorage/state.vscdb"; +export const PROFILES_DIR_NAME = "profiles" +export const PENDING_SESSION_NAME = "pending_session" +export const ACTIVE_SYMLINK_NAME = "active" +export const CONFIG_FILE_NAME = "config.json" +export const STATE_DB_PATH = "User/globalStorage/state.vscdb" export const CLEANUP_DIRECTORIES = [ - "Cache", - "CachedData", - "logs", - "Crashpad", - "GPUCache", - "blob_storage", - "Code Cache", - "DawnCache", - "Service Worker", -]; + "Cache", + "CachedData", + "logs", + "Crashpad", + "GPUCache", + "blob_storage", + "Code Cache", + "DawnCache", + "Service Worker", +] diff --git a/src/utils/antigravity-launcher.ts b/src/utils/antigravity-launcher.ts index 8933878..8b0ea19 100644 --- a/src/utils/antigravity-launcher.ts +++ b/src/utils/antigravity-launcher.ts @@ -1,162 +1,162 @@ -import { exec, spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { platform } from "node:os"; -import { promisify } from "node:util"; +import { exec, spawn } from "node:child_process" +import { existsSync } from "node:fs" +import { platform } from "node:os" +import { promisify } from "node:util" -const execAsync = promisify(exec); +const execAsync = promisify(exec) function getAntigravityPath(): string | null { - const os = platform(); - - if (os === "darwin") { - const paths = [ - "/Applications/Antigravity.app", - `${process.env["HOME"]}/Applications/Antigravity.app`, - ]; - for (const p of paths) { - if (existsSync(p)) { - return p; - } - } - } else if (os === "linux") { - const paths = ["/usr/bin/antigravity", "/usr/local/bin/antigravity"]; - for (const p of paths) { - if (existsSync(p)) { - return p; - } - } - } else if (os === "win32") { - const localAppData = process.env["LOCALAPPDATA"] || ""; - const paths = [ - "C:\\Program Files\\Antigravity\\antigravity.exe", - `${localAppData}\\Programs\\Antigravity\\antigravity.exe`, - ]; - for (const p of paths) { - if (existsSync(p)) { - return p; - } - } - } - - return null; + const os = platform() + + if (os === "darwin") { + const paths = [ + "/Applications/Antigravity.app", + `${process.env["HOME"]}/Applications/Antigravity.app`, + ] + for (const p of paths) { + if (existsSync(p)) { + return p + } + } + } else if (os === "linux") { + const paths = ["/usr/bin/antigravity", "/usr/local/bin/antigravity"] + for (const p of paths) { + if (existsSync(p)) { + return p + } + } + } else if (os === "win32") { + const localAppData = process.env["LOCALAPPDATA"] || "" + const paths = [ + "C:\\Program Files\\Antigravity\\antigravity.exe", + `${localAppData}\\Programs\\Antigravity\\antigravity.exe`, + ] + for (const p of paths) { + if (existsSync(p)) { + return p + } + } + } + + return null } export async function openAntigravity( - profilePath?: string, - workspacePath?: string, + profilePath?: string, + workspacePath?: string ): Promise { - const os = platform(); - - if (os === "darwin") { - const appPath = getAntigravityPath(); - if (!appPath) { - throw new Error( - "Antigravity.app not found in /Applications or ~/Applications", - ); - } - - // Build the command with optional arguments - let command = `open -a "${appPath}"`; - - // Add workspace path as the folder to open - if (workspacePath) { - command += ` "${workspacePath}"`; - } - - // Note: Antigravity may ignore --user-data-dir, so we just open it - // and let it use its default data directory - if (profilePath) { - command += ` --args --user-data-dir="${profilePath}"`; - } - - await execAsync(command); - } else if (os === "linux") { - const binPath = getAntigravityPath(); - if (!binPath) { - throw new Error("antigravity command not found"); - } - - const args: string[] = []; - if (workspacePath) { - args.push(workspacePath); - } - if (profilePath) { - args.push(`--user-data-dir=${profilePath}`); - } - spawn(binPath, args, { - detached: true, - stdio: "ignore", - }).unref(); - } else if (os === "win32") { - const exePath = getAntigravityPath(); - if (!exePath) { - throw new Error("antigravity.exe not found"); - } - - const args: string[] = []; - if (workspacePath) { - args.push(workspacePath); - } - if (profilePath) { - args.push(`--user-data-dir=${profilePath}`); - } - spawn(exePath, args, { - detached: true, - stdio: "ignore", - shell: true, - }).unref(); - } else { - throw new Error(`Unsupported platform: ${os}`); - } + const os = platform() + + if (os === "darwin") { + const appPath = getAntigravityPath() + if (!appPath) { + throw new Error( + "Antigravity.app not found in /Applications or ~/Applications" + ) + } + + // Build the command with optional arguments + let command = `open -a "${appPath}"` + + // Add workspace path as the folder to open + if (workspacePath) { + command += ` "${workspacePath}"` + } + + // Note: Antigravity may ignore --user-data-dir, so we just open it + // and let it use its default data directory + if (profilePath) { + command += ` --args --user-data-dir="${profilePath}"` + } + + await execAsync(command) + } else if (os === "linux") { + const binPath = getAntigravityPath() + if (!binPath) { + throw new Error("antigravity command not found") + } + + const args: string[] = [] + if (workspacePath) { + args.push(workspacePath) + } + if (profilePath) { + args.push(`--user-data-dir=${profilePath}`) + } + spawn(binPath, args, { + detached: true, + stdio: "ignore", + }).unref() + } else if (os === "win32") { + const exePath = getAntigravityPath() + if (!exePath) { + throw new Error("antigravity.exe not found") + } + + const args: string[] = [] + if (workspacePath) { + args.push(workspacePath) + } + if (profilePath) { + args.push(`--user-data-dir=${profilePath}`) + } + spawn(exePath, args, { + detached: true, + stdio: "ignore", + shell: true, + }).unref() + } else { + throw new Error(`Unsupported platform: ${os}`) + } } export function isAntigravityInstalled(): boolean { - return getAntigravityPath() !== null; + return getAntigravityPath() !== null } export async function isAntigravityRunning(): Promise { - const os = platform(); - - try { - if (os === "darwin") { - const { stdout } = await execAsync('pgrep -li "Antigravity"'); - return stdout.trim().length > 0; - } - - if (os === "linux") { - const { stdout } = await execAsync("pgrep -x antigravity"); - return stdout.trim().length > 0; - } - - if (os === "win32") { - const { stdout } = await execAsync( - 'tasklist /FI "IMAGENAME eq antigravity.exe" /NH', - ); - return stdout.toLowerCase().includes("antigravity.exe"); - } - - return false; - } catch { - return false; - } + const os = platform() + + try { + if (os === "darwin") { + const { stdout } = await execAsync('pgrep -li "Antigravity"') + return stdout.trim().length > 0 + } + + if (os === "linux") { + const { stdout } = await execAsync("pgrep -x antigravity") + return stdout.trim().length > 0 + } + + if (os === "win32") { + const { stdout } = await execAsync( + 'tasklist /FI "IMAGENAME eq antigravity.exe" /NH' + ) + return stdout.toLowerCase().includes("antigravity.exe") + } + + return false + } catch { + return false + } } export async function quitAntigravity(): Promise { - const os = platform(); - - try { - if (os === "darwin") { - // await execAsync('osascript -e \'quit app "Antigravity"\''); - await execAsync('pkill -9 -f "Antigravity"'); - } else if (os === "linux") { - await execAsync("pkill -x antigravity"); - } else if (os === "win32") { - await execAsync("taskkill /IM antigravity.exe /F"); - } - - await new Promise((resolve) => setTimeout(resolve, 500)); - return true; - } catch { - return false; - } + const os = platform() + + try { + if (os === "darwin") { + // await execAsync('osascript -e \'quit app "Antigravity"\''); + await execAsync('pkill -9 -f "Antigravity"') + } else if (os === "linux") { + await execAsync("pkill -x antigravity") + } else if (os === "win32") { + await execAsync("taskkill /IM antigravity.exe /F") + } + + await new Promise((resolve) => setTimeout(resolve, 500)) + return true + } catch { + return false + } } diff --git a/src/utils/branding.ts b/src/utils/branding.ts index 50a5e0d..773d0f6 100644 --- a/src/utils/branding.ts +++ b/src/utils/branding.ts @@ -1,92 +1,92 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import figlet from "figlet"; -import gradient from "gradient-string"; -import pc from "picocolors"; +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import figlet from "figlet" +import gradient from "gradient-string" +import pc from "picocolors" -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = dirname(fileURLToPath(import.meta.url)) interface PackageJson { - version: string; - name: string; + version: string + name: string } function getPackageJson(): PackageJson { - try { - const pkgPath = join(__dirname, "..", "package.json"); - const content = readFileSync(pkgPath, "utf-8"); - return JSON.parse(content) as PackageJson; - } catch { - return { version: "0.0.0", name: "antigravity-kit" }; - } + try { + const pkgPath = join(__dirname, "..", "package.json") + const content = readFileSync(pkgPath, "utf-8") + return JSON.parse(content) as PackageJson + } catch { + return { version: "0.0.0", name: "antigravity-kit" } + } } const antigravityGradient = gradient([ - { color: "#98FB98", pos: 0 }, - { color: "#7FFFD4", pos: 0.3 }, - { color: "#40E0D0", pos: 0.5 }, - { color: "#4169E1", pos: 1 }, -]); + { color: "#98FB98", pos: 0 }, + { color: "#7FFFD4", pos: 0.3 }, + { color: "#40E0D0", pos: 0.5 }, + { color: "#4169E1", pos: 1 }, +]) export function printBanner(): void { - const banner = figlet.textSync("ANTIGRAVITY", { - font: "ANSI Shadow", - horizontalLayout: "fitted", - }); + const banner = figlet.textSync("ANTIGRAVITY", { + font: "ANSI Shadow", + horizontalLayout: "fitted", + }) - console.log(antigravityGradient.multiline(banner)); + console.log(antigravityGradient.multiline(banner)) } export function printVersion(): void { - const pkg = getPackageJson(); - console.log(pc.dim(`v${pkg.version} โ€ข Made with ${pc.red("โ™ฅ")}`)); - console.log(); + const pkg = getPackageJson() + console.log(pc.dim(`v${pkg.version} โ€ข Made with ${pc.red("โ™ฅ")}`)) + console.log() } export function printCommand(cmd: string): void { - const boxChar = { - topLeft: "โ”Œ", - topRight: "โ”", - bottomLeft: "โ””", - bottomRight: "โ”˜", - horizontal: "โ”€", - vertical: "โ”‚", - }; + const boxChar = { + topLeft: "โ”Œ", + topRight: "โ”", + bottomLeft: "โ””", + bottomRight: "โ”˜", + horizontal: "โ”€", + vertical: "โ”‚", + } - const padding = 2; - const innerWidth = cmd.length + padding * 2; - const top = `${boxChar.topLeft}${boxChar.horizontal.repeat(innerWidth)}${boxChar.topRight}`; - const bottom = `${boxChar.bottomLeft}${boxChar.horizontal.repeat(innerWidth)}${boxChar.bottomRight}`; - const middle = `${boxChar.vertical}${" ".repeat(padding)}${pc.cyan(cmd)}${" ".repeat(padding)}${boxChar.vertical}`; + const padding = 2 + const innerWidth = cmd.length + padding * 2 + const top = `${boxChar.topLeft}${boxChar.horizontal.repeat(innerWidth)}${boxChar.topRight}` + const bottom = `${boxChar.bottomLeft}${boxChar.horizontal.repeat(innerWidth)}${boxChar.bottomRight}` + const middle = `${boxChar.vertical}${" ".repeat(padding)}${pc.cyan(cmd)}${" ".repeat(padding)}${boxChar.vertical}` - console.log(pc.dim(top)); - console.log(pc.dim(middle)); - console.log(pc.dim(bottom)); - console.log(); + console.log(pc.dim(top)) + console.log(pc.dim(middle)) + console.log(pc.dim(bottom)) + console.log() } export function printHeader(command?: string): void { - console.log(); - printBanner(); - printVersion(); - if (command) { - printCommand(command); - } + console.log() + printBanner() + printVersion() + if (command) { + printCommand(command) + } } export function printSuccess(message: string): void { - console.log(`${pc.green("โœ”")} ${message}`); + console.log(`${pc.green("โœ”")} ${message}`) } export function printError(message: string): void { - console.log(`${pc.red("โœ–")} ${message}`); + console.log(`${pc.red("โœ–")} ${message}`) } export function printInfo(message: string): void { - console.log(`${pc.blue("โ„น")} ${message}`); + console.log(`${pc.blue("โ„น")} ${message}`) } export function printWarning(message: string): void { - console.log(`${pc.yellow("โš ")} ${message}`); + console.log(`${pc.yellow("โš ")} ${message}`) } diff --git a/src/utils/google-api.ts b/src/utils/google-api.ts index 9299266..0cb0758 100644 --- a/src/utils/google-api.ts +++ b/src/utils/google-api.ts @@ -1,6 +1,6 @@ /** * Google API Utilities - * + * * Shared Google API functions for fetching user information. */ @@ -8,18 +8,18 @@ // CONFIGURATION // ============================================ -export const USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; +export const USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" // ============================================ // TYPES // ============================================ export interface UserInfo { - email: string; - name?: string; - given_name?: string; - family_name?: string; - picture?: string; + email: string + name?: string + given_name?: string + family_name?: string + picture?: string } // ============================================ @@ -32,12 +32,12 @@ export interface UserInfo { export async function getUserInfo(accessToken: string): Promise { const response = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` }, - }); + }) if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to get user info: ${errorText}`); + const errorText = await response.text() + throw new Error(`Failed to get user info: ${errorText}`) } - return (await response.json()) as UserInfo; + return (await response.json()) as UserInfo } diff --git a/src/utils/oauth-server.ts b/src/utils/oauth-server.ts index 733f0ac..f47f399 100644 --- a/src/utils/oauth-server.ts +++ b/src/utils/oauth-server.ts @@ -1,29 +1,29 @@ /** * OAuth Server Utilities - * + * * Local HTTP server for OAuth callback and browser opening utilities. - * + * * Original Author: lbjlaq (https://github.com/lbjlaq) * License: CC-BY-NC-SA-4.0 (https://creativecommons.org/licenses/by-nc-sa/4.0/) * Note: This version contains modifications based on the original source code. */ -import * as http from "node:http"; -import * as url from "node:url"; -import { exec } from "node:child_process"; -import * as os from "node:os"; -import { getAuthUrl, exchangeCode, type TokenResponse } from "./oauth.js"; -import { getUserInfo, type UserInfo } from "./google-api.js"; -import { fetchProjectId, fetchQuota, type QuotaResult } from "./quota.js"; +import * as http from "node:http" +import * as url from "node:url" +import { exec } from "node:child_process" +import * as os from "node:os" +import { getAuthUrl, exchangeCode, type TokenResponse } from "./oauth.js" +import { getUserInfo, type UserInfo } from "./google-api.js" +import { fetchProjectId, fetchQuota, type QuotaResult } from "./quota.js" // ============================================ // TYPES // ============================================ export interface OAuthFlowResult { - tokens: TokenResponse & { refresh_token: string }; - userInfo: UserInfo; - quota: QuotaResult; + tokens: TokenResponse & { refresh_token: string } + userInfo: UserInfo + quota: QuotaResult } // ============================================ @@ -34,26 +34,26 @@ export interface OAuthFlowResult { * Open URL in default browser */ export function openBrowser(targetUrl: string): void { - const platform = os.platform(); - let command: string; + const platform = os.platform() + let command: string switch (platform) { case "darwin": - command = `open "${targetUrl}"`; - break; + command = `open "${targetUrl}"` + break case "win32": - command = `start "" "${targetUrl}"`; - break; + command = `start "" "${targetUrl}"` + break default: - command = `xdg-open "${targetUrl}"`; + command = `xdg-open "${targetUrl}"` } exec(command, (error) => { if (error) { - console.error(`โš ๏ธ Could not open browser: ${error.message}`); - console.log(`\n๐Ÿ“‹ Please open this URL manually:\n${targetUrl}\n`); + console.error(`โš ๏ธ Could not open browser: ${error.message}`) + console.log(`\n๐Ÿ“‹ Please open this URL manually:\n${targetUrl}\n`) } - }); + }) } // ============================================ @@ -67,22 +67,22 @@ function startCallbackServer( port: number ): Promise<{ server: http.Server; codePromise: Promise }> { return new Promise((resolve) => { - let resolveCode: (code: string) => void; - let rejectCode: (err: Error) => void; + let resolveCode: (code: string) => void + let rejectCode: (err: Error) => void const codePromise = new Promise((res, rej) => { - resolveCode = res; - rejectCode = rej; - }); + resolveCode = res + rejectCode = rej + }) const server = http.createServer((req, res) => { - const parsedUrl = url.parse(req.url || "", true); + const parsedUrl = url.parse(req.url || "", true) if (parsedUrl.pathname === "/oauth-callback") { - const code = parsedUrl.query["code"] as string | undefined; + const code = parsedUrl.query["code"] as string | undefined if (code) { - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(` @@ -91,10 +91,10 @@ function startCallbackServer( - `); - resolveCode(code); + `) + resolveCode(code) } else { - res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) res.end(` @@ -102,16 +102,16 @@ function startCallbackServer(

Could not get authorization code. Please try again.

- `); - rejectCode(new Error("No authorization code received")); + `) + rejectCode(new Error("No authorization code received")) } } - }); + }) server.listen(port, "127.0.0.1", () => { - resolve({ server, codePromise }); - }); - }); + resolve({ server, codePromise }) + }) + }) } /** @@ -119,58 +119,58 @@ function startCallbackServer( */ export async function startOAuthFlow(): Promise { // Find available port - const port = 8085 + Math.floor(Math.random() * 100); - const redirectUri = `http://127.0.0.1:${port}/oauth-callback`; + const port = 8085 + Math.floor(Math.random() * 100) + const redirectUri = `http://127.0.0.1:${port}/oauth-callback` - console.log("๐Ÿš€ Starting OAuth flow..."); - console.log(`๐Ÿ“ก Callback server listening on port ${port}`); + console.log("๐Ÿš€ Starting OAuth flow...") + console.log(`๐Ÿ“ก Callback server listening on port ${port}`) // Start callback server - const { server, codePromise } = await startCallbackServer(port); + const { server, codePromise } = await startCallbackServer(port) // Generate and open auth URL - const authUrl = getAuthUrl(redirectUri); - console.log("\n๐ŸŒ Opening browser for authorization..."); - console.log(`\n๐Ÿ“‹ If browser doesn't open, visit:\n${authUrl}\n`); - openBrowser(authUrl); + const authUrl = getAuthUrl(redirectUri) + console.log("\n๐ŸŒ Opening browser for authorization...") + console.log(`\n๐Ÿ“‹ If browser doesn't open, visit:\n${authUrl}\n`) + openBrowser(authUrl) try { // Wait for callback - console.log("โณ Waiting for authorization..."); - const code = await codePromise; - console.log("โœ… Authorization code received!"); + console.log("โณ Waiting for authorization...") + const code = await codePromise + console.log("โœ… Authorization code received!") // Exchange code for tokens - console.log("๐Ÿ”„ Exchanging code for tokens..."); - const tokens = await exchangeCode(code, redirectUri); + console.log("๐Ÿ”„ Exchanging code for tokens...") + const tokens = await exchangeCode(code, redirectUri) if (!tokens.refresh_token) { throw new Error( "No refresh_token received. Visit https://myaccount.google.com/permissions to revoke access and try again." - ); + ) } // Get user info - console.log("๐Ÿ‘ค Fetching user info..."); - const userInfo = await getUserInfo(tokens.access_token); - console.log(`โœ… Logged in as: ${userInfo.email}`); + console.log("๐Ÿ‘ค Fetching user info...") + const userInfo = await getUserInfo(tokens.access_token) + console.log(`โœ… Logged in as: ${userInfo.email}`) // Get quota - console.log("๐Ÿ“Š Fetching quota..."); + console.log("๐Ÿ“Š Fetching quota...") const { projectId, subscriptionTier } = await fetchProjectId( tokens.access_token - ); - const quota = await fetchQuota(tokens.access_token, projectId); + ) + const quota = await fetchQuota(tokens.access_token, projectId) if (subscriptionTier) { - quota.subscriptionTier = subscriptionTier; + quota.subscriptionTier = subscriptionTier } return { tokens: tokens as TokenResponse & { refresh_token: string }, userInfo, quota, - }; + } } finally { - server.close(); + server.close() } } diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index 7e1d62b..526631b 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -1,9 +1,9 @@ /** - * + * * OAuth Authentication Utilities - * + * * Handles Google OAuth token management for Antigravity accounts. - * + * * Original Author: lbjlaq (https://github.com/lbjlaq) * License: CC-BY-NC-SA-4.0 (https://creativecommons.org/licenses/by-nc-sa/4.0/) * Note: This version contains modifications based on the original source code. @@ -14,10 +14,10 @@ // ============================================ export const CLIENT_ID = - "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; -export const CLIENT_SECRET = "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"; -export const TOKEN_URL = "https://oauth2.googleapis.com/token"; -export const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; + "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" +export const CLIENT_SECRET = "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" +export const TOKEN_URL = "https://oauth2.googleapis.com/token" +export const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" // OAuth scopes export const SCOPES = [ @@ -26,17 +26,17 @@ export const SCOPES = [ "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/cclog", "https://www.googleapis.com/auth/experimentsandconfigs", -]; +] // ============================================ // TYPES // ============================================ export interface TokenResponse { - access_token: string; - expires_in: number; - token_type?: string; - refresh_token?: string; + access_token: string + expires_in: number + token_type?: string + refresh_token?: string } // ============================================ @@ -55,9 +55,9 @@ export function getAuthUrl(redirectUri: string): string { access_type: "offline", prompt: "consent", include_granted_scopes: "true", - }); + }) - return `${AUTH_URL}?${params.toString()}`; + return `${AUTH_URL}?${params.toString()}` } /** @@ -73,28 +73,28 @@ export async function exchangeCode( code: code, redirect_uri: redirectUri, grant_type: "authorization_code", - }); + }) const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString(), - }); + }) if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Token exchange failed: ${errorText}`); + const errorText = await response.text() + throw new Error(`Token exchange failed: ${errorText}`) } - const tokenRes = (await response.json()) as TokenResponse; + const tokenRes = (await response.json()) as TokenResponse if (!tokenRes.refresh_token) { console.warn( "โš ๏ธ Warning: No refresh_token returned. You may need to revoke access at https://myaccount.google.com/permissions" - ); + ) } - return tokenRes; + return tokenRes } /** @@ -108,18 +108,18 @@ export async function refreshAccessToken( client_secret: CLIENT_SECRET, refresh_token: refreshToken, grant_type: "refresh_token", - }); + }) const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString(), - }); + }) if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Token refresh failed: ${errorText}`); + const errorText = await response.text() + throw new Error(`Token refresh failed: ${errorText}`) } - return (await response.json()) as TokenResponse; + return (await response.json()) as TokenResponse } diff --git a/src/utils/profile-manager.ts b/src/utils/profile-manager.ts index 95504a7..c9c8707 100644 --- a/src/utils/profile-manager.ts +++ b/src/utils/profile-manager.ts @@ -1,417 +1,417 @@ import { - type FSWatcher, - cpSync, - existsSync, - lstatSync, - mkdirSync, - readdirSync, - renameSync, - rmSync, - statSync, - watch, -} from "node:fs"; -import { homedir, platform } from "node:os"; -import { join } from "node:path"; -import type { AuthSessionData, Profile } from "../types/auth.js"; + type FSWatcher, + cpSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + statSync, + watch, +} from "node:fs" +import { homedir, platform } from "node:os" +import { join } from "node:path" +import type { AuthSessionData, Profile } from "../types/auth.js" import { - CLEANUP_DIRECTORIES, - PENDING_SESSION_NAME, - PROFILES_DIR_NAME, - STATE_DB_PATH, -} from "../types/auth.js"; -import { getFirstAuthSession } from "./sqlite-reader.js"; -import { isActiveProfile, setActiveProfile } from "./symlink-manager.js"; + CLEANUP_DIRECTORIES, + PENDING_SESSION_NAME, + PROFILES_DIR_NAME, + STATE_DB_PATH, +} from "../types/auth.js" +import { getFirstAuthSession } from "./sqlite-reader.js" +import { isActiveProfile, setActiveProfile } from "./symlink-manager.js" function getBaseDir(): string { - return join(homedir(), ".antigravity-kit"); + return join(homedir(), ".antigravity-kit") } export function getProfilesDir(): string { - return join(getBaseDir(), PROFILES_DIR_NAME); + return join(getBaseDir(), PROFILES_DIR_NAME) } export function getDefaultAntigravityDataDir(): string { - const os = platform(); - if (os === "darwin") { - return join(homedir(), "Library", "Application Support", "Antigravity"); - } - if (os === "win32") { - return join( - process.env["APPDATA"] || join(homedir(), "AppData", "Roaming"), - "Antigravity", - ); - } - // Linux - return join(homedir(), ".config", "Antigravity"); + const os = platform() + if (os === "darwin") { + return join(homedir(), "Library", "Application Support", "Antigravity") + } + if (os === "win32") { + return join( + process.env["APPDATA"] || join(homedir(), "AppData", "Roaming"), + "Antigravity" + ) + } + // Linux + return join(homedir(), ".config", "Antigravity") } export function getDefaultStateDbPath(): string { - return join(getDefaultAntigravityDataDir(), STATE_DB_PATH); + return join(getDefaultAntigravityDataDir(), STATE_DB_PATH) } export function ensureProfilesDir(): void { - const profilesDir = getProfilesDir(); - if (!existsSync(profilesDir)) { - mkdirSync(profilesDir, { recursive: true }); - } + const profilesDir = getProfilesDir() + if (!existsSync(profilesDir)) { + mkdirSync(profilesDir, { recursive: true }) + } } export function getPendingProfilePath(): string { - return join(getProfilesDir(), PENDING_SESSION_NAME); + return join(getProfilesDir(), PENDING_SESSION_NAME) } export function getStateDbPath(profilePath: string): string { - return join(profilePath, STATE_DB_PATH); + return join(profilePath, STATE_DB_PATH) } export function createPendingProfile(): string { - ensureProfilesDir(); - const pendingPath = getPendingProfilePath(); + ensureProfilesDir() + const pendingPath = getPendingProfilePath() - if (existsSync(pendingPath)) { - rmSync(pendingPath, { recursive: true, force: true }); - } + if (existsSync(pendingPath)) { + rmSync(pendingPath, { recursive: true, force: true }) + } - mkdirSync(pendingPath, { recursive: true }); - return pendingPath; + mkdirSync(pendingPath, { recursive: true }) + return pendingPath } export function cleanupProfile(profilePath: string): void { - for (const dirName of CLEANUP_DIRECTORIES) { - const dirPath = join(profilePath, dirName); - if (existsSync(dirPath)) { - try { - rmSync(dirPath, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - } - } + for (const dirName of CLEANUP_DIRECTORIES) { + const dirPath = join(profilePath, dirName) + if (existsSync(dirPath)) { + try { + rmSync(dirPath, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } + } + } } export function finalizeProfile(pendingPath: string, email: string): string { - const safeEmail = email.replace(/[^a-zA-Z0-9@._-]/g, "_"); - const finalPath = join(getProfilesDir(), safeEmail); + const safeEmail = email.replace(/[^a-zA-Z0-9@._-]/g, "_") + const finalPath = join(getProfilesDir(), safeEmail) - if (existsSync(finalPath)) { - rmSync(finalPath, { recursive: true, force: true }); - } + if (existsSync(finalPath)) { + rmSync(finalPath, { recursive: true, force: true }) + } - renameSync(pendingPath, finalPath); - return finalPath; + renameSync(pendingPath, finalPath) + return finalPath } export function getProfileSize(profilePath: string): number { - let totalSize = 0; - - function calculateSize(dirPath: string): void { - try { - const entries = readdirSync(dirPath, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dirPath, entry.name); - if (entry.isDirectory()) { - calculateSize(fullPath); - } else if (entry.isFile()) { - totalSize += statSync(fullPath).size; - } - } - } catch { - // Ignore errors - } - } - - calculateSize(profilePath); - return totalSize; + let totalSize = 0 + + function calculateSize(dirPath: string): void { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + calculateSize(fullPath) + } else if (entry.isFile()) { + totalSize += statSync(fullPath).size + } + } + } catch { + // Ignore errors + } + } + + calculateSize(profilePath) + return totalSize } export function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` } export function listProfiles(): Profile[] { - ensureProfilesDir(); - const profilesDir = getProfilesDir(); - const profiles: Profile[] = []; - - try { - const entries = readdirSync(profilesDir, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === PENDING_SESSION_NAME) continue; - - const profilePath = join(profilesDir, entry.name); - const stats = statSync(profilePath); - - profiles.push({ - id: entry.name, - email: entry.name, - name: entry.name, - profilePath, - createdAt: stats.birthtimeMs, - updatedAt: stats.mtimeMs, - }); - } - } catch { - // Return empty array on error - } - - return profiles; + ensureProfilesDir() + const profilesDir = getProfilesDir() + const profiles: Profile[] = [] + + try { + const entries = readdirSync(profilesDir, { withFileTypes: true }) + + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (entry.name === PENDING_SESSION_NAME) continue + + const profilePath = join(profilesDir, entry.name) + const stats = statSync(profilePath) + + profiles.push({ + id: entry.name, + email: entry.name, + name: entry.name, + profilePath, + createdAt: stats.birthtimeMs, + updatedAt: stats.mtimeMs, + }) + } + } catch { + // Return empty array on error + } + + return profiles } export function getProfileByEmail(email: string): Profile | null { - const profiles = listProfiles(); - return profiles.find((p) => p.email === email) ?? null; + const profiles = listProfiles() + return profiles.find((p) => p.email === email) ?? null } export function removeProfile(profilePath: string): boolean { - try { - if (existsSync(profilePath)) { - rmSync(profilePath, { recursive: true, force: true }); - return true; - } - return false; - } catch { - return false; - } + try { + if (existsSync(profilePath)) { + rmSync(profilePath, { recursive: true, force: true }) + return true + } + return false + } catch { + return false + } } export interface WatchResult { - session: AuthSessionData; + session: AuthSessionData } export function watchForAuth(timeoutMs = 300000): Promise { - return new Promise((resolve, reject) => { - // Watch the DEFAULT Antigravity data directory, not custom profile - // because Antigravity doesn't respect --user-data-dir argument - const dbPath = getDefaultStateDbPath(); - const dbDir = join(getDefaultAntigravityDataDir(), "User", "globalStorage"); - let watcher: FSWatcher | null = null; - let checkInterval: NodeJS.Timeout | null = null; - let timeoutId: NodeJS.Timeout | null = null; - let initialAuthState: AuthSessionData | null = null; - - const cleanup = () => { - if (watcher) { - watcher.close(); - watcher = null; - } - if (checkInterval) { - clearInterval(checkInterval); - checkInterval = null; - } - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - }; - - // Capture initial auth state to detect NEW logins - if (existsSync(dbPath)) { - initialAuthState = getFirstAuthSession(dbPath); - } - - const checkForAuth = () => { - if (!existsSync(dbPath)) { - return false; - } - - const session = getFirstAuthSession(dbPath); - if (session) { - // Check if this is a NEW login (different from initial state) - // or if there was no initial auth - const isNewLogin = - !initialAuthState || session.email !== initialAuthState.email; - - if (isNewLogin) { - cleanup(); - resolve({ session }); - return true; - } - } - return false; - }; - - // Set timeout - timeoutId = setTimeout(() => { - cleanup(); - reject(new Error("Authentication timeout - no login detected")); - }, timeoutMs); - - // Periodic check (main mechanism since file watching can be unreliable) - checkInterval = setInterval(() => { - checkForAuth(); - }, 2000); - - // Watch for file changes as backup - const startWatcher = () => { - try { - if (existsSync(dbDir)) { - watcher = watch(dbDir, (_eventType, filename) => { - if (filename === "state.vscdb") { - checkForAuth(); - } - }); - } - } catch { - // Fall back to interval checking only - } - }; - - // Start watching after a short delay - setTimeout(startWatcher, 1000); - }); + return new Promise((resolve, reject) => { + // Watch the DEFAULT Antigravity data directory, not custom profile + // because Antigravity doesn't respect --user-data-dir argument + const dbPath = getDefaultStateDbPath() + const dbDir = join(getDefaultAntigravityDataDir(), "User", "globalStorage") + let watcher: FSWatcher | null = null + let checkInterval: NodeJS.Timeout | null = null + let timeoutId: NodeJS.Timeout | null = null + let initialAuthState: AuthSessionData | null = null + + const cleanup = () => { + if (watcher) { + watcher.close() + watcher = null + } + if (checkInterval) { + clearInterval(checkInterval) + checkInterval = null + } + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + } + + // Capture initial auth state to detect NEW logins + if (existsSync(dbPath)) { + initialAuthState = getFirstAuthSession(dbPath) + } + + const checkForAuth = () => { + if (!existsSync(dbPath)) { + return false + } + + const session = getFirstAuthSession(dbPath) + if (session) { + // Check if this is a NEW login (different from initial state) + // or if there was no initial auth + const isNewLogin = + !initialAuthState || session.email !== initialAuthState.email + + if (isNewLogin) { + cleanup() + resolve({ session }) + return true + } + } + return false + } + + // Set timeout + timeoutId = setTimeout(() => { + cleanup() + reject(new Error("Authentication timeout - no login detected")) + }, timeoutMs) + + // Periodic check (main mechanism since file watching can be unreliable) + checkInterval = setInterval(() => { + checkForAuth() + }, 2000) + + // Watch for file changes as backup + const startWatcher = () => { + try { + if (existsSync(dbDir)) { + watcher = watch(dbDir, (_eventType, filename) => { + if (filename === "state.vscdb") { + checkForAuth() + } + }) + } + } catch { + // Fall back to interval checking only + } + } + + // Start watching after a short delay + setTimeout(startWatcher, 1000) + }) } export function createEmptyProfile(email: string): string { - const safeEmail = email.replace(/[^a-zA-Z0-9@._-]/g, "_"); - const profilePath = join(getProfilesDir(), safeEmail); + const safeEmail = email.replace(/[^a-zA-Z0-9@._-]/g, "_") + const profilePath = join(getProfilesDir(), safeEmail) - ensureProfilesDir(); + ensureProfilesDir() - // Remove existing profile if exists - if (existsSync(profilePath)) { - rmSync(profilePath, { recursive: true, force: true }); - } + // Remove existing profile if exists + if (existsSync(profilePath)) { + rmSync(profilePath, { recursive: true, force: true }) + } - // Create profile directory structure needed for Antigravity - mkdirSync(profilePath, { recursive: true }); - mkdirSync(join(profilePath, "User"), { recursive: true }); - mkdirSync(join(profilePath, "User", "globalStorage"), { recursive: true }); + // Create profile directory structure needed for Antigravity + mkdirSync(profilePath, { recursive: true }) + mkdirSync(join(profilePath, "User"), { recursive: true }) + mkdirSync(join(profilePath, "User", "globalStorage"), { recursive: true }) - return profilePath; + return profilePath } export function copyDefaultProfileToStorage(email: string): string { - const defaultDataDir = getDefaultAntigravityDataDir(); - const safeEmail = email.replace(/[^a-zA-Z0-9@._-]/g, "_"); - const profilePath = join(getProfilesDir(), safeEmail); - - ensureProfilesDir(); - - // Remove existing profile if exists - if (existsSync(profilePath)) { - rmSync(profilePath, { recursive: true, force: true }); - } - - // Copy the entire default Antigravity data directory - // Filter out socket files and other special files that cannot be copied - cpSync(defaultDataDir, profilePath, { - recursive: true, - filter: (source) => { - try { - const stats = lstatSync(source); - // Skip socket files, FIFOs, and device files (only copy files, directories, and symlinks) - if ( - stats.isSocket() || - stats.isFIFO() || - stats.isBlockDevice() || - stats.isCharacterDevice() - ) { - return false; - } - return true; - } catch { - // If we can't stat the file, skip it to be safe - return false; - } - }, - }); - - // Clean up cache directories to save space - cleanupProfile(profilePath); - - return profilePath; + const defaultDataDir = getDefaultAntigravityDataDir() + const safeEmail = email.replace(/[^a-zA-Z0-9@._-]/g, "_") + const profilePath = join(getProfilesDir(), safeEmail) + + ensureProfilesDir() + + // Remove existing profile if exists + if (existsSync(profilePath)) { + rmSync(profilePath, { recursive: true, force: true }) + } + + // Copy the entire default Antigravity data directory + // Filter out socket files and other special files that cannot be copied + cpSync(defaultDataDir, profilePath, { + recursive: true, + filter: (source) => { + try { + const stats = lstatSync(source) + // Skip socket files, FIFOs, and device files (only copy files, directories, and symlinks) + if ( + stats.isSocket() || + stats.isFIFO() || + stats.isBlockDevice() || + stats.isCharacterDevice() + ) { + return false + } + return true + } catch { + // If we can't stat the file, skip it to be safe + return false + } + }, + }) + + // Clean up cache directories to save space + cleanupProfile(profilePath) + + return profilePath } export class AntigravityRunningError extends Error { - constructor(message: string) { - super(message); - this.name = "AntigravityRunningError"; - } + constructor(message: string) { + super(message) + this.name = "AntigravityRunningError" + } } export function restoreProfileToDefault(profilePath: string): boolean { - const defaultDataDir = getDefaultAntigravityDataDir(); - - if (!existsSync(profilePath)) { - return false; - } - - // Remove existing default data directory - if (existsSync(defaultDataDir)) { - try { - rmSync(defaultDataDir, { recursive: true, force: true }); - } catch (error) { - if ( - error instanceof Error && - (error.message.includes("ENOTEMPTY") || - error.message.includes("EBUSY") || - error.message.includes("EPERM")) - ) { - throw new AntigravityRunningError( - "Cannot switch profile while Antigravity is running. Please close Antigravity and try again.", - ); - } - throw error; - } - } - - // Copy profile data to the default Antigravity data directory - // Filter out socket files and other special files that cannot be copied - cpSync(profilePath, defaultDataDir, { - recursive: true, - filter: (source) => { - try { - const stats = lstatSync(source); - // Skip socket files, FIFOs, and device files (only copy files, directories, and symlinks) - if ( - stats.isSocket() || - stats.isFIFO() || - stats.isBlockDevice() || - stats.isCharacterDevice() - ) { - return false; - } - return true; - } catch { - // If we can't stat the file, skip it to be safe - return false; - } - }, - }); - - return true; + const defaultDataDir = getDefaultAntigravityDataDir() + + if (!existsSync(profilePath)) { + return false + } + + // Remove existing default data directory + if (existsSync(defaultDataDir)) { + try { + rmSync(defaultDataDir, { recursive: true, force: true }) + } catch (error) { + if ( + error instanceof Error && + (error.message.includes("ENOTEMPTY") || + error.message.includes("EBUSY") || + error.message.includes("EPERM")) + ) { + throw new AntigravityRunningError( + "Cannot switch profile while Antigravity is running. Please close Antigravity and try again." + ) + } + throw error + } + } + + // Copy profile data to the default Antigravity data directory + // Filter out socket files and other special files that cannot be copied + cpSync(profilePath, defaultDataDir, { + recursive: true, + filter: (source) => { + try { + const stats = lstatSync(source) + // Skip socket files, FIFOs, and device files (only copy files, directories, and symlinks) + if ( + stats.isSocket() || + stats.isFIFO() || + stats.isBlockDevice() || + stats.isCharacterDevice() + ) { + return false + } + return true + } catch { + // If we can't stat the file, skip it to be safe + return false + } + }, + }) + + return true } export function generateProfileId(): string { - return `profile_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + return `profile_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` } export function addProfileAndSetActive( - email: string, - profilePath: string, + email: string, + profilePath: string ): Profile { - const profile: Profile = { - id: generateProfileId(), - email, - name: email, - profilePath, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - - setActiveProfile(profilePath); - - return profile; + const profile: Profile = { + id: generateProfileId(), + email, + name: email, + profilePath, + createdAt: Date.now(), + updatedAt: Date.now(), + } + + setActiveProfile(profilePath) + + return profile } export function getActiveProfileEmail(): string | null { - const profiles = listProfiles(); - const activeProfile = profiles.find((p) => isActiveProfile(p.profilePath)); - return activeProfile?.email ?? null; + const profiles = listProfiles() + const activeProfile = profiles.find((p) => isActiveProfile(p.profilePath)) + return activeProfile?.email ?? null } diff --git a/src/utils/quota.ts b/src/utils/quota.ts index 71ddb61..a4972df 100644 --- a/src/utils/quota.ts +++ b/src/utils/quota.ts @@ -1,46 +1,46 @@ /** * Quota Fetching Utilities - * + * * Handles fetching quota information from Google Cloud Code API. - * + * * Original Author: lbjlaq (https://github.com/lbjlaq) * License: CC-BY-NC-SA-4.0 (https://creativecommons.org/licenses/by-nc-sa/4.0/) * Note: This version contains modifications based on the original source code. */ -import { refreshAccessToken } from "./oauth.js"; -import { getUserInfo } from "./google-api.js"; +import { refreshAccessToken } from "./oauth.js" +import { getUserInfo } from "./google-api.js" // ============================================ // CONFIGURATION // ============================================ -export const CLOUD_CODE_BASE_URL = "https://cloudcode-pa.googleapis.com"; -export const QUOTA_API_URL = `${CLOUD_CODE_BASE_URL}/v1internal:fetchAvailableModels`; -export const LOAD_CODE_ASSIST_URL = `${CLOUD_CODE_BASE_URL}/v1internal:loadCodeAssist`; -export const USER_AGENT = "antigravity/1.11.3 Darwin/arm64"; +export const CLOUD_CODE_BASE_URL = "https://cloudcode-pa.googleapis.com" +export const QUOTA_API_URL = `${CLOUD_CODE_BASE_URL}/v1internal:fetchAvailableModels` +export const LOAD_CODE_ASSIST_URL = `${CLOUD_CODE_BASE_URL}/v1internal:loadCodeAssist` +export const USER_AGENT = "antigravity/1.11.3 Darwin/arm64" // ============================================ // TYPES // ============================================ export interface ModelQuota { - name: string; - percentage: number; - resetTime: string; + name: string + percentage: number + resetTime: string } export interface QuotaResult { - projectId?: string | undefined; - subscriptionTier?: string | undefined; - models: ModelQuota[]; - isForbidden?: boolean | undefined; + projectId?: string | undefined + subscriptionTier?: string | undefined + models: ModelQuota[] + isForbidden?: boolean | undefined } interface LoadProjectResponse { - cloudaicompanionProject?: string; - currentTier?: { id?: string; quotaTier?: string; name?: string }; - paidTier?: { id?: string; quotaTier?: string; name?: string }; + cloudaicompanionProject?: string + currentTier?: { id?: string; quotaTier?: string; name?: string } + paidTier?: { id?: string; quotaTier?: string; name?: string } } interface QuotaApiResponse { @@ -48,11 +48,11 @@ interface QuotaApiResponse { string, { quotaInfo?: { - remainingFraction?: number; - resetTime?: string; - }; + remainingFraction?: number + resetTime?: string + } } - >; + > } // ============================================ @@ -64,7 +64,10 @@ interface QuotaApiResponse { */ export async function fetchProjectId( accessToken: string -): Promise<{ projectId?: string | undefined; subscriptionTier?: string | undefined }> { +): Promise<{ + projectId?: string | undefined + subscriptionTier?: string | undefined +}> { const response = await fetch(LOAD_CODE_ASSIST_URL, { method: "POST", headers: { @@ -73,22 +76,22 @@ export async function fetchProjectId( "User-Agent": "antigravity/windows/amd64", }, body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }), - }); + }) if (!response.ok) { - console.warn(`โš ๏ธ loadCodeAssist failed: ${response.status}`); - return {}; + console.warn(`โš ๏ธ loadCodeAssist failed: ${response.status}`) + return {} } - const data = (await response.json()) as LoadProjectResponse; + const data = (await response.json()) as LoadProjectResponse // Priority: paid_tier > current_tier - const subscriptionTier = data.paidTier?.id || data.currentTier?.id; + const subscriptionTier = data.paidTier?.id || data.currentTier?.id return { projectId: data.cloudaicompanionProject ?? undefined, subscriptionTier: subscriptionTier ?? undefined, - }; + } } /** @@ -98,7 +101,7 @@ export async function fetchQuota( accessToken: string, projectId?: string ): Promise { - const finalProjectId = projectId || "bamboo-precept-lgxtn"; + const finalProjectId = projectId || "bamboo-precept-lgxtn" const response = await fetch(QUOTA_API_URL, { method: "POST", @@ -108,27 +111,27 @@ export async function fetchQuota( "User-Agent": USER_AGENT, }, body: JSON.stringify({ project: finalProjectId }), - }); + }) // Handle 403 Forbidden if (response.status === 403) { - console.warn("โš ๏ธ Account forbidden (403)"); - return { models: [], isForbidden: true }; + console.warn("โš ๏ธ Account forbidden (403)") + return { models: [], isForbidden: true } } if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Quota API error: ${response.status} - ${errorText}`); + const errorText = await response.text() + throw new Error(`Quota API error: ${response.status} - ${errorText}`) } - const data = (await response.json()) as QuotaApiResponse; - const models: ModelQuota[] = []; + const data = (await response.json()) as QuotaApiResponse + const models: ModelQuota[] = [] for (const [name, info] of Object.entries(data.models)) { if (info.quotaInfo) { const percentage = info.quotaInfo.remainingFraction ? Math.round(info.quotaInfo.remainingFraction * 100) - : 0; + : 0 // Include all gemini and claude models if (name.includes("gemini") || name.includes("claude")) { @@ -136,12 +139,12 @@ export async function fetchQuota( name, percentage, resetTime: info.quotaInfo.resetTime || "", - }); + }) } } } - return { models, projectId: finalProjectId }; + return { models, projectId: finalProjectId } } /** @@ -151,27 +154,28 @@ export async function getQuotaWithRefresh( refreshToken: string ): Promise { // 1. Refresh access token - const tokenResponse = await refreshAccessToken(refreshToken); - const accessToken = tokenResponse.access_token; + const tokenResponse = await refreshAccessToken(refreshToken) + const accessToken = tokenResponse.access_token // 2. Get user info - let email: string | undefined; + let email: string | undefined try { - const userInfo = await getUserInfo(accessToken); - email = userInfo.email; + const userInfo = await getUserInfo(accessToken) + email = userInfo.email } catch { - console.warn("โš ๏ธ Could not fetch user info"); + console.warn("โš ๏ธ Could not fetch user info") } // 3. Get project ID and subscription tier - const { projectId, subscriptionTier } = await fetchProjectId(accessToken); + const { projectId, subscriptionTier } = await fetchProjectId(accessToken) // 4. Fetch quota - const quotaResult = await fetchQuota(accessToken, projectId); + const quotaResult = await fetchQuota(accessToken, projectId) return { ...quotaResult, email: email ?? undefined, - subscriptionTier: subscriptionTier ?? quotaResult.subscriptionTier ?? undefined, - }; + subscriptionTier: + subscriptionTier ?? quotaResult.subscriptionTier ?? undefined, + } } diff --git a/src/utils/sqlite-reader.ts b/src/utils/sqlite-reader.ts index 99f86b4..0d2327d 100644 --- a/src/utils/sqlite-reader.ts +++ b/src/utils/sqlite-reader.ts @@ -1,232 +1,234 @@ -import { existsSync, mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import Database from "better-sqlite3"; -import type { AuthSessionData } from "../types/auth.js"; +import { existsSync, mkdirSync } from "node:fs" +import { dirname } from "node:path" +import Database from "better-sqlite3" +import type { AuthSessionData } from "../types/auth.js" export interface AntigravityAuthStatus { - name?: string; - email?: string; - apiKey?: string; - userStatusProtoBinaryBase64?: string; + name?: string + email?: string + apiKey?: string + userStatusProtoBinaryBase64?: string } export function readAuthSessions(dbPath: string): AuthSessionData[] { - try { - const db = new Database(dbPath, { readonly: true }); + try { + const db = new Database(dbPath, { readonly: true }) - // Antigravity stores auth in 'antigravityAuthStatus' key - const query = ` + // Antigravity stores auth in 'antigravityAuthStatus' key + const query = ` SELECT key, value FROM ItemTable WHERE key = 'antigravityAuthStatus' OR key LIKE '%authentication.sessions%' OR key LIKE '%google.auth%' OR key LIKE '%auth.sessions%' - `; - - const rows = db.prepare(query).all() as Array<{ - key: string; - value: string; - }>; - db.close(); - - const sessions: AuthSessionData[] = []; - - for (const row of rows) { - // Handle Antigravity-specific auth format - if (row.key === "antigravityAuthStatus") { - const authData = parseAntigravityAuth(row.value); - if (authData) { - sessions.push(authData); - } - } else { - // Fallback for other auth formats - const parsed = parseAuthSession(row.value); - if (parsed) { - sessions.push(...parsed); - } - } - } - - return sessions; - } catch { - return []; - } + ` + + const rows = db.prepare(query).all() as Array<{ + key: string + value: string + }> + db.close() + + const sessions: AuthSessionData[] = [] + + for (const row of rows) { + // Handle Antigravity-specific auth format + if (row.key === "antigravityAuthStatus") { + const authData = parseAntigravityAuth(row.value) + if (authData) { + sessions.push(authData) + } + } else { + // Fallback for other auth formats + const parsed = parseAuthSession(row.value) + if (parsed) { + sessions.push(...parsed) + } + } + } + + return sessions + } catch { + return [] + } } function parseAntigravityAuth(jsonValue: string): AuthSessionData | null { - try { - const parsed = JSON.parse(jsonValue) as AntigravityAuthStatus; - - if (parsed.email) { - const result: AuthSessionData = { - email: parsed.email, - accountId: parsed.email, - sessionId: parsed.apiKey?.substring(0, 20) || "antigravity", - }; - - if (parsed.name) { - result.name = parsed.name; - } - - return result; - } - - return null; - } catch { - return null; - } + try { + const parsed = JSON.parse(jsonValue) as AntigravityAuthStatus + + if (parsed.email) { + const result: AuthSessionData = { + email: parsed.email, + accountId: parsed.email, + sessionId: parsed.apiKey?.substring(0, 20) || "antigravity", + } + + if (parsed.name) { + result.name = parsed.name + } + + return result + } + + return null + } catch { + return null + } } export function parseAuthSession(jsonValue: string): AuthSessionData[] | null { - try { - const parsed = JSON.parse(jsonValue); - const sessions: AuthSessionData[] = []; - - if (Array.isArray(parsed)) { - for (const item of parsed) { - const sessionData = extractSessionData(item); - if (sessionData) { - sessions.push(sessionData); - } - } - } else if (typeof parsed === "object" && parsed !== null) { - const sessionData = extractSessionData(parsed); - if (sessionData) { - sessions.push(sessionData); - } - } - - return sessions.length > 0 ? sessions : null; - } catch { - return null; - } + try { + const parsed = JSON.parse(jsonValue) + const sessions: AuthSessionData[] = [] + + if (Array.isArray(parsed)) { + for (const item of parsed) { + const sessionData = extractSessionData(item) + if (sessionData) { + sessions.push(sessionData) + } + } + } else if (typeof parsed === "object" && parsed !== null) { + const sessionData = extractSessionData(parsed) + if (sessionData) { + sessions.push(sessionData) + } + } + + return sessions.length > 0 ? sessions : null + } catch { + return null + } } interface AuthAccount { - label?: string; - id?: string; + label?: string + id?: string } interface AuthSessionItem { - id?: string; - account?: AuthAccount; + id?: string + account?: AuthAccount } function extractSessionData(item: unknown): AuthSessionData | null { - if (typeof item !== "object" || item === null) { - return null; - } + if (typeof item !== "object" || item === null) { + return null + } - const session = item as AuthSessionItem; + const session = item as AuthSessionItem - if (session.account?.label && session.account?.id) { - return { - email: session.account.label, - accountId: session.account.id, - sessionId: session.id || "unknown", - }; - } + if (session.account?.label && session.account?.id) { + return { + email: session.account.label, + accountId: session.account.id, + sessionId: session.id || "unknown", + } + } - return null; + return null } export function hasAuthSessions(dbPath: string): boolean { - const sessions = readAuthSessions(dbPath); - return sessions.length > 0; + const sessions = readAuthSessions(dbPath) + return sessions.length > 0 } export function getFirstAuthSession(dbPath: string): AuthSessionData | null { - const sessions = readAuthSessions(dbPath); - return sessions[0] ?? null; + const sessions = readAuthSessions(dbPath) + return sessions[0] ?? null } function ensureDbDirectory(dbPath: string): void { - const dir = dirname(dbPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } + const dir = dirname(dbPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } } function ensureItemTable(db: Database.Database): void { - db.exec(` + db.exec(` CREATE TABLE IF NOT EXISTS ItemTable ( key TEXT PRIMARY KEY, value TEXT ) - `); + `) } export function writeAntigravityAuthStatus( - dbPath: string, - authStatus: AntigravityAuthStatus, + dbPath: string, + authStatus: AntigravityAuthStatus ): boolean { - try { - ensureDbDirectory(dbPath); + try { + ensureDbDirectory(dbPath) - const db = new Database(dbPath); - ensureItemTable(db); + const db = new Database(dbPath) + ensureItemTable(db) - const jsonValue = JSON.stringify(authStatus); + const jsonValue = JSON.stringify(authStatus) - const stmt = db.prepare(` + const stmt = db.prepare(` INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('antigravityAuthStatus', ?) - `); + `) - stmt.run(jsonValue); - db.close(); + stmt.run(jsonValue) + db.close() - return true; - } catch { - return false; - } + return true + } catch { + return false + } } export function clearAntigravityAuthStatus(dbPath: string): boolean { - try { - if (!existsSync(dbPath)) { - return true; - } + try { + if (!existsSync(dbPath)) { + return true + } - const db = new Database(dbPath); + const db = new Database(dbPath) - const stmt = db.prepare(` + const stmt = db.prepare(` DELETE FROM ItemTable WHERE key = 'antigravityAuthStatus' - `); + `) - stmt.run(); - db.close(); + stmt.run() + db.close() - return true; - } catch { - return false; - } + return true + } catch { + return false + } } export function readAntigravityAuthStatus( - dbPath: string, + dbPath: string ): AntigravityAuthStatus | null { - try { - if (!existsSync(dbPath)) { - return null; - } + try { + if (!existsSync(dbPath)) { + return null + } - const db = new Database(dbPath, { readonly: true }); + const db = new Database(dbPath, { readonly: true }) - const row = db - .prepare(` + const row = db + .prepare( + ` SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus' - `) - .get() as { value: string } | undefined; + ` + ) + .get() as { value: string } | undefined - db.close(); + db.close() - if (!row) { - return null; - } + if (!row) { + return null + } - return JSON.parse(row.value) as AntigravityAuthStatus; - } catch { - return null; - } + return JSON.parse(row.value) as AntigravityAuthStatus + } catch { + return null + } } diff --git a/src/utils/symlink-manager.ts b/src/utils/symlink-manager.ts index a69e538..301cd3a 100644 --- a/src/utils/symlink-manager.ts +++ b/src/utils/symlink-manager.ts @@ -1,85 +1,85 @@ import { - existsSync, - lstatSync, - readlinkSync, - rmSync, - symlinkSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; -import { ACTIVE_SYMLINK_NAME } from "../types/auth.js"; + existsSync, + lstatSync, + readlinkSync, + rmSync, + symlinkSync, +} from "node:fs" +import { homedir } from "node:os" +import { basename, join } from "node:path" +import { ACTIVE_SYMLINK_NAME } from "../types/auth.js" function getBaseDir(): string { - return join(homedir(), ".antigravity-kit"); + return join(homedir(), ".antigravity-kit") } export function getActiveSymlinkPath(): string { - return join(getBaseDir(), ACTIVE_SYMLINK_NAME); + return join(getBaseDir(), ACTIVE_SYMLINK_NAME) } export function setActiveProfile(profilePath: string): boolean { - try { - const symlinkPath = getActiveSymlinkPath(); + try { + const symlinkPath = getActiveSymlinkPath() - if (existsSync(symlinkPath)) { - rmSync(symlinkPath, { recursive: true, force: true }); - } + if (existsSync(symlinkPath)) { + rmSync(symlinkPath, { recursive: true, force: true }) + } - symlinkSync(profilePath, symlinkPath); - return true; - } catch (error) { - console.error("Failed to set active profile:", error); - return false; - } + symlinkSync(profilePath, symlinkPath) + return true + } catch (error) { + console.error("Failed to set active profile:", error) + return false + } } export function getActiveProfilePath(): string | null { - try { - const symlinkPath = getActiveSymlinkPath(); + try { + const symlinkPath = getActiveSymlinkPath() - if (!existsSync(symlinkPath)) { - return null; - } + if (!existsSync(symlinkPath)) { + return null + } - const stats = lstatSync(symlinkPath); - if (!stats.isSymbolicLink()) { - return null; - } + const stats = lstatSync(symlinkPath) + if (!stats.isSymbolicLink()) { + return null + } - const target = readlinkSync(symlinkPath); - return target; - } catch { - return null; - } + const target = readlinkSync(symlinkPath) + return target + } catch { + return null + } } export function getActiveProfileName(): string | null { - const profilePath = getActiveProfilePath(); - if (!profilePath) { - return null; - } - return basename(profilePath); + const profilePath = getActiveProfilePath() + if (!profilePath) { + return null + } + return basename(profilePath) } export function isActiveProfile(profilePath: string): boolean { - const activeProfilePath = getActiveProfilePath(); - if (!activeProfilePath) { - return false; - } - return ( - activeProfilePath === profilePath || - basename(activeProfilePath) === basename(profilePath) - ); + const activeProfilePath = getActiveProfilePath() + if (!activeProfilePath) { + return false + } + return ( + activeProfilePath === profilePath || + basename(activeProfilePath) === basename(profilePath) + ) } export function removeActiveSymlink(): boolean { - try { - const symlinkPath = getActiveSymlinkPath(); - if (existsSync(symlinkPath)) { - rmSync(symlinkPath, { recursive: true, force: true }); - } - return true; - } catch { - return false; - } + try { + const symlinkPath = getActiveSymlinkPath() + if (existsSync(symlinkPath)) { + rmSync(symlinkPath, { recursive: true, force: true }) + } + return true + } catch { + return false + } } diff --git a/src/utils/token-storage.ts b/src/utils/token-storage.ts index a83f7fa..f8cc29d 100644 --- a/src/utils/token-storage.ts +++ b/src/utils/token-storage.ts @@ -1,6 +1,6 @@ /** * Token Storage Utilities - * + * * Manages refresh token storage with secure keychain as default. * Cross-platform support via keytar: * - macOS: Keychain @@ -8,48 +8,48 @@ * - Linux: libsecret (GNOME Keyring / KWallet) */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" // ============================================ // CONFIGURATION // ============================================ -const BASE_DIR = join(homedir(), ".antigravity-kit"); -const TOKENS_FILE = join(BASE_DIR, "tokens.json"); -const KEYCHAIN_SERVICE = "antigravity-kit"; +const BASE_DIR = join(homedir(), ".antigravity-kit") +const TOKENS_FILE = join(BASE_DIR, "tokens.json") +const KEYCHAIN_SERVICE = "antigravity-kit" // ============================================ // TYPES // ============================================ interface TokenEntry { - refreshToken: string; - createdAt: number; - updatedAt: number; + refreshToken: string + createdAt: number + updatedAt: number } interface TokenStore { - [email: string]: TokenEntry; + [email: string]: TokenEntry } export interface TokenMetadata { - email: string; - createdAt: number; - updatedAt: number; - isKeychain: boolean; - hasToken: boolean; + email: string + createdAt: number + updatedAt: number + isKeychain: boolean + hasToken: boolean } // ============================================ // KEYTAR LOADER (Cross-platform secure storage) // ============================================ -type Keytar = typeof import("keytar"); +type Keytar = typeof import("keytar") -let keytarModule: Keytar | null = null; -let keytarLoadAttempted = false; +let keytarModule: Keytar | null = null +let keytarLoadAttempted = false /** * Dynamically load keytar module @@ -57,17 +57,17 @@ let keytarLoadAttempted = false; */ async function loadKeytar(): Promise { if (keytarLoadAttempted) { - return keytarModule; + return keytarModule } - keytarLoadAttempted = true; + keytarLoadAttempted = true try { - keytarModule = await import("keytar"); - return keytarModule; + keytarModule = await import("keytar") + return keytarModule } catch { // keytar not available (native bindings failed, not installed, etc.) - return null; + return null } } @@ -77,26 +77,26 @@ async function loadKeytar(): Promise { function ensureBaseDir(): void { if (!existsSync(BASE_DIR)) { - mkdirSync(BASE_DIR, { recursive: true }); + mkdirSync(BASE_DIR, { recursive: true }) } } function loadTokenStore(): TokenStore { - ensureBaseDir(); + ensureBaseDir() if (!existsSync(TOKENS_FILE)) { - return {}; + return {} } try { - const content = readFileSync(TOKENS_FILE, "utf-8"); - return JSON.parse(content) as TokenStore; + const content = readFileSync(TOKENS_FILE, "utf-8") + return JSON.parse(content) as TokenStore } catch { - return {}; + return {} } } function saveTokenStore(store: TokenStore): void { - ensureBaseDir(); - writeFileSync(TOKENS_FILE, JSON.stringify(store, null, 2)); + ensureBaseDir() + writeFileSync(TOKENS_FILE, JSON.stringify(store, null, 2)) } // ============================================ @@ -108,32 +108,35 @@ function saveTokenStore(store: TokenStore): void { * Works on macOS (Keychain), Windows (Credential Manager), Linux (libsecret) */ export async function isKeychainAvailable(): Promise { - const keytar = await loadKeytar(); + const keytar = await loadKeytar() if (!keytar) { - return false; + return false } // Test if keytar actually works by trying a harmless operation try { - await keytar.findCredentials(KEYCHAIN_SERVICE); - return true; + await keytar.findCredentials(KEYCHAIN_SERVICE) + return true } catch { - return false; + return false } } /** * Save token to system keychain */ -async function saveToKeychain(email: string, refreshToken: string): Promise { - const keytar = await loadKeytar(); - if (!keytar) return false; +async function saveToKeychain( + email: string, + refreshToken: string +): Promise { + const keytar = await loadKeytar() + if (!keytar) return false try { - await keytar.setPassword(KEYCHAIN_SERVICE, email, refreshToken); - return true; + await keytar.setPassword(KEYCHAIN_SERVICE, email, refreshToken) + return true } catch { - return false; + return false } } @@ -141,14 +144,14 @@ async function saveToKeychain(email: string, refreshToken: string): Promise { - const keytar = await loadKeytar(); - if (!keytar) return null; + const keytar = await loadKeytar() + if (!keytar) return null try { - const password = await keytar.getPassword(KEYCHAIN_SERVICE, email); - return password; + const password = await keytar.getPassword(KEYCHAIN_SERVICE, email) + return password } catch { - return null; + return null } } @@ -156,14 +159,14 @@ async function getFromKeychain(email: string): Promise { * Delete token from system keychain */ async function deleteFromKeychain(email: string): Promise { - const keytar = await loadKeytar(); - if (!keytar) return false; + const keytar = await loadKeytar() + if (!keytar) return false try { - const deleted = await keytar.deletePassword(KEYCHAIN_SERVICE, email); - return deleted; + const deleted = await keytar.deletePassword(KEYCHAIN_SERVICE, email) + return deleted } catch { - return false; + return false } } @@ -183,32 +186,32 @@ export async function saveRefreshToken( refreshToken: string, insecure = false ): Promise { - const useKeychain = !insecure && await isKeychainAvailable(); + const useKeychain = !insecure && (await isKeychainAvailable()) if (useKeychain) { - const success = await saveToKeychain(email, refreshToken); + const success = await saveToKeychain(email, refreshToken) if (success) { // Save metadata (without token) to file for tracking - const store = loadTokenStore(); + const store = loadTokenStore() store[email] = { refreshToken: "[keychain]", createdAt: store[email]?.createdAt || Date.now(), updatedAt: Date.now(), - }; - saveTokenStore(store); - return; + } + saveTokenStore(store) + return } // Fall back to file storage if keychain fails - console.warn("โš ๏ธ Keychain storage failed, using file storage"); + console.warn("โš ๏ธ Keychain storage failed, using file storage") } - const store = loadTokenStore(); + const store = loadTokenStore() store[email] = { refreshToken, createdAt: store[email]?.createdAt || Date.now(), updatedAt: Date.now(), - }; - saveTokenStore(store); + } + saveTokenStore(store) } /** @@ -217,17 +220,17 @@ export async function saveRefreshToken( * @returns Refresh token or null if not found */ export async function getRefreshToken(email: string): Promise { - const store = loadTokenStore(); - const entry = store[email]; + const store = loadTokenStore() + const entry = store[email] - if (!entry) return null; + if (!entry) return null // Check if token is in keychain if (entry.refreshToken === "[keychain]") { - return getFromKeychain(email); + return getFromKeychain(email) } - return entry.refreshToken; + return entry.refreshToken } /** @@ -235,52 +238,54 @@ export async function getRefreshToken(email: string): Promise { * @param email - Account email */ export async function deleteRefreshToken(email: string): Promise { - const store = loadTokenStore(); - const entry = store[email]; + const store = loadTokenStore() + const entry = store[email] if (entry?.refreshToken === "[keychain]") { - await deleteFromKeychain(email); + await deleteFromKeychain(email) } - delete store[email]; - saveTokenStore(store); + delete store[email] + saveTokenStore(store) } /** * List all accounts with stored tokens */ export function listStoredAccounts(): string[] { - const store = loadTokenStore(); - return Object.keys(store); + const store = loadTokenStore() + return Object.keys(store) } /** * Check if an account has a stored token */ export async function hasStoredToken(email: string): Promise { - return (await getRefreshToken(email)) !== null; + return (await getRefreshToken(email)) !== null } /** * Check if an account's token is stored in Keychain */ export function isTokenInKeychain(email: string): boolean { - const store = loadTokenStore(); - const entry = store[email]; - return entry?.refreshToken === "[keychain]"; + const store = loadTokenStore() + const entry = store[email] + return entry?.refreshToken === "[keychain]" } /** * Get token metadata for an account */ -export async function getTokenMetadata(email: string): Promise { - const store = loadTokenStore(); - const entry = store[email]; +export async function getTokenMetadata( + email: string +): Promise { + const store = loadTokenStore() + const entry = store[email] - if (!entry) return null; + if (!entry) return null - const isKeychain = entry.refreshToken === "[keychain]"; - const hasToken = isKeychain ? (await getFromKeychain(email)) !== null : true; + const isKeychain = entry.refreshToken === "[keychain]" + const hasToken = isKeychain ? (await getFromKeychain(email)) !== null : true return { email, @@ -288,22 +293,22 @@ export async function getTokenMetadata(email: string): Promise { - const store = loadTokenStore(); - const result: TokenMetadata[] = []; + const store = loadTokenStore() + const result: TokenMetadata[] = [] for (const email of Object.keys(store)) { - const metadata = await getTokenMetadata(email); + const metadata = await getTokenMetadata(email) if (metadata) { - result.push(metadata); + result.push(metadata) } } - return result; + return result } diff --git a/src/utils/version-checker.ts b/src/utils/version-checker.ts index 403cd1d..19a47dd 100644 --- a/src/utils/version-checker.ts +++ b/src/utils/version-checker.ts @@ -1,68 +1,68 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import pc from "picocolors"; +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import pc from "picocolors" -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = dirname(fileURLToPath(import.meta.url)) -const NPM_REGISTRY_URL = "https://registry.npmjs.org/antigravity-kit"; +const NPM_REGISTRY_URL = "https://registry.npmjs.org/antigravity-kit" interface PackageJson { - version: string; - name: string; + version: string + name: string } interface NpmDistTags { - latest?: string; - beta?: string; - [key: string]: string | undefined; + latest?: string + beta?: string + [key: string]: string | undefined } interface NpmPackageInfo { - "dist-tags"?: NpmDistTags; + "dist-tags"?: NpmDistTags } export interface UpdateInfo { - currentVersion: string; - latestVersion?: string; - betaVersion?: string; - hasLatestUpdate: boolean; - hasBetaUpdate: boolean; + currentVersion: string + latestVersion?: string + betaVersion?: string + hasLatestUpdate: boolean + hasBetaUpdate: boolean } /** * Get the current package version from package.json */ export function getPackageJson(): PackageJson { - try { - // When bundled by tsup, all output is flat in dist/ - // So we only need to go up one level to find package.json - const pkgPath = join(__dirname, "..", "package.json"); - const content = readFileSync(pkgPath, "utf-8"); - return JSON.parse(content) as PackageJson; - } catch { - return { version: "0.0.0", name: "antigravity-kit" }; - } + try { + // When bundled by tsup, all output is flat in dist/ + // So we only need to go up one level to find package.json + const pkgPath = join(__dirname, "..", "package.json") + const content = readFileSync(pkgPath, "utf-8") + return JSON.parse(content) as PackageJson + } catch { + return { version: "0.0.0", name: "antigravity-kit" } + } } /** * Parse a semantic version string into components */ function parseVersion(version: string): { - major: number; - minor: number; - patch: number; - prerelease: string | null; + major: number + minor: number + patch: number + prerelease: string | null } { - const cleanVersion = version.replace(/^v/, ""); - const [mainPart = "0.0.0", prerelease] = cleanVersion.split("-"); - const [major, minor, patch] = mainPart.split(".").map(Number); - return { - major: major || 0, - minor: minor || 0, - patch: patch || 0, - prerelease: prerelease || null, - }; + const cleanVersion = version.replace(/^v/, "") + const [mainPart = "0.0.0", prerelease] = cleanVersion.split("-") + const [major, minor, patch] = mainPart.split(".").map(Number) + return { + major: major || 0, + minor: minor || 0, + patch: patch || 0, + prerelease: prerelease || null, + } } /** @@ -70,159 +70,158 @@ function parseVersion(version: string): { * Returns: 1 if v1 > v2, -1 if v1 < v2, 0 if equal */ export function compareVersions(v1: string, v2: string): number { - const parsed1 = parseVersion(v1); - const parsed2 = parseVersion(v2); - - // Compare major.minor.patch - if (parsed1.major !== parsed2.major) { - return parsed1.major > parsed2.major ? 1 : -1; - } - if (parsed1.minor !== parsed2.minor) { - return parsed1.minor > parsed2.minor ? 1 : -1; - } - if (parsed1.patch !== parsed2.patch) { - return parsed1.patch > parsed2.patch ? 1 : -1; - } - - // Handle prerelease comparison - // No prerelease > prerelease (e.g., 1.0.0 > 1.0.0-beta.1) - if (!parsed1.prerelease && parsed2.prerelease) return 1; - if (parsed1.prerelease && !parsed2.prerelease) return -1; - if (parsed1.prerelease && parsed2.prerelease) { - return parsed1.prerelease.localeCompare(parsed2.prerelease); - } - - return 0; + const parsed1 = parseVersion(v1) + const parsed2 = parseVersion(v2) + + // Compare major.minor.patch + if (parsed1.major !== parsed2.major) { + return parsed1.major > parsed2.major ? 1 : -1 + } + if (parsed1.minor !== parsed2.minor) { + return parsed1.minor > parsed2.minor ? 1 : -1 + } + if (parsed1.patch !== parsed2.patch) { + return parsed1.patch > parsed2.patch ? 1 : -1 + } + + // Handle prerelease comparison + // No prerelease > prerelease (e.g., 1.0.0 > 1.0.0-beta.1) + if (!parsed1.prerelease && parsed2.prerelease) return 1 + if (parsed1.prerelease && !parsed2.prerelease) return -1 + if (parsed1.prerelease && parsed2.prerelease) { + return parsed1.prerelease.localeCompare(parsed2.prerelease) + } + + return 0 } /** * Fetch latest versions from npm registry */ async function fetchLatestVersions(): Promise<{ - latest?: string; - beta?: string; + latest?: string + beta?: string }> { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout - - const response = await fetch(NPM_REGISTRY_URL, { - signal: controller.signal, - headers: { - Accept: "application/json", - }, - }); - - clearTimeout(timeout); - - if (!response.ok) { - return {}; - } - - const data = (await response.json()) as NpmPackageInfo; - const latest = data["dist-tags"]?.latest; - const beta = data["dist-tags"]?.beta; - const result: { latest?: string; beta?: string } = {}; - if (latest) result.latest = latest; - if (beta) result.beta = beta; - return result; - } catch { - // Network error or timeout - fail silently - return {}; - } + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 3000) // 3s timeout + + const response = await fetch(NPM_REGISTRY_URL, { + signal: controller.signal, + headers: { + Accept: "application/json", + }, + }) + + clearTimeout(timeout) + + if (!response.ok) { + return {} + } + + const data = (await response.json()) as NpmPackageInfo + const latest = data["dist-tags"]?.latest + const beta = data["dist-tags"]?.beta + const result: { latest?: string; beta?: string } = {} + if (latest) result.latest = latest + if (beta) result.beta = beta + return result + } catch { + // Network error or timeout - fail silently + return {} + } } /** * Check if updates are available */ export async function checkForUpdates(): Promise { - try { - const pkg = getPackageJson(); - const currentVersion = pkg.version; - const { latest, beta } = await fetchLatestVersions(); - - const hasLatestUpdate = latest - ? compareVersions(latest, currentVersion) > 0 - : false; - const hasBetaUpdate = beta - ? compareVersions(beta, currentVersion) > 0 - : false; - - if (!hasLatestUpdate && !hasBetaUpdate) { - return null; - } - - const result: UpdateInfo = { - currentVersion, - hasLatestUpdate, - hasBetaUpdate, - }; - if (latest) result.latestVersion = latest; - if (beta) result.betaVersion = beta; - return result; - } catch { - return null; - } + try { + const pkg = getPackageJson() + const currentVersion = pkg.version + const { latest, beta } = await fetchLatestVersions() + + const hasLatestUpdate = latest + ? compareVersions(latest, currentVersion) > 0 + : false + const hasBetaUpdate = beta + ? compareVersions(beta, currentVersion) > 0 + : false + + if (!hasLatestUpdate && !hasBetaUpdate) { + return null + } + + const result: UpdateInfo = { + currentVersion, + hasLatestUpdate, + hasBetaUpdate, + } + if (latest) result.latestVersion = latest + if (beta) result.betaVersion = beta + return result + } catch { + return null + } } /** * Print update notification box */ export function printUpdateNotification(updateInfo: UpdateInfo): void { - const boxChar = { - topLeft: "โ”Œ", - topRight: "โ”", - bottomLeft: "โ””", - bottomRight: "โ”˜", - horizontal: "โ”€", - vertical: "โ”‚", - }; - - const lines: string[] = []; - - if (updateInfo.hasLatestUpdate && updateInfo.latestVersion) { - lines.push( - `๐Ÿš€ Update available! ${pc.dim(updateInfo.currentVersion)} โ†’ ${pc.green(updateInfo.latestVersion)}`, - ); - lines.push(` Run: ${pc.cyan("npm install -g antigravity-kit@latest")}`); - } - - if (updateInfo.hasBetaUpdate && updateInfo.betaVersion) { - if (lines.length > 0) lines.push(""); - lines.push( - `๐Ÿงช Beta available! ${pc.dim(updateInfo.currentVersion)} โ†’ ${pc.yellow(updateInfo.betaVersion)}`, - ); - lines.push(` Run: ${pc.cyan("npm install -g antigravity-kit@beta")}`); - } - - if (lines.length === 0) return; - - // Calculate box width (account for ANSI codes) - const stripAnsi = (str: string) => - str.replace(/\x1b\[[0-9;]*m/g, ""); - const maxLineLength = Math.max(...lines.map((l) => stripAnsi(l).length)); - const boxWidth = maxLineLength + 4; - - console.log(); - console.log( - pc.dim( - `${boxChar.topLeft}${boxChar.horizontal.repeat(boxWidth)}${boxChar.topRight}`, - ), - ); - - for (const line of lines) { - const padding = boxWidth - stripAnsi(line).length - 2; - console.log( - pc.dim(boxChar.vertical) + - ` ${line}${" ".repeat(padding)} ` + - pc.dim(boxChar.vertical), - ); - } - - console.log( - pc.dim( - `${boxChar.bottomLeft}${boxChar.horizontal.repeat(boxWidth)}${boxChar.bottomRight}`, - ), - ); - console.log(); + const boxChar = { + topLeft: "โ”Œ", + topRight: "โ”", + bottomLeft: "โ””", + bottomRight: "โ”˜", + horizontal: "โ”€", + vertical: "โ”‚", + } + + const lines: string[] = [] + + if (updateInfo.hasLatestUpdate && updateInfo.latestVersion) { + lines.push( + `๐Ÿš€ Update available! ${pc.dim(updateInfo.currentVersion)} โ†’ ${pc.green(updateInfo.latestVersion)}` + ) + lines.push(` Run: ${pc.cyan("npm install -g antigravity-kit@latest")}`) + } + + if (updateInfo.hasBetaUpdate && updateInfo.betaVersion) { + if (lines.length > 0) lines.push("") + lines.push( + `๐Ÿงช Beta available! ${pc.dim(updateInfo.currentVersion)} โ†’ ${pc.yellow(updateInfo.betaVersion)}` + ) + lines.push(` Run: ${pc.cyan("npm install -g antigravity-kit@beta")}`) + } + + if (lines.length === 0) return + + // Calculate box width (account for ANSI codes) + const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*m/g, "") + const maxLineLength = Math.max(...lines.map((l) => stripAnsi(l).length)) + const boxWidth = maxLineLength + 4 + + console.log() + console.log( + pc.dim( + `${boxChar.topLeft}${boxChar.horizontal.repeat(boxWidth)}${boxChar.topRight}` + ) + ) + + for (const line of lines) { + const padding = boxWidth - stripAnsi(line).length - 2 + console.log( + pc.dim(boxChar.vertical) + + ` ${line}${" ".repeat(padding)} ` + + pc.dim(boxChar.vertical) + ) + } + + console.log( + pc.dim( + `${boxChar.bottomLeft}${boxChar.horizontal.repeat(boxWidth)}${boxChar.bottomRight}` + ) + ) + console.log() } diff --git a/src/utils/workspace-storage.ts b/src/utils/workspace-storage.ts index da7026c..3653633 100644 --- a/src/utils/workspace-storage.ts +++ b/src/utils/workspace-storage.ts @@ -1,50 +1,50 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; -import Database from "better-sqlite3"; -import { STATE_DB_PATH } from "../types/auth.js"; -import { getDefaultAntigravityDataDir } from "./profile-manager.js"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" +import { join } from "node:path" +import Database from "better-sqlite3" +import { STATE_DB_PATH } from "../types/auth.js" +import { getDefaultAntigravityDataDir } from "./profile-manager.js" export interface WorkspaceInfo { - hash: string; - folderPath: string; - folderName: string; - lastModified: Date; + hash: string + folderPath: string + folderName: string + lastModified: Date } interface WorkspaceJson { - folder?: string; + folder?: string } /** * Get the path to the workspaceStorage directory */ export function getWorkspaceStoragePath(profilePath?: string): string { - const baseDir = profilePath || getDefaultAntigravityDataDir(); - return join(baseDir, "User", "workspaceStorage"); + const baseDir = profilePath || getDefaultAntigravityDataDir() + return join(baseDir, "User", "workspaceStorage") } /** * Parse a workspace.json file and extract the folder path */ export function parseWorkspaceJson(workspacePath: string): string | null { - const jsonPath = join(workspacePath, "workspace.json"); - - if (!existsSync(jsonPath)) { - return null; - } - - try { - const content = readFileSync(jsonPath, "utf-8"); - const data: WorkspaceJson = JSON.parse(content); - - if (data.folder) { - // Remove the file:// prefix if present - return data.folder.replace(/^file:\/\//, ""); - } - return null; - } catch { - return null; - } + const jsonPath = join(workspacePath, "workspace.json") + + if (!existsSync(jsonPath)) { + return null + } + + try { + const content = readFileSync(jsonPath, "utf-8") + const data: WorkspaceJson = JSON.parse(content) + + if (data.folder) { + // Remove the file:// prefix if present + return data.folder.replace(/^file:\/\//, "") + } + return null + } catch { + return null + } } /** @@ -52,86 +52,84 @@ export function parseWorkspaceJson(workspacePath: string): string | null { * Sorted by last modified (most recent first) */ export function listWorkspaces(profilePath?: string): WorkspaceInfo[] { - const storagePath = getWorkspaceStoragePath(profilePath); + const storagePath = getWorkspaceStoragePath(profilePath) - if (!existsSync(storagePath)) { - return []; - } + if (!existsSync(storagePath)) { + return [] + } - const workspaces: WorkspaceInfo[] = []; + const workspaces: WorkspaceInfo[] = [] - try { - const entries = readdirSync(storagePath, { withFileTypes: true }); + try { + const entries = readdirSync(storagePath, { withFileTypes: true }) - for (const entry of entries) { - if (!entry.isDirectory()) continue; + for (const entry of entries) { + if (!entry.isDirectory()) continue - const workspacePath = join(storagePath, entry.name); - const folderPath = parseWorkspaceJson(workspacePath); + const workspacePath = join(storagePath, entry.name) + const folderPath = parseWorkspaceJson(workspacePath) - if (!folderPath) continue; + if (!folderPath) continue - // Check if the folder still exists - if (!existsSync(folderPath)) continue; + // Check if the folder still exists + if (!existsSync(folderPath)) continue - const stats = statSync(workspacePath); - const folderName = folderPath.split("/").pop() || folderPath; + const stats = statSync(workspacePath) + const folderName = folderPath.split("/").pop() || folderPath - workspaces.push({ - hash: entry.name, - folderPath, - folderName, - lastModified: stats.mtime, - }); - } - } catch { - return []; - } + workspaces.push({ + hash: entry.name, + folderPath, + folderName, + lastModified: stats.mtime, + }) + } + } catch { + return [] + } - // Sort by last modified (most recent first) - return workspaces.sort( - (a, b) => b.lastModified.getTime() - a.lastModified.getTime(), - ); + // Sort by last modified (most recent first) + return workspaces.sort( + (a, b) => b.lastModified.getTime() - a.lastModified.getTime() + ) } /** * Get the most recently accessed workspace */ export function getLastOpenedWorkspace( - profilePath?: string, + profilePath?: string ): WorkspaceInfo | null { - const workspaces = listWorkspaces(profilePath); - return workspaces.length > 0 ? workspaces?.[0] || null : null; + const workspaces = listWorkspaces(profilePath) + return workspaces.length > 0 ? workspaces?.[0] || null : null } /** * Get a workspace by folder name (partial match) */ export function findWorkspaceByName( - name: string, - profilePath?: string, + name: string, + profilePath?: string ): WorkspaceInfo | null { - const workspaces = listWorkspaces(profilePath); - const lowerName = name.toLowerCase(); - - // First try exact match - const exact = workspaces.find( - (w) => w.folderName.toLowerCase() === lowerName, - ); - if (exact) return exact; - - // Then try partial match - return ( - workspaces.find((w) => w.folderName.toLowerCase().includes(lowerName)) || - null - ); + const workspaces = listWorkspaces(profilePath) + const lowerName = name.toLowerCase() + + // First try exact match + const exact = workspaces.find((w) => w.folderName.toLowerCase() === lowerName) + if (exact) return exact + + // Then try partial match + return ( + workspaces.find((w) => w.folderName.toLowerCase().includes(lowerName)) || + null + ) } interface RecentlyOpenedPathsList { - entries?: Array<{ - folderUri?: string; - fileUri?: string; - }>; + entries?: Array<{ + folderUri?: string + fileUri?: string + }> } /** @@ -140,44 +138,44 @@ interface RecentlyOpenedPathsList { * the most recently opened folders, with the first entry being the current one. */ export function getCurrentWorkspaceFromState( - profilePath?: string, + profilePath?: string ): string | null { - const baseDir = profilePath || getDefaultAntigravityDataDir(); - const dbPath = join(baseDir, STATE_DB_PATH); + const baseDir = profilePath || getDefaultAntigravityDataDir() + const dbPath = join(baseDir, STATE_DB_PATH) - if (!existsSync(dbPath)) { - return null; - } + if (!existsSync(dbPath)) { + return null + } - try { - const db = new Database(dbPath, { readonly: true }); + try { + const db = new Database(dbPath, { readonly: true }) - const row = db - .prepare( - `SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'`, - ) - .get() as { value: string } | undefined; + const row = db + .prepare( + `SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'` + ) + .get() as { value: string } | undefined - db.close(); + db.close() - if (!row) { - return null; - } + if (!row) { + return null + } - const data: RecentlyOpenedPathsList = JSON.parse(row.value); + const data: RecentlyOpenedPathsList = JSON.parse(row.value) - if (data.entries && data.entries.length > 0) { - const firstEntry = data.entries[0]; - const uri = firstEntry?.folderUri || firstEntry?.fileUri; + if (data.entries && data.entries.length > 0) { + const firstEntry = data.entries[0] + const uri = firstEntry?.folderUri || firstEntry?.fileUri - if (uri) { - // Remove file:// prefix - return uri.replace(/^file:\/\//, ""); - } - } + if (uri) { + // Remove file:// prefix + return uri.replace(/^file:\/\//, "") + } + } - return null; - } catch { - return null; - } + return null + } catch { + return null + } } diff --git a/tsconfig.json b/tsconfig.json index 1a9fdb4..b450799 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,38 +1,38 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "strictBindCallApply": true, - "strictPropertyInitialization": true, - "noImplicitThis": true, - "useUnknownInCatchVariables": true, - "alwaysStrict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "skipLibCheck": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } diff --git a/tsup.config.ts b/tsup.config.ts index 803bbfb..d798b0c 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,26 +1,26 @@ -import { defineConfig } from "tsup"; +import { defineConfig } from "tsup" export default defineConfig([ - { - entry: { - cli: "src/cli.ts", - }, - format: ["esm"], - dts: false, - clean: true, - sourcemap: true, - splitting: false, - shims: true, - }, - { - entry: { - index: "src/index.ts", - }, - format: ["esm", "cjs"], - dts: true, - clean: false, - sourcemap: true, - splitting: false, - shims: true, - }, -]); + { + entry: { + cli: "src/cli.ts", + }, + format: ["esm"], + dts: false, + clean: true, + sourcemap: true, + splitting: false, + shims: true, + }, + { + entry: { + index: "src/index.ts", + }, + format: ["esm", "cjs"], + dts: true, + clean: false, + sourcemap: true, + splitting: false, + shims: true, + }, +]) From 938c25598d6bd80d0ab51eab0890140e5d45f682 Mon Sep 17 00:00:00 2001 From: duongductrong Date: Sun, 4 Jan 2026 15:34:25 +0700 Subject: [PATCH 3/4] chore: add dependabot configuration for npm updates --- .github/dependabot.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d5a8c8e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "npm" + target-branch: "master" + directory: "/" + schedule: + interval: "monthly" + groups: + minor-and-patch-updates: + patterns: + - "*" + update-types: + - "minor" + - "patch" \ No newline at end of file From 4f7c9e1fa6212b955e2d7aa745150c537f5ba179 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 08:34:40 +0000 Subject: [PATCH 4/4] chore(master): release 0.0.5-beta --- .release-please-manifest.json | 2 +- CHANGELOG.md | 17 +++++++++++++++++ package.json | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 02d22dd..a3df0ac 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{ ".": "0.0.4" } +{".":"0.0.5-beta"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e9865c..383a576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.0.5-beta](https://github.com/duongductrong/antigravity-kit/compare/v0.0.4...v0.0.5-beta) (2026-01-04) + + +### ๐Ÿ“š Documentation + +* Add original author and license information to oauth, oauth-server, and quota utility files. ([c61547e](https://github.com/duongductrong/antigravity-kit/commit/c61547e1e876c7ef61f5d1a9705b3c5f18a76dfe)) + + +### ๐Ÿ  Chores + +* add dependabot configuration for npm updates ([938c255](https://github.com/duongductrong/antigravity-kit/commit/938c25598d6bd80d0ab51eab0890140e5d45f682)) + + +### โ™ป๏ธ Code Refactoring + +* standardize code formatting and improve readability across utility modules ([c998265](https://github.com/duongductrong/antigravity-kit/commit/c998265f9b24dff4aa40f2082253f36e20a893e6)) + ## [0.0.4](https://github.com/duongductrong/antigravity-kit/compare/v0.0.3...v0.0.4) (2026-01-04) ### ๐Ÿš€ Features diff --git a/package.json b/package.json index ad78b76..aa41a61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "antigravity-kit", - "version": "0.0.4", + "version": "0.0.5-beta", "description": "CLI toolkit to manage antigravity IDE rules and commands", "type": "module", "exports": {