Skip to content

feat(cli): squad upgrade --self to update the CLI package#802

Merged
tamirdresher merged 13 commits intodevfrom
squad/798-self-upgrade
Apr 5, 2026
Merged

feat(cli): squad upgrade --self to update the CLI package#802
tamirdresher merged 13 commits intodevfrom
squad/798-self-upgrade

Conversation

@tamirdresher
Copy link
Copy Markdown
Collaborator

@tamirdresher tamirdresher commented Apr 4, 2026

What

Adds squad upgrade --self to update the Squad CLI package itself, with optional --insider flag for prerelease builds.

Why

Users must manually run npm install -g @bradygaster/squad-cli@latest to update. This should be a single command. Closes #801

How

  • selfUpgradeCli() function in upgrade.ts runs npm/pnpm/yarn global install
  • --self installs @bradygaster/squad-cli@latest (stable)
  • --self --insider installs @bradygaster/squad-cli@insider (prerelease)
  • Auto-detects package manager from npm_config_user_agent
  • After self-upgrade, continues with repo upgrade to apply new templates
  • Clear error on permission denied (suggests sudo or npx)
  • Help text updated

Testing

  • npm run build passes
  • npm test passes (4 new tests — type validation, flag parsing, help text, package name)

Docs

  • Changeset entry (self-upgrade.md)
  • Feature doc page (will be added in follow-up commit)
  • README command table update

Exports

N/A

Breaking Changes

None — new flag on existing command.

Waivers

None

- --self installs @bradygaster/squad-cli@latest (stable)
- --self --insider installs @bradygaster/squad-cli@insider (prerelease)
- Auto-continues with repo upgrade after self-install
- Detects package manager (npm/pnpm/yarn)
- 4 new tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tamirdresher tamirdresher requested review from Copilot and diberry April 4, 2026 05:39
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

🛫 PR Readiness Check

ℹ️ This comment updates on each push. Last checked: commit 4957038

⚠️ 4 item(s) to address before review

Status Check Details
Single commit 13 commits — consider squashing before review
Not in draft Ready for review
Branch up to date Up to date with dev
Copilot review No Copilot review yet — it may still be processing
Changeset present Changeset file found
Scope clean No .squad/ or docs/proposals/ files
No merge conflicts No merge conflicts
Copilot threads resolved 1 unresolved Copilot thread(s) — fix and resolve before merging
CI passing 14 check(s) still running

This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new CLI upgrade mode that can update the globally installed Squad CLI package itself (squad upgrade --self), optionally using an --insider prerelease tag, and then proceeds with the normal repo template/skills upgrade flow.

Changes:

  • Added --self / --insider options to the upgrade command and wired them from cli-entry.ts into runUpgrade().
  • Implemented a self-upgrade routine in packages/squad-cli/src/cli/core/upgrade.ts that runs a global install via npm/pnpm/yarn and reports version changes.
  • Added a changeset and a new test file covering flag wiring/help text (mostly via source assertions).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
packages/squad-cli/src/cli/core/upgrade.ts Implements the self-upgrade install flow and threads insider into runUpgrade() options.
packages/squad-cli/src/cli-entry.ts Adds help text and parses --self / --insider flags for the upgrade subcommand.
test/self-upgrade.test.ts Adds tests that assert wiring/help text primarily by reading source files.
.changeset/self-upgrade.md Publishes a minor changeset describing the new CLI self-upgrade capability.

Comment on lines +525 to +528
// After self-upgrade, the new CLI will have new templates.
// Continue with the regular upgrade to apply them to the repo.
info('Continuing with repo upgrade using the new CLI version...');
console.log();
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After --self installs the new global package, this process continues running the old in-memory code (e.g., TEMPLATE_MANIFEST and upgrade logic). If the upgraded CLI version introduces new manifest entries or changes upgrade behavior, the “continue with repo upgrade” step can’t apply those changes reliably. Consider re-execing the newly installed CLI (e.g., spawn npx ${PACKAGE_NAME}@${tag} upgrade ... and exit) or printing an explicit instruction to rerun squad upgrade with the new version instead of continuing in-process.

Suggested change
// After self-upgrade, the new CLI will have new templates.
// Continue with the regular upgrade to apply them to the repo.
info('Continuing with repo upgrade using the new CLI version...');
console.log();
// Re-run the CLI in a fresh process so the repo upgrade uses the newly
// installed code instead of this process's already-loaded modules.
const rerunArgs = process.argv.slice(1).filter((arg) => arg !== '--self');
info('Re-running upgrade with the newly installed CLI version...');
console.log();
execFileSync(process.execPath, rerunArgs, {
stdio: 'inherit',
});
process.exit(0);

Copilot uses AI. Check for mistakes.
Comment on lines +466 to +499

try {
if (isPnpm) {
execFileSync('pnpm', ['add', '-g', `${PACKAGE_NAME}@${tag}`], {
stdio: 'inherit',
timeout: 120_000,
});
} else if (isYarn) {
execFileSync('yarn', ['global', 'add', `${PACKAGE_NAME}@${tag}`], {
stdio: 'inherit',
timeout: 120_000,
});
} else {
// Default: npm
execFileSync('npm', ['install', '-g', `${PACKAGE_NAME}@${tag}`], {
stdio: 'inherit',
timeout: 120_000,
});
}
} catch (err) {
const msg = (err as Error).message;
if (msg.includes('EACCES') || msg.includes('permission')) {
fatal(
`Permission denied. Try:\n` +
` sudo npm install -g ${PACKAGE_NAME}@${tag}\n` +
`Or use npx: npx ${PACKAGE_NAME}@${tag} upgrade`
);
}
fatal(`Self-upgrade failed: ${msg}`);
}

// Read the new version after install
try {
const newVersionOutput = execFileSync('npx', ['--yes', PACKAGE_NAME, '--version'], {
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The permission-denied handling relies on substring checks against err.message and always suggests sudo npm ... / npx ... even when the selected installer is pnpm or yarn. It’d be more reliable to branch on the error’s structured fields (code/errno/status, and captured stderr) and tailor the remediation to the actual command being run (or include pnpm/yarn equivalents).

Suggested change
try {
if (isPnpm) {
execFileSync('pnpm', ['add', '-g', `${PACKAGE_NAME}@${tag}`], {
stdio: 'inherit',
timeout: 120_000,
});
} else if (isYarn) {
execFileSync('yarn', ['global', 'add', `${PACKAGE_NAME}@${tag}`], {
stdio: 'inherit',
timeout: 120_000,
});
} else {
// Default: npm
execFileSync('npm', ['install', '-g', `${PACKAGE_NAME}@${tag}`], {
stdio: 'inherit',
timeout: 120_000,
});
}
} catch (err) {
const msg = (err as Error).message;
if (msg.includes('EACCES') || msg.includes('permission')) {
fatal(
`Permission denied. Try:\n` +
` sudo npm install -g ${PACKAGE_NAME}@${tag}\n` +
`Or use npx: npx ${PACKAGE_NAME}@${tag} upgrade`
);
}
fatal(`Self-upgrade failed: ${msg}`);
}
// Read the new version after install
try {
const newVersionOutput = execFileSync('npx', ['--yes', PACKAGE_NAME, '--version'], {
const installerCommand = isPnpm ? 'pnpm' : isYarn ? 'yarn' : 'npm';
const installerArgs = isPnpm
? ['add', '-g', `${PACKAGE_NAME}@${tag}`]
: isYarn
? ['global', 'add', `${PACKAGE_NAME}@${tag}`]
: ['install', '-g', `${PACKAGE_NAME}@${tag}`];
const runnerCommand = isPnpm ? 'pnpm' : isYarn ? 'yarn' : 'npx';
const runnerArgs = isPnpm
? ['dlx', PACKAGE_NAME, '--version']
: isYarn
? ['dlx', PACKAGE_NAME, '--version']
: ['--yes', PACKAGE_NAME, '--version'];
try {
const installOutput = execFileSync(installerCommand, installerArgs, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 120_000,
});
if (installOutput) {
process.stdout.write(installOutput);
}
} catch (err) {
const childErr = err as Error & {
code?: string | number;
errno?: string | number;
status?: number | null;
stderr?: string | Buffer;
stdout?: string | Buffer;
};
const msg = childErr.message ?? 'Unknown error';
const stderr =
typeof childErr.stderr === 'string'
? childErr.stderr
: childErr.stderr?.toString() ?? '';
const stdout =
typeof childErr.stdout === 'string'
? childErr.stdout
: childErr.stdout?.toString() ?? '';
if (stdout) {
process.stdout.write(stdout);
}
if (stderr) {
process.stderr.write(stderr);
}
const errorText = `${msg}\n${stderr}`.toLowerCase();
const isPermissionDenied =
childErr.code === 'EACCES' ||
childErr.errno === 'EACCES' ||
childErr.status === 126 ||
errorText.includes('eacces') ||
errorText.includes('permission denied') ||
errorText.includes('operation not permitted');
if (isPermissionDenied) {
const installHelp = `${installerCommand} ${installerArgs.join(' ')}`;
const elevateHelp = `sudo ${installHelp}`;
const runHelp = `${runnerCommand} ${isPnpm || isYarn ? `dlx ${PACKAGE_NAME} upgrade` : `${PACKAGE_NAME}@${tag} upgrade`}`;
fatal(
`Permission denied while running "${installHelp}". Try:\n` +
` ${elevateHelp}\n` +
`Or run without a global install:\n` +
` ${runHelp}`
);
}
fatal(`Self-upgrade failed: ${stderr.trim() || msg}`);
}
// Read the new version after install
try {
const newVersionOutput = execFileSync(runnerCommand, runnerArgs, {

Copilot uses AI. Check for mistakes.
Comment on lines 311 to 329
const migrateDir = args.includes('--migrate-directory');
const selfUpgrade = args.includes('--self');
const forceUpgrade = args.includes('--force');
const insiderUpgrade = args.includes('--insider');
const dest = hasGlobal ? (await lazySquadSdk()).resolveGlobalSquadPath() : process.cwd();

// Handle --migrate-directory flag
if (migrateDir) {
await migrateDirectory(dest);
// Continue with regular upgrade after migration
}

// Run upgrade
await runUpgrade(dest, {
migrateDirectory: migrateDir,
self: selfUpgrade,
force: forceUpgrade
force: forceUpgrade,
insider: insiderUpgrade,
});
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--insider is parsed and passed into runUpgrade even when --self is not provided. In that case the flag is silently ignored (since insider is only used for self-upgrade), which can confuse users. Consider validating that --insider requires --self (fatal/warn), or define/implement a non-self meaning for insider upgrades.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +49
import { describe, it, expect, vi } from 'vitest';

// We test the UpgradeOptions interface and the self-upgrade code path structure.
// Actual npm install is not tested (would modify the system), but we verify:
// - The option is wired correctly
// - The function signature accepts insider flag
// - The package name constant is correct

describe('squad upgrade --self', () => {
it('UpgradeOptions includes self and insider flags', async () => {
const { runUpgrade } = await import('../packages/squad-cli/src/cli/core/upgrade.js');
// Verify the function accepts the options without type error
expect(typeof runUpgrade).toBe('function');
});

it('cli-entry parses --self and --insider flags', async () => {
// Read the cli-entry source and verify flag parsing
const fs = await import('node:fs');
const path = await import('node:path');
const entrySource = fs.readFileSync(
path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli-entry.ts'),
'utf-8',
);
expect(entrySource).toContain("args.includes('--self')");
expect(entrySource).toContain("args.includes('--insider')");
expect(entrySource).toContain('insider: insiderUpgrade');
});

it('help text documents --self and --insider', async () => {
const fs = await import('node:fs');
const path = await import('node:path');
const entrySource = fs.readFileSync(
path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli-entry.ts'),
'utf-8',
);
expect(entrySource).toContain('--self (upgrade the CLI package itself)');
expect(entrySource).toContain('--self --insider');
});

it('upgrade module references correct npm package name', async () => {
const fs = await import('node:fs');
const path = await import('node:path');
const upgradeSource = fs.readFileSync(
path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'core', 'upgrade.ts'),
'utf-8',
);
expect(upgradeSource).toContain("@bradygaster/squad-cli");
expect(upgradeSource).toContain("'insider'");
expect(upgradeSource).toContain("'latest'");
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests mostly assert on source-text substrings and don’t exercise the new --self behavior (e.g., that runUpgrade(..., { self: true, insider: true }) invokes the correct execFileSync command/tag). This makes the tests brittle to refactors and leaves the new code path effectively untested; consider mocking node:child_process and asserting the self-upgrade flow/arguments instead (and use expectTypeOf if you want to validate UpgradeOptions shape).

Suggested change
import { describe, it, expect, vi } from 'vitest';
// We test the UpgradeOptions interface and the self-upgrade code path structure.
// Actual npm install is not tested (would modify the system), but we verify:
// - The option is wired correctly
// - The function signature accepts insider flag
// - The package name constant is correct
describe('squad upgrade --self', () => {
it('UpgradeOptions includes self and insider flags', async () => {
const { runUpgrade } = await import('../packages/squad-cli/src/cli/core/upgrade.js');
// Verify the function accepts the options without type error
expect(typeof runUpgrade).toBe('function');
});
it('cli-entry parses --self and --insider flags', async () => {
// Read the cli-entry source and verify flag parsing
const fs = await import('node:fs');
const path = await import('node:path');
const entrySource = fs.readFileSync(
path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli-entry.ts'),
'utf-8',
);
expect(entrySource).toContain("args.includes('--self')");
expect(entrySource).toContain("args.includes('--insider')");
expect(entrySource).toContain('insider: insiderUpgrade');
});
it('help text documents --self and --insider', async () => {
const fs = await import('node:fs');
const path = await import('node:path');
const entrySource = fs.readFileSync(
path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli-entry.ts'),
'utf-8',
);
expect(entrySource).toContain('--self (upgrade the CLI package itself)');
expect(entrySource).toContain('--self --insider');
});
it('upgrade module references correct npm package name', async () => {
const fs = await import('node:fs');
const path = await import('node:path');
const upgradeSource = fs.readFileSync(
path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'core', 'upgrade.ts'),
'utf-8',
);
expect(upgradeSource).toContain("@bradygaster/squad-cli");
expect(upgradeSource).toContain("'insider'");
expect(upgradeSource).toContain("'latest'");
import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
const childProcessMocks = vi.hoisted(() => ({
execFileSync: vi.fn(),
}));
vi.mock('node:child_process', () => ({
execFileSync: childProcessMocks.execFileSync,
}));
describe('squad upgrade --self', () => {
beforeEach(() => {
vi.resetModules();
childProcessMocks.execFileSync.mockReset();
});
it('UpgradeOptions includes self and insider flags', async () => {
const { runUpgrade } = await import('../packages/squad-cli/src/cli/core/upgrade.js');
expectTypeOf(runUpgrade).toBeFunction();
expectTypeOf<Parameters<typeof runUpgrade>[1]>().toMatchTypeOf<{
self?: boolean;
insider?: boolean;
}>();
});
it('runs self-upgrade with the insider tag', async () => {
const { runUpgrade } = await import('../packages/squad-cli/src/cli/core/upgrade.js');
await runUpgrade(process.cwd(), { self: true, insider: true });
expect(childProcessMocks.execFileSync).toHaveBeenCalledTimes(1);
const [command, args] = childProcessMocks.execFileSync.mock.calls[0] ?? [];
expect(typeof command).toBe('string');
expect(Array.isArray(args)).toBe(true);
expect(args).toEqual(
expect.arrayContaining(['install', '-g', '@bradygaster/squad-cli@insider']),
);
});
it('runs self-upgrade with the latest tag by default', async () => {
const { runUpgrade } = await import('../packages/squad-cli/src/cli/core/upgrade.js');
await runUpgrade(process.cwd(), { self: true });
expect(childProcessMocks.execFileSync).toHaveBeenCalledTimes(1);
const [command, args] = childProcessMocks.execFileSync.mock.calls[0] ?? [];
expect(typeof command).toBe('string');
expect(Array.isArray(args)).toBe(true);
expect(args).toEqual(
expect.arrayContaining(['install', '-g', '@bradygaster/squad-cli@latest']),
);

Copilot uses AI. Check for mistakes.
Copilot and others added 2 commits April 4, 2026 08:45
- cleanup.md: automated housekeeping watch capability
- scratch-dir.md: .squad/.scratch/ temp file management
- external-state.md: squad externalize/internalize commands
- self-upgrade.md: squad upgrade --self/--insider
- built-in-roles.md: add fact-checker role (13th engineering role)
- skills.md: document 8 built-in skills shipped with init/upgrade
- README.md: add externalize/internalize + upgrade --self to command table (15→17)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

🏗️ Architectural Review

⚠️ Architectural review: 1 warning(s).

Severity Category Finding Files
🟡 warning bootstrap-area 1 file(s) in the bootstrap area (packages/squad-cli/src/cli/core/) were modified. These files must maintain zero external dependencies. Review carefully. packages/squad-cli/src/cli/core/upgrade.ts

Automated architectural review — informational only.

- Add restart message after --self upgrade (stale code warning)
- Fix permission handling: only suggest sudo for npm, not pnpm/yarn
- Warn when --insider used without --self
- Add test verifying selfUpgradeCli is called with correct args
- Export selfUpgradeCli, call it from cli-entry before runUpgrade (exit early)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tamirdresher tamirdresher requested a review from bradygaster April 4, 2026 21:39
# Conflicts:
#	packages/squad-cli/src/cli-entry.ts
#	test/cli/upgrade.test.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

🟡 Impact Analysis — PR #802

Risk tier: 🟡 MEDIUM

📊 Summary

Metric Count
Files changed 11
Files added 6
Files modified 5
Files deleted 0
Modules touched 4

🎯 Risk Factors

  • 11 files changed (6-20 → MEDIUM)
  • 4 modules touched (2-4 → MEDIUM)

📦 Modules Affected

docs (6 files)
  • docs/src/content/docs/features/built-in-roles.md
  • docs/src/content/docs/features/cleanup.md
  • docs/src/content/docs/features/external-state.md
  • docs/src/content/docs/features/scratch-dir.md
  • docs/src/content/docs/features/self-upgrade.md
  • docs/src/content/docs/features/skills.md
root (2 files)
  • .changeset/self-upgrade.md
  • README.md
squad-cli (2 files)
  • packages/squad-cli/src/cli-entry.ts
  • packages/squad-cli/src/cli/core/upgrade.ts
tests (1 file)
  • test/self-upgrade.test.ts

This report is generated automatically for every PR. See #733 for details.

@diberry
Copy link
Copy Markdown
Collaborator

diberry commented Apr 4, 2026

🏗️ Dina Review: APPROVE WITH FOLLOW-UP

squad upgrade --self — CLI self-update

Core functionality is safe and working. Self-upgrade exits immediately (avoids stale code issues). Permission handling is functional. Comment #3 (--insider\ without --self) is already addressed in code. 120s timeout protection is good.

Follow-up items (non-blocking, create issues):

  1. Rollback capability — No way to revert if upgrade breaks. Add \squad upgrade --rollback\ or version backup.
  2. Integrity verification — No checksum/signature validation of downloaded package. Standard for npm but worth hardening.
  3. Structured error handling — Permission errors use string matching (\�rr.message.includes('EACCES')). Use \�rr.code === 'EACCES'\ for robustness.
  4. Pre-install version check — Currently installs even if already on target version.

✅ Ready to merge. Follow-up issues recommended for rollback and integrity.

… validation

- Check err.code === 'EACCES' instead of substring matching on err.message
- Use detected installer name (npm/pnpm/yarn) in sudo suggestion
- Improve --insider without --self warning with actionable message

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tamirdresher tamirdresher merged commit f090b3a into dev Apr 5, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] squad upgrade --self to update the CLI package

4 participants