diff --git a/completions-cron/src/__generated__/completions-index.ts b/completions-cron/src/__generated__/completions-index.ts index bcf804c5..a3142781 100644 --- a/completions-cron/src/__generated__/completions-index.ts +++ b/completions-cron/src/__generated__/completions-index.ts @@ -27,6 +27,9 @@ import mod22 from '../../../src/resources/cursor/completions/cursor.extensions.j import mod23 from '../../../src/resources/asdf/completions/asdf.plugins.js'; import mod24 from '../../../src/resources/asdf/completions/asdf-plugin.plugin.js'; import mod25 from '../../../src/resources/apt/completions/apt.install.js'; +import mod26 from '../../../src/resources/android/android-studios/completions/android-studio.version.js'; +import mod27 from '../../../src/resources/android/android-cli/completions/android-cli.sdkPackages.js'; +import mod28 from '../../../src/resources/android/android-cli/completions/android-cli.emulators.js'; export interface CompletionModule { resourceType: string @@ -61,4 +64,7 @@ export const completionModules: CompletionModule[] = [ { resourceType: 'asdf', parameterPath: '/plugins', fetch: mod23 }, { resourceType: 'asdf-plugin', parameterPath: '/plugin', fetch: mod24 }, { resourceType: 'apt', parameterPath: '/install', fetch: mod25 }, + { resourceType: 'android-studio', parameterPath: '/version', fetch: mod26 }, + { resourceType: 'android-cli', parameterPath: '/sdkPackages', fetch: mod27 }, + { resourceType: 'android-cli', parameterPath: '/emulators', fetch: mod28 }, ] diff --git a/completions-cron/src/index.ts b/completions-cron/src/index.ts index 6ff5d11f..9c87babd 100644 --- a/completions-cron/src/index.ts +++ b/completions-cron/src/index.ts @@ -6,22 +6,25 @@ const BATCH_SIZE = 1000 async function getResourceId( supabase: SupabaseClient, resourceType: string, + prerelease: boolean, cache: Map ): Promise { - if (cache.has(resourceType)) { - return cache.get(resourceType)! + const cacheKey = `${resourceType}:${prerelease}` + if (cache.has(cacheKey)) { + return cache.get(cacheKey)! } const { data, error } = await supabase .from('registry_resources') .select('id') .eq('type', resourceType) + .eq('prerelease', prerelease) if (error || !data?.[0]?.id) { - throw new Error(`Resource type '${resourceType}' not found in registry_resources`) + throw new Error(`Resource type '${resourceType}' (prerelease=${prerelease}) not found in registry_resources`) } - cache.set(resourceType, data[0].id) + cache.set(cacheKey, data[0].id) return data[0].id } @@ -30,6 +33,7 @@ async function processModule( resourceType: string, parameterPath: string, fetchFn: () => Promise, + prerelease: boolean, resourceIdCache: Map ): Promise { console.log(`Processing ${resourceType}${parameterPath}...`) @@ -37,7 +41,7 @@ async function processModule( const values = await fetchFn() console.log(` [${resourceType}${parameterPath}] Fetched ${values.length} values`) - const resourceId = await getResourceId(supabase, resourceType, resourceIdCache) + const resourceId = await getResourceId(supabase, resourceType, prerelease, resourceIdCache) await supabase .from('resource_parameter_completions') @@ -66,31 +70,44 @@ async function processModule( console.log(` [${resourceType}${parameterPath}] Done: inserted ${values.length} completions`) } +async function runCompletions(env: Env): Promise { + const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY) + const prerelease = env.PRERELEASE === 'true' + const resourceIdCache = new Map() + + const results = await Promise.allSettled( + completionModules.map(({ resourceType, parameterPath, fetch }: CompletionModule) => + processModule(supabase, resourceType, parameterPath, fetch, prerelease, resourceIdCache) + ) + ) + + for (const result of results) { + if (result.status === 'rejected') { + console.error('Completion module failed:', result.reason) + } + } + + console.log('Successfully processed all resource completion tasks') +} + export default { - async fetch(req: Request) { + async fetch(req: Request, env: Env, ctx: ExecutionContext) { const url = new URL(req.url) + + if (req.method === 'POST' && url.pathname === '/trigger') { + if (req.headers.get('Authorization') !== env.TRIGGER_SECRET) { + return new Response('Unauthorized', { status: 401 }) + } + ctx.waitUntil(runCompletions(env)) + return new Response('Triggered', { status: 202 }) + } + url.pathname = '/__scheduled' url.searchParams.append('cron', '* * * * *') return new Response(`To test the scheduled handler, ensure you have used the "--test-scheduled" then try running "curl ${url.href}".`) }, async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise { - console.log('hihi') - const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY) - const resourceIdCache = new Map() - - const results = await Promise.allSettled( - completionModules.map(({ resourceType, parameterPath, fetch }: CompletionModule) => - processModule(supabase, resourceType, parameterPath, fetch, resourceIdCache) - ) - ) - - for (const result of results) { - if (result.status === 'rejected') { - console.error('Completion module failed:', result.reason) - } - } - - console.log('Successfully processed all resource completion tasks') + await runCompletions(env) }, } satisfies ExportedHandler diff --git a/completions-cron/types/worker-configuration.d.ts b/completions-cron/types/worker-configuration.d.ts index ddcdff5b..6d484ed6 100644 --- a/completions-cron/types/worker-configuration.d.ts +++ b/completions-cron/types/worker-configuration.d.ts @@ -6,6 +6,10 @@ declare namespace Cloudflare { mainModule: typeof import("../src"); } interface Env { + SUPABASE_URL: string; + SUPABASE_SERVICE_ROLE_KEY: string; + PRERELEASE: string; + TRIGGER_SECRET: string; } } interface Env extends Cloudflare.Env {} diff --git a/completions-cron/wrangler.toml b/completions-cron/wrangler.toml index 6b212c6a..410a010d 100644 --- a/completions-cron/wrangler.toml +++ b/completions-cron/wrangler.toml @@ -10,6 +10,18 @@ head_sampling_rate = 1 [vars] SUPABASE_URL = "https://kdctbvqvqjfquplxhqrm.supabase.co" +PRERELEASE = "false" [triggers] crons = ["0 5 * * *"] + +# Beta environment — deploys as a separate worker: resource-completions-cron-beta +[env.beta] +name = "resource-completions-cron-beta" + +[env.beta.vars] +SUPABASE_URL = "https://kdctbvqvqjfquplxhqrm.supabase.co" +PRERELEASE = "true" + +[env.beta.triggers] +crons = ["0 5 * * *"] diff --git a/docs/resources/(resources)/ai-agents/meta.json b/docs/resources/(resources)/ai-agents/meta.json index 8ed55c6b..82293d90 100644 --- a/docs/resources/(resources)/ai-agents/meta.json +++ b/docs/resources/(resources)/ai-agents/meta.json @@ -1,4 +1,9 @@ { - "title": "ai & agents", - "pages": ["claude-code", "claude-code-project", "ollama", "openclaw"] + "title": "AI & Agents", + "pages": [ + "claude-code", + "claude-code-project", + "ollama", + "openclaw" + ] } diff --git a/docs/resources/(resources)/android/android-cli.mdx b/docs/resources/(resources)/android/android-cli.mdx new file mode 100644 index 00000000..aa5a43b0 --- /dev/null +++ b/docs/resources/(resources)/android/android-cli.mdx @@ -0,0 +1,72 @@ +--- +title: android-cli +description: Reference pages for the Android CLI resources +--- + +The `android-cli` resource installs and configures [Android CLI](https://developer.android.com/tools/agents/android-cli), Google's command-line tool for managing the Android development environment. It manages the CLI itself, SDK packages, and Android Virtual Devices (AVDs) in a single resource. + +On macOS, Android CLI is installed via the official curl script (ARM64 and x86_64 supported). On Linux, only AMD64/x86_64 is supported. + +## Parameters + +- **sdkPath**: *(string)* Path to the Android SDK directory. Written to `~/.androidrc` as `--sdk=`. Defaults to the android CLI's built-in default location if omitted. +- **sdkPackages**: *(string[])* Android SDK packages to install declaratively. Package paths use forward-slash notation (e.g. `platforms/android-35`, `build-tools/35.0.0`, `cmdline-tools/latest`, `platform-tools`, `system-images/android-35/google_apis_playstore/x86_64`). Run `android sdk list --all` to see all available identifiers. +- **emulators**: *(string[])* Android emulator profiles to create as AVDs. Each string is a hardware profile name (e.g. `medium_phone`, `pixel_9`). Emulators are always created after `sdkPackages` are installed. Run `android emulator create --list-profiles` to see available profiles. + +## Example usage + +Install the CLI with essential SDK packages: + +```json title="codify.jsonc" +[ + { + "type": "android-cli", + "sdkPackages": [ + "cmdline-tools/latest", + "platform-tools", + "platforms/android-35", + "build-tools/35.0.0" + ] + } +] +``` + +Full Android development environment with an emulator: + +```json title="codify.jsonc" +[ + { + "type": "android-cli", + "sdkPackages": [ + "cmdline-tools/latest", + "platform-tools", + "platforms/android-35", + "build-tools/35.0.0", + "system-images/android-35/google_apis_playstore/x86_64" + ], + "emulators": ["pixel_9"] + } +] +``` + +## Common emulator profiles + +| Profile | Description | +|---------|-------------| +| `medium_phone` | Generic medium phone (default) | +| `small_phone` | Generic small phone | +| `foldable` | Foldable form factor | +| `medium_tablet` | Generic medium tablet | +| `pixel_9` | Google Pixel 9 | +| `pixel_9_pro` | Google Pixel 9 Pro | +| `pixel_9_pro_fold` | Google Pixel 9 Pro Fold | +| `pixel_8` | Google Pixel 8 | +| `wear_os_large_round` | Wear OS round watch | +| `tv_1080p` | Android TV 1080p | +| `automotive_1024p_landscape` | Android Automotive | + +## Notes + +- Linux ARM64 is **not** supported. Only AMD64/x86_64 is supported on Linux. +- AVDs are removed using `android emulator remove`. +- Run `android info` to display the default SDK path in use. diff --git a/docs/resources/(resources)/editors-ides/android-studio.mdx b/docs/resources/(resources)/android/android-studio.mdx similarity index 100% rename from docs/resources/(resources)/editors-ides/android-studio.mdx rename to docs/resources/(resources)/android/android-studio.mdx diff --git a/docs/resources/(resources)/android/meta.json b/docs/resources/(resources)/android/meta.json new file mode 100644 index 00000000..5f93f272 --- /dev/null +++ b/docs/resources/(resources)/android/meta.json @@ -0,0 +1,7 @@ +{ + "title": "Android", + "pages": [ + "android-cli", + "android-studio" + ] +} diff --git a/docs/resources/(resources)/asdf/meta.json b/docs/resources/(resources)/asdf/meta.json index b5e9284b..363cc6ba 100644 --- a/docs/resources/(resources)/asdf/meta.json +++ b/docs/resources/(resources)/asdf/meta.json @@ -1,4 +1,8 @@ { - "title": "asdf", - "pages": ["asdf", "asdf-install", "asdf-plugin"] + "title": "Asdf", + "pages": [ + "asdf", + "asdf-install", + "asdf-plugin" + ] } diff --git a/docs/resources/(resources)/editors-ides/meta.json b/docs/resources/(resources)/editors-ides/meta.json index 5ce119b6..74fa45d2 100644 --- a/docs/resources/(resources)/editors-ides/meta.json +++ b/docs/resources/(resources)/editors-ides/meta.json @@ -1,4 +1,7 @@ { - "title": "editors & ides", - "pages": ["vscode", "cursor", "intellij-idea", "clion", "goland", "phpstorm", "pycharm", "rider", "rubymine", "rustrover", "webstorm", "android-studio"] + "title": "Editors & IDEs", + "pages": [ + "[android-studio](/docs/resources/android/android-studio)", + "..." + ] } diff --git a/docs/resources/(resources)/git/meta.json b/docs/resources/(resources)/git/meta.json index 88354b5d..c3207ec9 100644 --- a/docs/resources/(resources)/git/meta.json +++ b/docs/resources/(resources)/git/meta.json @@ -1,4 +1,9 @@ { - "title": "git", - "pages": ["git", "git-lfs", "git-repository", "wait-github-ssh-key"] + "title": "Git", + "pages": [ + "git", + "git-lfs", + "git-repository", + "wait-github-ssh-key" + ] } diff --git a/docs/resources/(resources)/go/meta.json b/docs/resources/(resources)/go/meta.json index a4a2b7c7..f63cc27e 100644 --- a/docs/resources/(resources)/go/meta.json +++ b/docs/resources/(resources)/go/meta.json @@ -1,4 +1,6 @@ { - "title": "go", - "pages": ["goenv"] + "title": "Go", + "pages": [ + "goenv" + ] } diff --git a/docs/resources/(resources)/javascript/meta.json b/docs/resources/(resources)/javascript/meta.json index 8e63b54f..aa7c41c3 100644 --- a/docs/resources/(resources)/javascript/meta.json +++ b/docs/resources/(resources)/javascript/meta.json @@ -1,4 +1,10 @@ { - "title": "javascript", - "pages": ["fast-node-manager", "npm", "npm-login", "nvm", "pnpm"] + "title": "JavaScript", + "pages": [ + "fast-node-manager", + "npm", + "npm-login", + "nvm", + "pnpm" + ] } diff --git a/docs/resources/(resources)/meta.json b/docs/resources/(resources)/meta.json index a6e0de73..da392709 100644 --- a/docs/resources/(resources)/meta.json +++ b/docs/resources/(resources)/meta.json @@ -3,6 +3,7 @@ "ai-agents", "package-managers", "editors-ides", + "android-development", "asdf", "git", "javascript", diff --git a/docs/resources/(resources)/package-managers/meta.json b/docs/resources/(resources)/package-managers/meta.json index 24e86742..397caba5 100644 --- a/docs/resources/(resources)/package-managers/meta.json +++ b/docs/resources/(resources)/package-managers/meta.json @@ -1,4 +1,11 @@ { - "title": "package-managers", - "pages": ["homebrew", "apt", "dnf", "macports", "snap", "yum"] + "title": "Package Managers", + "pages": [ + "homebrew", + "apt", + "dnf", + "macports", + "snap", + "yum" + ] } diff --git a/docs/resources/(resources)/python/meta.json b/docs/resources/(resources)/python/meta.json index cbf1677e..3516e52a 100644 --- a/docs/resources/(resources)/python/meta.json +++ b/docs/resources/(resources)/python/meta.json @@ -1,4 +1,12 @@ { - "title": "python", - "pages": ["pip", "pip-sync", "pyenv", "uv", "venv-project", "virtualenv", "virtualenv-project"] + "title": "Python", + "pages": [ + "pip", + "pip-sync", + "pyenv", + "uv", + "venv-project", + "virtualenv", + "virtualenv-project" + ] } diff --git a/docs/resources/(resources)/ruby/meta.json b/docs/resources/(resources)/ruby/meta.json index b549e174..7553f068 100644 --- a/docs/resources/(resources)/ruby/meta.json +++ b/docs/resources/(resources)/ruby/meta.json @@ -1,4 +1,6 @@ { - "title": "ruby", - "pages": ["rbenv"] -} \ No newline at end of file + "title": "Ruby", + "pages": [ + "rbenv" + ] +} diff --git a/docs/resources/(resources)/scripting/meta.json b/docs/resources/(resources)/scripting/meta.json index 98ab21a9..d6c98592 100644 --- a/docs/resources/(resources)/scripting/meta.json +++ b/docs/resources/(resources)/scripting/meta.json @@ -1,3 +1,3 @@ { - "title": "scripting" + "title": "Scripting" } diff --git a/docs/resources/(resources)/shell/meta.json b/docs/resources/(resources)/shell/meta.json index af2d01b7..909d759d 100644 --- a/docs/resources/(resources)/shell/meta.json +++ b/docs/resources/(resources)/shell/meta.json @@ -1,3 +1,3 @@ { - "title": "shell" + "title": "Shell" } diff --git a/docs/resources/(resources)/ssh/meta.json b/docs/resources/(resources)/ssh/meta.json index 1bd6dc3a..a13178f8 100644 --- a/docs/resources/(resources)/ssh/meta.json +++ b/docs/resources/(resources)/ssh/meta.json @@ -1,4 +1,8 @@ { - "title": "ssh", - "pages": ["ssh-add", "ssh-config", "ssh-key"] + "title": "SSH", + "pages": [ + "ssh-add", + "ssh-config", + "ssh-key" + ] } diff --git a/docs/resources/(resources)/syncthing/meta.json b/docs/resources/(resources)/syncthing/meta.json index e1b2c1dc..9c798262 100644 --- a/docs/resources/(resources)/syncthing/meta.json +++ b/docs/resources/(resources)/syncthing/meta.json @@ -1,4 +1,8 @@ { - "title": "syncthing", - "pages": ["syncthing", "syncthing-device", "syncthing-folder"] -} \ No newline at end of file + "title": "Syncthing", + "pages": [ + "syncthing", + "syncthing-device", + "syncthing-folder" + ] +} diff --git a/docs/resources/(resources)/tart/meta.json b/docs/resources/(resources)/tart/meta.json index 06c8902a..22a4b377 100644 --- a/docs/resources/(resources)/tart/meta.json +++ b/docs/resources/(resources)/tart/meta.json @@ -1,4 +1,7 @@ { - "title": "tart", - "pages": ["tart", "tart-vm"] -} \ No newline at end of file + "title": "Tart", + "pages": [ + "tart", + "tart-vm" + ] +} diff --git a/docs/resources/index.mdx b/docs/resources/index.mdx index a5db2c41..992160cc 100644 --- a/docs/resources/index.mdx +++ b/docs/resources/index.mdx @@ -94,6 +94,7 @@ Run AI models locally: Configure popular development environments: - **[vscode](/docs/resources/vscode)** - Visual Studio Code extensions and settings +- **[android-cli](/docs/resources/android-cli)** - Android CLI, SDK packages, and emulators - **[android-studio](/docs/resources/android-studio)** - Android Studio IDE - **[xcode-tools](/docs/resources/xcode-tools)** - Xcode Command Line Tools - **[pgcli](/docs/resources/pgcli)** - Postgres CLI with auto-completion diff --git a/package-lock.json b/package-lock.json index f4311153..5d0e5c33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "default", - "version": "1.11.0", + "version": "1.12.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "default", - "version": "1.11.0", + "version": "1.12.0-beta.1", "license": "ISC", "dependencies": { "@codifycli/plugin-core": "^1.2.5", diff --git a/package.json b/package.json index 67aded2d..6c8fc107 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.11.0", + "version": "1.12.0", "description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux", "main": "dist/index.js", "scripts": { diff --git a/scripts/deploy.ts b/scripts/deploy.ts index debc9cdb..82152723 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -86,6 +86,9 @@ const versionRow = await client.from('registry_plugin_versions').upsert({ await uploadResources(isBeta); if (isBeta) { + console.log('Deploying beta completions worker...') + cp.spawnSync('source ~/.zshrc; npm run build:completions && cd completions-cron && npx wrangler deploy --env beta', { shell: 'zsh', stdio: 'inherit' }) + // Generate embeddings for prerelease resources so the AI agent can find them via semantic search console.log('Triggering vector reindex for prerelease resources...') const reindexKey = process.env.REINDEX_API_KEY @@ -129,6 +132,27 @@ if (!isBeta) { } } +// Trigger an immediate completions run so completions are populated right after deploy +// (the daily cron keeps them updated over time) +console.log('Triggering completions run...') +const workerUrl = isBeta + ? process.env.COMPLETIONS_BETA_WORKER_URL + : process.env.COMPLETIONS_WORKER_URL +const triggerSecret = process.env.COMPLETIONS_TRIGGER_SECRET +if (!workerUrl || !triggerSecret) { + console.warn('COMPLETIONS_WORKER_URL / COMPLETIONS_BETA_WORKER_URL / COMPLETIONS_TRIGGER_SECRET not set — skipping completions trigger') +} else { + const res = await fetch(`${workerUrl}/trigger`, { + method: 'POST', + headers: { Authorization: triggerSecret }, + }) + if (!res.ok) { + console.error(`Completions trigger failed: ${res.status} ${await res.text()}`) + } else { + console.log('Completions trigger accepted (running in background on worker)') + } +} + async function uploadResources(prerelease: boolean) { const Metadata: Array> = require('../dist/metadata.json'); diff --git a/src/index.ts b/src/index.ts index 0cb3831b..2e0ab5dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { Plugin, runPlugin } from '@codifycli/plugin-core'; -import { AndroidStudioResource } from './resources/android/android-studio.js'; +import { AndroidCliResource } from './resources/android/android-cli/android-cli.js'; import { AptResource } from './resources/apt/apt.js'; import { AsdfResource } from './resources/asdf/asdf.js'; import { AsdfInstallResource } from './resources/asdf/asdf-install.js'; @@ -74,6 +74,7 @@ import { PhpStormResource } from './resources/jetbrains/phpstorm/phpstorm.js'; import { GoLandResource } from './resources/jetbrains/goland/goland.js'; import { RiderResource } from './resources/jetbrains/rider/rider.js'; import { RubyMineResource } from './resources/jetbrains/rubymine/rubymine.js'; +import {AndroidStudioResource} from "./resources/android/android-studios/android-studio.js"; export const MIN_SUPPORTED_CLI_VERSION: string | undefined = '1.1.0'; @@ -113,6 +114,7 @@ runPlugin(Plugin.create( new GitRepositoryResource(), new GitRepositoriesResource(), new AndroidStudioResource(), + new AndroidCliResource(), new AsdfResource(), new AsdfPluginResource(), new AsdfInstallResource(), diff --git a/src/resources/android/android-cli/android-cli.ts b/src/resources/android/android-cli/android-cli.ts new file mode 100644 index 00000000..a595b5ba --- /dev/null +++ b/src/resources/android/android-cli/android-cli.ts @@ -0,0 +1,177 @@ +import { + CreatePlan, + DestroyPlan, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + SpawnStatus, + Utils, + getPty, + z, + CodifyCliSender, + ApplyNotes, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { AndroidEmulatorsParameter } from './android-emulators-parameter.js'; +import { AndroidSdkPackagesParameter } from './android-sdk-packages-parameter.js'; +import { exampleAndroidCliBasic, exampleAndroidCliFullSetup } from './examples.js'; + +export const schema = z + .object({ + sdkPath: z + .string() + .describe( + 'Path to the Android SDK directory. Written to ~/.androidrc as --sdk=. Defaults to the android CLI default location.' + ) + .optional(), + sdkPackages: z + .array(z.string()) + .describe( + 'Android SDK packages to install. Examples: "platforms/android-35", "build-tools/35.0.0", "platform-tools", "cmdline-tools/latest", "system-images/android-35/google_apis_playstore/x86_64".' + ) + .optional(), + emulators: z + .array(z.string()) + .describe( + 'Android emulator profiles to create as AVDs (e.g. "medium_phone", "pixel_9"). Run `android emulator create --list-profiles` to see available options.' + ) + .optional(), + }) + .describe('Android CLI — installs the android command-line tool and manages the Android SDK environment'); + +export type AndroidCliConfig = z.infer; + +const ANDROIDRC_PATH = path.join(os.homedir(), '.androidrc'); + +const defaultConfig: Partial = { + sdkPackages: [], + emulators: [], +}; + +export class AndroidCliResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'android-cli', + defaultConfig, + exampleConfigs: { + example1: exampleAndroidCliBasic, + example2: exampleAndroidCliFullSetup, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + removeStatefulParametersBeforeDestroy: true, + parameterSettings: { + sdkPath: { type: 'directory', canModify: true }, + sdkPackages: { type: 'stateful', definition: new AndroidSdkPackagesParameter(), order: 1 }, + emulators: { type: 'stateful', definition: new AndroidEmulatorsParameter(), order: 2 }, + }, + }; + } + + async refresh(params: Partial): Promise | null> { + const $ = getPty(); + + const { status } = await $.spawnSafe('which android'); + if (status === SpawnStatus.ERROR) return null; + + const result: Partial = {}; + + if (params.sdkPath) { + try { + const rcContent = await fs.readFile(ANDROIDRC_PATH, 'utf8'); + const sdkLine = rcContent.split('\n').find((l) => l.startsWith('--sdk=')); + if (!sdkLine) return null; + result.sdkPath = sdkLine.replace('--sdk=', '').trim(); + } catch { + return null; + } + } + + return result; + } + + async create(plan: CreatePlan): Promise { + const $ = getPty(); + + const isArm = await Utils.isArmArch(); + + if (Utils.isMacOS()) { + const arch = isArm ? 'darwin_arm64' : 'darwin_x86_64'; + await $.spawn( + `curl -fsSL https://dl.google.com/android/cli/latest/${arch}/install.sh | bash`, + { interactive: true } + ); + } else { + if (isArm) { + throw new Error( + 'Android CLI does not support Linux ARM64. Only AMD64/x86_64 is supported on Linux.' + ); + } + await $.spawn( + 'curl -fsSL https://dl.google.com/android/cli/latest/linux_x86_64/install.sh | bash', + { interactive: true } + ); + } + + if (plan.desiredConfig.sdkPath) { + await this.setSdkPath(plan.desiredConfig.sdkPath); + } + + CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED, 'android-cli') + } + + async modify(pc: ParameterChange, _plan: ModifyPlan): Promise { + if (pc.name === 'sdkPath') { + if (pc.newValue) { + await this.setSdkPath(pc.newValue as string); + } else { + await this.removeSdkPath(); + } + } + } + + async destroy(plan: DestroyPlan): Promise { + const androidBinPath = path.join(os.homedir(), '.local', 'bin', 'android'); + await fs.rm(androidBinPath, { force: true }); + + if (plan.currentConfig.sdkPath) { + await this.removeSdkPath(); + } + } + + private async setSdkPath(sdkPath: string): Promise { + let rcContent = ''; + try { + rcContent = await fs.readFile(ANDROIDRC_PATH, 'utf8'); + } catch { /* file doesn't exist yet */ } + + const lines = rcContent.split('\n').filter(Boolean); + const sdkIndex = lines.findIndex((l) => l.startsWith('--sdk=')); + + if (sdkIndex >= 0) { + lines[sdkIndex] = `--sdk=${sdkPath}`; + } else { + lines.push(`--sdk=${sdkPath}`); + } + + await fs.writeFile(ANDROIDRC_PATH, lines.join('\n') + '\n', 'utf8'); + } + + private async removeSdkPath(): Promise { + try { + const rcContent = await fs.readFile(ANDROIDRC_PATH, 'utf8'); + const remaining = rcContent.split('\n').filter((l) => !l.startsWith('--sdk=')); + + if (remaining.filter(Boolean).length === 0) { + await fs.rm(ANDROIDRC_PATH, { force: true }); + } else { + await fs.writeFile(ANDROIDRC_PATH, remaining.join('\n') + '\n', 'utf8'); + } + } catch { /* file doesn't exist, nothing to do */ } + } +} diff --git a/src/resources/android/android-cli/android-emulators-parameter.ts b/src/resources/android/android-cli/android-emulators-parameter.ts new file mode 100644 index 00000000..621a0711 --- /dev/null +++ b/src/resources/android/android-cli/android-emulators-parameter.ts @@ -0,0 +1,27 @@ +import { ArrayStatefulParameter, getPty, SpawnStatus } from '@codifycli/plugin-core'; + +import { AndroidCliConfig } from './android-cli.js'; + +export class AndroidEmulatorsParameter extends ArrayStatefulParameter { + async refresh(_desired: string[] | null): Promise { + const $ = getPty(); + + const { status, data } = await $.spawnSafe('android emulator list', { interactive: true }); + if (status === SpawnStatus.ERROR) return null; + + return data + .split('\n') + .map((l) => l.trim().split(/\s+/)[0]) + .filter(Boolean); + } + + async addItem(item: string): Promise { + const $ = getPty(); + await $.spawn(`android emulator create "${item}"`, { interactive: true }); + } + + async removeItem(item: string): Promise { + const $ = getPty(); + await $.spawn(`android emulator remove "${item}"`, { interactive: true }); + } +} diff --git a/src/resources/android/android-cli/android-sdk-packages-parameter.ts b/src/resources/android/android-cli/android-sdk-packages-parameter.ts new file mode 100644 index 00000000..5c96f28c --- /dev/null +++ b/src/resources/android/android-cli/android-sdk-packages-parameter.ts @@ -0,0 +1,28 @@ +import { ArrayStatefulParameter, getPty, SpawnStatus } from '@codifycli/plugin-core'; + +import { AndroidCliConfig } from './android-cli.js'; + +export class AndroidSdkPackagesParameter extends ArrayStatefulParameter { + async refresh(_desired: string[] | null): Promise { + const $ = getPty(); + + const { status, data } = await $.spawnSafe('android sdk list'); + if (status === SpawnStatus.ERROR) return null; + + return data + .split('\n') + .filter((l) => l.match(/^\s{2}\S/)) + .map((l) => l.trim().split(/\s+/)[0]) + .filter(Boolean); + } + + async addItem(item: string): Promise { + const $ = getPty(); + await $.spawn(`android sdk install "${item}"`, { interactive: true }); + } + + async removeItem(item: string): Promise { + const $ = getPty(); + await $.spawn(`android sdk remove "${item}"`, { interactive: true }); + } +} diff --git a/src/resources/android/android-cli/completions/android-cli.emulators.ts b/src/resources/android/android-cli/completions/android-cli.emulators.ts new file mode 100644 index 00000000..b59b01e2 --- /dev/null +++ b/src/resources/android/android-cli/completions/android-cli.emulators.ts @@ -0,0 +1,12 @@ +// Known Android hardware profiles from the AVD Manager device definitions. +// These correspond to profiles accepted by `android emulator create --profile=`. +export default async function loadAndroidEmulatorProfiles(): Promise { + return [ + 'large_desktop', + 'medium_desktop', + 'medium_phone', + 'medium_tablet', + 'small_desktop', + 'small_phone', + ]; +} diff --git a/src/resources/android/android-cli/completions/android-cli.sdkPackages.ts b/src/resources/android/android-cli/completions/android-cli.sdkPackages.ts new file mode 100644 index 00000000..d997e902 --- /dev/null +++ b/src/resources/android/android-cli/completions/android-cli.sdkPackages.ts @@ -0,0 +1,22 @@ +export default async function loadAndroidSdkPackages(): Promise { + const response = await fetch('https://dl.google.com/android/repository/repository2-3.xml', { + headers: { 'User-Agent': 'codify-completions-cron' }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Android SDK repository: ${response.status} ${response.statusText}`); + } + + const xml = await response.text(); + + // Extract path attributes from package elements and convert ; separators to / + const paths = new Set(); + const regex = /\bpath="([^"]+)"/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(xml)) !== null) { + // Convert legacy semicolon path separators to the android CLI forward-slash format + paths.add(match[1].replace(/;/g, '/')); + } + + return [...paths].sort(); +} diff --git a/src/resources/android/android-cli/examples.ts b/src/resources/android/android-cli/examples.ts new file mode 100644 index 00000000..6e5744f3 --- /dev/null +++ b/src/resources/android/android-cli/examples.ts @@ -0,0 +1,30 @@ +import { ExampleConfig } from '@codifycli/plugin-core'; + +export const exampleAndroidCliBasic: ExampleConfig = { + title: 'Android development environment', + description: 'Install the Android CLI with essential SDK packages for building Android apps — platform, build tools, and ADB.', + configs: [ + { + type: 'android-cli', + sdkPackages: ['cmdline-tools/latest', 'platform-tools', 'platforms/android-35', 'build-tools/35.0.0'], + }, + ], +}; + +export const exampleAndroidCliFullSetup: ExampleConfig = { + title: 'Android environment with emulator', + description: 'Install Android CLI with SDK packages and provision a Pixel 9 emulator for local development and testing.', + configs: [ + { + type: 'android-cli', + sdkPackages: [ + 'cmdline-tools/latest', + 'platform-tools', + 'platforms/android-35', + 'build-tools/35.0.0', + 'system-images/android-35/google_apis_playstore/x86_64', + ], + emulators: ['pixel_9'], + }, + ], +}; diff --git a/src/resources/android/README.md b/src/resources/android/android-studios/README.md similarity index 100% rename from src/resources/android/README.md rename to src/resources/android/android-studios/README.md diff --git a/src/resources/android/android-studio.ts b/src/resources/android/android-studios/android-studio.ts similarity index 88% rename from src/resources/android/android-studio.ts rename to src/resources/android/android-studios/android-studio.ts index 4e488622..e732759a 100644 --- a/src/resources/android/android-studio.ts +++ b/src/resources/android/android-studios/android-studio.ts @@ -1,11 +1,11 @@ -import { CreatePlan, DestroyPlan, Resource, ResourceSettings, Utils, getPty, z } from '@codifycli/plugin-core'; +import { CreatePlan, DestroyPlan, Resource, ResourceSettings, SpawnStatus, Utils, getPty, z } from '@codifycli/plugin-core'; import { OS } from '@codifycli/schemas'; import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import plist from 'plist'; -import { Utils as LocalUtils } from '../../utils/index.js'; +import { Utils as LocalUtils } from '../../../utils/index.js'; import { AndroidStudioPlist, AndroidStudioVersionData } from './types.js'; export const schema = z.object({ @@ -123,7 +123,7 @@ export class AndroidStudioResource extends Resource { return { directory, - version: installedVersion, + ...(installedVersion ? { version: installedVersion } : {}), }; } @@ -143,7 +143,7 @@ export class AndroidStudioResource extends Resource { const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codify-android-')) try { - await $.spawn(`curl -fsSL ${downloadLink.link} -o android-studio.dmg`, { cwd: temporaryDir }); + await $.spawn(`curl -fsSL --retry 5 --retry-delay 3 --retry-connrefused ${downloadLink.link} -o android-studio.dmg`, { cwd: temporaryDir }); const mountedDir = '/Volumes/android-studio' const { data } = await $.spawn('hdiutil attach android-studio.dmg -mountpoint "/Volumes/android-studio"', { cwd: temporaryDir }); @@ -189,7 +189,12 @@ export class AndroidStudioResource extends Resource { const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codify-android-')) try { - await $.spawn(`curl -fsSL ${downloadLink.link} -o android-studio.tar.gz`, { cwd: temporaryDir }); + const { status: curlStatus } = await $.spawnSafe('curl --version'); + if (curlStatus === SpawnStatus.SUCCESS) { + await $.spawn(`curl -fsSL --retry 5 --retry-delay 3 --retry-connrefused ${downloadLink.link} -o android-studio.tar.gz`, { cwd: temporaryDir }); + } else { + await $.spawn(`wget -q --tries=5 --waitretry=3 -O android-studio.tar.gz ${downloadLink.link}`, { cwd: temporaryDir }); + } await $.spawn(`tar -xzf android-studio.tar.gz`, { cwd: temporaryDir }); // Remove existing install if present @@ -250,12 +255,17 @@ export class AndroidStudioResource extends Resource { || parameters.version === plist.CFBundleShortVersionString ) - return matched.length > 0 - ? { - directory: path.dirname(matched[0].location), - version: matched[0].webInfo?.version ?? matched[0].plist.CFBundleShortVersionString - } - : null; + if (matched.length === 0) return null; + + const best = matched[0]; + const version = best.webInfo?.version + ?? this.allAndroidStudioVersions?.find((v) => v.build === best.plist.CFBundleVersion)?.version + ?? best.plist.CFBundleShortVersionString; + + return { + directory: path.dirname(best.location), + version, + }; } private getVersionData( diff --git a/src/resources/android/android-studios/completions/android-studio.version.ts b/src/resources/android/android-studios/completions/android-studio.version.ts new file mode 100644 index 00000000..a1a5c78d --- /dev/null +++ b/src/resources/android/android-studios/completions/android-studio.version.ts @@ -0,0 +1,15 @@ +import { AndroidStudioVersionData } from '../types.js'; + +export default async function loadAndroidStudioVersions(): Promise { + const response = await fetch('https://jb.gg/android-studio-releases-list.json'); + + if (!response.ok) { + throw new Error(`Failed to fetch Android Studio releases: ${response.status} ${await response.text()}`); + } + + const data = await response.json() as { content: { item: AndroidStudioVersionData[] } }; + + return data.content.item + .filter((item) => item.channel === 'Release') + .map((item) => item.version); +} diff --git a/src/resources/android/types.ts b/src/resources/android/android-studios/types.ts similarity index 100% rename from src/resources/android/types.ts rename to src/resources/android/android-studios/types.ts diff --git a/src/resources/tart/clone-parameter.ts b/src/resources/tart/clone-parameter.ts index f607983d..b2a842d3 100644 --- a/src/resources/tart/clone-parameter.ts +++ b/src/resources/tart/clone-parameter.ts @@ -15,11 +15,35 @@ export class TartCloneParameter extends ArrayStatefulParameter | null> { + async refresh(desired: Array | null): Promise | null> { const $ = getPty(); // List all available VMs in JSON format const { status, data } = await $.spawnSafe('tart list --format json', { interactive: true }); + + // A non-zero exit can mean two very different things, and exit code alone can't + // distinguish them — so we parse the output: + // 1. Tart isn't installed / has nothing to report -> the resource doesn't exist (null). + // 2. Tart failed to *access* its storage -> a real error we must not swallow. + // The most common #2 is macOS blocking access to the TART_HOME directory (e.g. an + // external/removable volume without Full Disk Access), which surfaces as + // "Operation not permitted" / "you don't have permission to view it" even though the + // Unix permissions are fine. Silently returning null there makes Codify believe + // declared VMs are missing and offer to re-clone them. + const permissionDenied = /operation not permitted|don.?t have permission|permission to view/i.test(data ?? ''); + if (permissionDenied) { + const tartHome = process.env.TART_HOME; + throw new Error( + `Failed to list Tart VMs — macOS denied access to Tart's storage` + + (tartHome ? ` (TART_HOME="${tartHome}")` : '') + + `.\n\n${data}\n\n` + + `If TART_HOME points at an external or removable volume, grant the app running ` + + `Codify (your terminal and/or the Codify desktop app) access under System Settings ` + + `→ Privacy & Security → Files and Folders (Removable Volumes) or Full Disk Access, ` + + `then restart the app.` + ); + } + if (status !== SpawnStatus.SUCCESS) { return null; } diff --git a/test/android/android-cli.test.ts b/test/android/android-cli.test.ts new file mode 100644 index 00000000..01335583 --- /dev/null +++ b/test/android/android-cli.test.ts @@ -0,0 +1,102 @@ +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import { SpawnStatus } from '@codifycli/schemas'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const isLinuxArm = os.platform() === 'linux' && os.arch() === 'arm64'; + +describe.skipIf(isLinuxArm)('Android CLI integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + beforeAll(async () => { + const result = await testSpawn('which android'); + if (result.status === SpawnStatus.SUCCESS) { + await PluginTester.uninstall(pluginPath, [{ type: 'android-cli' }]); + } + }, 120_000); + + it('Can install and uninstall Android CLI', { timeout: 300_000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [{ type: 'android-cli' }], + { + validateApply: async () => { + const result = await testSpawn('which android'); + expect(result.status).toBe(SpawnStatus.SUCCESS); + }, + validateDestroy: async () => { + const result = await testSpawn('which android'); + expect(result.status).toBe(SpawnStatus.ERROR); + }, + } + ); + }); + + it('Can install Android CLI with SDK packages', { timeout: 600_000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [ + { + type: 'android-cli', + sdkPackages:['cmdline-tools/latest', 'platform-tools'], + }, + ], + { + validateApply: async () => { + const which = await testSpawn('which android'); + expect(which.status).toBe(SpawnStatus.SUCCESS); + + const list = await testSpawn('android sdk list'); + expect(list.status).toBe(SpawnStatus.SUCCESS); + expect(list.data).toContain('platform-tools'); + }, + validateDestroy: async () => { + const result = await testSpawn('which android'); + expect(result.status).toBe(SpawnStatus.ERROR); + }, + } + ); + }); +}); + +describe.skipIf(isLinuxArm)('Android Emulator integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + beforeAll(async () => { + const result = await testSpawn('which android'); + if (result.status === SpawnStatus.SUCCESS) { + await PluginTester.uninstall(pluginPath, [{ type: 'android-cli' }]); + } + }, 120_000); + + it('Can create and destroy an Android emulator', { timeout: 900_000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [ + { + type: 'android-cli', + sdkPackages: [ + 'cmdline-tools/latest', + 'platform-tools', + 'platforms/android-35', + 'system-images/android-35/google_apis_playstore/x86_64', + ], + emulators: ['medium_phone'], + }, + ], + { + validateApply: async () => { + const list = await testSpawn('android emulator list'); + expect(list.status).toBe(SpawnStatus.SUCCESS); + expect(list.data).toContain('medium_phone'); + }, + validateDestroy: async () => { + const list = await testSpawn('android emulator list'); + expect(list.data).not.toContain('medium_phone'); + }, + } + ); + }); + +});