Code Conventions
Standards and patterns used throughout the GPC codebase. Follow these when contributing or building plugins.
TypeScript
Strict Mode
All packages use TypeScript strict mode ("strict": true in tsconfig). This enables strictNullChecks, noImplicitAny, and all other strict checks.
ESM-First
All packages use ES modules. No CommonJS require() calls.
// Correct
import { PlayApiClient } from "@gpc-cli/api";
// Wrong
const { PlayApiClient } = require("@gpc-cli/api");Named Exports Only
No default exports anywhere in the codebase. Every module uses named exports.
// Correct
export { PlayApiClient };
export { ServiceAccountAuth };
// Wrong
export default PlayApiClient;Explicit Return Types
All exported functions have explicit return type annotations.
// Correct
export function createClient(options: ClientOptions): PlayApiClient {
// ...
}
// Wrong — missing return type
export function createClient(options: ClientOptions) {
// ...
}No any
Use unknown and narrow with type guards instead of any.
// Correct
function parseResponse(data: unknown): AppInfo {
if (typeof data !== "object" || data === null) {
throw new ApiError("Invalid response");
}
// narrow and validate
}
// Wrong
function parseResponse(data: any): AppInfo {
return data as AppInfo;
}Barrel Exports
Each package has an index.ts that re-exports the public API.
// packages/api/src/index.ts
export { PlayApiClient } from "./client.js";
export { ReportingApiClient } from "./reporting-client.js";
export type { ClientOptions, ApiResponse } from "./types.js";Naming Conventions
| Entity | Convention | Example |
|---|---|---|
| Files | kebab-case | rate-limiter.ts |
| Classes | PascalCase | ApiClient |
| Interfaces | PascalCase (no I prefix) | AuthStrategy |
| Types | PascalCase | TrackRelease |
| Functions | camelCase | uploadBundle() |
| Constants | UPPER_SNAKE_CASE | MAX_RETRY_COUNT |
| Env vars | UPPER*SNAKE_CASE with GPC* prefix | GPC_SERVICE_ACCOUNT |
| CLI flags | kebab-case | --service-account |
| npm packages | @gpc-cli/<name> | @gpc-cli/core |
Import Order
Sort imports in this order, with a blank line between groups:
- Node.js built-ins
- External dependencies
- Internal packages (
@gpc-cli/*) - Relative imports
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { Command } from "commander";
import { PlayApiClient } from "@gpc-cli/api";
import { ServiceAccountAuth } from "@gpc-cli/auth";
import { formatOutput } from "./formatters.js";
import type { UploadOptions } from "./types.js";Git Conventions
Branch Strategy
Trunk-based development on main. Short-lived branches only for risky experiments.
main # Primary branch (direct commits)
feat/<scope>/<short-desc> # Feature branches (when needed)
fix/<scope>/<short-desc> # Bug fixes (when needed)
chore/<scope>/<short-desc> # Maintenance
docs/<short-desc> # DocumentationCommit Messages
Follow Conventional Commits:
<type>(<scope>): <description>
[optional body]
[optional footer]Types: feat, fix, docs, chore, refactor, test, perf, ci, build
Scopes: api, auth, config, core, cli, plugin-sdk, ci, docs
Examples:
feat(cli): add gpc releases upload command
fix(auth): handle expired refresh tokens gracefully
docs(api): add rate limiting section to API reference
chore(deps): update googleapis to v130
refactor(core): extract rollout logic into dedicated module
test(auth): add service account auth integration testsPull Requests
- One feature/fix per PR
- Require at least 1 review
- Must pass CI (lint, typecheck, test)
- Squash merge to
main - PR title follows commit convention
Testing Conventions
Framework
All tests use Vitest. Tests are TypeScript-native and ESM-first.
File Structure
Tests live in a tests/ directory inside each package:
packages/api/
├── src/
│ ├── client.ts
│ └── rate-limiter.ts
└── tests/
├── client.test.ts
├── rate-limiter.test.ts
└── fixtures/
└── mock-responses.jsonCoverage Targets
| Package | Target |
|---|---|
@gpc-cli/api | 90% |
@gpc-cli/auth | 90% |
@gpc-cli/config | 95% |
@gpc-cli/core | 85% |
@gpc-cli/cli | 80% |
Mock External APIs
Never call real Google APIs in tests. Mock fetch with vi.stubGlobal:
import { describe, it, expect, vi, beforeEach } from "vitest";
describe("PlayApiClient", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ apps: [] }),
}),
);
});
it("lists apps", async () => {
const client = new PlayApiClient({ auth, packageName: "com.example" });
const result = await client.apps.list();
expect(result.apps).toEqual([]);
expect(fetch).toHaveBeenCalledOnce();
});
});Test Commands
pnpm test # Run all tests
pnpm test --filter @gpc-cli/api # Run tests for specific package
pnpm test:watch # Watch mode
pnpm test:coverage # With coverage report
pnpm test:e2e # End-to-end testsDependency Rules
Between Packages
Dependencies flow in one direction. No circular dependencies.
cli -> core -> api
auth
config
plugin-sdk (zero deps)Enforced rules:
cliimports fromcoreonly -- never directly fromapi,auth, orconfigcoreimports fromapi,auth, andconfigapi,auth, andconfigdo not import from each otherplugin-sdkhas zero internal dependencies
External Dependencies
- Prefer Node.js built-ins over external packages
- Pin major versions in
package.json pnpm auditruns in CI on every PR- No
postinstallscripts in production dependencies - New dependencies must be reviewed for maintenance status, download count, and license
Error Handling Rules
- Every error has a unique
codestring (e.g.,AUTH_TOKEN_EXPIRED) - Every error includes a human-readable
message - Actionable errors include a
suggestionfield - API errors preserve the original HTTP status and response body
- Errors are thrown, never returned -- use try/catch at boundaries
Configuration Priority
Settings are resolved in this order (highest priority first):
- CLI flags (
--app,--profile) - Environment variables (
GPC_APP,GPC_PROFILE) - Project config (
.gpcrc.json,gpc.config.ts,package.json#gpc) - User config (
~/.config/gpc/config.json) - Defaults
Versioning
- Changesets for version management
- Semantic versioning (semver)
- All packages versioned independently
@gpc-cli/cliversion displayed as the "GPC version" to users- Current series:
0.9.xpre-release →1.0.0public launch - Pre-1.0: breaking changes bump minor, features/fixes bump patch
- Post-1.0: standard semver rules
Release Process
- Create changeset:
pnpm changeset - PR merges to
main - Changesets bot creates "Version Packages" PR
- Merge version PR → publishes to npm
- Create umbrella GitHub Release with user-facing notes (see template below)
GitHub Release Notes Template
One release per version. Per-package changesets releases are not created — only umbrella v* releases.
## What's Changed
- feat: user-facing description of feature
- fix: user-facing description of fix
- perf: user-facing description of improvement
- breaking: description of breaking change
**Full Changelog**: https://github.com/yasserstudio/gpc/compare/vPREVIOUS...vCURRENTRules:
- Use
feat:,fix:,perf:,breaking:,docs:,ci:prefixes - Write for users, not contributors ("faster CLI startup", not "cached homedir at module level")
- No package scopes in prefixes (
feat:notfeat(core):) - No internal jargon (no "mutex", "token bucket", "barrel exports")
- Always include Full Changelog link
- Attach binaries when applicable