--- name: linear-local-dev-loop description: 'Set up local Linear development environment and testing workflow. Use when configuring local dev, testing integrations, or setting up a development workflow with Linear webhooks. Trigger: "linear local development", "linear dev setup", "test linear locally", "linear development environment". ' allowed-tools: Read, Write, Edit, Bash(npm:*), Bash(npx:*), Grep version: 1.0.0 license: MIT author: Jeremy Longshore tags: - saas - linear - testing - workflow compatibility: Designed for Claude Code, also compatible with Codex and OpenClaw --- # Linear Local Dev Loop ## Overview Set up an efficient local development workflow for building Linear integrations. Covers project scaffolding, environment config, test utilities, webhook tunneling with ngrok, and integration testing with vitest. ## Prerequisites - Node.js 18+ with TypeScript - `@linear/sdk` package - Separate Linear workspace or team for development (recommended) - ngrok or cloudflared for webhook tunnel testing ## Instructions ### Step 1: Project Scaffolding ```bash set -euo pipefail mkdir linear-integration && cd linear-integration npm init -y npm install @linear/sdk dotenv npm install -D typescript @types/node vitest tsx # TypeScript config npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --strict ``` ### Step 2: Environment Configuration ```bash # .env (never commit) cat > .env << 'EOF' LINEAR_API_KEY=lin_api_dev_xxxxxxxxxxxx LINEAR_WEBHOOK_SECRET=whsec_dev_xxxxxxxxxxxx LINEAR_DEV_TEAM_KEY=DEV NODE_ENV=development EOF # .env.example (commit this for onboarding) cat > .env.example << 'EOF' LINEAR_API_KEY=lin_api_your_key_here LINEAR_WEBHOOK_SECRET= LINEAR_DEV_TEAM_KEY=DEV NODE_ENV=development EOF echo -e ".env\n.env.local\n.env.*.local" >> .gitignore ``` ### Step 3: Client Module with Connection Verification ```typescript // src/client.ts import { LinearClient } from "@linear/sdk"; import "dotenv/config"; let _client: LinearClient | null = null; export function getClient(): LinearClient { if (!_client) { const apiKey = process.env.LINEAR_API_KEY; if (!apiKey) throw new Error("LINEAR_API_KEY not set — copy .env.example to .env"); _client = new LinearClient({ apiKey }); } return _client; } export async function verifyConnection(): Promise { const client = getClient(); const viewer = await client.viewer; const teams = await client.teams(); console.log(`[Linear] Connected as ${viewer.name} (${viewer.email})`); console.log(`[Linear] Teams: ${teams.nodes.map(t => t.key).join(", ")}`); } ``` ### Step 4: Test Data Utilities ```typescript // src/test-utils.ts import { getClient } from "./client"; const TEST_PREFIX = "[DEV-TEST]"; export async function getDevTeam() { const client = getClient(); const teamKey = process.env.LINEAR_DEV_TEAM_KEY ?? "DEV"; const teams = await client.teams({ filter: { key: { eq: teamKey } } }); const team = teams.nodes[0]; if (!team) throw new Error(`Team ${teamKey} not found — set LINEAR_DEV_TEAM_KEY`); return team; } export async function createTestIssue(title?: string) { const client = getClient(); const team = await getDevTeam(); const result = await client.createIssue({ teamId: team.id, title: `${TEST_PREFIX} ${title ?? new Date().toISOString()}`, description: "Automated test issue — safe to delete", priority: 4, // Low }); return result.issue; } export async function cleanupTestIssues() { const client = getClient(); const team = await getDevTeam(); const issues = await client.issues({ filter: { team: { id: { eq: team.id } }, title: { startsWith: TEST_PREFIX }, }, first: 100, }); let deleted = 0; for (const issue of issues.nodes) { await issue.delete(); deleted++; } console.log(`Cleaned up ${deleted} test issues`); } ``` ### Step 5: Integration Tests with Vitest ```typescript // tests/linear.integration.test.ts import { describe, it, expect, afterAll } from "vitest"; import { getClient } from "../src/client"; import { createTestIssue, cleanupTestIssues, getDevTeam } from "../src/test-utils"; describe("Linear Integration", () => { afterAll(async () => { await cleanupTestIssues(); }); it("authenticates successfully", async () => { const client = getClient(); const viewer = await client.viewer; expect(viewer.name).toBeDefined(); expect(viewer.email).toBeDefined(); }); it("creates and updates an issue", async () => { const client = getClient(); const issue = await createTestIssue("vitest create"); expect(issue).toBeDefined(); expect(issue?.title).toContain("[DEV-TEST]"); // Update it await client.updateIssue(issue!.id, { priority: 2 }); const updated = await client.issue(issue!.id); expect(updated.priority).toBe(2); }); it("queries workflow states", async () => { const team = await getDevTeam(); const states = await team.states(); const types = states.nodes.map(s => s.type); expect(types).toContain("unstarted"); expect(types).toContain("completed"); }); }); ``` ### Step 6: Package Scripts ```json { "scripts": { "dev": "tsx watch src/index.ts", "verify": "tsx src/verify-connection.ts", "test": "vitest run", "test:watch": "vitest --watch", "cleanup": "tsx src/cleanup.ts" } } ``` ### Step 7: Webhook Local Development with ngrok ```bash # Terminal 1: Start your webhook server npm run dev # Terminal 2: Expose port 3000 via ngrok ngrok http 3000 # Copy the https://xxxx.ngrok-free.app URL # Register webhook in Linear: # Settings > API > Webhooks > New webhook # URL: https://xxxx.ngrok-free.app/webhooks/linear # Select: Issues, Comments ``` Minimal webhook receiver for local testing: ```typescript // src/webhook-dev.ts import express from "express"; import crypto from "crypto"; const app = express(); app.post("/webhooks/linear", express.raw({ type: "*/*" }), (req, res) => { const body = req.body.toString(); const sig = req.headers["linear-signature"] as string; const secret = process.env.LINEAR_WEBHOOK_SECRET!; if (secret && sig) { const expected = crypto.createHmac("sha256", secret).update(body).digest("hex"); if (sig !== expected) { console.warn("Signature mismatch — check LINEAR_WEBHOOK_SECRET"); } } const event = JSON.parse(body); console.log(`[Webhook] ${event.type}.${event.action}:`, event.data?.identifier ?? event.data?.id); res.json({ ok: true }); }); app.listen(3000, () => console.log("Webhook server on http://localhost:3000")); ``` ## Error Handling | Error | Cause | Solution | |-------|-------|----------| | `LINEAR_API_KEY not set` | Missing .env | Copy `.env.example` to `.env` and fill in values | | `Team DEV not found` | Wrong team key | Set `LINEAR_DEV_TEAM_KEY` to a valid team key | | `Cannot find module` | TypeScript path issue | Check `tsconfig.json` module resolution | | Webhook not received | Tunnel not running | Start `ngrok http 3000` and register the URL | | `Authentication required` | Expired dev API key | Regenerate in Linear Settings > Account > API | ## Examples ### Quick Connection Test Script ```typescript // src/verify-connection.ts import { verifyConnection } from "./client"; verifyConnection() .then(() => console.log("Connection OK")) .catch((err) => { console.error("Connection FAILED:", err.message); process.exit(1); }); ``` ## Resources - [Linear SDK Documentation](https://linear.app/developers/sdk) - [Linear Webhooks](https://linear.app/developers/webhooks) - [Vitest Documentation](https://vitest.dev) - [ngrok Quickstart](https://ngrok.com/docs/getting-started/)