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..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'; @@ -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 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) { + 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'] = resolveGlobalSquadPath(); + 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..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 */ @@ -15,6 +16,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 +56,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 +70,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 +93,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 +113,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..42a1b2b04 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'; @@ -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). */ @@ -102,7 +105,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..b3293e415 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) && storage.isDirectorySync(resolved)) return resolved; + return null; + } + const globalDir = resolveGlobalSquadPath(); const personalDir = path.join(globalDir, 'personal-squad'); 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(); 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