From 4e6ef275830eca6a7e63e31398ba69a0814e95e3 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Wed, 24 Jun 2026 02:28:46 -0700 Subject: [PATCH 1/3] fix: respect SQUAD_HOME env var, implement SQUAD_PERSONAL_DIR, fix Windows loop preflight - capabilities.ts: use resolveSquadHome() instead of hardcoded ~/.squad/ (#1280) - comms-teams.ts: use resolveSquadHome() for Teams OAuth token storage (#1279) - resolution.ts: implement SQUAD_PERSONAL_DIR env var override (#1278) - loop.ts: add shell flag for Windows copilot.ps1/.cmd resolution (#1372) - init.ts: link to personal squad teamRoot when available (#1010, #984) Closes #1280, Closes #1279, Closes #1278, Closes #1372, Closes #1010, Closes #984 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/fix-squad-home-env-bugs.md | 12 +++++++++++ packages/squad-cli/src/cli/commands/loop.ts | 2 +- packages/squad-cli/src/cli/core/init.ts | 21 +++++++++++++++++++ .../squad-sdk/src/platform/comms-teams.ts | 17 +++++++++------ packages/squad-sdk/src/ralph/capabilities.ts | 7 +++++-- packages/squad-sdk/src/resolution.ts | 8 +++++++ 6 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-squad-home-env-bugs.md diff --git a/.changeset/fix-squad-home-env-bugs.md b/.changeset/fix-squad-home-env-bugs.md new file mode 100644 index 000000000..e5bd0da9d --- /dev/null +++ b/.changeset/fix-squad-home-env-bugs.md @@ -0,0 +1,12 @@ +--- +"@bradygaster/squad-sdk": patch +"@bradygaster/squad-cli": patch +--- + +fix: respect SQUAD_HOME in capabilities.ts and comms-teams.ts, implement SQUAD_PERSONAL_DIR env var, fix Windows shell flag in loop preflight, link personal squad during init + +- capabilities.ts: use `resolveSquadHome()` instead of hardcoded `~/.squad/` for machine-capabilities.json (#1280) +- comms-teams.ts: use `resolveSquadHome()` instead of hardcoded `~/.squad/` for Teams OAuth token storage (#1279) +- resolution.ts: implement `SQUAD_PERSONAL_DIR` env var override in `resolvePersonalSquadDir()` (#1278) +- loop.ts: add `shell: process.platform === 'win32'` to `checkCopilotCli()` execFile call (#1372) +- init.ts: set `teamRoot` in config.json to personal squad directory when one exists (#1010, #984) diff --git a/packages/squad-cli/src/cli/commands/loop.ts b/packages/squad-cli/src/cli/commands/loop.ts index e52432e83..c544d1196 100644 --- a/packages/squad-cli/src/cli/commands/loop.ts +++ b/packages/squad-cli/src/cli/commands/loop.ts @@ -248,7 +248,7 @@ function createNoopAdapter(): ReturnType { /** Verify the copilot CLI is available. */ async function checkCopilotCli(): Promise { return new Promise((resolve, reject) => { - execFile('copilot', ['--version'], (err) => { + execFile('copilot', ['--version'], { shell: process.platform === 'win32', timeout: 5000 }, (err) => { if (err) reject(err); else resolve(); }); diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 1c1e71eb8..4bc3da696 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -284,6 +284,27 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // immediately instead of after the 5-second TTL. clearResolveSquadCache(); + // ── Personal squad linking ───────────────────────────────────────── + // When a personal squad directory exists and this is a repo init (not global), + // set teamRoot in config.json to point to the personal squad directory. + // This enables "remote mode" resolution so the project inherits team identity + // from the personal squad. (See #984, #1010) + if (!options.isGlobal) { + const personalDir = resolvePersonalSquadDir(); + if (personalDir) { + const configPath = path.join(squadDir, 'config.json'); + let config: Record = {}; + try { + const raw = storage.readSync(configPath); + if (raw) config = JSON.parse(raw); + } catch { /* start fresh */ } + if (!config['teamRoot']) { + config['teamRoot'] = personalDir; + storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); + } + } + } + // Ensure version is fully stamped in squad.agent.md const agentPath = path.join(agentFileRoot, '.github', 'agents', 'squad.agent.md'); if (storage.existsSync(agentPath)) { diff --git a/packages/squad-sdk/src/platform/comms-teams.ts b/packages/squad-sdk/src/platform/comms-teams.ts index eb4c1a557..487ab925a 100644 --- a/packages/squad-sdk/src/platform/comms-teams.ts +++ b/packages/squad-sdk/src/platform/comms-teams.ts @@ -15,6 +15,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSy import { createServer } from 'node:http'; import { randomBytes, createHash } from 'node:crypto'; import { execFile } from 'node:child_process'; +import { resolveSquadHome } from '../resolution.js'; import type { CommunicationAdapter, CommunicationChannel, CommunicationReply } from './types.js'; // ─── Defaults ──────────────────────────────────────────────────────── @@ -54,9 +55,12 @@ interface StoredTokens { authenticatedUserId?: string; } -const SQUAD_DIR = join(homedir(), '.squad'); +function getSquadDir(): string { + return resolveSquadHome(true) ?? join(homedir(), '.squad'); +} + /** Legacy single-file path (pre-tenant-scoped) — migrated away on first use */ -const LEGACY_TOKEN_PATH = join(SQUAD_DIR, 'teams-tokens.json'); +const LEGACY_TOKEN_PATH = join(homedir(), '.squad', 'teams-tokens.json'); /** * Derive a safe, collision-resistant filename for a token cache entry. @@ -65,7 +69,7 @@ const LEGACY_TOKEN_PATH = join(SQUAD_DIR, 'teams-tokens.json'); */ function getTokenPath(tenantId: string, clientId: string): string { const hash = createHash('sha256').update(`${tenantId}:${clientId}`).digest('hex').slice(0, 16); - return join(SQUAD_DIR, `teams-tokens-${hash}.json`); + return join(getSquadDir(), `teams-tokens-${hash}.json`); } function loadTokens(tenantId: string, clientId: string): StoredTokens | null { @@ -88,8 +92,9 @@ function loadTokens(tenantId: string, clientId: string): StoredTokens | null { // Security: tokens stored with 0o600 permissions (owner-only read/write). function saveTokens(tenantId: string, clientId: string, tokens: StoredTokens): void { const tokenPath = getTokenPath(tenantId, clientId); - if (!existsSync(SQUAD_DIR)) { - mkdirSync(SQUAD_DIR, { recursive: true, mode: 0o700 }); + const squadDir = getSquadDir(); + if (!existsSync(squadDir)) { + mkdirSync(squadDir, { recursive: true, mode: 0o700 }); } const jwtClaims = extractJwtClaims(tokens.accessToken); const withMeta: StoredTokens = { @@ -107,7 +112,7 @@ function saveTokens(tenantId: string, clientId: string, tokens: StoredTokens): v if (err) console.warn('⚠️ Could not restrict token file permissions:', err.message); }); } else { - chmodSync(SQUAD_DIR, 0o700); + chmodSync(squadDir, 0o700); chmodSync(tokenPath, 0o600); } } diff --git a/packages/squad-sdk/src/ralph/capabilities.ts b/packages/squad-sdk/src/ralph/capabilities.ts index 2909fa350..a19e54fcc 100644 --- a/packages/squad-sdk/src/ralph/capabilities.ts +++ b/packages/squad-sdk/src/ralph/capabilities.ts @@ -10,9 +10,9 @@ import path from 'node:path'; import { FSStorageProvider } from '../storage/fs-storage-provider.js'; +import { resolveSquadHome } from '../resolution.js'; const storage = new FSStorageProvider(); -import os from 'node:os'; /** Deployment mode for capability routing */ export type DeploymentMode = 'agent-per-node' | 'squad-per-pod'; @@ -102,7 +102,10 @@ export async function loadCapabilities( } candidates.push(path.join(teamRoot, '.squad', 'machine-capabilities.json')); } - candidates.push(path.join(os.homedir(), '.squad', 'machine-capabilities.json')); + const squadHome = resolveSquadHome(); + if (squadHome) { + candidates.push(path.join(squadHome, 'machine-capabilities.json')); + } for (const candidate of candidates) { if (storage.existsSync(candidate)) { diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index 92ee46046..a02bf774c 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -442,6 +442,14 @@ export function resolveGlobalSquadPath(): string { export function resolvePersonalSquadDir(): string | null { if (process.env['SQUAD_NO_PERSONAL']) return null; + // Honor SQUAD_PERSONAL_DIR env var override + const envDir = process.env['SQUAD_PERSONAL_DIR']; + if (envDir) { + const resolved = path.resolve(envDir); + if (storage.existsSync(resolved)) return resolved; + return null; + } + const globalDir = resolveGlobalSquadPath(); const personalDir = path.join(globalDir, 'personal-squad'); From 8cb3c23c2ae3a0b0268b12b9505858abe60bc47e Mon Sep 17 00:00:00 2001 From: brady gaster Date: Wed, 24 Jun 2026 09:37:57 -0700 Subject: [PATCH 2/3] fix: address PR review feedback - teamRoot, directory validation, JSDoc, tests - init.ts: use resolveGlobalSquadPath() for teamRoot instead of personal-squad dir - resolution.ts: validate SQUAD_PERSONAL_DIR is a directory, not just exists - comms-teams.ts: update JSDoc to reflect resolveSquadHome() usage - capabilities.ts: update JSDoc search order to document SQUAD_HOME support - test: add SQUAD_PERSONAL_DIR env var test cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli/core/init.ts | 10 +++---- .../squad-sdk/src/platform/comms-teams.ts | 3 +- packages/squad-sdk/src/ralph/capabilities.ts | 7 +++-- packages/squad-sdk/src/resolution.ts | 2 +- test/personal-squad-init.test.ts | 29 +++++++++++++++++++ 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 4bc3da696..c226e3c8e 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -11,7 +11,7 @@ import { success, BOLD, RESET, YELLOW, GREEN, DIM } from './output.js'; import { fatal } from './errors.js'; import { detectProjectType } from './project-type.js'; import { getPackageVersion, stampVersion } from './version.js'; -import { initSquad as sdkInitSquad, cleanupOrphanInitPrompt, ensurePersonalSquadDir, resolvePersonalSquadDir, clearResolveSquadCache, type InitOptions } from '@bradygaster/squad-sdk'; +import { initSquad as sdkInitSquad, cleanupOrphanInitPrompt, ensurePersonalSquadDir, resolveGlobalSquadPath, resolvePersonalSquadDir, clearResolveSquadCache, type InitOptions } from '@bradygaster/squad-sdk'; import { installGitHooks } from '../commands/install-hooks.js'; import { liftInitMutableStateOntoOrphan } from '../commands/migrate-backend.js'; import { resolveSquadStateMcpSpec } from './mcp-spec.js'; @@ -286,9 +286,9 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // ── Personal squad linking ───────────────────────────────────────── // When a personal squad directory exists and this is a repo init (not global), - // set teamRoot in config.json to point to the personal squad directory. - // This enables "remote mode" resolution so the project inherits team identity - // from the personal squad. (See #984, #1010) + // set teamRoot in config.json to point to the global squad root that contains + // `.squad/`. This enables "remote mode" resolution so the project inherits + // team identity from the personal squad. (See #984, #1010) if (!options.isGlobal) { const personalDir = resolvePersonalSquadDir(); if (personalDir) { @@ -299,7 +299,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi if (raw) config = JSON.parse(raw); } catch { /* start fresh */ } if (!config['teamRoot']) { - config['teamRoot'] = personalDir; + config['teamRoot'] = resolveGlobalSquadPath(); storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); } } diff --git a/packages/squad-sdk/src/platform/comms-teams.ts b/packages/squad-sdk/src/platform/comms-teams.ts index 487ab925a..64580047e 100644 --- a/packages/squad-sdk/src/platform/comms-teams.ts +++ b/packages/squad-sdk/src/platform/comms-teams.ts @@ -4,7 +4,8 @@ * Auth priority: cached token → refresh → browser PKCE → device code fallback. * Uses the Microsoft Graph PowerShell first-party client ID by default (works * in every Microsoft tenant with no custom Entra registration required). - * Tokens stored per-identity in ~/.squad/teams-tokens-{hash(tenantId:clientId)}.json. + * Tokens stored per-identity in /teams-tokens-{hash(tenantId:clientId)}.json + * (squad-home defaults to ~/.squad/ but respects the SQUAD_HOME env var). * * @module platform/comms-teams */ diff --git a/packages/squad-sdk/src/ralph/capabilities.ts b/packages/squad-sdk/src/ralph/capabilities.ts index a19e54fcc..42a1b2b04 100644 --- a/packages/squad-sdk/src/ralph/capabilities.ts +++ b/packages/squad-sdk/src/ralph/capabilities.ts @@ -80,11 +80,14 @@ export function generatePodCapabilitiesPath(teamRoot: string, podId: string): st * `squad-per-pod`, the search order becomes: * 1. `.squad/machine-capabilities-{podId}.json` (pod-specific) * 2. `.squad/machine-capabilities.json` (shared fallback) - * 3. `~/.squad/machine-capabilities.json` (user home fallback) + * 3. `/machine-capabilities.json` (user fallback resolved via + * `resolveSquadHome()`, defaulting to `~/.squad/` and respecting + * `SQUAD_HOME`) * * Otherwise (default `agent-per-node` mode): * 1. `.squad/machine-capabilities.json` in the team root - * 2. `~/.squad/machine-capabilities.json` in the user home + * 2. `/machine-capabilities.json` resolved via + * `resolveSquadHome()` (defaults to `~/.squad/`, respects `SQUAD_HOME`) * * Returns null if no capabilities file exists (all issues pass through). */ diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index a02bf774c..b3293e415 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -446,7 +446,7 @@ export function resolvePersonalSquadDir(): string | null { const envDir = process.env['SQUAD_PERSONAL_DIR']; if (envDir) { const resolved = path.resolve(envDir); - if (storage.existsSync(resolved)) return resolved; + if (storage.existsSync(resolved) && storage.isDirectorySync(resolved)) return resolved; return null; } diff --git a/test/personal-squad-init.test.ts b/test/personal-squad-init.test.ts index 3a2c4ad18..913429cee 100644 --- a/test/personal-squad-init.test.ts +++ b/test/personal-squad-init.test.ts @@ -92,17 +92,22 @@ describe('resolveGlobalSquadPath — platform paths', () => { // --------------------------------------------------------------------------- describe('resolvePersonalSquadDir — discovery & kill-switch', () => { let savedNoPersonal: string | undefined; + let savedPersonalDir: string | undefined; beforeEach(() => { cleanup(); mkdirSync(TEST_ROOT, { recursive: true }); savedNoPersonal = process.env['SQUAD_NO_PERSONAL']; + savedPersonalDir = process.env['SQUAD_PERSONAL_DIR']; delete process.env['SQUAD_NO_PERSONAL']; + delete process.env['SQUAD_PERSONAL_DIR']; }); afterEach(() => { if (savedNoPersonal !== undefined) process.env['SQUAD_NO_PERSONAL'] = savedNoPersonal; else delete process.env['SQUAD_NO_PERSONAL']; + if (savedPersonalDir !== undefined) process.env['SQUAD_PERSONAL_DIR'] = savedPersonalDir; + else delete process.env['SQUAD_PERSONAL_DIR']; cleanup(); vi.unstubAllEnvs(); }); @@ -142,6 +147,30 @@ describe('resolvePersonalSquadDir — discovery & kill-switch', () => { } }); + it('returns env override when SQUAD_PERSONAL_DIR points to an existing directory', () => { + const customPersonalDir = join(TEST_ROOT, 'env-personal-squad'); + mkdirSync(customPersonalDir, { recursive: true }); + + process.env['SQUAD_PERSONAL_DIR'] = customPersonalDir; + + expect(resolvePersonalSquadDir()).toBe(customPersonalDir); + }); + + it('returns null when SQUAD_PERSONAL_DIR points to a missing path', () => { + process.env['SQUAD_PERSONAL_DIR'] = join(TEST_ROOT, 'missing-personal-squad'); + + expect(resolvePersonalSquadDir()).toBeNull(); + }); + + it('returns null when SQUAD_PERSONAL_DIR points to a file instead of a directory', async () => { + const personalFile = join(TEST_ROOT, 'personal-squad.txt'); + await writeFile(personalFile, 'not a directory', 'utf-8'); + + process.env['SQUAD_PERSONAL_DIR'] = personalFile; + + expect(resolvePersonalSquadDir()).toBeNull(); + }); + it('works regardless of how the CLI was invoked (npx, global, local)', () => { // resolvePersonalSquadDir uses os.homedir / env vars — not process.argv[0] // Verify it does NOT inspect process.argv or require a specific install path From 9b372a6e89f545992593fbb069d2a70a82ba7b6e Mon Sep 17 00:00:00 2001 From: brady gaster Date: Wed, 24 Jun 2026 10:45:10 -0700 Subject: [PATCH 3/3] test: fix Teams home resolution mocks Closes #1280 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/comms-teams-integration.test.ts | 32 ++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/test/comms-teams-integration.test.ts b/test/comms-teams-integration.test.ts index 689b000ae..7d08e9473 100644 --- a/test/comms-teams-integration.test.ts +++ b/test/comms-teams-integration.test.ts @@ -25,6 +25,9 @@ const fsMocks = vi.hoisted(() => ({ expiresAt: Date.now() + 3_600_000, }), ), + statSync: vi.fn(() => ({ + isDirectory: () => true, + })), writeFileSync: vi.fn(), mkdirSync: vi.fn(), chmodSync: vi.fn(), @@ -71,7 +74,20 @@ const httpMocks = vi.hoisted(() => ({ vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); - return { ...actual, ...fsMocks }; + return { + ...actual, + ...fsMocks, + default: { ...actual, ...fsMocks }, + }; +}); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + ...fsMocks, + default: { ...actual, ...fsMocks }, + }; }); vi.mock('node:child_process', async () => { @@ -81,7 +97,16 @@ vi.mock('node:child_process', async () => { vi.mock('node:os', async () => { const actual = await vi.importActual('node:os'); - return { ...actual, platform: osMocks.platform, homedir: osMocks.homedir }; + return { + ...actual, + platform: osMocks.platform, + homedir: osMocks.homedir, + default: { + ...actual, + platform: osMocks.platform, + homedir: osMocks.homedir, + }, + }; }); vi.mock('node:http', async () => { @@ -127,6 +152,9 @@ beforeEach(() => { expiresAt: Date.now() + 3_600_000, }), ); + fsMocks.statSync.mockReturnValue({ + isDirectory: () => true, + }); fsMocks.writeFileSync.mockReset(); fsMocks.mkdirSync.mockReset();