Compare commits

...
Author SHA1 Message Date
code-infra-01 e3944935cc Merge remote-tracking branch 'origin/next' into fix/1327-setuppath-idempotency
ci/woodpecker/pr/ci Pipeline was successful
2026-08-20 10:57:01 -05:00
code-infra-01 ebbf682374 fix(#1327): sentinel-managed PATH block, default-home-only profile writes
setupPath() guarded its profile append on the binDir value it was about
to write, which is blind to accumulation across different Mosaic homes:
every wizard run against a fresh temp home appended a permanent # Mosaic
block to the operator's real shell profile (1,061 measured appends on
sb-it-1-dt, naming 1,058 unique /tmp dirs of which zero exist).

- S1/S5: managed block between >>> mosaic begin/end <<< sentinels,
  rewritten in place, both the POSIX and $env:Path arms
- S2: a target home that is not the resolved default skips the profile
  write entirely; a test harness can no longer touch the operator profile
- S3: byte-identical profile across repeated runs (any homes)
- S4: legacy unmarked # Mosaic pairs collapse into the managed block

S2 arm captured RED against pre-fix code (expected 'skipped', got
'added'), then GREEN post-fix; full suite 1585 passed.
2026-08-19 18:24:52 -05:00
2 changed files with 224 additions and 16 deletions
@@ -0,0 +1,152 @@
/**
* setupPath profile management (issue #1327, MOSAIC-IMPROVEMENTS 4c / D25).
*
* The profile append used to be guarded on the binDir value it was about to
* write, which is blind to accumulation across different Mosaic homes: every
* wizard run against a fresh temp home appended a permanent block to the
* operator's real shell profile (1,061 measured appends on sb-it-1-dt).
*
* Arms below map to the requirements:
* S1 sentinel-managed block, rewritten in place
* S2 a non-default target home never touches the operator profile
* S3 byte-identical profile across repeated runs
* S4 legacy unmarked `# Mosaic` blocks collapse into the managed block
* S5 the Windows ($env:Path) arm shares the same block logic
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
let profilePathMock: string | null = null;
vi.mock('../platform/detect.js', () => ({
getShellProfilePath: (): string | null => profilePathMock,
}));
import { setupPath, managedBlockFor, stripLegacyPathBlocks } from './finalize.js';
// The real resolved default on this host. Tests use it as the comparator a
// non-default home must fail against, exactly as the wizard would.
const REAL_DEFAULT_HOME = join(homedir(), '.config', 'mosaic');
function tempHome(prefix: string): string {
const dir = join(tmpdir(), prefix);
mkdirSync(join(dir, 'bin'), { recursive: true });
return dir;
}
describe('setupPath profile management (#1327)', () => {
let workDir: string;
let profileFile: string;
let defaultLikeHome: string;
let otherHome: string;
const baseline = '# existing operator content\nexport EDITOR=vim\n';
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'setuppath-spec-'));
profileFile = join(workDir, '.bashrc');
writeFileSync(profileFile, baseline, 'utf-8');
profilePathMock = profileFile;
defaultLikeHome = tempHome(join(workDir, 'home-a', '.config', 'mosaic'));
otherHome = tempHome(join(workDir, 'home-b', '.config', 'mosaic'));
});
afterEach(() => {
profilePathMock = null;
rmSync(workDir, { recursive: true, force: true });
});
// S2 — the arm that MUST fail against the pre-fix code: a home that is not
// the resolved default may not modify the operator profile at all.
it('does not touch the operator profile when the target home is not the resolved default', () => {
const action = setupPath(otherHome, REAL_DEFAULT_HOME);
expect(action).toBe('skipped');
expect(readFileSync(profileFile, 'utf-8')).toBe(baseline);
});
it('returns skipped when no shell profile can be resolved', () => {
profilePathMock = null;
const action = setupPath(defaultLikeHome, defaultLikeHome);
expect(action).toBe('skipped');
});
// S1 + S3 — two distinct homes (each run as the resolved default in turn,
// the shape of two legitimate installs against one operator profile) and
// repeated runs against the same home both leave exactly one block.
it('leaves exactly one managed block after runs against two distinct homes', () => {
const first = setupPath(defaultLikeHome, defaultLikeHome);
expect(first).toBe('added');
const second = setupPath(otherHome, otherHome);
expect(second).toBe('added');
const content = readFileSync(profileFile, 'utf-8');
const beginCount = content.split('# >>> mosaic begin >>>').length - 1;
const endCount = content.split('# <<< mosaic end <<<').length - 1;
expect(beginCount).toBe(1);
expect(endCount).toBe(1);
expect(content).toContain(join(otherHome, 'bin'));
expect(content).toContain(baseline);
});
it('is byte-identical across repeated runs against the same home', () => {
setupPath(defaultLikeHome, defaultLikeHome);
const afterFirst = readFileSync(profileFile, 'utf-8');
const again = setupPath(defaultLikeHome, defaultLikeHome);
expect(again).toBe('already');
expect(readFileSync(profileFile, 'utf-8')).toBe(afterFirst);
});
// S4 — pre-existing unmarked blocks from the old append logic collapse
// into the single managed block instead of accumulating beside it.
it('collapses legacy unmarked # Mosaic blocks into the managed block', () => {
const legacy =
'# existing operator content\n' +
'# Mosaic\n' +
'export PATH="/tmp/mosaic-dead-wizard-1/bin:$PATH"\n' +
'export EDITOR=vim\n' +
'# Mosaic\n' +
'export PATH="/tmp/mosaic-dead-wizard-2/bin:$PATH"\n';
writeFileSync(profileFile, legacy, 'utf-8');
const action = setupPath(defaultLikeHome, defaultLikeHome);
expect(action).toBe('added');
const content = readFileSync(profileFile, 'utf-8');
expect(content).not.toContain('/tmp/mosaic-dead-wizard-1/bin');
expect(content).not.toContain('/tmp/mosaic-dead-wizard-2/bin');
expect(content).toContain('export EDITOR=vim');
expect(content.split('# >>> mosaic begin >>>').length - 1).toBe(1);
expect(content).toContain(join(defaultLikeHome, 'bin'));
});
});
describe('managed block helpers (#1327)', () => {
// S5 — the Windows arm shares markers and shape with the POSIX arm.
it('builds the $env:Path variant inside the same markers', () => {
const block = managedBlockFor('C:\\Users\\op\\.config\\mosaic\\bin', true);
expect(block).toContain('# >>> mosaic begin >>>');
expect(block).toContain('# <<< mosaic end <<<');
expect(block).toContain('$env:Path = "C:\\Users\\op\\.config\\mosaic\\bin;$env:Path"');
});
it('builds the POSIX export variant inside the same markers', () => {
const block = managedBlockFor('/home/op/.config/mosaic/bin', false);
expect(block).toContain('# >>> mosaic begin >>>');
expect(block).toContain('export PATH="/home/op/.config/mosaic/bin:$PATH"');
expect(block).toContain('# <<< mosaic end <<<');
});
it('strips legacy $env:Path pairs on the Windows arm', () => {
const legacy =
'# Mosaic\n$env:Path = "C:\\tmp\\dead\\bin;$env:Path"\n' +
'# Mosaic\n$env:Path = "C:\\tmp\\dead2\\bin;$env:Path"\n' +
'Write-Host hi\n';
const stripped = stripLegacyPathBlocks(legacy, true);
expect(stripped).not.toContain('C:\\tmp\\dead');
expect(stripped).toContain('Write-Host hi');
});
});
+72 -16
View File
@@ -1,11 +1,12 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { platform } from 'node:os';
import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js';
import { getShellProfilePath } from '../platform/detect.js';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { ManifestError } from '../framework/manifest.js';
import {
getDefaultSkillPaths,
@@ -144,32 +145,87 @@ function runDoctor(mosaicHome: string): DoctorResult {
type PathAction = 'already' | 'added' | 'skipped';
function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
const binDir = join(mosaicHome, 'bin');
const currentPath = process.env['PATH'] ?? '';
const PATH_BLOCK_BEGIN = '# >>> mosaic begin >>>';
const PATH_BLOCK_END = '# <<< mosaic end <<<';
const PATH_BLOCK_NOTE = '# Managed by the Mosaic installer; this block is rewritten on install.';
if (currentPath.includes(binDir)) {
return 'already';
/**
* The managed PATH block written into the operator's shell profile.
*
* The block is delimited by begin/end sentinels so any number of installs,
* against any homes, collapse to exactly one block: the writer replaces the
* region between the sentinels instead of appending a second copy (#1327).
*/
export function managedBlockFor(binDir: string, isWindows: boolean): string {
const exportLine = isWindows
? `$env:Path = "${binDir};$env:Path"`
: `export PATH="${binDir}:$PATH"`;
return `${PATH_BLOCK_BEGIN}\n${PATH_BLOCK_NOTE}\n${exportLine}\n${PATH_BLOCK_END}\n`;
}
/**
* Remove legacy unmarked `# Mosaic` PATH pairs appended by pre-#1327
* installs. Only the exact two-line shape this installer used to write is
* removed; any other `# Mosaic` comment line is left alone.
*/
export function stripLegacyPathBlocks(content: string, isWindows: boolean): string {
const legacyExport = isWindows ? /^\$env:Path = ".*;\$env:Path"$/ : /^export PATH=".*:\$PATH"$/;
const lines = content.split('\n');
const kept: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
const next = i + 1 < lines.length ? lines[i + 1] : undefined;
if (line === '# Mosaic' && next !== undefined && legacyExport.test(next)) {
i += 1;
continue;
}
kept.push(line);
}
return kept.join('\n');
}
/** Drop the region between the managed-block sentinels, first occurrence. */
function withoutManagedBlock(content: string): string {
const beginIdx = content.indexOf(PATH_BLOCK_BEGIN);
if (beginIdx < 0) return content;
const endIdx = content.indexOf(PATH_BLOCK_END, beginIdx);
if (endIdx < 0) return content;
return content.slice(0, beginIdx) + content.slice(endIdx + PATH_BLOCK_END.length);
}
export function setupPath(mosaicHome: string, resolvedDefaultHome: string): PathAction {
// Never write outside the home under test (#1327 S2): a wizard run against
// a non-default home (test harnesses, throwaway installs) must not mutate
// the operator's real shell profile.
if (mosaicHome !== resolvedDefaultHome) {
return 'skipped';
}
const binDir = join(mosaicHome, 'bin');
const profilePath = getShellProfilePath();
if (!profilePath) return 'skipped';
const isWindows = platform() === 'win32';
const exportLine = isWindows
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
const block = managedBlockFor(binDir, isWindows);
// Check if already in profile
let content = '';
if (existsSync(profilePath)) {
const content = readFileSync(profilePath, 'utf-8');
if (content.includes(binDir)) {
return 'already';
}
content = readFileSync(profilePath, 'utf-8');
}
// Migration (#1327 S4): legacy unmarked blocks collapse into the managed
// block, and an existing managed block is rewritten in place rather than
// appended beside itself (S1/S3).
const base = stripLegacyPathBlocks(withoutManagedBlock(content), isWindows);
const trimmed = base.replace(/\n+$/, '');
const next = trimmed.length === 0 ? block : `${trimmed}\n${block}`;
if (next === content) {
return 'already';
}
try {
appendFileSync(profilePath, exportLine, 'utf-8');
writeFileSync(profilePath, next, 'utf-8');
return 'added';
} catch {
return 'skipped';
@@ -286,7 +342,7 @@ export async function finalizeStage(
}
// 7. PATH setup
const pathAction = setupPath(state.mosaicHome, p);
const pathAction = setupPath(state.mosaicHome, DEFAULT_MOSAIC_HOME);
let summaryShown = false;
const showSummary = () => {