Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/fix-squad-home-env-bugs.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion packages/squad-cli/src/cli/commands/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ function createNoopAdapter(): ReturnType<typeof createPlatformAdapter> {
/** Verify the copilot CLI is available. */
async function checkCopilotCli(): Promise<void> {
return new Promise<void>((resolve, reject) => {
execFile('copilot', ['--version'], (err) => {
execFile('copilot', ['--version'], { shell: process.platform === 'win32', timeout: 5000 }, (err) => {
if (err) reject(err);
else resolve();
});
Expand Down
23 changes: 22 additions & 1 deletion packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown> = {};
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)) {
Expand Down
20 changes: 13 additions & 7 deletions packages/squad-sdk/src/platform/comms-teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <squad-home>/teams-tokens-{hash(tenantId:clientId)}.json
* (squad-home defaults to ~/.squad/ but respects the SQUAD_HOME env var).
*
* @module platform/comms-teams
*/
Expand All @@ -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';
Comment thread
bradygaster marked this conversation as resolved.
Resolved

// ─── Defaults ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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 = {
Expand All @@ -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);
}
}
Expand Down
14 changes: 10 additions & 4 deletions packages/squad-sdk/src/ralph/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. `<squad-home>/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. `<squad-home>/machine-capabilities.json` resolved via
* `resolveSquadHome()` (defaults to `~/.squad/`, respects `SQUAD_HOME`)
*
* Returns null if no capabilities file exists (all issues pass through).
*/
Expand All @@ -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'));
Comment thread
bradygaster marked this conversation as resolved.
Resolved
}

for (const candidate of candidates) {
if (storage.existsSync(candidate)) {
Expand Down
8 changes: 8 additions & 0 deletions packages/squad-sdk/src/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
bradygaster marked this conversation as resolved.
Resolved
Comment thread
bradygaster marked this conversation as resolved.
Resolved

const globalDir = resolveGlobalSquadPath();
const personalDir = path.join(globalDir, 'personal-squad');

Expand Down
32 changes: 30 additions & 2 deletions test/comms-teams-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -71,7 +74,20 @@ const httpMocks = vi.hoisted(() => ({

vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return { ...actual, ...fsMocks };
return {
...actual,
...fsMocks,
default: { ...actual, ...fsMocks },
};
});

vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
...fsMocks,
default: { ...actual, ...fsMocks },
};
});

vi.mock('node:child_process', async () => {
Expand All @@ -81,7 +97,16 @@ vi.mock('node:child_process', async () => {

vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('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 () => {
Expand Down Expand Up @@ -127,6 +152,9 @@ beforeEach(() => {
expiresAt: Date.now() + 3_600_000,
}),
);
fsMocks.statSync.mockReturnValue({
isDirectory: () => true,
});
fsMocks.writeFileSync.mockReset();
fsMocks.mkdirSync.mockReset();

Expand Down
29 changes: 29 additions & 0 deletions test/personal-squad-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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
Expand Down