Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/init-upgrade-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bradygaster/squad-cli': patch
---

Improve doctor UX for global installs — ESM checks show ℹ️ info instead of ⚠️ warn with clearer messaging. Export ENSURE_DIRECTORIES for parity testing.
90 changes: 90 additions & 0 deletions .github/workflows/squad-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -820,3 +820,93 @@ jobs:

console.log('✅ All ' + passed + ' subpath exports resolve and import successfully');
"

# ═══════════════════════════════════════════════════════════════════════
# Upgrade → Doctor validation (FR-3, Issue #817)
# Verifies that init → upgrade → doctor produces no failures.
# Only runs when init/upgrade/doctor/templates code changes.
# ═══════════════════════════════════════════════════════════════════════
upgrade-doctor-check:
needs: changes
if: >-
github.event_name == 'pull_request'
&& !cancelled()
&& (needs.changes.outputs.code == 'true'
|| needs.changes.result == 'failure')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'

- name: Check for relevant changes
id: relevant
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED=$(git diff --name-only "$BASE"..."$HEAD")
RELEVANT=$(echo "$CHANGED" | grep -E '^packages/squad-cli/src/cli/(commands/doctor|core/init|core/upgrade|core/templates)\.ts$' || true)
if [ -z "$RELEVANT" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "No init/upgrade/doctor/templates changes — skipping"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "Relevant files changed:"
echo "$RELEVANT"
fi

- name: Install dependencies
if: steps.relevant.outputs.skip != 'true'
run: npm install

- name: Build
if: steps.relevant.outputs.skip != 'true'
run: npm run build

- name: "🩺 Init → Upgrade → Doctor validation"
if: steps.relevant.outputs.skip != 'true'
run: |
node --input-type=module -e "
import { join } from 'path';
import { mkdirSync, rmSync, existsSync } from 'fs';
import { randomBytes } from 'crypto';

// Dynamic imports for built CLI modules
const { runInit } = await import('./packages/squad-cli/dist/cli/core/init.js');
const { runUpgrade } = await import('./packages/squad-cli/dist/cli/core/upgrade.js');
const { runDoctor } = await import('./packages/squad-cli/dist/cli/commands/doctor.js');

const testDir = join(process.cwd(), '.ci-upgrade-doctor-' + randomBytes(4).toString('hex'));
try {
mkdirSync(testDir, { recursive: true });

console.log('Step 1: squad init');
await runInit(testDir);

console.log('Step 2: squad upgrade');
await runUpgrade(testDir, { force: true });

console.log('Step 3: squad doctor');
const checks = await runDoctor(testDir);
const failures = checks.filter(c => c.status === 'fail');

if (failures.length > 0) {
console.error('');
console.error('::error::Doctor reported ' + failures.length + ' failure(s) after init + upgrade:');
failures.forEach(f => console.error(' ❌ ' + f.name + ' — ' + f.message));
process.exit(1);
}

console.log('✅ Doctor passed — no failures after init + upgrade (' + checks.length + ' checks)');
} finally {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
}
"
27 changes: 17 additions & 10 deletions packages/squad-cli/src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ function tryReadJson(p: string): unknown | undefined {
}
}

/**
* Detect whether we appear to be running from a global npm install.
* Heuristic: no node_modules directory in the cwd means the CLI was
* likely installed globally (npm i -g) rather than as a project dependency.
*/
export function looksLikeGlobalInstall(cwd: string): boolean {
return !isDirectory(path.join(cwd, 'node_modules'));
}

// ── mode detection ──────────────────────────────────────────────────

function detectMode(cwd: string): ModeInfo {
Expand Down Expand Up @@ -330,20 +339,19 @@ function checkVscodeJsonrpcExports(cwd: string): DoctorCheck {
}

// Detect whether we're in a local dev context (node_modules exists) or global install
const hasNodeModules = isDirectory(path.join(cwd, 'node_modules'));
if (hasNodeModules) {
if (looksLikeGlobalInstall(cwd)) {
return {
name: 'vscode-jsonrpc exports field',
status: 'warn',
message: 'not found in node_modules — run npm install or check dependencies',
severity: 'info',
message: 'Expected for global CLI installs — ESM patches applied at package install time',
};
}

return {
name: 'vscode-jsonrpc exports field',
status: 'warn',
severity: 'info',
message: 'not found in node_modules (expected for global installs)',
message: 'not found in node_modules — run npm install or check dependencies',
};
}

Expand Down Expand Up @@ -386,20 +394,19 @@ function checkCopilotSdkSessionPatch(cwd: string): DoctorCheck {
}

// Detect whether we're in a local dev context (node_modules exists) or global install
const hasNodeModules = isDirectory(path.join(cwd, 'node_modules'));
if (hasNodeModules) {
if (looksLikeGlobalInstall(cwd)) {
return {
name: 'copilot-sdk session.js ESM patch',
status: 'warn',
message: 'not found in node_modules — run npm install or check dependencies',
severity: 'info',
message: 'Expected for global CLI installs — ESM patches applied at package install time',
};
}

return {
name: 'copilot-sdk session.js ESM patch',
status: 'warn',
severity: 'info',
message: 'not found in node_modules (expected for global installs)',
message: 'not found in node_modules — run npm install or check dependencies',
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ const GITIGNORE_ENTRIES = [
'.squad-workstream',
];

const ENSURE_DIRECTORIES = [
export const ENSURE_DIRECTORIES = [
'.squad/identity',
'.squad/orchestration-log',
'.squad/log',
Expand Down
39 changes: 34 additions & 5 deletions test/cli/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { mkdir, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
import { randomBytes } from 'crypto';
import { runDoctor, getDoctorMode, checkNodeVersion } from '@bradygaster/squad-cli/commands/doctor';
import { runDoctor, getDoctorMode, checkNodeVersion, looksLikeGlobalInstall } from '@bradygaster/squad-cli/commands/doctor';
import type { DoctorCheck } from '@bradygaster/squad-cli/commands/doctor';

const TEST_ROOT = join(process.cwd(), `.test-doctor-${randomBytes(4).toString('hex')}`);
Expand Down Expand Up @@ -210,26 +210,28 @@ describe('squad doctor', () => {

// ── #565 — Actionable resolution hints in warnings ────────────────

it('vscode-jsonrpc info says "expected for global installs" when not in node_modules', async () => {
it('vscode-jsonrpc info says "Expected for global CLI installs" when not in node_modules', async () => {
await scaffold(TEST_ROOT);

const checks = await runDoctor(TEST_ROOT);
const jsonrpcCheck = checks.find((c: DoctorCheck) => c.name === 'vscode-jsonrpc exports field');
expect(jsonrpcCheck).toBeDefined();
expect(jsonrpcCheck?.status).toBe('warn');
expect(jsonrpcCheck?.severity).toBe('info');
expect(jsonrpcCheck?.message).toContain('expected for global installs');
expect(jsonrpcCheck?.message).toContain('Expected for global CLI installs');
expect(jsonrpcCheck?.message).toContain('ESM patches applied at package install time');
});

it('copilot-sdk info says "expected for global installs" when not in node_modules', async () => {
it('copilot-sdk info says "Expected for global CLI installs" when not in node_modules', async () => {
await scaffold(TEST_ROOT);

const checks = await runDoctor(TEST_ROOT);
const sdkCheck = checks.find((c: DoctorCheck) => c.name === 'copilot-sdk session.js ESM patch');
expect(sdkCheck).toBeDefined();
expect(sdkCheck?.status).toBe('warn');
expect(sdkCheck?.severity).toBe('info');
expect(sdkCheck?.message).toContain('expected for global installs');
expect(sdkCheck?.message).toContain('Expected for global CLI installs');
expect(sdkCheck?.message).toContain('ESM patches applied at package install time');
});

it('absolute teamRoot warning includes "Edit .squad/config.json"', async () => {
Expand Down Expand Up @@ -291,4 +293,31 @@ describe('squad doctor', () => {
expect(agentMdCheck?.status).toBe('fail');
expect(agentMdCheck?.message).toContain('squad upgrade');
});

// ── #817 — Global install detection ──────────────────────────────

it('looksLikeGlobalInstall returns true when no node_modules exists', async () => {
await scaffold(TEST_ROOT);
// TEST_ROOT has no node_modules — should detect as global
expect(looksLikeGlobalInstall(TEST_ROOT)).toBe(true);
});

it('looksLikeGlobalInstall returns false when node_modules exists', async () => {
await scaffold(TEST_ROOT);
await mkdir(join(TEST_ROOT, 'node_modules'), { recursive: true });
expect(looksLikeGlobalInstall(TEST_ROOT)).toBe(false);
});

it('ESM checks show warn (not info) when node_modules exists but packages missing', async () => {
await scaffold(TEST_ROOT);
// Create node_modules to simulate local install where packages are missing
await mkdir(join(TEST_ROOT, 'node_modules'), { recursive: true });

const checks = await runDoctor(TEST_ROOT);
const jsonrpcCheck = checks.find((c: DoctorCheck) => c.name === 'vscode-jsonrpc exports field');
expect(jsonrpcCheck).toBeDefined();
expect(jsonrpcCheck?.status).toBe('warn');
expect(jsonrpcCheck?.severity).toBeUndefined();
expect(jsonrpcCheck?.message).toContain('run npm install');
});
});
Loading
Loading