From c14d2b56ff3acc2c6d2347f476f2bfc7b6ce4021 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 15:31:07 +0200 Subject: [PATCH 01/19] docs(cli): design onboarding git fingerprints --- ...-cli-onboarding-git-fingerprints-design.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md diff --git a/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md b/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md new file mode 100644 index 0000000000..84acbde9de --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md @@ -0,0 +1,150 @@ +# CLI Onboarding Git Fingerprints Design + +## Problem + +The CLI checks for a dirty Git repository on every onboarding invocation. This is useful before onboarding starts, but it also runs after restoring saved progress. Automatic onboarding operations may already have changed `package.json`, a lockfile, Capacitor configuration, or source files. A user who stops and resumes therefore sees the same warning used for unrelated local edits, even when every dirty file still exactly matches a change made by the previous Capgo run. + +Selecting **Continue anyway** only bypasses the check for the current invocation. It is intentionally not persisted, so it does not solve the resume defect. + +## Goals + +- Suppress the dirty-Git warning when every current dirty entry exactly matches a successful automatic mutation made by the same onboarding progress record. +- Continue warning when any path is unknown or has changed since Capgo recorded it. +- Discover package-manager and generated filenames dynamically rather than maintaining a filename list. +- Keep the change local to the existing onboarding progress and Git-check code. +- Preserve existing behavior for manual setup paths and older progress records. + +## Non-goals + +- Persisting a general “ignore dirty Git” preference. +- Treating user-run manual commands or edits as Capgo-owned. +- Proving filesystem authorship beyond an exact before/after comparison around a CLI-controlled operation. +- Refactoring the onboarding state format into a new subsystem. +- Changing Git state, staging files, committing files, or cleaning the repository for the user. + +## Chosen Design + +### Progress data + +Add one optional field to the existing local onboarding progress JSON: + +```ts +interface InitGitChangeFingerprint { + status: string + sha256: string | null + mode: number | null +} + +interface InitGitChanges { + version: 1 + repoRoot: string + files: Record +} +``` + +Keys are Git-relative paths using `/` separators. `status` preserves the two-character porcelain status so staged, unstaged, untracked, and deleted states do not compare as equivalent. `sha256` hashes the raw working-tree bytes and is `null` when no working-tree file exists. `mode` records the filesystem mode and is `null` for an absent file. + +The field is optional. A progress file without it retains the current conservative dirty-Git behavior. + +### Tracking automatic mutations + +Add one reusable helper that runs an automatic mutation between two Git snapshots. It returns the operation result unchanged and accepts a success predicate for APIs that report failure without throwing: + +```ts +await trackInitGitChanges(() => automaticMutation(), result => result.success) +``` + +The helper: + +1. Captures the dirty-path fingerprints immediately before the operation. +2. Runs the operation. +3. Captures fingerprints immediately after successful completion. +4. Compares the two maps and updates only paths whose fingerprint changed. +5. Removes a previously owned path when the operation returns it to a clean state. +6. Persists the accumulated map through the existing onboarding progress writer without advancing `step_done`. + +This is deliberately operation-scoped, not step-scoped. Prompts, waits, and manual work are outside tracked windows. For example, if `src/main.ts` is already dirty before the automatic dependency installation, and the installation changes only `package.json` and a lockfile, only the package files are added. + +An operation that throws is unsuccessful. An operation that returns a result is successful when its supplied predicate returns `true`; operations that return `void` are successful when they return without throwing. If an operation fails after partially changing files, those changes remain unrecognized and the next resume uses the existing warning. + +Instrument only existing automatic repository mutations in the onboarding command: + +- CLI-run package installations. +- CLI-run Capacitor `init`, platform-add, and sync commands. +- Direct Capacitor/app/updater configuration writes. +- Automatic updater source injection. +- Automatic encryption key/configuration creation. +- Automatic temporary test edits and their cleanup. + +Do not wrap user prompts, manual-install/manual-edit waits, project build scripts, uploads, or commands the user runs themselves. The existing special handling for the temporary auto-test change remains as a backward-compatible fallback rather than being refactored away in this PR. + +### Minimal persistence change + +Keep the existing progress payload and file. Extract its current serialization into a small shared writer used by: + +- `markStepDone()`, which updates `step_done` and writes progress as today. +- `trackInitGitChanges()`, which writes an updated optional `gitChanges` field while preserving the last completed step. + +If no resumable progress has been established yet, tracked fingerprints stay in memory and are included by the next normal `markStepDone()` call. This avoids creating a step-zero progress record that the current resume logic would reject. + +### Resume classification + +After restoring progress and before showing the dirty-Git prompt, classify every current dirty entry: + +- **Recognized:** repository roots match and path, status, SHA-256, and mode exactly match the saved fingerprint. +- **Unsafe:** no saved entry exists, any fingerprint field differs, the saved data is invalid, the repository root differs, or the current file cannot be fingerprinted. + +Saved paths that are no longer dirty are discarded so an old fingerprint cannot become trusted again after a commit or cleanup. + +The user experience is: + +| Current state | Behavior | +| --- | --- | +| Repository clean | Continue normally. | +| Every dirty entry recognized | Skip the warning and print a neutral line: `Resuming with uncommitted changes created by the previous Capgo onboarding run.` | +| At least one unsafe entry | Reuse the existing warning and its **Check again** / **Continue anyway** actions, but list only unsafe entries. When recognized entries also exist, note their count separately. | +| No valid saved fingerprints | Treat every dirty entry as unsafe, matching current behavior. | + +**Continue anyway** remains scoped to the current invocation. It does not add unknown files to the saved fingerprint set. A later resume warns again unless those files subsequently become part of a successful automatic tracked mutation. + +## Failure Handling + +Fingerprint tracking is an optimization and must not break onboarding. + +- A Git, filesystem, hashing, or parsing failure logs through the existing diagnostic path and records no new trusted state. +- A progress write failure leaves no durable new trusted state; the current onboarding run continues. +- An unsupported or unreadable dirty entry remains unsafe. +- Rename, copy, symlink, directory, and other non-regular-file states remain unsafe rather than gaining special tracking logic. +- A failed automatic operation records no fingerprints, including partial filesystem changes. +- Malformed, unknown-version, or wrong-repository saved data is ignored and falls back to the existing warning. +- No recovery path mutates Git or removes user files. + +## Minimal Code Scope + +Keep the production change in the existing onboarding command file unless a pre-existing test boundary requires otherwise: + +- One SHA-256 import. +- Small fingerprint/progress types. +- Snapshot, diff/update, tracking, and classification helpers. +- One optional progress field restored and serialized with existing state. +- Small wrapper calls only at automatic mutation sites. +- A conditional branch inside the existing dirty-Git warning flow. + +Do not introduce a service, database change, new prompt, package-manager mapping, or broad onboarding refactor. + +## Testing + +Use three compact tests rather than a full onboarding test suite: + +1. **Attribution regression:** `src/main.ts` is dirty before a tracked operation changes `package.json` and a lockfile; only the package files are recorded. +2. **Resume classification:** table-driven cases cover an exact match, changed fingerprint, additional path, and absent saved data. +3. **Conservative failure:** a failed snapshot or automatic operation records no trusted changes. + +Include staged, deleted, and mode-changed fingerprints as additional rows in the same classification table; do not create separate fixtures or tests for them. Do not add a full end-to-end onboarding test for this isolated behavior. + +## Alternatives Considered + +- **Persist “Continue anyway”:** smallest implementation, but it silently accepts unrelated edits made after the user opted out and weakens the safety check. +- **Hard-code expected filenames:** small initially, but brittle across npm, Yarn, pnpm, Bun, Capacitor configuration variants, and generated files. +- **Snapshot whole onboarding steps:** fewer tracking call sites, but incorrectly attributes unrelated edits made between prompts or steps to Capgo. +- **Recommended — operation-scoped before/after fingerprints:** slightly more call sites, but remains small and preserves the boundary between automatic Capgo changes and unrelated user work. From 5e8e2c76dfc6df82aa8b49374823732f1b1e608a Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 16:14:36 +0200 Subject: [PATCH 02/19] fix(cli): add onboarding git fingerprint helpers --- cli/src/init/command.ts | 202 +++++++++++++++++++++++++++++- cli/test/test-init-guardrails.mjs | 179 +++++++++++++++++++++++++- 2 files changed, 379 insertions(+), 2 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index f042df52b9..c2fca86a64 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -5,7 +5,8 @@ import type { Organization } from '../utils' import type { SupportedPackageManager } from './command-execution' import type { InitCodeDiff, InitEncryptionPhase, InitEncryptionSummary } from './runtime' import { spawn, spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs' import path, { dirname, join } from 'node:path' import { chdir, cwd, env, exit, platform, stderr, stdin, stdout } from 'node:process' import { canParse, format, increment, lessThan, parse } from '@std/semver' @@ -115,6 +116,24 @@ interface GitRepoStatus { error?: string } +export interface InitGitChangeFingerprint { + status: string + sha256: string | null + mode: number | null +} + +export interface InitGitChanges { + version: 1 + repoRoot: string + files: Record +} + +export interface InitGitClassification { + unsafePaths: string[] + recognizedCount: number + retained: InitGitChanges | undefined +} + interface InitAutoTestChange { filePath: string displayPath: string @@ -343,6 +362,187 @@ export function getGitRepoStatus(startDir = cwd()): GitRepoStatus { } } +function initGitFingerprintMatches(left: InitGitChangeFingerprint | undefined, right: InitGitChangeFingerprint | undefined) { + return left !== undefined + && right !== undefined + && left.status === right.status + && left.sha256 === right.sha256 + && left.mode === right.mode +} + +function isUsableInitGitChanges(changes: InitGitChanges | undefined): changes is InitGitChanges { + return changes?.version === 1 + && typeof changes.repoRoot === 'string' + && changes.repoRoot.length > 0 + && changes.files !== null + && typeof changes.files === 'object' +} + +function cloneInitGitChanges(changes: InitGitChanges, files = changes.files): InitGitChanges | undefined { + const clonedFiles = Object.fromEntries(Object.entries(files).map(([filePath, fingerprint]) => [filePath, { ...fingerprint }])) + return Object.keys(clonedFiles).length > 0 + ? { version: 1, repoRoot: changes.repoRoot, files: clonedFiles } + : undefined +} + +function isDeletedRegularGitPath(repoRoot: string, filePath: string) { + const indexResult = spawnSync('git', ['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', filePath], { + cwd: repoRoot, + stdio: 'pipe', + encoding: 'utf8', + }) + if (indexResult.error || indexResult.status !== 0) + return false + + const indexEntries = indexResult.stdout?.toString().split('\0').filter(Boolean) ?? [] + if (indexEntries.length > 1) + return false + if (indexEntries.length === 1) { + const [metadata] = indexEntries[0].split('\t', 1) + const [mode, , stage] = metadata.split(' ') + return (mode === '100644' || mode === '100755') && stage === '0' + } + + const headResult = spawnSync('git', ['--literal-pathspecs', 'ls-tree', '-z', 'HEAD', '--', filePath], { + cwd: repoRoot, + stdio: 'pipe', + encoding: 'utf8', + }) + if (headResult.error || headResult.status !== 0) + return false + + const headEntries = headResult.stdout?.toString().split('\0').filter(Boolean) ?? [] + if (headEntries.length !== 1) + return false + const [metadata] = headEntries[0].split('\t', 1) + const [mode, type] = metadata.split(' ') + return (mode === '100644' || mode === '100755') && type === 'blob' +} + +export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undefined { + try { + const repoResult = spawnSync('git', ['rev-parse', '--show-toplevel'], { + cwd: startDir, + stdio: 'pipe', + encoding: 'utf8', + }) + if (repoResult.error || repoResult.status !== 0) + return undefined + + const repoRootValue = repoResult.stdout?.toString().trim() + if (!repoRootValue) + return undefined + const repoRoot = realpathSync(repoRootValue) + const statusResult = spawnSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { + cwd: repoRoot, + stdio: 'pipe', + encoding: 'utf8', + }) + if (statusResult.error || statusResult.status !== 0) + return undefined + + const entries = statusResult.stdout?.toString().split('\0').filter(Boolean) ?? [] + const files: Record = {} + for (const entry of entries) { + if (entry.length < 4 || entry[2] !== ' ') + return undefined + + const status = entry.slice(0, 2) + const filePath = entry.slice(3) + if (!filePath || status.includes('R') || status.includes('C') || status.includes('T') || status.includes('U') || status === 'AA' || status === 'DD' || files[filePath]) + return undefined + + const absolutePath = path.resolve(repoRoot, filePath) + const pathFromRoot = path.relative(repoRoot, absolutePath) + if (pathFromRoot === '..' || pathFromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(pathFromRoot)) + return undefined + + if (status.includes('D')) { + if (!isDeletedRegularGitPath(repoRoot, filePath)) + return undefined + files[filePath] = { status, sha256: null, mode: null } + continue + } + + const fileStats = lstatSync(absolutePath) + if (!fileStats.isFile()) + return undefined + files[filePath] = { + status, + sha256: createHash('sha256').update(readFileSync(absolutePath)).digest('hex'), + mode: fileStats.mode, + } + } + + return { version: 1, repoRoot, files } + } + catch { + return undefined + } +} + +export function mergeInitGitChanges(existing: InitGitChanges | undefined, before: InitGitChanges | undefined, after: InitGitChanges | undefined): InitGitChanges | undefined { + const usableBefore = isUsableInitGitChanges(before) ? before : undefined + const usableAfter = isUsableInitGitChanges(after) ? after : undefined + const existingMatchesRepo = isUsableInitGitChanges(existing) + && (!usableBefore || existing.repoRoot === usableBefore.repoRoot) + const retainedFiles: Record = {} + const recognizedBefore = new Set() + + if (existingMatchesRepo) { + for (const [filePath, fingerprint] of Object.entries(existing.files)) { + if (!usableBefore || initGitFingerprintMatches(fingerprint, usableBefore.files[filePath])) { + retainedFiles[filePath] = { ...fingerprint } + recognizedBefore.add(filePath) + } + } + } + + if (!usableBefore || !usableAfter || usableBefore.repoRoot !== usableAfter.repoRoot) { + if (!isUsableInitGitChanges(existing) || Object.keys(retainedFiles).length === 0) + return undefined + return { version: 1, repoRoot: existing.repoRoot, files: retainedFiles } + } + + for (const filePath of new Set([...Object.keys(usableBefore.files), ...Object.keys(usableAfter.files)])) { + const beforeFingerprint = usableBefore.files[filePath] + const afterFingerprint = usableAfter.files[filePath] + if (initGitFingerprintMatches(beforeFingerprint, afterFingerprint)) + continue + if (beforeFingerprint && !recognizedBefore.has(filePath)) + continue + if (afterFingerprint) + retainedFiles[filePath] = { ...afterFingerprint } + else + delete retainedFiles[filePath] + } + + return cloneInitGitChanges({ version: 1, repoRoot: usableAfter.repoRoot, files: retainedFiles }) +} + +export function classifyInitGitChanges(current: InitGitChanges | undefined, saved: InitGitChanges | undefined): InitGitClassification { + if (!isUsableInitGitChanges(current)) + return { unsafePaths: [], recognizedCount: 0, retained: undefined } + + const compatibleSaved = isUsableInitGitChanges(saved) && saved.repoRoot === current.repoRoot ? saved : undefined + const unsafePaths: string[] = [] + const retainedFiles: Record = {} + for (const filePath of Object.keys(current.files).sort()) { + const fingerprint = current.files[filePath] + if (compatibleSaved && initGitFingerprintMatches(fingerprint, compatibleSaved.files[filePath])) + retainedFiles[filePath] = { ...fingerprint } + else + unsafePaths.push(filePath) + } + + const retained = cloneInitGitChanges({ version: 1, repoRoot: current.repoRoot, files: retainedFiles }) + return { + unsafePaths, + recognizedCount: Object.keys(retainedFiles).length, + retained, + } +} + export function getInitUpdaterPluginConfig(appId: string, directInstall: boolean) { return { version: initNativeBundleVersion, diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index ea0728723d..c8acfbef64 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -2,11 +2,13 @@ import assert from 'node:assert/strict' import { execSync, spawn } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { applyInitAutoTestChange, + captureInitGitSnapshot, + classifyInitGitChanges, getDirtyGitStatusActionOptions, getGitRepoStatus, getInitOtaVersionBase, @@ -16,6 +18,7 @@ import { getNativePlatformAvailability, injectInitCode, isOnlyAllowedInitAutoTestChange, + mergeInitGitChanges, revertInitAutoTestChangeContent, runInheritedCommand, } from '../src/init/command.ts' @@ -384,6 +387,180 @@ t('resume allowlist only accepts the exact cli-managed test diff', () => { }) }) +t('git fingerprints attribute only files changed during the onboarding mutation window', () => { + withTempDir((root) => { + execSync('git init', { cwd: root, stdio: 'ignore' }) + execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) + execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) + execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + + mkdirSync(join(root, 'src'), { recursive: true }) + writeFileSync(join(root, 'src', 'main.ts'), 'console.log(\'initial\')\n', 'utf8') + writeFileSync(join(root, 'package.json'), '{"name":"example","dependencies":{}}\n', 'utf8') + writeFileSync(join(root, 'package-lock.json'), '{"name":"example","lockfileVersion":3}\n', 'utf8') + execSync('git add src/main.ts package.json package-lock.json', { cwd: root, stdio: 'ignore' }) + execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + + writeFileSync(join(root, 'src', 'main.ts'), 'console.log(\'user edit\')\n', 'utf8') + const before = captureInitGitSnapshot(root) + assert.ok(before) + + writeFileSync(join(root, 'package.json'), '{"name":"example","dependencies":{"@capgo/capacitor-updater":"latest"}}\n', 'utf8') + writeFileSync(join(root, 'package-lock.json'), '{"name":"example","lockfileVersion":3,"packages":{"capgo":{}}}\n', 'utf8') + const after = captureInitGitSnapshot(root) + assert.ok(after) + + const saved = mergeInitGitChanges(undefined, before, after) + assert.ok(saved) + assert.deepEqual(Object.keys(saved.files).sort(), ['package-lock.json', 'package.json']) + + const classification = classifyInitGitChanges(after, saved) + assert.deepEqual(classification.unsafePaths, ['src/main.ts']) + assert.equal(classification.recognizedCount, 2) + assert.deepEqual(Object.keys(classification.retained?.files ?? {}).sort(), ['package-lock.json', 'package.json']) + }) +}) + +t('git snapshot fingerprints a deleted regular file with null content and mode', () => { + withTempDir((root) => { + execSync('git init', { cwd: root, stdio: 'ignore' }) + execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) + execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) + execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + writeFileSync(join(root, 'deleted.txt'), 'tracked\n', 'utf8') + execSync('git add deleted.txt', { cwd: root, stdio: 'ignore' }) + execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + + unlinkSync(join(root, 'deleted.txt')) + + const snapshot = captureInitGitSnapshot(root) + assert.ok(snapshot) + assert.deepEqual(snapshot.files['deleted.txt'], { + status: ' D', + sha256: null, + mode: null, + }) + }) +}) + +t('git snapshot declines unsupported symlinks and renames', () => { + withTempDir((root) => { + execSync('git init', { cwd: root, stdio: 'ignore' }) + writeFileSync(join(root, 'target.txt'), 'target\n', 'utf8') + symlinkSync('target.txt', join(root, 'link.txt')) + + assert.equal(captureInitGitSnapshot(root), undefined) + }) + + withTempDir((root) => { + execSync('git init', { cwd: root, stdio: 'ignore' }) + execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) + execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) + execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + writeFileSync(join(root, 'before.txt'), 'tracked\n', 'utf8') + execSync('git add before.txt', { cwd: root, stdio: 'ignore' }) + execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + execSync('git mv before.txt after.txt', { cwd: root, stdio: 'ignore' }) + + assert.equal(captureInitGitSnapshot(root), undefined) + }) +}) + +t('git snapshot declines a tracked symlink changed into a regular file', () => { + withTempDir((root) => { + execSync('git init', { cwd: root, stdio: 'ignore' }) + execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) + execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) + execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + writeFileSync(join(root, 'target.txt'), 'target\n', 'utf8') + symlinkSync('target.txt', join(root, 'link.txt')) + execSync('git add target.txt link.txt', { cwd: root, stdio: 'ignore' }) + execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + + unlinkSync(join(root, 'link.txt')) + writeFileSync(join(root, 'link.txt'), 'now regular\n', 'utf8') + + assert.equal(captureInitGitSnapshot(root), undefined) + }) +}) + +t('git fingerprint classification requires exact path, status, hash, and mode matches', () => { + const repoRoot = '/example/repo' + const fingerprint = (status, sha256, mode = 0o100644) => ({ status, sha256, mode }) + const changes = files => ({ version: 1, repoRoot, files }) + const cases = [ + { + name: 'exact saved fingerprint is recognized', + current: changes({ 'package.json': fingerprint(' M', 'saved') }), + saved: changes({ 'package.json': fingerprint(' M', 'saved') }), + unsafePaths: [], + recognizedCount: 1, + retainedPaths: ['package.json'], + }, + { + name: 'same path subsequently changed is unsafe', + current: changes({ 'package.json': fingerprint(' M', 'changed') }), + saved: changes({ 'package.json': fingerprint(' M', 'saved') }), + unsafePaths: ['package.json'], + recognizedCount: 0, + retainedPaths: [], + }, + { + name: 'additional dirty path is unsafe', + current: changes({ + 'package.json': fingerprint(' M', 'saved'), + 'src/main.ts': fingerprint(' M', 'user-edit'), + }), + saved: changes({ 'package.json': fingerprint(' M', 'saved') }), + unsafePaths: ['src/main.ts'], + recognizedCount: 1, + retainedPaths: ['package.json'], + }, + { + name: 'staged status differing from saved status is unsafe', + current: changes({ 'package.json': fingerprint('M ', 'saved') }), + saved: changes({ 'package.json': fingerprint(' M', 'saved') }), + unsafePaths: ['package.json'], + recognizedCount: 0, + retainedPaths: [], + }, + { + name: 'deleted path with an exact null fingerprint is recognized', + current: changes({ 'package.json': fingerprint(' D', null, null) }), + saved: changes({ 'package.json': fingerprint(' D', null, null) }), + unsafePaths: [], + recognizedCount: 1, + retainedPaths: ['package.json'], + }, + { + name: 'mode change is unsafe', + current: changes({ 'scripts/setup.sh': fingerprint(' M', 'saved', 0o100755) }), + saved: changes({ 'scripts/setup.sh': fingerprint(' M', 'saved', 0o100644) }), + unsafePaths: ['scripts/setup.sh'], + recognizedCount: 0, + retainedPaths: [], + }, + { + name: 'no saved fingerprint state leaves current dirty paths unsafe', + current: changes({ + 'package.json': fingerprint(' M', 'saved'), + 'src/main.ts': fingerprint(' M', 'user-edit'), + }), + saved: undefined, + unsafePaths: ['package.json', 'src/main.ts'], + recognizedCount: 0, + retainedPaths: [], + }, + ] + + for (const testCase of cases) { + const classification = classifyInitGitChanges(testCase.current, testCase.saved) + assert.deepEqual(classification.unsafePaths, testCase.unsafePaths, testCase.name) + assert.equal(classification.recognizedCount, testCase.recognizedCount, testCase.name) + assert.deepEqual(Object.keys(classification.retained?.files ?? {}).sort(), testCase.retainedPaths, testCase.name) + } +}) + async function tAsync(name, fn) { try { await fn() From 33aa59c85eb9bee01cb32ea110f8ef7a8a23651c Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 16:24:28 +0200 Subject: [PATCH 03/19] test(cli): cover deletion after saved git fingerprint --- cli/test/test-init-guardrails.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index c8acfbef64..9288b1cf8c 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -532,6 +532,14 @@ t('git fingerprint classification requires exact path, status, hash, and mode ma recognizedCount: 1, retainedPaths: ['package.json'], }, + { + name: 'path deleted after a regular fingerprint was saved is unsafe', + current: changes({ 'package.json': fingerprint(' D', null, null) }), + saved: changes({ 'package.json': fingerprint(' M', 'saved') }), + unsafePaths: ['package.json'], + recognizedCount: 0, + retainedPaths: [], + }, { name: 'mode change is unsafe', current: changes({ 'scripts/setup.sh': fingerprint(' M', 'saved', 0o100755) }), From ad2b10d017bcde7d4e817d340196e8b483fd2aa8 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 16:45:21 +0200 Subject: [PATCH 04/19] fix(cli): harden onboarding git fingerprints --- cli/src/init/command.ts | 67 ++++++++++++----- cli/test/test-init-guardrails.mjs | 115 +++++++++++++++++++++++++++++- 2 files changed, 163 insertions(+), 19 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index c2fca86a64..78031ceff9 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -385,23 +385,45 @@ function cloneInitGitChanges(changes: InitGitChanges, files = changes.files): In : undefined } -function isDeletedRegularGitPath(repoRoot: string, filePath: string) { +function isRegularGitBlobMode(mode: string | null) { + return mode === '100644' || mode === '100755' +} + +function isGitObjectId(value: string) { + return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value) +} + +function getInitGitIndexMode(repoRoot: string, filePath: string): string | null | undefined { const indexResult = spawnSync('git', ['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', filePath], { cwd: repoRoot, stdio: 'pipe', encoding: 'utf8', }) if (indexResult.error || indexResult.status !== 0) - return false + return undefined - const indexEntries = indexResult.stdout?.toString().split('\0').filter(Boolean) ?? [] - if (indexEntries.length > 1) - return false - if (indexEntries.length === 1) { - const [metadata] = indexEntries[0].split('\t', 1) - const [mode, , stage] = metadata.split(' ') - return (mode === '100644' || mode === '100755') && stage === '0' - } + const output = indexResult.stdout?.toString() ?? '' + if (!output) + return null + if (!output.endsWith('\0')) + return undefined + + const entries = output.slice(0, -1).split('\0') + if (entries.length !== 1) + return undefined + const separatorIndex = entries[0].indexOf('\t') + if (separatorIndex < 1 || entries[0].slice(separatorIndex + 1) !== filePath) + return undefined + const metadata = entries[0].slice(0, separatorIndex).split(' ') + if (metadata.length !== 3) + return undefined + const [mode, objectId, stage] = metadata + return /^[0-7]{6}$/.test(mode) && isGitObjectId(objectId) && stage === '0' ? mode : undefined +} + +function isDeletedRegularGitPath(repoRoot: string, filePath: string, indexMode: string | null) { + if (indexMode !== null) + return isRegularGitBlobMode(indexMode) const headResult = spawnSync('git', ['--literal-pathspecs', 'ls-tree', '-z', 'HEAD', '--', filePath], { cwd: repoRoot, @@ -411,12 +433,20 @@ function isDeletedRegularGitPath(repoRoot: string, filePath: string) { if (headResult.error || headResult.status !== 0) return false - const headEntries = headResult.stdout?.toString().split('\0').filter(Boolean) ?? [] + const output = headResult.stdout?.toString() ?? '' + if (!output.endsWith('\0')) + return false + const headEntries = output.slice(0, -1).split('\0') if (headEntries.length !== 1) return false - const [metadata] = headEntries[0].split('\t', 1) - const [mode, type] = metadata.split(' ') - return (mode === '100644' || mode === '100755') && type === 'blob' + const separatorIndex = headEntries[0].indexOf('\t') + if (separatorIndex < 1 || headEntries[0].slice(separatorIndex + 1) !== filePath) + return false + const metadata = headEntries[0].slice(0, separatorIndex).split(' ') + if (metadata.length !== 3) + return false + const [mode, type, objectId] = metadata + return isRegularGitBlobMode(mode) && type === 'blob' && isGitObjectId(objectId) } export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undefined { @@ -433,7 +463,7 @@ export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undef if (!repoRootValue) return undefined const repoRoot = realpathSync(repoRootValue) - const statusResult = spawnSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { + const statusResult = spawnSync('git', ['-c', 'status.renames=copies', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: repoRoot, stdio: 'pipe', encoding: 'utf8', @@ -457,12 +487,17 @@ export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undef if (pathFromRoot === '..' || pathFromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(pathFromRoot)) return undefined + const indexMode = getInitGitIndexMode(repoRoot, filePath) + if (indexMode === undefined) + return undefined if (status.includes('D')) { - if (!isDeletedRegularGitPath(repoRoot, filePath)) + if (!isDeletedRegularGitPath(repoRoot, filePath, indexMode)) return undefined files[filePath] = { status, sha256: null, mode: null } continue } + if (status === '??' ? indexMode !== null : !isRegularGitBlobMode(indexMode)) + return undefined const fileStats = lstatSync(absolutePath) if (!fileStats.isFile()) diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index 9288b1cf8c..c88527168f 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { execSync, spawn } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -49,6 +49,18 @@ function withTempDir(fn) { } } +function tryCreateTestSymlink(target, filePath) { + try { + symlinkSync(target, filePath) + return true + } + catch (error) { + if (['EACCES', 'ENOSYS', 'ENOTSUP', 'EPERM'].includes(error?.code)) + return false + throw error + } +} + function t(name, fn) { try { fn() @@ -447,7 +459,8 @@ t('git snapshot declines unsupported symlinks and renames', () => { withTempDir((root) => { execSync('git init', { cwd: root, stdio: 'ignore' }) writeFileSync(join(root, 'target.txt'), 'target\n', 'utf8') - symlinkSync('target.txt', join(root, 'link.txt')) + if (!tryCreateTestSymlink('target.txt', join(root, 'link.txt'))) + return assert.equal(captureInitGitSnapshot(root), undefined) }) @@ -472,8 +485,10 @@ t('git snapshot declines a tracked symlink changed into a regular file', () => { execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + execSync('git config core.symlinks true', { cwd: root, stdio: 'ignore' }) writeFileSync(join(root, 'target.txt'), 'target\n', 'utf8') - symlinkSync('target.txt', join(root, 'link.txt')) + if (!tryCreateTestSymlink('target.txt', join(root, 'link.txt'))) + return execSync('git add target.txt link.txt', { cwd: root, stdio: 'ignore' }) execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) @@ -484,6 +499,100 @@ t('git snapshot declines a tracked symlink changed into a regular file', () => { }) }) +t('git snapshot checks tracked index mode when core.symlinks is disabled', () => { + withTempDir((root) => { + execSync('git init', { cwd: root, stdio: 'ignore' }) + execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) + execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) + execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + execSync('git config core.symlinks true', { cwd: root, stdio: 'ignore' }) + writeFileSync(join(root, 'target.txt'), 'target\n', 'utf8') + const linkPath = join(root, 'link.txt') + if (!tryCreateTestSymlink('target.txt', linkPath)) + return + execSync('git add target.txt link.txt', { cwd: root, stdio: 'ignore' }) + execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + execSync('git config core.symlinks false', { cwd: root, stdio: 'ignore' }) + unlinkSync(linkPath) + execSync('git checkout -- link.txt', { cwd: root, stdio: 'ignore' }) + assert.equal(lstatSync(linkPath).isFile(), true) + + writeFileSync(linkPath, 'modified placeholder\n', 'utf8') + + assert.equal(captureInitGitSnapshot(root), undefined) + }) +}) + +t('git snapshot forces rename detection when repository status config disables it', () => { + withTempDir((root) => { + execSync('git init', { cwd: root, stdio: 'ignore' }) + execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) + execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) + execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + execSync('git config status.renames false', { cwd: root, stdio: 'ignore' }) + writeFileSync(join(root, 'before.txt'), 'tracked\n', 'utf8') + execSync('git add before.txt', { cwd: root, stdio: 'ignore' }) + execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + execSync('git mv before.txt after.txt', { cwd: root, stdio: 'ignore' }) + + assert.equal(captureInitGitSnapshot(root), undefined) + }) +}) + +t('git fingerprint merge retains only existing fingerprints proven safe before a mutation', () => { + const repoRoot = '/example/repo' + const fingerprint = sha256 => ({ status: ' M', sha256, mode: 0o100644 }) + const changes = (files, root = repoRoot) => ({ version: 1, repoRoot: root, files }) + const existing = changes({ 'package.json': fingerprint('saved') }) + const cases = [ + { + name: 'exact existing fingerprint remains retained', + before: changes({ 'package.json': fingerprint('saved') }), + after: changes({ 'package.json': fingerprint('saved') }), + expected: existing, + }, + { + name: 'user change before the window prunes the existing fingerprint', + before: changes({ 'package.json': fingerprint('user-edit') }), + after: changes({ 'package.json': fingerprint('user-edit') }), + expected: undefined, + }, + { + name: 'stale path changed again during the window is not re-attributed', + before: changes({ 'package.json': fingerprint('user-edit') }), + after: changes({ 'package.json': fingerprint('second-edit') }), + expected: undefined, + }, + { + name: 'missing before snapshot retains existing state without claiming after paths', + before: undefined, + after: changes({ + 'package.json': fingerprint('after-edit'), + 'new-file.txt': fingerprint('new-file'), + }), + expected: existing, + }, + { + name: 'missing after snapshot retains existing state without claiming before paths', + before: changes({ + 'package.json': fingerprint('saved'), + 'user-file.txt': fingerprint('user-edit'), + }), + after: undefined, + expected: existing, + }, + { + name: 'incompatible after snapshot retains existing state without claiming paths', + before: changes({ 'package.json': fingerprint('saved') }), + after: changes({ 'new-file.txt': fingerprint('new-file') }, '/different/repo'), + expected: existing, + }, + ] + + for (const testCase of cases) + assert.deepEqual(mergeInitGitChanges(existing, testCase.before, testCase.after), testCase.expected, testCase.name) +}) + t('git fingerprint classification requires exact path, status, hash, and mode matches', () => { const repoRoot = '/example/repo' const fingerprint = (status, sha256, mode = 0o100644) => ({ status, sha256, mode }) From fae9bf42bc2b57e2ebb75090a3a4ab571185f3c5 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 16:56:39 +0200 Subject: [PATCH 05/19] perf(cli): batch onboarding git fingerprint metadata --- cli/src/init/command.ts | 112 ++++++++++++++---------------- cli/test/test-init-guardrails.mjs | 30 ++++++++ 2 files changed, 83 insertions(+), 59 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 78031ceff9..68919b6c56 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -393,60 +393,51 @@ function isGitObjectId(value: string) { return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value) } -function getInitGitIndexMode(repoRoot: string, filePath: string): string | null | undefined { - const indexResult = spawnSync('git', ['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', filePath], { - cwd: repoRoot, - stdio: 'pipe', - encoding: 'utf8', - }) - if (indexResult.error || indexResult.status !== 0) - return undefined +interface InitGitStatusRecord { + status: string + filePath: string + headMode: string | null + indexMode: string | null + worktreeMode: string | null +} - const output = indexResult.stdout?.toString() ?? '' - if (!output) - return null - if (!output.endsWith('\0')) - return undefined +function splitInitGitStatusFields(value: string, count: number) { + const fields: string[] = [] + let offset = 0 + while (fields.length < count - 1) { + const separatorIndex = value.indexOf(' ', offset) + if (separatorIndex < 0) + return undefined + fields.push(value.slice(offset, separatorIndex)) + offset = separatorIndex + 1 + } + fields.push(value.slice(offset)) + return fields +} - const entries = output.slice(0, -1).split('\0') - if (entries.length !== 1) +function parseInitGitStatusRecord(value: string): InitGitStatusRecord | undefined { + if (value.startsWith('? ')) { + const filePath = value.slice(2) + return filePath + ? { status: '??', filePath, headMode: null, indexMode: null, worktreeMode: null } + : undefined + } + if (!value.startsWith('1 ')) return undefined - const separatorIndex = entries[0].indexOf('\t') - if (separatorIndex < 1 || entries[0].slice(separatorIndex + 1) !== filePath) + + const fields = splitInitGitStatusFields(value, 9) + if (!fields) return undefined - const metadata = entries[0].slice(0, separatorIndex).split(' ') - if (metadata.length !== 3) + const [, rawStatus, submodule, headMode, indexMode, worktreeMode, headObjectId, indexObjectId, filePath] = fields + const status = rawStatus.replaceAll('.', ' ') + if (!filePath + || !/^[ MADRCUT]{2}$/.test(status) + || submodule !== 'N...' + || ![headMode, indexMode, worktreeMode].every(mode => /^[0-7]{6}$/.test(mode)) + || !isGitObjectId(headObjectId) + || !isGitObjectId(indexObjectId)) return undefined - const [mode, objectId, stage] = metadata - return /^[0-7]{6}$/.test(mode) && isGitObjectId(objectId) && stage === '0' ? mode : undefined -} - -function isDeletedRegularGitPath(repoRoot: string, filePath: string, indexMode: string | null) { - if (indexMode !== null) - return isRegularGitBlobMode(indexMode) - - const headResult = spawnSync('git', ['--literal-pathspecs', 'ls-tree', '-z', 'HEAD', '--', filePath], { - cwd: repoRoot, - stdio: 'pipe', - encoding: 'utf8', - }) - if (headResult.error || headResult.status !== 0) - return false - - const output = headResult.stdout?.toString() ?? '' - if (!output.endsWith('\0')) - return false - const headEntries = output.slice(0, -1).split('\0') - if (headEntries.length !== 1) - return false - const separatorIndex = headEntries[0].indexOf('\t') - if (separatorIndex < 1 || headEntries[0].slice(separatorIndex + 1) !== filePath) - return false - const metadata = headEntries[0].slice(0, separatorIndex).split(' ') - if (metadata.length !== 3) - return false - const [mode, type, objectId] = metadata - return isRegularGitBlobMode(mode) && type === 'blob' && isGitObjectId(objectId) + return { status, filePath, headMode, indexMode, worktreeMode } } export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undefined { @@ -463,7 +454,7 @@ export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undef if (!repoRootValue) return undefined const repoRoot = realpathSync(repoRootValue) - const statusResult = spawnSync('git', ['-c', 'status.renames=copies', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], { + const statusResult = spawnSync('git', ['-c', 'status.renames=copies', 'status', '--porcelain=v2', '-z', '--untracked-files=all'], { cwd: repoRoot, stdio: 'pipe', encoding: 'utf8', @@ -471,14 +462,17 @@ export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undef if (statusResult.error || statusResult.status !== 0) return undefined - const entries = statusResult.stdout?.toString().split('\0').filter(Boolean) ?? [] + const statusOutput = statusResult.stdout?.toString() ?? '' + if (statusOutput && !statusOutput.endsWith('\0')) + return undefined + const entries = statusOutput ? statusOutput.slice(0, -1).split('\0') : [] const files: Record = {} for (const entry of entries) { - if (entry.length < 4 || entry[2] !== ' ') + const parsedEntry = parseInitGitStatusRecord(entry) + if (!parsedEntry) return undefined - const status = entry.slice(0, 2) - const filePath = entry.slice(3) + const { status, filePath, headMode, indexMode, worktreeMode } = parsedEntry if (!filePath || status.includes('R') || status.includes('C') || status.includes('T') || status.includes('U') || status === 'AA' || status === 'DD' || files[filePath]) return undefined @@ -487,16 +481,16 @@ export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undef if (pathFromRoot === '..' || pathFromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(pathFromRoot)) return undefined - const indexMode = getInitGitIndexMode(repoRoot, filePath) - if (indexMode === undefined) - return undefined if (status.includes('D')) { - if (!isDeletedRegularGitPath(repoRoot, filePath, indexMode)) + const deletedMode = status[0] === 'D' ? headMode : indexMode + if (!isRegularGitBlobMode(deletedMode) + || (status[0] === 'D' && indexMode !== '000000') + || (status[1] === 'D' && worktreeMode !== '000000')) return undefined files[filePath] = { status, sha256: null, mode: null } continue } - if (status === '??' ? indexMode !== null : !isRegularGitBlobMode(indexMode)) + if (status !== '??' && (!isRegularGitBlobMode(indexMode) || !isRegularGitBlobMode(worktreeMode))) return undefined const fileStats = lstatSync(absolutePath) diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index c88527168f..4cf341af91 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -455,6 +455,36 @@ t('git snapshot fingerprints a deleted regular file with null content and mode', }) }) +t('git snapshot handles mixed tracked, untracked, and staged-deleted paths together', () => { + withTempDir((root) => { + execSync('git init', { cwd: root, stdio: 'ignore' }) + execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) + execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) + execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + mkdirSync(join(root, 'src'), { recursive: true }) + writeFileSync(join(root, 'src', 'tracked file.ts'), 'initial\n', 'utf8') + writeFileSync(join(root, 'old file ü.txt'), 'delete me\n', 'utf8') + execSync('git add .', { cwd: root, stdio: 'ignore' }) + execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + + unlinkSync(join(root, 'old file ü.txt')) + execSync('git add -u', { cwd: root, stdio: 'ignore' }) + writeFileSync(join(root, 'src', 'tracked file.ts'), 'modified\n', 'utf8') + writeFileSync(join(root, 'new file é.txt'), 'untracked\n', 'utf8') + + const snapshot = captureInitGitSnapshot(root) + assert.ok(snapshot) + assert.deepEqual(Object.keys(snapshot.files).sort(), ['new file é.txt', 'old file ü.txt', 'src/tracked file.ts']) + assert.deepEqual(snapshot.files['old file ü.txt'], { status: 'D ', sha256: null, mode: null }) + assert.equal(snapshot.files['src/tracked file.ts']?.status, ' M') + assert.equal(typeof snapshot.files['src/tracked file.ts']?.sha256, 'string') + assert.equal(typeof snapshot.files['src/tracked file.ts']?.mode, 'number') + assert.equal(snapshot.files['new file é.txt']?.status, '??') + assert.equal(typeof snapshot.files['new file é.txt']?.sha256, 'string') + assert.equal(typeof snapshot.files['new file é.txt']?.mode, 'number') + }) +}) + t('git snapshot declines unsupported symlinks and renames', () => { withTempDir((root) => { execSync('git init', { cwd: root, stdio: 'ignore' }) From 9d661241381701d115b9ddcf4e969131f56b9056 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 17:16:12 +0200 Subject: [PATCH 06/19] fix(cli): recognize saved onboarding git changes --- cli/src/init/command.ts | 277 +++++++++++++++++++++++++++--- cli/test/test-init-guardrails.mjs | 197 +++++++++++++++++++++ 2 files changed, 448 insertions(+), 26 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 68919b6c56..7481fa19cc 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -134,6 +134,15 @@ export interface InitGitClassification { retained: InitGitChanges | undefined } +export interface InitGitRepoDecision { + warningEntries: string[] + recognizedCount: number + nextSaved: InitGitChanges | undefined + shouldPersist: boolean + skipPrompt: boolean + infoMessages: string[] +} + interface InitAutoTestChange { filePath: string displayPath: string @@ -152,6 +161,8 @@ let globalDelta = false let globalCurrentVersion: string | undefined let globalAppId: string | undefined let globalSupaHost: string | undefined +let globalStepDone = 0 +let globalInitGitChanges: InitGitChanges | undefined export function resolveInitTargetPath(value: string | undefined, label: string, initialCwd = cwd()): string | undefined { if (!value) @@ -370,6 +381,98 @@ function initGitFingerprintMatches(left: InitGitChangeFingerprint | undefined, r && left.mode === right.mode } +function isPlainInitGitRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function hasOnlyInitGitKeys(value: Record, expectedKeys: string[]) { + const keys = Reflect.ownKeys(value) + return keys.length === expectedKeys.length + && keys.every((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + return typeof key === 'string' + && expectedKeys.includes(key) + && descriptor?.enumerable === true + && Object.hasOwn(descriptor, 'value') + }) +} + +function hasOnlyInitGitRecordEntries(value: Record) { + return Reflect.ownKeys(value).every((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + return typeof key === 'string' + && descriptor?.enumerable === true + && Object.hasOwn(descriptor, 'value') + }) +} + +function isSafeInitGitRelativePath(filePath: string) { + if (!filePath + || filePath.includes('\0') + || filePath.includes('\\') + || path.posix.isAbsolute(filePath) + || path.win32.isAbsolute(filePath) + || /^[a-z]:/i.test(filePath)) + return false + const segments = filePath.split('/') + return path.posix.normalize(filePath) === filePath + && segments.every(segment => segment && segment !== '.' && segment !== '..') +} + +function isSupportedInitGitFileMode(mode: unknown): mode is number | null { + return mode === null + || (Number.isSafeInteger(mode) + && (mode as number) >= 0o100000 + && (mode as number) <= 0o107777) +} + +function parseInitGitChangesValue(value: unknown, allowEmpty: boolean): InitGitChanges | undefined { + try { + if (!isPlainInitGitRecord(value) || !hasOnlyInitGitKeys(value, ['version', 'repoRoot', 'files'])) + return undefined + if (value.version !== 1 + || typeof value.repoRoot !== 'string' + || value.repoRoot.trim().length === 0 + || !isPlainInitGitRecord(value.files) + || !hasOnlyInitGitRecordEntries(value.files)) + return undefined + + const entries = Object.entries(value.files) + if (!allowEmpty && entries.length === 0) + return undefined + + const parsedEntries: [string, InitGitChangeFingerprint][] = [] + for (const [filePath, rawFingerprint] of entries) { + if (!isSafeInitGitRelativePath(filePath) + || !isPlainInitGitRecord(rawFingerprint) + || !hasOnlyInitGitKeys(rawFingerprint, ['status', 'sha256', 'mode']) + || typeof rawFingerprint.status !== 'string' + || rawFingerprint.status.length !== 2 + || !(rawFingerprint.sha256 === null || (typeof rawFingerprint.sha256 === 'string' && /^[0-9a-f]{64}$/.test(rawFingerprint.sha256))) + || !isSupportedInitGitFileMode(rawFingerprint.mode)) + return undefined + + parsedEntries.push([filePath, { + status: rawFingerprint.status, + sha256: rawFingerprint.sha256, + mode: rawFingerprint.mode, + }]) + } + + return { version: 1, repoRoot: value.repoRoot, files: Object.fromEntries(parsedEntries) } + } + catch { + return undefined + } +} + +export function parseInitGitChanges(value: unknown): InitGitChanges | undefined { + return parseInitGitChangesValue(value, false) +} + function isUsableInitGitChanges(changes: InitGitChanges | undefined): changes is InitGitChanges { return changes?.version === 1 && typeof changes.repoRoot === 'string' @@ -703,6 +806,73 @@ function getGitStatusEntryPath(entry: string) { return rawPath.slice(rawPath.lastIndexOf(renameSeparator) + renameSeparator.length) } +function getNormalizedGitStatusEntryPath(entry: string) { + return getGitStatusEntryPath(entry).split(path.sep).join('/') +} + +export function evaluateInitGitRepoState( + status: GitRepoStatus, + currentValue: InitGitChanges | undefined, + savedValue: InitGitChanges | undefined, +): InitGitRepoDecision { + const saved = parseInitGitChanges(savedValue) + if (status.clean) { + return { + warningEntries: [], + recognizedCount: 0, + nextSaved: undefined, + shouldPersist: true, + skipPrompt: true, + infoMessages: [], + } + } + + const current = parseInitGitChangesValue(currentValue, true) + const statusPaths = status.entries.map(getNormalizedGitStatusEntryPath) + const currentPaths = current ? Object.keys(current.files).sort() : [] + const snapshotMatchesStatus = Boolean( + current + && status.repoRoot + && path.resolve(current.repoRoot) === path.resolve(status.repoRoot) + && statusPaths.length === currentPaths.length + && [...statusPaths].sort().every((filePath, index) => filePath === currentPaths[index]), + ) + if (!current || !saved || !snapshotMatchesStatus) { + return { + warningEntries: status.entries, + recognizedCount: 0, + nextSaved: saved, + shouldPersist: false, + skipPrompt: false, + infoMessages: [], + } + } + + const classification = classifyInitGitChanges(current, saved) + if (classification.unsafePaths.length === 0 && classification.recognizedCount > 0) { + return { + warningEntries: [], + recognizedCount: classification.recognizedCount, + nextSaved: classification.retained, + shouldPersist: true, + skipPrompt: true, + infoMessages: ['Resuming with uncommitted changes created by the previous Capgo onboarding run.'], + } + } + + const unsafePaths = new Set(classification.unsafePaths) + return { + warningEntries: status.entries.filter(entry => unsafePaths.has(getNormalizedGitStatusEntryPath(entry))), + recognizedCount: classification.recognizedCount, + nextSaved: classification.retained, + shouldPersist: true, + skipPrompt: false, + infoMessages: classification.recognizedCount > 0 + ? [`${classification.recognizedCount} recognized Capgo ${classification.recognizedCount === 1 ? 'change was' : 'changes were'} omitted from this warning.`] + : [], + } +} + export function isOnlyAllowedInitAutoTestChange(status: GitRepoStatus, allowedChange?: InitAutoTestChange) { if (!allowedChange || !status.inRepo || !status.repoRoot || status.error || status.clean || status.entries.length === 0) return false @@ -790,6 +960,8 @@ async function ensureGitRepoCleanBeforeInit(allowedAutoTestChange?: InitAutoTest } if (status.clean) { + globalInitGitChanges = undefined + persistInitProgressSafely() if (warned) pLog.success('Git repository is clean ✅') return @@ -798,13 +970,23 @@ async function ensureGitRepoCleanBeforeInit(allowedAutoTestChange?: InitAutoTest if (isOnlyAllowedInitAutoTestChange(status, allowedAutoTestChange)) return + const decision = evaluateInitGitRepoState(status, captureInitGitSnapshot(status.repoRoot), globalInitGitChanges) + if (decision.shouldPersist) { + globalInitGitChanges = decision.nextSaved + persistInitProgressSafely() + } + for (const message of decision.infoMessages) + pLog.info(message) + if (decision.skipPrompt) + return + warned = true pLog.warn(`Git repository is not clean: ${status.repoRoot}`) - for (const entry of status.entries.slice(0, 10)) { + for (const entry of decision.warningEntries.slice(0, 10)) { pLog.warn(` ${entry}`) } - if (status.entries.length > 10) { - pLog.warn(` ...and ${status.entries.length - 10} more`) + if (decision.warningEntries.length > 10) { + pLog.warn(` ...and ${decision.warningEntries.length - 10} more`) } pLog.info('Clean, commit, or stash those changes before init continues, or continue anyway if you accept the risk.') @@ -1501,33 +1683,72 @@ async function ensureWorkspaceReadyForInit(initialAppId?: string): Promise { + const valid = { + version: 1, + repoRoot: '/repo', + files: { + 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 }, + 'src/deleted.ts': { status: ' D', sha256: null, mode: null }, + }, + } + const invalidCases = [ + ['undefined value', undefined], + ['null value', null], + ['array value', []], + ['unsupported version', { ...valid, version: 2 }], + ['empty repository root', { ...valid, repoRoot: '' }], + ['array file map', { ...valid, files: [] }], + ['empty file map', { ...valid, files: {} }], + ['absolute path', { ...valid, files: { '/package.json': valid.files['package.json'] } }], + ['Windows absolute path', { ...valid, files: { 'C:\\package.json': valid.files['package.json'] } }], + ['Windows drive-relative path', { ...valid, files: { 'C:package.json': valid.files['package.json'] } }], + ['escaping path', { ...valid, files: { '../package.json': valid.files['package.json'] } }], + ['normalized escaping path', { ...valid, files: { 'src/../../package.json': valid.files['package.json'] } }], + ['array fingerprint', { ...valid, files: { 'package.json': [] } }], + ['short status', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: 'M' } } }], + ['long status', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: ' M ' } } }], + ['uppercase hash', { ...valid, files: { 'package.json': { ...valid.files['package.json'], sha256: 'A'.repeat(64) } } }], + ['short hash', { ...valid, files: { 'package.json': { ...valid.files['package.json'], sha256: 'a'.repeat(63) } } }], + ['fractional mode', { ...valid, files: { 'package.json': { ...valid.files['package.json'], mode: 0o100644 + 0.5 } } }], + ['directory mode', { ...valid, files: { 'package.json': { ...valid.files['package.json'], mode: 0o040755 } } }], + ['symlink mode', { ...valid, files: { 'package.json': { ...valid.files['package.json'], mode: 0o120777 } } }], + ['unexpected top-level property', { ...valid, ignored: true }], + ['unexpected fingerprint property', { ...valid, files: { 'package.json': { ...valid.files['package.json'], ignored: true } } }], + ] + + assert.deepEqual(parseInitGitChanges(valid), valid) + assert.notEqual(parseInitGitChanges(valid), valid) + assert.notEqual(parseInitGitChanges(valid)?.files, valid.files) + for (const [name, value] of invalidCases) + assert.equal(parseInitGitChanges(value), undefined, name) + + const inheritedShape = Object.create(valid) + assert.equal(parseInitGitChanges(inheritedShape), undefined) + const inheritedFiles = Object.create({ inherited: valid.files['package.json'] }) + inheritedFiles['package.json'] = valid.files['package.json'] + assert.equal(parseInitGitChanges({ ...valid, files: inheritedFiles }), undefined) +}) + +t('saved Capgo git changes skip only the unsafe-state prompt they exactly cover', () => { + const repoRoot = '/repo' + const saved = { + version: 1, + repoRoot, + files: { + 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 }, + }, + } + const status = { + inRepo: true, + clean: false, + repoRoot, + entries: [' M package.json'], + } + + const decision = evaluateInitGitRepoState(status, saved, saved) + assert.equal(decision.skipPrompt, true) + assert.deepEqual(decision.warningEntries, []) + assert.deepEqual(decision.infoMessages, [ + 'Resuming with uncommitted changes created by the previous Capgo onboarding run.', + ]) + assert.equal(decision.recognizedCount, 1) +}) + +t('mixed saved and unknown git changes keep the warning but omit recognized entries', () => { + const repoRoot = '/repo' + const fingerprint = sha256 => ({ status: ' M', sha256, mode: 0o100644 }) + const saved = { + version: 1, + repoRoot, + files: { 'package.json': fingerprint('a'.repeat(64)) }, + } + const current = { + version: 1, + repoRoot, + files: { + 'package.json': fingerprint('a'.repeat(64)), + 'src/main.ts': fingerprint('b'.repeat(64)), + }, + } + const status = { + inRepo: true, + clean: false, + repoRoot, + entries: [' M package.json', ' M src/main.ts'], + } + + const decision = evaluateInitGitRepoState(status, current, saved) + assert.equal(decision.skipPrompt, false) + assert.deepEqual(decision.warningEntries, [' M src/main.ts']) + assert.deepEqual(decision.infoMessages, ['1 recognized Capgo change was omitted from this warning.']) + assert.deepEqual(Object.keys(decision.nextSaved?.files ?? {}), ['package.json']) + assert.equal(decision.shouldPersist, true) +}) + +t('changed, missing, and malformed saved git fingerprints preserve the unsafe warning', () => { + const repoRoot = '/repo' + const saved = { + version: 1, + repoRoot, + files: { 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 } }, + } + const current = { + version: 1, + repoRoot, + files: { 'package.json': { status: ' M', sha256: 'b'.repeat(64), mode: 0o100644 } }, + } + const status = { + inRepo: true, + clean: false, + repoRoot, + entries: [' M package.json'], + } + + for (const [name, rawSaved] of [ + ['subsequently modified', saved], + ['legacy progress', undefined], + ['malformed progress', { ...saved, files: [] }], + ]) { + const parsedSaved = name === 'subsequently modified' ? rawSaved : parseInitGitChanges(rawSaved) + const decision = evaluateInitGitRepoState(status, current, parsedSaved) + assert.equal(decision.skipPrompt, false, name) + assert.deepEqual(decision.warningEntries, [' M package.json'], name) + assert.equal(decision.recognizedCount, 0, name) + } +}) + +t('clean git state clears saved fingerprints and continue-anyway never attributes unsafe paths', () => { + const repoRoot = '/repo' + const saved = { + version: 1, + repoRoot, + files: { 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 } }, + } + const cleanDecision = evaluateInitGitRepoState({ + inRepo: true, + clean: true, + repoRoot, + entries: [], + }, { version: 1, repoRoot, files: {} }, saved) + assert.equal(cleanDecision.skipPrompt, true) + assert.equal(cleanDecision.nextSaved, undefined) + assert.equal(cleanDecision.shouldPersist, true) + + const mixedCurrent = { + version: 1, + repoRoot, + files: { + ...saved.files, + 'src/user.ts': { status: '??', sha256: 'b'.repeat(64), mode: 0o100644 }, + }, + } + const dirtyDecision = evaluateInitGitRepoState({ + inRepo: true, + clean: false, + repoRoot, + entries: [' M package.json', '?? src/user.ts'], + }, mixedCurrent, saved) + assert.deepEqual(Object.keys(dirtyDecision.nextSaved?.files ?? {}), ['package.json']) + assert.equal(dirtyDecision.nextSaved?.files['src/user.ts'], undefined) +}) + +t('fresh, declined, and discarded onboarding state cannot retain saved git fingerprints', () => { + const saved = { + version: 1, + repoRoot: '/repo', + files: { 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 } }, + } + + try { + restoreInitProgressState(4, saved) + assert.deepEqual(getInitProgressStateForTesting(), { stepDone: 4, gitChanges: saved }) + + for (const flow of ['fresh start', 'declined resume', 'discarded resume']) { + resetInitProgressState() + assert.deepEqual(getInitProgressStateForTesting(), { stepDone: 0, gitChanges: undefined }, flow) + restoreInitProgressState(4, saved) + } + } + finally { + resetInitProgressState() + } +}) + async function tAsync(name, fn) { try { await fn() From 7c7405f6e0f51fc20b7e8c935fe670ccdca007ce Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 17:31:14 +0200 Subject: [PATCH 07/19] test(cli): exercise onboarding git resume guardrails --- cli/src/init/command.ts | 132 ++++++++++++------ cli/test/test-init-guardrails.mjs | 221 ++++++++++++++---------------- 2 files changed, 191 insertions(+), 162 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 7481fa19cc..8072361e52 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -108,6 +108,24 @@ type CancelablePromptValue = boolean | string | symbol type InitAutoTestChangeKind = 'html-banner' | 'vue-banner' | 'css-background' type DirtyGitStatusAction = 'check-again' | 'continue-dirty' +interface InitGitCleanGateLog { + error: (message: string) => void + info: (message: string) => void + success: (message: string) => void + warn: (message: string) => void +} + +export interface InitGitCleanGateDependencies { + getStatus?: () => GitRepoStatus + captureSnapshot?: (startDir?: string) => InitGitChanges | undefined + isOnlyAllowedAutoTestChange?: (status: GitRepoStatus, allowedChange?: InitAutoTestChange) => boolean + persistProgress?: () => void + log?: InitGitCleanGateLog + selectAction?: (prompt: { message: string, options: ReturnType }) => Promise + cancelAction?: (action: DirtyGitStatusAction | symbol) => Promise + waitForRetry?: () => Promise +} + interface GitRepoStatus { inRepo: boolean clean: boolean @@ -938,13 +956,24 @@ async function waitForGitRepoCleanRetry() { await cancelBeforeAuthenticatedOnboarding(ready) } -async function ensureGitRepoCleanBeforeInit(allowedAutoTestChange?: InitAutoTestChange) { +export async function ensureGitRepoCleanBeforeInit( + allowedAutoTestChange?: InitAutoTestChange, + dependencies: InitGitCleanGateDependencies = {}, +) { + const getStatus = dependencies.getStatus ?? getGitRepoStatus + const captureSnapshot = dependencies.captureSnapshot ?? captureInitGitSnapshot + const isAllowedAutoTestChange = dependencies.isOnlyAllowedAutoTestChange ?? isOnlyAllowedInitAutoTestChange + const persistProgress = dependencies.persistProgress ?? persistInitProgressSafely + const log = dependencies.log ?? pLog + const selectAction = dependencies.selectAction ?? (prompt => pSelect(prompt)) + const cancelAction = dependencies.cancelAction ?? (action => cancelBeforeAuthenticatedOnboarding(action)) + const waitForRetry = dependencies.waitForRetry ?? waitForGitRepoCleanRetry let warned = false while (true) { - const status = getGitRepoStatus() + const status = getStatus() if (status.error && !status.inRepo) { - pLog.warn(`Could not verify git status, skipping clean-repo check: ${status.error}`) + log.warn(`Could not verify git status, skipping clean-repo check: ${status.error}`) return } @@ -953,51 +982,51 @@ async function ensureGitRepoCleanBeforeInit(allowedAutoTestChange?: InitAutoTest if (status.error) { warned = true - pLog.error(`Could not verify git status for ${status.repoRoot}: ${status.error}`) - pLog.info('Fix the git error first, then retry onboarding.') - await waitForGitRepoCleanRetry() + log.error(`Could not verify git status for ${status.repoRoot}: ${status.error}`) + log.info('Fix the git error first, then retry onboarding.') + await waitForRetry() continue } if (status.clean) { globalInitGitChanges = undefined - persistInitProgressSafely() + persistProgress() if (warned) - pLog.success('Git repository is clean ✅') + log.success('Git repository is clean ✅') return } - if (isOnlyAllowedInitAutoTestChange(status, allowedAutoTestChange)) + if (isAllowedAutoTestChange(status, allowedAutoTestChange)) return - const decision = evaluateInitGitRepoState(status, captureInitGitSnapshot(status.repoRoot), globalInitGitChanges) + const decision = evaluateInitGitRepoState(status, captureSnapshot(status.repoRoot), globalInitGitChanges) if (decision.shouldPersist) { globalInitGitChanges = decision.nextSaved - persistInitProgressSafely() + persistProgress() } for (const message of decision.infoMessages) - pLog.info(message) + log.info(message) if (decision.skipPrompt) return warned = true - pLog.warn(`Git repository is not clean: ${status.repoRoot}`) + log.warn(`Git repository is not clean: ${status.repoRoot}`) for (const entry of decision.warningEntries.slice(0, 10)) { - pLog.warn(` ${entry}`) + log.warn(` ${entry}`) } if (decision.warningEntries.length > 10) { - pLog.warn(` ...and ${decision.warningEntries.length - 10} more`) + log.warn(` ...and ${decision.warningEntries.length - 10} more`) } - pLog.info('Clean, commit, or stash those changes before init continues, or continue anyway if you accept the risk.') + log.info('Clean, commit, or stash those changes before init continues, or continue anyway if you accept the risk.') - const action = await pSelect({ + const action = await selectAction({ message: 'How do you want to handle the dirty git status?', options: getDirtyGitStatusActionOptions(), }) - await cancelBeforeAuthenticatedOnboarding(action) + await cancelAction(action) if (action === 'continue-dirty') { - pLog.warn('Continuing with dirty git status. This is not recommended.') + log.warn('Continuing with dirty git status. This is not recommended.') return } } @@ -1688,6 +1717,15 @@ export function resetInitProgressState() { globalInitGitChanges = undefined } +export function beginFreshInitProgress() { + resetInitProgressState() +} + +interface InitProgressTransitionDependencies { + clearCodeDiff?: () => void + clearEncryptionSummary?: () => void +} + export function restoreInitProgressState(stepDone: number, gitChanges: unknown) { globalStepDone = stepDone globalInitGitChanges = parseInitGitChanges(gitChanges) @@ -1980,13 +2018,7 @@ async function tryResumeOnboarding( // User chose to start over — delete the saved progress and drop any // restored code diff / encryption summary so a fresh manual path // doesn't re-show stale content. - cleanupStepsDone() - globalCodeDiff = undefined - setInitCodeDiff(undefined) - globalEncryptionSummary = undefined - setInitEncryptionSummary(undefined) - globalAutoTestChange = undefined - globalNodeModulesPath = undefined + declineInitProgressResume() return undefined } catch (err) { @@ -2018,6 +2050,37 @@ function cleanupStepsDone() { } } +export function declineInitProgressResume(dependencies: InitProgressTransitionDependencies = {}) { + const clearCodeDiff = dependencies.clearCodeDiff ?? (() => setInitCodeDiff(undefined)) + const clearEncryptionSummary = dependencies.clearEncryptionSummary ?? (() => setInitEncryptionSummary(undefined)) + cleanupStepsDone() + globalCodeDiff = undefined + clearCodeDiff() + globalEncryptionSummary = undefined + clearEncryptionSummary() + globalAutoTestChange = undefined + globalNodeModulesPath = undefined +} + +export function discardResumedInitProgress(dependencies: InitProgressTransitionDependencies = {}) { + const clearCodeDiff = dependencies.clearCodeDiff ?? (() => setInitCodeDiff(undefined)) + const clearEncryptionSummary = dependencies.clearEncryptionSummary ?? (() => setInitEncryptionSummary(undefined)) + globalNodeModulesPath = undefined + globalChannelName = defaultChannel + globalPlatform = 'ios' + globalDelta = false + globalCurrentVersion = undefined + globalAppId = undefined + globalOrgId = undefined + globalOrgName = undefined + globalCodeDiff = undefined + clearCodeDiff() + globalEncryptionSummary = undefined + clearEncryptionSummary() + globalAutoTestChange = undefined + cleanupStepsDone() +} + async function cancelCommand(command: boolean | string | symbol, orgId: string, apikey: string) { if (pIsCancel(command)) { await markInitSnag(orgId, apikey, 'canceled', undefined, '🤷') @@ -5501,7 +5564,7 @@ async function maybeStarCapgoRepo(includeSkillsRepository = false, repository?: } export async function initApp(apikeyCommand: string, appId: string, options: SuperOptions) { - resetInitProgressState() + beginFreshInitProgress() const initialCwd = cwd() const packageJsonPath = resolveInitTargetPath(options.packageJson, 'Package JSON path', initialCwd) const capacitorConfigPath = getConfigWriteTarget() ?? resolveCapacitorConfigTargetPath(options.capacitorConfig, initialCwd) @@ -5701,20 +5764,7 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup const discardResumedState = async () => { stepToSkip = 0 resumed = undefined - globalNodeModulesPath = undefined - globalChannelName = defaultChannel - globalPlatform = 'ios' - globalDelta = false - globalCurrentVersion = undefined - globalAppId = undefined - globalOrgId = undefined - globalOrgName = undefined - globalCodeDiff = undefined - setInitCodeDiff(undefined) - globalEncryptionSummary = undefined - setInitEncryptionSummary(undefined) - globalAutoTestChange = undefined - cleanupStepsDone() + discardResumedInitProgress() globalPathToPackageJson = initialTargets.pathToPackageJson globalCapacitorConfigPath = initialTargets.capacitorConfigPath globalConfigLoadDir = initialTargets.configLoadDir diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index 7f0a52e70f..db50db9de4 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -7,9 +7,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { applyInitAutoTestChange, + beginFreshInitProgress, captureInitGitSnapshot, classifyInitGitChanges, - evaluateInitGitRepoState, + declineInitProgressResume, + discardResumedInitProgress, + ensureGitRepoCleanBeforeInit, getDirtyGitStatusActionOptions, getGitRepoStatus, getInitProgressStateForTesting, @@ -22,7 +25,6 @@ import { isOnlyAllowedInitAutoTestChange, mergeInitGitChanges, parseInitGitChanges, - resetInitProgressState, restoreInitProgressState, revertInitAutoTestChangeContent, runInheritedCommand, @@ -760,32 +762,19 @@ t('saved init git fingerprints require a strict versioned runtime shape', () => assert.equal(parseInitGitChanges({ ...valid, files: inheritedFiles }), undefined) }) -t('saved Capgo git changes skip only the unsafe-state prompt they exactly cover', () => { - const repoRoot = '/repo' - const saved = { - version: 1, - repoRoot, - files: { - 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 }, - }, +async function tAsync(name, fn) { + try { + await fn() + console.log(`✓ ${name}`) } - const status = { - inRepo: true, - clean: false, - repoRoot, - entries: [' M package.json'], + catch (error) { + failures += 1 + console.error(`❌ ${name}`) + console.error(error) } +} - const decision = evaluateInitGitRepoState(status, saved, saved) - assert.equal(decision.skipPrompt, true) - assert.deepEqual(decision.warningEntries, []) - assert.deepEqual(decision.infoMessages, [ - 'Resuming with uncommitted changes created by the previous Capgo onboarding run.', - ]) - assert.equal(decision.recognizedCount, 1) -}) - -t('mixed saved and unknown git changes keep the warning but omit recognized entries', () => { +await tAsync('live git cleanliness gate filters trusted changes without weakening the existing prompt', async () => { const repoRoot = '/repo' const fingerprint = sha256 => ({ status: ' M', sha256, mode: 0o100644 }) const saved = { @@ -797,126 +786,116 @@ t('mixed saved and unknown git changes keep the warning but omit recognized entr version: 1, repoRoot, files: { - 'package.json': fingerprint('a'.repeat(64)), - 'src/main.ts': fingerprint('b'.repeat(64)), + ...saved.files, + 'src/user.ts': { status: '??', sha256: 'b'.repeat(64), mode: 0o100644 }, }, } - const status = { + const dirtyStatus = { inRepo: true, clean: false, repoRoot, - entries: [' M package.json', ' M src/main.ts'], + entries: [' M package.json', '?? src/user.ts'], } - const decision = evaluateInitGitRepoState(status, current, saved) - assert.equal(decision.skipPrompt, false) - assert.deepEqual(decision.warningEntries, [' M src/main.ts']) - assert.deepEqual(decision.infoMessages, ['1 recognized Capgo change was omitted from this warning.']) - assert.deepEqual(Object.keys(decision.nextSaved?.files ?? {}), ['package.json']) - assert.equal(decision.shouldPersist, true) -}) - -t('changed, missing, and malformed saved git fingerprints preserve the unsafe warning', () => { - const repoRoot = '/repo' - const saved = { - version: 1, - repoRoot, - files: { 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 } }, - } - const current = { - version: 1, - repoRoot, - files: { 'package.json': { status: ' M', sha256: 'b'.repeat(64), mode: 0o100644 } }, - } - const status = { - inRepo: true, - clean: false, - repoRoot, - entries: [' M package.json'], + const runGate = async ({ status, snapshot, savedValue, action = 'continue-dirty' }) => { + restoreInitProgressState(4, savedValue) + const events = [] + await ensureGitRepoCleanBeforeInit(undefined, { + getStatus: () => status, + captureSnapshot: () => snapshot, + isOnlyAllowedAutoTestChange: () => false, + persistProgress: () => events.push({ type: 'persist', state: getInitProgressStateForTesting() }), + log: { + error: message => events.push({ type: 'error', message }), + info: message => events.push({ type: 'info', message }), + success: message => events.push({ type: 'success', message }), + warn: message => events.push({ type: 'warn', message }), + }, + selectAction: async (prompt) => { + events.push({ type: 'prompt', prompt }) + return action + }, + cancelAction: async selectedAction => events.push({ type: 'cancel-check', action: selectedAction }), + waitForRetry: async () => assert.fail('retry prompt was not expected'), + }) + return { events, state: getInitProgressStateForTesting() } } - for (const [name, rawSaved] of [ - ['subsequently modified', saved], - ['legacy progress', undefined], - ['malformed progress', { ...saved, files: [] }], - ]) { - const parsedSaved = name === 'subsequently modified' ? rawSaved : parseInitGitChanges(rawSaved) - const decision = evaluateInitGitRepoState(status, current, parsedSaved) - assert.equal(decision.skipPrompt, false, name) - assert.deepEqual(decision.warningEntries, [' M package.json'], name) - assert.equal(decision.recognizedCount, 0, name) - } -}) + try { + const exact = await runGate({ + status: { ...dirtyStatus, entries: [' M package.json'] }, + snapshot: saved, + savedValue: saved, + }) + assert.equal(exact.events.some(event => event.type === 'prompt'), false) + assert.deepEqual(exact.events.filter(event => event.type === 'info').map(event => event.message), [ + 'Resuming with uncommitted changes created by the previous Capgo onboarding run.', + ]) + + const mixed = await runGate({ status: dirtyStatus, snapshot: current, savedValue: saved }) + const mixedPrompt = mixed.events.find(event => event.type === 'prompt') + assert.deepEqual(mixedPrompt?.prompt, { + message: 'How do you want to handle the dirty git status?', + options: getDirtyGitStatusActionOptions(), + }) + assert.deepEqual( + mixed.events.filter(event => event.type === 'warn' && event.message.startsWith(' ')).map(event => event.message), + [' ?? src/user.ts'], + ) + assert.equal(mixed.events.some(event => event.type === 'info' && event.message === '1 recognized Capgo change was omitted from this warning.'), true) + const mixedPersistIndex = mixed.events.findIndex(event => event.type === 'persist') + assert.equal(mixedPersistIndex < mixed.events.findIndex(event => event.type === 'prompt'), true) + assert.deepEqual(Object.keys(mixed.events[mixedPersistIndex]?.state.gitChanges?.files ?? {}), ['package.json']) + assert.deepEqual(Object.keys(mixed.state.gitChanges?.files ?? {}), ['package.json']) + assert.equal(mixed.state.gitChanges?.files['src/user.ts'], undefined) + assert.equal(mixed.events.some(event => event.type === 'warn' && event.message === 'Continuing with dirty git status. This is not recommended.'), true) + + for (const [name, savedValue] of [ + ['missing fingerprints', undefined], + ['malformed fingerprints', { ...saved, files: [] }], + ]) { + const fallback = await runGate({ status: dirtyStatus, snapshot: current, savedValue }) + assert.deepEqual( + fallback.events.filter(event => event.type === 'warn' && event.message.startsWith(' ')).map(event => event.message), + [' M package.json', ' ?? src/user.ts'], + name, + ) + assert.equal(fallback.events.some(event => event.type === 'prompt'), true, name) + } -t('clean git state clears saved fingerprints and continue-anyway never attributes unsafe paths', () => { - const repoRoot = '/repo' - const saved = { - version: 1, - repoRoot, - files: { 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 } }, + const clean = await runGate({ + status: { inRepo: true, clean: true, repoRoot, entries: [] }, + snapshot: undefined, + savedValue: saved, + }) + assert.equal(clean.events.some(event => event.type === 'prompt'), false) + assert.deepEqual(clean.events.filter(event => event.type === 'persist').map(event => event.state.gitChanges), [undefined]) + assert.deepEqual(clean.state, { stepDone: 4, gitChanges: undefined }) } - const cleanDecision = evaluateInitGitRepoState({ - inRepo: true, - clean: true, - repoRoot, - entries: [], - }, { version: 1, repoRoot, files: {} }, saved) - assert.equal(cleanDecision.skipPrompt, true) - assert.equal(cleanDecision.nextSaved, undefined) - assert.equal(cleanDecision.shouldPersist, true) - - const mixedCurrent = { - version: 1, - repoRoot, - files: { - ...saved.files, - 'src/user.ts': { status: '??', sha256: 'b'.repeat(64), mode: 0o100644 }, - }, + finally { + beginFreshInitProgress() } - const dirtyDecision = evaluateInitGitRepoState({ - inRepo: true, - clean: false, - repoRoot, - entries: [' M package.json', '?? src/user.ts'], - }, mixedCurrent, saved) - assert.deepEqual(Object.keys(dirtyDecision.nextSaved?.files ?? {}), ['package.json']) - assert.equal(dirtyDecision.nextSaved?.files['src/user.ts'], undefined) }) -t('fresh, declined, and discarded onboarding state cannot retain saved git fingerprints', () => { +t('production onboarding lifecycle transitions clear resumed fingerprint state', () => { const saved = { version: 1, repoRoot: '/repo', files: { 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 } }, } + const transitions = [ + ['fresh onboarding start', beginFreshInitProgress], + ['declined resume', () => declineInitProgressResume({ clearCodeDiff: () => {}, clearEncryptionSummary: () => {} })], + ['discarded resumed onboarding', () => discardResumedInitProgress({ clearCodeDiff: () => {}, clearEncryptionSummary: () => {} })], + ] - try { + for (const [name, transition] of transitions) { restoreInitProgressState(4, saved) - assert.deepEqual(getInitProgressStateForTesting(), { stepDone: 4, gitChanges: saved }) - - for (const flow of ['fresh start', 'declined resume', 'discarded resume']) { - resetInitProgressState() - assert.deepEqual(getInitProgressStateForTesting(), { stepDone: 0, gitChanges: undefined }, flow) - restoreInitProgressState(4, saved) - } - } - finally { - resetInitProgressState() + transition() + assert.deepEqual(getInitProgressStateForTesting(), { stepDone: 0, gitChanges: undefined }, name) } }) -async function tAsync(name, fn) { - try { - await fn() - console.log(`✓ ${name}`) - } - catch (error) { - failures += 1 - console.error(`❌ ${name}`) - console.error(error) - } -} - await tAsync('command settlement preserves ENOENT instead of close code -2', async () => { const child = spawn('__capgo_missing_stream_command__', [], { stdio: ['ignore', 'pipe', 'pipe'], From 61e15efc9c3bc337525d03c0ad23497733a3207f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 17:52:48 +0200 Subject: [PATCH 08/19] fix(cli): harden onboarding git resume state --- cli/src/init/command.ts | 101 +++++++++++++++++++++--------- cli/test/test-init-guardrails.mjs | 53 ++++++++++++++++ 2 files changed, 126 insertions(+), 28 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 8072361e52..accf99f996 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -440,11 +440,26 @@ function isSafeInitGitRelativePath(filePath: string) { && segments.every(segment => segment && segment !== '.' && segment !== '..') } -function isSupportedInitGitFileMode(mode: unknown): mode is number | null { - return mode === null - || (Number.isSafeInteger(mode) - && (mode as number) >= 0o100000 - && (mode as number) <= 0o107777) +function isSupportedInitGitFileMode(mode: unknown): mode is number { + return Number.isSafeInteger(mode) + && (mode as number) >= 0o100000 + && (mode as number) <= 0o107777 +} + +function getInitGitFingerprintKind(status: unknown): 'regular' | 'deleted' | undefined { + if (status === '??') + return 'regular' + if (status === ' D' || status === 'D ' || status === 'MD' || status === 'AD') + return 'deleted' + if (status === ' M' || status === 'M ' || status === 'MM' || status === 'A ' || status === 'AM') + return 'regular' + return undefined +} + +function isSafeInitGitRepoRoot(repoRoot: unknown): repoRoot is string { + return typeof repoRoot === 'string' + && !repoRoot.includes('\0') + && (path.posix.isAbsolute(repoRoot) || path.win32.isAbsolute(repoRoot)) } function parseInitGitChangesValue(value: unknown, allowEmpty: boolean): InitGitChanges | undefined { @@ -452,8 +467,7 @@ function parseInitGitChangesValue(value: unknown, allowEmpty: boolean): InitGitC if (!isPlainInitGitRecord(value) || !hasOnlyInitGitKeys(value, ['version', 'repoRoot', 'files'])) return undefined if (value.version !== 1 - || typeof value.repoRoot !== 'string' - || value.repoRoot.trim().length === 0 + || !isSafeInitGitRepoRoot(value.repoRoot) || !isPlainInitGitRecord(value.files) || !hasOnlyInitGitRecordEntries(value.files)) return undefined @@ -464,17 +478,27 @@ function parseInitGitChangesValue(value: unknown, allowEmpty: boolean): InitGitC const parsedEntries: [string, InitGitChangeFingerprint][] = [] for (const [filePath, rawFingerprint] of entries) { + const fingerprintKind = isPlainInitGitRecord(rawFingerprint) + ? getInitGitFingerprintKind(rawFingerprint.status) + : undefined if (!isSafeInitGitRelativePath(filePath) || !isPlainInitGitRecord(rawFingerprint) || !hasOnlyInitGitKeys(rawFingerprint, ['status', 'sha256', 'mode']) - || typeof rawFingerprint.status !== 'string' - || rawFingerprint.status.length !== 2 - || !(rawFingerprint.sha256 === null || (typeof rawFingerprint.sha256 === 'string' && /^[0-9a-f]{64}$/.test(rawFingerprint.sha256))) - || !isSupportedInitGitFileMode(rawFingerprint.mode)) + || !fingerprintKind) return undefined + if (fingerprintKind === 'deleted') { + if (rawFingerprint.sha256 !== null || rawFingerprint.mode !== null) + return undefined + parsedEntries.push([filePath, { status: rawFingerprint.status as string, sha256: null, mode: null }]) + continue + } + if (typeof rawFingerprint.sha256 !== 'string' + || !/^[0-9a-f]{64}$/.test(rawFingerprint.sha256) + || !isSupportedInitGitFileMode(rawFingerprint.mode)) + return undefined parsedEntries.push([filePath, { - status: rawFingerprint.status, + status: rawFingerprint.status as string, sha256: rawFingerprint.sha256, mode: rawFingerprint.mode, }]) @@ -594,7 +618,8 @@ export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undef return undefined const { status, filePath, headMode, indexMode, worktreeMode } = parsedEntry - if (!filePath || status.includes('R') || status.includes('C') || status.includes('T') || status.includes('U') || status === 'AA' || status === 'DD' || files[filePath]) + const fingerprintKind = getInitGitFingerprintKind(status) + if (!filePath || !fingerprintKind || files[filePath]) return undefined const absolutePath = path.resolve(repoRoot, filePath) @@ -602,7 +627,7 @@ export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undef if (pathFromRoot === '..' || pathFromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(pathFromRoot)) return undefined - if (status.includes('D')) { + if (fingerprintKind === 'deleted') { const deletedMode = status[0] === 'D' ? headMode : indexMode if (!isRegularGitBlobMode(deletedMode) || (status[0] === 'D' && indexMode !== '000000') @@ -1800,6 +1825,16 @@ interface ResumeResult { appId?: string } +interface InitResumeDependencies { + readProgress?: () => string + validateAccess?: (resume: ResumeResult) => Promise + selectResume?: (prompt: { message: string, options: { value: string, label: string }[] }) => Promise + afterProgressRestored?: () => void + clearCodeDiff?: () => void + clearEncryptionSummary?: () => void + log?: Pick +} + export function getResumedOnboardingAccessError( resume: ResumeResult, organization: Organization | undefined, @@ -1851,15 +1886,23 @@ async function validateResumedOnboardingAccess( } } -async function tryResumeOnboarding( +export async function tryResumeOnboarding( apikey: string, initialTargets: InitTargetPaths, initialCwd: string, supabase: Awaited>, hostOptions?: { supaHost?: string, supaAnon?: string }, + dependencies: InitResumeDependencies = {}, ): Promise { + const readProgress = dependencies.readProgress ?? (() => readFileSync(getTmpObjectPath(), 'utf-8')) + const validateAccess = dependencies.validateAccess ?? (resume => validateResumedOnboardingAccess(supabase, apikey, resume, hostOptions)) + const selectResume = dependencies.selectResume ?? (prompt => pSelect(prompt)) + const afterProgressRestored = dependencies.afterProgressRestored ?? (() => {}) + const clearCodeDiff = dependencies.clearCodeDiff ?? (() => setInitCodeDiff(undefined)) + const clearEncryptionSummary = dependencies.clearEncryptionSummary ?? (() => setInitEncryptionSummary(undefined)) + const log = dependencies.log ?? pLog try { - const rawData = readFileSync(getTmpObjectPath(), 'utf-8') + const rawData = readProgress() if (!rawData || rawData.length === 0) return undefined @@ -1883,24 +1926,24 @@ async function tryResumeOnboarding( gitChanges, } = JSON.parse(rawData) if (!orgId || !step_done) { - pLog.warn('⚠️ Found previous onboarding progress, but it was saved in an older format.') - pLog.info(' Starting fresh. Your previous progress cannot be resumed.') + log.warn('⚠️ Found previous onboarding progress, but it was saved in an older format.') + log.info(' Starting fresh. Your previous progress cannot be resumed.') return undefined } const resume: ResumeResult = { stepDone: step_done, orgId, orgName, appId: savedAppId } - const accessError = await validateResumedOnboardingAccess(supabase, apikey, resume, hostOptions) + const accessError = await validateAccess(resume) if (accessError) { - pLog.warn(accessError) + log.warn(accessError) cleanupStepsDone() return undefined } - pLog.info(formatInitResumeMessage(step_done, initOnboardingSteps.length)) + log.info(formatInitResumeMessage(step_done, initOnboardingSteps.length)) if (orgName) { - pLog.info(` Organization: ${orgName}`) + log.info(` Organization: ${orgName}`) } - const resumeChoice = await pSelect({ + const resumeChoice = await selectResume({ message: 'Would you like to continue from where you left off?', options: [ { value: 'yes', label: '✅ Yes, continue' }, @@ -1916,7 +1959,7 @@ async function tryResumeOnboarding( mainFilePath: typeof mainFilePath === 'string' ? mainFilePath : undefined, }, initialCwd) if (!resumedTargets) { - pLog.warn('Saved onboarding targets are no longer available. Starting over.') + log.warn('Saved onboarding targets are no longer available. Starting over.') cleanupStepsDone() globalCodeDiff = undefined setInitCodeDiff(undefined) @@ -1926,6 +1969,7 @@ async function tryResumeOnboarding( return undefined } restoreInitProgressState(step_done, gitChanges) + afterProgressRestored() globalPathToPackageJson = resumedTargets.pathToPackageJson globalCapacitorConfigPath = resumedTargets.capacitorConfigPath globalConfigLoadDir = resumedTargets.configLoadDir @@ -2022,12 +2066,13 @@ async function tryResumeOnboarding( return undefined } catch (err) { - pLog.error(`Cannot read which steps have been completed, error:\n${err}`) - pLog.warn('Onboarding will continue but please report it to the capgo team!') + beginFreshInitProgress() + log.error(`Cannot read which steps have been completed, error:\n${err}`) + log.warn('Onboarding will continue but please report it to the capgo team!') globalCodeDiff = undefined - setInitCodeDiff(undefined) + clearCodeDiff() globalEncryptionSummary = undefined - setInitEncryptionSummary(undefined) + clearEncryptionSummary() globalAutoTestChange = undefined globalNodeModulesPath = undefined return undefined diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index db50db9de4..e96422988d 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -28,6 +28,7 @@ import { restoreInitProgressState, revertInitAutoTestChangeContent, runInheritedCommand, + tryResumeOnboarding, } from '../src/init/command.ts' import { createMissingExecutableError, @@ -730,6 +731,8 @@ t('saved init git fingerprints require a strict versioned runtime shape', () => ['array value', []], ['unsupported version', { ...valid, version: 2 }], ['empty repository root', { ...valid, repoRoot: '' }], + ['relative repository root', { ...valid, repoRoot: 'repo' }], + ['NUL repository root', { ...valid, repoRoot: '/repo\0other' }], ['array file map', { ...valid, files: [] }], ['empty file map', { ...valid, files: {} }], ['absolute path', { ...valid, files: { '/package.json': valid.files['package.json'] } }], @@ -740,6 +743,18 @@ t('saved init git fingerprints require a strict versioned runtime shape', () => ['array fingerprint', { ...valid, files: { 'package.json': [] } }], ['short status', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: 'M' } } }], ['long status', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: ' M ' } } }], + ['empty status', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: ' ' } } }], + ['garbage status', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: 'zz' } } }], + ['renamed status', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: 'R ' } } }], + ['copied status', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: ' C' } } }], + ['type-changed status', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: 'T ' } } }], + ['unmerged status', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: 'U ' } } }], + ['unsupported added pair', { ...valid, files: { 'package.json': { ...valid.files['package.json'], status: 'AA' } } }], + ['unsupported deleted pair', { ...valid, files: { 'package.json': { status: 'DD', sha256: null, mode: null } } }], + ['delete with content', { ...valid, files: { 'package.json': { status: ' D', sha256: 'a'.repeat(64), mode: 0o100644 } } }], + ['delete with hash only', { ...valid, files: { 'package.json': { status: 'D ', sha256: 'a'.repeat(64), mode: null } } }], + ['non-delete with null content', { ...valid, files: { 'package.json': { status: ' M', sha256: null, mode: null } } }], + ['non-delete with null mode', { ...valid, files: { 'package.json': { status: 'M ', sha256: 'a'.repeat(64), mode: null } } }], ['uppercase hash', { ...valid, files: { 'package.json': { ...valid.files['package.json'], sha256: 'A'.repeat(64) } } }], ['short hash', { ...valid, files: { 'package.json': { ...valid.files['package.json'], sha256: 'a'.repeat(63) } } }], ['fractional mode', { ...valid, files: { 'package.json': { ...valid.files['package.json'], mode: 0o100644 + 0.5 } } }], @@ -760,6 +775,13 @@ t('saved init git fingerprints require a strict versioned runtime shape', () => const inheritedFiles = Object.create({ inherited: valid.files['package.json'] }) inheritedFiles['package.json'] = valid.files['package.json'] assert.equal(parseInitGitChanges({ ...valid, files: inheritedFiles }), undefined) + + for (const [status, deleted] of [[' M', false], ['M ', false], ['MM', false], ['A ', false], ['??', false], [' D', true], ['D ', true]]) { + const fingerprint = deleted + ? { status, sha256: null, mode: null } + : { status, sha256: 'b'.repeat(64), mode: 0o100755 } + assert.deepEqual(parseInitGitChanges({ version: 1, repoRoot: '/repo', files: { 'file.txt': fingerprint } })?.files['file.txt'], fingerprint, status) + } }) async function tAsync(name, fn) { @@ -896,6 +918,37 @@ t('production onboarding lifecycle transitions clear resumed fingerprint state', } }) +await tAsync('resume fallback clears fingerprints when post-confirmation restoration throws', async () => { + const saved = { + version: 1, + repoRoot: '/repo', + files: { 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 } }, + } + let stateBeforeFailure + + const resumed = await tryResumeOnboarding('test-key', {}, process.cwd(), {}, undefined, { + readProgress: () => JSON.stringify({ + step_done: 1, + orgId: 'org-id', + orgName: 'Saved org', + gitChanges: saved, + }), + validateAccess: async () => undefined, + selectResume: async () => 'yes', + afterProgressRestored: () => { + stateBeforeFailure = getInitProgressStateForTesting() + throw new Error('post-restore failure') + }, + clearCodeDiff: () => {}, + clearEncryptionSummary: () => {}, + log: { error: () => {}, info: () => {}, warn: () => {} }, + }) + + assert.deepEqual(stateBeforeFailure, { stepDone: 1, gitChanges: saved }) + assert.equal(resumed, undefined) + assert.deepEqual(getInitProgressStateForTesting(), { stepDone: 0, gitChanges: undefined }) +}) + await tAsync('command settlement preserves ENOENT instead of close code -2', async () => { const child = spawn('__capgo_missing_stream_command__', [], { stdio: ['ignore', 'pipe', 'pipe'], From c466f09726c27d279c02f6a1fd09d57d5cb23845 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 18:14:12 +0200 Subject: [PATCH 09/19] fix(cli): track automatic onboarding git changes --- cli/src/init/command.ts | 229 +++++++++++++++++++++--------- cli/test/test-init-guardrails.mjs | 174 ++++++++++++++++++++++- 2 files changed, 332 insertions(+), 71 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index accf99f996..5b3565950f 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -695,6 +695,22 @@ export function mergeInitGitChanges(existing: InitGitChanges | undefined, before return cloneInitGitChanges({ version: 1, repoRoot: usableAfter.repoRoot, files: retainedFiles }) } +export async function trackInitGitChanges( + existing: InitGitChanges | undefined, + operation: () => T | Promise, + options: { + startDir?: string + isSuccess?: (result: T) => boolean + } = {}, +): Promise<{ result: T, gitChanges: InitGitChanges | undefined }> { + const before = captureInitGitSnapshot(options.startDir) + const result = await operation() + if (options.isSuccess && !options.isSuccess(result)) + return { result, gitChanges: existing } + const after = captureInitGitSnapshot(options.startDir) + return { result, gitChanges: mergeInitGitChanges(existing, before, after) } +} + export function classifyInitGitChanges(current: InitGitChanges | undefined, saved: InitGitChanges | undefined): InitGitClassification { if (!isUsableInitGitChanges(current)) return { unsafePaths: [], recognizedCount: 0, retained: undefined } @@ -1577,7 +1593,10 @@ async function maybeRunCapacitorInit(projectDir: string, projectType: string, in try { spinner.start(`Installing Capacitor packages with ${pm.installCommand}`) - const installCoreResult = spawnSync(pm.pm, [pm.command, '@capacitor/core'], { stdio: 'pipe', cwd: projectDir }) + const installCoreResult = await runTrackedInitMutation( + () => spawnSync(pm.pm, [pm.command, '@capacitor/core'], { stdio: 'pipe', cwd: projectDir }), + { startDir: projectDir, isSuccess: result => !result.error && result.status === 0 }, + ) if (installCoreResult.error) throw installCoreResult.error if (installCoreResult.status !== 0) { @@ -1586,7 +1605,10 @@ async function maybeRunCapacitorInit(projectDir: string, projectType: string, in throw new Error(stderr || stdout || `${pm.installCommand} @capacitor/core exited with code ${installCoreResult.status}`) } - const installCliResult = spawnSync(pm.pm, [pm.command, '-D', '@capacitor/cli'], { stdio: 'pipe', cwd: projectDir }) + const installCliResult = await runTrackedInitMutation( + () => spawnSync(pm.pm, [pm.command, '-D', '@capacitor/cli'], { stdio: 'pipe', cwd: projectDir }), + { startDir: projectDir, isSuccess: result => !result.error && result.status === 0 }, + ) if (installCliResult.error) throw installCliResult.error if (installCliResult.status !== 0) { @@ -1596,7 +1618,10 @@ async function maybeRunCapacitorInit(projectDir: string, projectType: string, in } spinner.message(`Running: ${pm.runner} cap init "${appName}" "${capacitorAppId}" --web-dir ${webDir}`) - const initResult = spawnSync(pm.runner, ['cap', 'init', appName, capacitorAppId, '--web-dir', webDir], { stdio: 'pipe', cwd: projectDir }) + const initResult = await runTrackedInitMutation( + () => spawnSync(pm.runner, ['cap', 'init', appName, capacitorAppId, '--web-dir', webDir], { stdio: 'pipe', cwd: projectDir }), + { startDir: projectDir, isSuccess: result => !result.error && result.status === 0 }, + ) if (initResult.error) throw initResult.error if (initResult.status !== 0) { @@ -1631,12 +1656,15 @@ async function runCapacitorPlatformAdd(platformName: 'ios' | 'android', runner: spinner.start(runMessage) spinner.stop() - const result = await streamCommandInInitPanel({ - title: `Adding ${platformName.toUpperCase()} native project`, - runner, - args: ['cap', 'add', platformName], - cwd: commandCwd, - }) + const result = await runTrackedInitMutation( + () => streamCommandInInitPanel({ + title: `Adding ${platformName.toUpperCase()} native project`, + runner, + args: ['cap', 'add', platformName], + cwd: commandCwd, + }), + { startDir: commandCwd, isSuccess: result => result.success }, + ) await delay(result.success ? 750 : 3500) clearInitStreamingOutput() if (!result.success) { @@ -1802,6 +1830,32 @@ function persistInitProgressSafely() { } } +async function runTrackedInitMutation( + operation: () => T | Promise, + options: { + startDir?: string + isSuccess?: (result: T) => boolean + } = {}, +): Promise { + let successful = true + const isSuccess = options.isSuccess + const tracked = await trackInitGitChanges(globalInitGitChanges, operation, { + ...options, + isSuccess: isSuccess + ? (result) => { + successful = isSuccess(result) + return successful + } + : undefined, + }) + if (successful) { + globalInitGitChanges = tracked.gitChanges + if (globalStepDone > 0) + persistInitProgressSafely() + } + return tracked.result +} + function markStepDone(step: number, pathToPackageJson?: string, channelName?: string) { globalStepDone = step if (pathToPackageJson) @@ -2319,7 +2373,10 @@ async function markStep(orgId: string, apikey: string, step: string, appId: stri */ async function saveAppIdToCapacitorConfig(appId: string) { try { - await updateConfigUpdater({ appId }) + await runTrackedInitMutation( + () => updateConfigUpdater({ appId }), + { startDir: globalConfigLoadDir ?? (globalCapacitorConfigPath ? dirname(globalCapacitorConfigPath) : cwd()) }, + ) pLog.info(`💾 Saved new app ID "${appId}" to CapacitorUpdater config`) } catch (err) { @@ -2340,7 +2397,10 @@ async function syncPendingAppIdToCapacitorConfig(appId: string) { ...extConfig.config.plugins.CapacitorUpdater, appId, } - await writeConfigUpdater(extConfig, true) + await runTrackedInitMutation( + () => writeConfigUpdater(extConfig, true), + { startDir: globalConfigLoadDir ?? (globalCapacitorConfigPath ? dirname(globalCapacitorConfigPath) : cwd()) }, + ) pLog.info(`💾 Synced pending onboarding app ID "${appId}" to capacitor config`) } catch (err) { @@ -2392,24 +2452,26 @@ async function runNativeResetCommand(platformRunner: string, nativePlatform: Pla const resetSpinner = pSpinner() resetSpinner.start(`Running: ${resetAdvice.command}`) try { - rmSync(nativePlatform, { recursive: true, force: true }) - resetSpinner.stop() - - const addResult = await streamCommandInInitPanel({ - title: `Recreating ${nativePlatform.toUpperCase()} native project`, - runner: platformRunner, - args: ['cap', 'add', nativePlatform], - }) - if (!addResult.success) - throw addResult.error ?? new Error(`cap add ${nativePlatform} failed`) + await runTrackedInitMutation(async () => { + rmSync(nativePlatform, { recursive: true, force: true }) + resetSpinner.stop() + + const addResult = await streamCommandInInitPanel({ + title: `Recreating ${nativePlatform.toUpperCase()} native project`, + runner: platformRunner, + args: ['cap', 'add', nativePlatform], + }) + if (!addResult.success) + throw addResult.error ?? new Error(`cap add ${nativePlatform} failed`) - const syncResult = await streamCommandInInitPanel({ - title: `Syncing ${nativePlatform.toUpperCase()} native project`, - runner: platformRunner, - args: ['cap', 'sync', nativePlatform], + const syncResult = await streamCommandInInitPanel({ + title: `Syncing ${nativePlatform.toUpperCase()} native project`, + runner: platformRunner, + args: ['cap', 'sync', nativePlatform], + }) + if (!syncResult.success) + throw syncResult.error ?? new Error(`cap sync ${nativePlatform} failed`) }) - if (!syncResult.success) - throw syncResult.error ?? new Error(`cap sync ${nativePlatform} failed`) await delay(750) resetSpinner.stop(successMessage) @@ -2561,7 +2623,10 @@ async function ensureCapacitorProjectReady( const spinner = pSpinner() spinner.start(`Running: ${pm.runner} cap init "${appName}" "${appId}"`) try { - const initResult = spawnSync(pm.runner, ['cap', 'init', appName, appId], { stdio: 'pipe' as const }) + const initResult = await runTrackedInitMutation( + () => spawnSync(pm.runner, ['cap', 'init', appName, appId], { stdio: 'pipe' as const }), + { isSuccess: result => !result.error && result.status === 0 }, + ) if (initResult.error) throw initResult.error if (initResult.status !== 0) { @@ -3272,15 +3337,18 @@ function formatSpawnOutput(output: string | Buffer | null | undefined): string { return typeof output === 'string' ? output : output.toString('utf8') } -function runUpdaterInstallCommand(pm: PackageManagerInfo, packageJsonPath: string, versionToInstall: string): void { +async function runUpdaterInstallCommand(pm: PackageManagerInfo, packageJsonPath: string, versionToInstall: string): Promise { const [command, ...args] = pm.installCommand.split(whitespaceSplitPattern).filter(Boolean) if (!command) throw new Error('Cannot determine package manager install command') - const result = spawnSync(command, [...args, '--force', `${CAPGO_UPDATER_PACKAGE}@${versionToInstall}`], { - stdio: 'pipe', - cwd: dirname(packageJsonPath), - }) + const result = await runTrackedInitMutation( + () => spawnSync(command, [...args, '--force', `${CAPGO_UPDATER_PACKAGE}@${versionToInstall}`], { + stdio: 'pipe', + cwd: dirname(packageJsonPath), + }), + { startDir: dirname(packageJsonPath), isSuccess: result => !result.error && result.status === 0 }, + ) if (result.error || result.status !== 0) { const output = [formatSpawnOutput(result.stdout), formatSpawnOutput(result.stderr)] .map(text => text.trim()) @@ -3328,7 +3396,7 @@ async function waitForVerifiedUpdaterInstall( const s = pSpinner() try { s.start(`Running: ${getUpdaterInstallCommand(pm, versionToInstall, true)}`) - runUpdaterInstallCommand(pm, packageJsonPath, versionToInstall) + await runUpdaterInstallCommand(pm, packageJsonPath, versionToInstall) s.stop('Updater install command finished ✅') } catch (error) { @@ -3388,7 +3456,7 @@ async function addUpdaterStep(orgId: string, apikey: string, appId: string) { } else { try { - runUpdaterInstallCommand(pm, path, versionToInstall) + await runUpdaterInstallCommand(pm, path, versionToInstall) s.stop(`Install Done ✅`) } catch (error) { @@ -3415,12 +3483,15 @@ async function addUpdaterStep(orgId: string, apikey: string, appId: string) { s.start(`Updating config file`) delta = !!doDirectInstall const projectDir = dirname(path) - await withTemporaryCwd(getInitConfigLoadDir(projectDir), async () => { - if (doDirectInstall) { - await updateConfigbyKey('SplashScreen', { launchAutoHide: false }) - } - await updateConfigUpdater(getInitUpdaterPluginConfig(appId, delta)) - }) + await runTrackedInitMutation( + () => withTemporaryCwd(getInitConfigLoadDir(projectDir), async () => { + if (doDirectInstall) { + await updateConfigbyKey('SplashScreen', { launchAutoHide: false }) + } + await updateConfigUpdater(getInitUpdaterPluginConfig(appId, delta)) + }), + { startDir: projectDir }, + ) s.stop(`Config file updated ✅`) break } @@ -3532,10 +3603,12 @@ async function addCodeStep(orgId: string, apikey: string, appId: string) { else if (!alreadyConfigured) { const s = pSpinner() s.start(`Adding @capacitor-updater to your main file`) - if (created) { - mkdirSync(dirname(filePath), { recursive: true }) - } - writeFileSync(filePath, newContent, 'utf8') + await runTrackedInitMutation(() => { + if (created) { + mkdirSync(dirname(filePath), { recursive: true }) + } + writeFileSync(filePath, newContent, 'utf8') + }, { startDir: projectDir }) s.stop() globalCodeDiff = previewDiff setInitCodeDiff(globalCodeDiff) @@ -3712,7 +3785,10 @@ async function addEncryptionStep(orgId: string, apikey: string, appId: string) { // key is present in the config. try { const encryptionConfig = await withTemporaryCwd(getInitConfigLoadDir(projectDir), () => getConfigForWrite()) - await withTemporaryCwd(projectDir, () => createKeyInternal({ force: true, setupChannel: false }, true, encryptionConfig)) + await runTrackedInitMutation( + () => withTemporaryCwd(projectDir, () => createKeyInternal({ force: true, setupChannel: false }, true, encryptionConfig)), + { startDir: projectDir }, + ) // Intentionally stop without a success message: the persistent // encryption summary panel renders on the next step and already shows // the outcome. Passing a message here would push it into the rolling @@ -3734,12 +3810,15 @@ async function addEncryptionStep(orgId: string, apikey: string, appId: string) { // exactly what we want — the key needs to end up in whichever // native projects exist. await ensureUpdaterReadyBeforeSync(pm, orgId, apikey, packageJsonPath) - const syncResult = await streamCommandInInitPanel({ - title: '🔐 Syncing native project so the public key is bundled', - runner: pm.runner, - args: ['cap', 'sync'], - cwd: projectDir, - }) + const syncResult = await runTrackedInitMutation( + () => streamCommandInInitPanel({ + title: '🔐 Syncing native project so the public key is bundled', + runner: pm.runner, + args: ['cap', 'sync'], + cwd: projectDir, + }), + { startDir: projectDir, isSuccess: result => result.success }, + ) // Small dwell so the user can read the final state of the panel // (success banner or the last few lines of an error) before we // tear it down and move on. @@ -4165,12 +4244,15 @@ async function runBuildAndSyncLoop( // understand that the next streamed command is the native sync. await delay(1500) - const syncResult = await streamCommandInInitPanel({ - title: `Syncing ${platform.toUpperCase()} native project`, - runner: pm.runner, - args: ['cap', 'sync', platform], - cwd: buildAndSyncCwd, - }) + const syncResult = await runTrackedInitMutation( + () => streamCommandInInitPanel({ + title: `Syncing ${platform.toUpperCase()} native project`, + runner: pm.runner, + args: ['cap', 'sync', platform], + cwd: buildAndSyncCwd, + }), + { startDir: buildAndSyncCwd, isSuccess: result => result.success }, + ) if (!syncResult.success) { await delay(3500) clearInitStreamingOutput() @@ -5037,7 +5119,10 @@ async function addCodeChangeStep(orgId: string, apikey: string, appId: string, p const content = readFileSync(filePath, 'utf8') const appliedChange = applyInitAutoTestChange(relativeFilePath, content) if (appliedChange) { - writeFileSync(filePath, appliedChange.content, 'utf8') + await runTrackedInitMutation( + () => writeFileSync(filePath, appliedChange.content, 'utf8'), + { startDir: projectDir }, + ) const displayPath = formatInitFilePath(filePath) s.stop(`✅ Made test changes to ${displayPath}`) pLog.info(`📝 Added visible test modification to verify the update works`) @@ -5233,7 +5318,10 @@ async function maybeOfferAutoTestCleanup(orgId: string, apikey: string, appId: s pLog.warn(`Could not automatically revert ${autoTestChange.displayPath}. Please revert it manually.`) } else { - writeFileSync(autoTestChange.filePath, revertedContent, 'utf8') + await runTrackedInitMutation( + () => writeFileSync(autoTestChange.filePath, revertedContent, 'utf8'), + { startDir: dirname(autoTestChange.filePath) }, + ) pLog.success(`Reverted ${autoTestChange.displayPath} ✅`) reverted = true globalAutoTestChange = undefined @@ -5701,15 +5789,18 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup } } else { - extConfig = await withTemporaryCwd(getInitConfigLoadDir(selectedProjectDir), () => updateConfigUpdater({ - statsUrl: `${options.supaHost}/functions/v1/stats`, - channelUrl: `${options.supaHost}/functions/v1/channel_self`, - updateUrl: `${options.supaHost}/functions/v1/updates`, - localApiFiles: `${options.supaHost}/functions/v1`, - localS3: true, - localSupa: options.supaHost, - localSupaAnon: options.supaAnon, - })) + extConfig = await runTrackedInitMutation( + () => withTemporaryCwd(getInitConfigLoadDir(selectedProjectDir), () => updateConfigUpdater({ + statsUrl: `${options.supaHost}/functions/v1/stats`, + channelUrl: `${options.supaHost}/functions/v1/channel_self`, + updateUrl: `${options.supaHost}/functions/v1/updates`, + localApiFiles: `${options.supaHost}/functions/v1`, + localS3: true, + localSupa: options.supaHost, + localSupaAnon: options.supaAnon, + })), + { startDir: selectedProjectDir }, + ) } } await reloadSelectedProjectConfig() diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index e96422988d..639dbcc3fa 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -2,9 +2,9 @@ import assert from 'node:assert/strict' import { execSync, spawn } from 'node:child_process' -import { lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { applyInitAutoTestChange, beginFreshInitProgress, @@ -28,6 +28,7 @@ import { restoreInitProgressState, revertInitAutoTestChangeContent, runInheritedCommand, + trackInitGitChanges, tryResumeOnboarding, } from '../src/init/command.ts' import { @@ -57,6 +58,29 @@ function withTempDir(fn) { } } +async function withTempDirAsync(fn) { + const root = mkdtempSync(join(tmpdir(), 'capgo-init-guardrails-')) + try { + await fn(root) + } + finally { + rmSync(root, { recursive: true, force: true }) + } +} + +function initializeGitRepo(root, files) { + execSync('git init', { cwd: root, stdio: 'ignore' }) + execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) + execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) + execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + for (const [filePath, content] of Object.entries(files)) { + mkdirSync(dirname(join(root, filePath)), { recursive: true }) + writeFileSync(join(root, filePath), content, 'utf8') + } + execSync('git add .', { cwd: root, stdio: 'ignore' }) + execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) +} + function tryCreateTestSymlink(target, filePath) { try { symlinkSync(target, filePath) @@ -796,6 +820,152 @@ async function tAsync(name, fn) { } } +await tAsync('git mutation tracker attributes only changes made inside one async operation window', async () => { + await withTempDirAsync(async (root) => { + initializeGitRepo(root, { + 'src/main.ts': 'console.log(\'initial\')\n', + 'package.json': '{"name":"example","dependencies":{}}\n', + 'package-lock.json': '{"name":"example","lockfileVersion":3}\n', + }) + writeFileSync(join(root, 'src/main.ts'), 'console.log(\'user edit\')\n', 'utf8') + let calls = 0 + + const tracked = await trackInitGitChanges(undefined, async () => { + calls += 1 + await Promise.resolve() + writeFileSync(join(root, 'package.json'), '{"name":"example","dependencies":{"@capgo/capacitor-updater":"latest"}}\n', 'utf8') + writeFileSync(join(root, 'package-lock.json'), '{"name":"example","lockfileVersion":3,"packages":{"capgo":{}}}\n', 'utf8') + return 'installed' + }, { startDir: root }) + + assert.equal(calls, 1) + assert.equal(tracked.result, 'installed') + assert.deepEqual(Object.keys(tracked.gitChanges?.files ?? {}).sort(), ['package-lock.json', 'package.json']) + }) +}) + +await tAsync('git mutation tracker propagates the same error without changing existing state', async () => { + await withTempDirAsync(async (root) => { + initializeGitRepo(root, { 'package.json': '{"name":"example"}\n' }) + writeFileSync(join(root, 'package.json'), '{"name":"saved"}\n', 'utf8') + const existing = captureInitGitSnapshot(root) + const original = structuredClone(existing) + const expectedError = new Error('mutation failed') + let calls = 0 + let caught + + try { + await trackInitGitChanges(existing, async () => { + calls += 1 + writeFileSync(join(root, 'package.json'), '{"name":"partial"}\n', 'utf8') + throw expectedError + }, { startDir: root }) + } + catch (error) { + caught = error + } + + assert.equal(calls, 1) + assert.equal(caught, expectedError) + assert.deepEqual(existing, original) + }) +}) + +await tAsync('git mutation tracker rejects partially-mutating unsuccessful results', async () => { + await withTempDirAsync(async (root) => { + initializeGitRepo(root, { 'package.json': '{"name":"example"}\n' }) + writeFileSync(join(root, 'package.json'), '{"name":"saved"}\n', 'utf8') + const existing = captureInitGitSnapshot(root) + const original = structuredClone(existing) + + const tracked = await trackInitGitChanges(existing, () => { + writeFileSync(join(root, 'package.json'), '{"name":"partial"}\n', 'utf8') + return { success: false } + }, { + startDir: root, + isSuccess: result => result.success, + }) + + assert.deepEqual(tracked.result, { success: false }) + assert.equal(tracked.gitChanges, existing) + assert.deepEqual(existing, original) + }) +}) + +await tAsync('git mutation tracker prunes a user-changed fingerprint without re-attributing a later mutation', async () => { + await withTempDirAsync(async (root) => { + initializeGitRepo(root, { 'package.json': '{"name":"example"}\n' }) + const first = await trackInitGitChanges(undefined, () => { + writeFileSync(join(root, 'package.json'), '{"name":"capgo-first"}\n', 'utf8') + }, { startDir: root }) + assert.deepEqual(Object.keys(first.gitChanges?.files ?? {}), ['package.json']) + + writeFileSync(join(root, 'package.json'), '{"name":"user-edit"}\n', 'utf8') + const second = await trackInitGitChanges(first.gitChanges, () => { + writeFileSync(join(root, 'package.json'), '{"name":"capgo-second"}\n', 'utf8') + }, { startDir: root }) + + assert.equal(second.gitChanges, undefined) + }) +}) + +await tAsync('git mutation tracker retains existing fingerprints when capture is unavailable', async () => { + await withTempDirAsync(async (root) => { + const existing = { + version: 1, + repoRoot: '/existing/repo', + files: { + 'package.json': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 }, + }, + } + + const tracked = await trackInitGitChanges(existing, () => { + writeFileSync(join(root, 'new-file.txt'), 'not in a git repo\n', 'utf8') + return 'done' + }, { startDir: root }) + + assert.equal(tracked.result, 'done') + assert.deepEqual(tracked.gitChanges, existing) + assert.deepEqual(Object.keys(tracked.gitChanges?.files ?? {}), ['package.json']) + }) +}) + +t('automatic onboarding mutations use narrow tracking windows and user-controlled work stays outside them', () => { + const source = readFileSync(new URL('../src/init/command.ts', import.meta.url), 'utf8') + const sourceBetween = (start, end) => { + const startIndex = source.indexOf(start) + const endIndex = end ? source.indexOf(end, startIndex + start.length) : source.length + assert.notEqual(startIndex, -1, start) + assert.notEqual(endIndex, -1, end ?? 'end of file') + return source.slice(startIndex, endIndex) + } + const trackedCallCount = body => body.match(/\brunTrackedInitMutation\s*\(/g)?.length ?? 0 + const coverage = [ + ['updater dependency install', 'function runUpdaterInstallCommand(', 'function logUpdaterInstallStateDetails(', 1], + ['Capacitor package installs and init', 'async function maybeRunCapacitorInit(', 'async function runCapacitorPlatformAdd(', 3], + ['Capacitor platform add', 'async function runCapacitorPlatformAdd(', 'async function runCreateAppTemplate(', 1], + ['app-ID config update', 'async function saveAppIdToCapacitorConfig(', 'async function syncPendingAppIdToCapacitorConfig(', 1], + ['pending app-ID config sync', 'async function syncPendingAppIdToCapacitorConfig(', 'function logBrokenIosSync(', 1], + ['native reset delete/add/sync sequence', 'async function runNativeResetCommand(', 'async function waitForReadyConfirmation(', 1], + ['pending-app Capacitor init', 'async function ensureCapacitorProjectReady(', 'async function selectPendingOnboardingApp(', 1], + ['updater config update', 'async function addUpdaterStep(', 'async function addCodeStep(', 1], + ['source-code injection write', 'async function addCodeStep(', 'async function addEncryptionStep(', 1], + ['key creation and encryption sync', 'async function addEncryptionStep(', 'async function streamCommandInInitPanel(', 2], + ['primary automatic native sync', 'async function runBuildAndSyncLoop(', 'async function runProjectBuildAndSync(', 1], + ['updater test write', 'async function addCodeChangeStep(', 'function getSuggestedCleanupBundleVersion(', 1], + ['updater test cleanup write', 'async function maybeOfferAutoTestCleanup(', 'async function uploadStep(', 1], + ['self-host config update', 'export async function initApp(', undefined, 1], + ] + + for (const [name, start, end, expectedCalls] of coverage) + assert.equal(trackedCallCount(sourceBetween(start, end)), expectedCalls, name) + + assert.equal(trackedCallCount(sourceBetween('async function waitUntilSetupIsDone(', 'async function askForAppName(')), 0, 'manual setup wait') + assert.equal(trackedCallCount(sourceBetween('async function waitForReadyConfirmation(', 'async function waitForReadyRetry(')), 0, 'manual ready wait') + assert.equal(trackedCallCount(sourceBetween('async function runDeviceStep(', 'async function addCodeChangeStep(')), 0, 'cap run') + assert.equal(trackedCallCount(sourceBetween('const buildResult = await streamCommandInInitPanel({', '// Keep the completed build output visible')), 0, 'project build') +}) + await tAsync('live git cleanliness gate filters trusted changes without weakening the existing prompt', async () => { const repoRoot = '/repo' const fingerprint = sha256 => ({ status: ' M', sha256, mode: 0o100644 }) From eb1dd95a548faaea5d71cfe7b7a3c63d5ae7461f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 19:00:08 +0200 Subject: [PATCH 10/19] fix(cli): scope onboarding git tracking --- cli/src/init/command.ts | 368 +++++++++++++++++++++++++----- cli/test/test-init-guardrails.mjs | 187 ++++++++++++++- 2 files changed, 499 insertions(+), 56 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 5b3565950f..e07c35de14 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -33,7 +33,7 @@ import { copyToClipboard, revealInFinder } from '../support/clipboard' import { contactSupport } from '../support/contact-support' import { appendInternalLog, getInternalLogPath, startInternalLog } from '../support/internal-log' import { uploadSupportLogs } from '../support/support-upload' -import { consoleWebUrl, createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getConfigForWrite, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, setPMAndCommand, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils' +import { baseKeyPubV2, baseKeyV2, consoleWebUrl, createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getConfigForWrite, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, setPMAndCommand, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils' import { buildAppIdConflictSuggestions, isAppAlreadyExistsError } from './app-conflict' import { isChannelAlreadyExistsError } from './channel-conflict' import { createMissingExecutableError, getAvailablePackageManagers, getMissingPackageManagerExecutable, getPackageManagerInfo, preparePackageManagerCommandEnvironment, probeExecutable, probePackageManagerCommand, resolveExecutableProbeError, waitForCommandResult } from './command-execution' @@ -146,6 +146,22 @@ export interface InitGitChanges { files: Record } +export interface InitGitChangeScope { + exactPaths?: string[] + directoryPrefixes?: string[] +} + +interface NormalizedInitGitChangeScope { + exactPaths: string[] + directoryPrefixes: string[] +} + +export interface TrackInitGitChangesOptions { + startDir?: string + scope?: InitGitChangeScope + isSuccess?: (result: T) => boolean +} + export interface InitGitClassification { unsafePaths: string[] recognizedCount: number @@ -530,6 +546,128 @@ function cloneInitGitChanges(changes: InitGitChanges, files = changes.files): In : undefined } +function initGitChangesMatch(left: InitGitChanges | undefined, right: InitGitChanges | undefined) { + if (!left || !right) + return left === right + if (left.repoRoot !== right.repoRoot) + return false + const leftPaths = Object.keys(left.files).sort() + const rightPaths = Object.keys(right.files).sort() + return leftPaths.length === rightPaths.length + && leftPaths.every((filePath, index) => filePath === rightPaths[index] + && initGitFingerprintMatches(left.files[filePath], right.files[filePath])) +} + +function normalizeInitGitScope(scope: InitGitChangeScope): NormalizedInitGitChangeScope | undefined { + if (!isPlainInitGitRecord(scope)) + return undefined + const scopeKeys = Reflect.ownKeys(scope) + if (scopeKeys.some((key) => { + const descriptor = Object.getOwnPropertyDescriptor(scope, key) + return typeof key !== 'string' + || !['exactPaths', 'directoryPrefixes'].includes(key) + || descriptor?.enumerable !== true + || !Object.hasOwn(descriptor, 'value') + }) + || (scope.exactPaths !== undefined && !Array.isArray(scope.exactPaths)) + || (scope.directoryPrefixes !== undefined && !Array.isArray(scope.directoryPrefixes))) + return undefined + + const normalizeEntries = (entries: unknown[] | undefined) => { + const normalized: string[] = [] + for (const entry of entries ?? []) { + if (typeof entry !== 'string' || !entry || entry.includes('\0')) + return undefined + const posixPath = entry.replaceAll('\\', '/') + if (path.posix.isAbsolute(posixPath) + || /^[a-z]:\//i.test(posixPath) + || posixPath.split('/').includes('..')) + return undefined + const value = path.posix.normalize(posixPath).replace(/^\.\//, '').replace(/\/$/, '') + if (!value || value === '.') + return undefined + normalized.push(value) + } + return [...new Set(normalized)].sort() + } + + const exactPaths = normalizeEntries(scope.exactPaths) + const directoryPrefixes = normalizeEntries(scope.directoryPrefixes) + return exactPaths && directoryPrefixes ? { exactPaths, directoryPrefixes } : undefined +} + +function initGitScopeIncludes(scope: NormalizedInitGitChangeScope, filePath: string) { + return scope.exactPaths.includes(filePath) + || scope.directoryPrefixes.some(prefix => filePath === prefix || filePath.startsWith(`${prefix}/`)) +} + +function getInitGitRepoRoot(startDir = cwd()): string | undefined { + try { + const repoResult = spawnSync('git', ['rev-parse', '--show-toplevel'], { + cwd: startDir, + stdio: 'pipe', + encoding: 'utf8', + }) + if (repoResult.error || repoResult.status !== 0) + return undefined + const repoRootValue = repoResult.stdout?.toString().trim() + return repoRootValue ? realpathSync(repoRootValue) : undefined + } + catch { + return undefined + } +} + +function createInitGitChangeScope(startDir: string, exactTargets: string[] = [], directoryTargets: string[] = []): InitGitChangeScope { + const repoRoot = getInitGitRepoRoot(startDir) + if (!repoRoot) + return { exactPaths: [], directoryPrefixes: [] } + + const toRepoRelativePath = (target: string) => { + const relativePath = path.relative(repoRoot, path.resolve(startDir, target)).replaceAll(path.sep, '/') + if (!relativePath || relativePath === '..' || relativePath.startsWith('../') || path.posix.isAbsolute(relativePath)) + return undefined + return relativePath + } + const exactPaths = exactTargets.map(toRepoRelativePath) + const directoryPrefixes = directoryTargets.map(toRepoRelativePath) + if (exactPaths.some(value => !value) || directoryPrefixes.some(value => !value)) + return { exactPaths: [], directoryPrefixes: [] } + return { + exactPaths: exactPaths as string[], + directoryPrefixes: directoryPrefixes as string[], + } +} + +const initPackageLockfiles: Record = { + npm: ['package-lock.json', 'npm-shrinkwrap.json'], + pnpm: ['pnpm-lock.yaml', 'shrinkwrap.yaml'], + yarn: ['yarn.lock'], + bun: ['bun.lock', 'bun.lockb'], +} + +function getInitPackageMutationScope(pm: SupportedPackageManager | 'unknown', packageJsonPath: string): InitGitChangeScope { + const packageDir = dirname(packageJsonPath) + const repoRoot = getInitGitRepoRoot(packageDir) + if (!repoRoot) + return { exactPaths: [], directoryPrefixes: [] } + + const exactTargets = [packageJsonPath] + const lockfiles = pm === 'unknown' ? [] : initPackageLockfiles[pm] + let currentDir = packageDir + while (currentDir === repoRoot || currentDir.startsWith(`${repoRoot}${path.sep}`)) { + exactTargets.push(...lockfiles.map(fileName => join(currentDir, fileName))) + if (currentDir === repoRoot) + break + currentDir = dirname(currentDir) + } + return createInitGitChangeScope(packageDir, exactTargets) +} + +function getInitCapacitorConfigScope(projectDir: string): InitGitChangeScope { + return createInitGitChangeScope(projectDir, capacitorConfigFiles.map(fileName => join(projectDir, fileName))) +} + function isRegularGitBlobMode(mode: string | null) { return mode === '100644' || mode === '100755' } @@ -585,21 +723,22 @@ function parseInitGitStatusRecord(value: string): InitGitStatusRecord | undefine return { status, filePath, headMode, indexMode, worktreeMode } } -export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undefined { +export function captureInitGitSnapshot(startDir = cwd(), scope?: InitGitChangeScope): InitGitChanges | undefined { try { - const repoResult = spawnSync('git', ['rev-parse', '--show-toplevel'], { - cwd: startDir, - stdio: 'pipe', - encoding: 'utf8', - }) - if (repoResult.error || repoResult.status !== 0) + const repoRoot = getInitGitRepoRoot(startDir) + if (!repoRoot) return undefined - - const repoRootValue = repoResult.stdout?.toString().trim() - if (!repoRootValue) + const normalizedScope = scope ? normalizeInitGitScope(scope) : undefined + if (scope && !normalizedScope) return undefined - const repoRoot = realpathSync(repoRootValue) - const statusResult = spawnSync('git', ['-c', 'status.renames=copies', 'status', '--porcelain=v2', '-z', '--untracked-files=all'], { + if (normalizedScope && normalizedScope.exactPaths.length === 0 && normalizedScope.directoryPrefixes.length === 0) + return { version: 1, repoRoot, files: {} } + + const statusArgs = ['-c', 'status.renames=copies', 'status', '--porcelain=v2', '-z', '--untracked-files=all'] + if (normalizedScope) { + statusArgs.push('--', ...normalizedScope.exactPaths.map(filePath => `:(top,literal)${filePath}`), ...normalizedScope.directoryPrefixes.map(prefix => `:(top,literal)${prefix}`)) + } + const statusResult = spawnSync('git', statusArgs, { cwd: repoRoot, stdio: 'pipe', encoding: 'utf8', @@ -656,9 +795,51 @@ export function captureInitGitSnapshot(startDir = cwd()): InitGitChanges | undef } } -export function mergeInitGitChanges(existing: InitGitChanges | undefined, before: InitGitChanges | undefined, after: InitGitChanges | undefined): InitGitChanges | undefined { +export function mergeInitGitChanges( + existing: InitGitChanges | undefined, + before: InitGitChanges | undefined, + after: InitGitChanges | undefined, + scope?: InitGitChangeScope, +): InitGitChanges | undefined { const usableBefore = isUsableInitGitChanges(before) ? before : undefined const usableAfter = isUsableInitGitChanges(after) ? after : undefined + const normalizedScope = scope ? normalizeInitGitScope(scope) : undefined + if (scope) { + if (!normalizedScope || !usableBefore || !usableAfter || usableBefore.repoRoot !== usableAfter.repoRoot) + return isUsableInitGitChanges(existing) ? cloneInitGitChanges(existing) : undefined + + const existingMatchesRepo = isUsableInitGitChanges(existing) && existing.repoRoot === usableBefore.repoRoot + const retainedFiles: Record = {} + const recognizedBefore = new Set() + if (existingMatchesRepo) { + for (const [filePath, fingerprint] of Object.entries(existing.files)) { + if (!initGitScopeIncludes(normalizedScope, filePath)) { + retainedFiles[filePath] = { ...fingerprint } + } + else if (initGitFingerprintMatches(fingerprint, usableBefore.files[filePath])) { + retainedFiles[filePath] = { ...fingerprint } + recognizedBefore.add(filePath) + } + } + } + + for (const filePath of new Set([...Object.keys(usableBefore.files), ...Object.keys(usableAfter.files)])) { + if (!initGitScopeIncludes(normalizedScope, filePath)) + continue + const beforeFingerprint = usableBefore.files[filePath] + const afterFingerprint = usableAfter.files[filePath] + if (initGitFingerprintMatches(beforeFingerprint, afterFingerprint)) + continue + if (beforeFingerprint && !recognizedBefore.has(filePath)) + continue + if (afterFingerprint) + retainedFiles[filePath] = { ...afterFingerprint } + else + delete retainedFiles[filePath] + } + return cloneInitGitChanges({ version: 1, repoRoot: usableAfter.repoRoot, files: retainedFiles }) + } + const existingMatchesRepo = isUsableInitGitChanges(existing) && (!usableBefore || existing.repoRoot === usableBefore.repoRoot) const retainedFiles: Record = {} @@ -698,17 +879,22 @@ export function mergeInitGitChanges(existing: InitGitChanges | undefined, before export async function trackInitGitChanges( existing: InitGitChanges | undefined, operation: () => T | Promise, - options: { - startDir?: string - isSuccess?: (result: T) => boolean - } = {}, + options: TrackInitGitChangesOptions = {}, ): Promise<{ result: T, gitChanges: InitGitChanges | undefined }> { - const before = captureInitGitSnapshot(options.startDir) + const before = captureInitGitSnapshot(options.startDir, options.scope) const result = await operation() if (options.isSuccess && !options.isSuccess(result)) return { result, gitChanges: existing } - const after = captureInitGitSnapshot(options.startDir) - return { result, gitChanges: mergeInitGitChanges(existing, before, after) } + const after = captureInitGitSnapshot(options.startDir, options.scope) + return { result, gitChanges: mergeInitGitChanges(existing, before, after, options.scope) } +} + +export function isSuccessfulInitProcessResult(result: { error?: unknown, status: number | null }) { + return !result.error && result.status === 0 +} + +export function isSuccessfulInitCommandResult(result: { success: boolean }) { + return result.success } export function classifyInitGitChanges(current: InitGitChanges | undefined, saved: InitGitChanges | undefined): InitGitClassification { @@ -1595,7 +1781,11 @@ async function maybeRunCapacitorInit(projectDir: string, projectType: string, in spinner.start(`Installing Capacitor packages with ${pm.installCommand}`) const installCoreResult = await runTrackedInitMutation( () => spawnSync(pm.pm, [pm.command, '@capacitor/core'], { stdio: 'pipe', cwd: projectDir }), - { startDir: projectDir, isSuccess: result => !result.error && result.status === 0 }, + { + startDir: projectDir, + scope: getInitPackageMutationScope(pm.pm, join(projectDir, PACKNAME)), + isSuccess: isSuccessfulInitProcessResult, + }, ) if (installCoreResult.error) throw installCoreResult.error @@ -1607,7 +1797,11 @@ async function maybeRunCapacitorInit(projectDir: string, projectType: string, in const installCliResult = await runTrackedInitMutation( () => spawnSync(pm.pm, [pm.command, '-D', '@capacitor/cli'], { stdio: 'pipe', cwd: projectDir }), - { startDir: projectDir, isSuccess: result => !result.error && result.status === 0 }, + { + startDir: projectDir, + scope: getInitPackageMutationScope(pm.pm, join(projectDir, PACKNAME)), + isSuccess: isSuccessfulInitProcessResult, + }, ) if (installCliResult.error) throw installCliResult.error @@ -1620,7 +1814,11 @@ async function maybeRunCapacitorInit(projectDir: string, projectType: string, in spinner.message(`Running: ${pm.runner} cap init "${appName}" "${capacitorAppId}" --web-dir ${webDir}`) const initResult = await runTrackedInitMutation( () => spawnSync(pm.runner, ['cap', 'init', appName, capacitorAppId, '--web-dir', webDir], { stdio: 'pipe', cwd: projectDir }), - { startDir: projectDir, isSuccess: result => !result.error && result.status === 0 }, + { + startDir: projectDir, + scope: getInitCapacitorConfigScope(projectDir), + isSuccess: isSuccessfulInitProcessResult, + }, ) if (initResult.error) throw initResult.error @@ -1647,7 +1845,7 @@ async function maybeRunCapacitorInit(projectDir: string, projectType: string, in } } -async function runCapacitorPlatformAdd(platformName: 'ios' | 'android', runner: string, commandCwd = cwd()): Promise { +async function runCapacitorPlatformAdd(platformName: 'ios' | 'android', runner: string, commandCwd = cwd(), nativePlatformDir: string = platformName): Promise { const command = formatRunnerCommand(runner, ['cap', 'add', platformName]) const spinner = pSpinner() const runMessage = commandCwd === cwd() @@ -1663,7 +1861,11 @@ async function runCapacitorPlatformAdd(platformName: 'ios' | 'android', runner: args: ['cap', 'add', platformName], cwd: commandCwd, }), - { startDir: commandCwd, isSuccess: result => result.success }, + { + startDir: commandCwd, + scope: createInitGitChangeScope(commandCwd, [], [path.resolve(commandCwd, nativePlatformDir)]), + isSuccess: isSuccessfulInitCommandResult, + }, ) await delay(result.success ? 750 : 3500) clearInitStreamingOutput() @@ -1830,13 +2032,12 @@ function persistInitProgressSafely() { } } -async function runTrackedInitMutation( +export async function runTrackedInitMutation( operation: () => T | Promise, - options: { - startDir?: string - isSuccess?: (result: T) => boolean - } = {}, + options: TrackInitGitChangesOptions = {}, + dependencies: { persistProgress?: () => void } = {}, ): Promise { + const previousGitChanges = globalInitGitChanges let successful = true const isSuccess = options.isSuccess const tracked = await trackInitGitChanges(globalInitGitChanges, operation, { @@ -1848,10 +2049,10 @@ async function runTrackedInitMutation( } : undefined, }) - if (successful) { + if (successful && !initGitChangesMatch(previousGitChanges, tracked.gitChanges)) { globalInitGitChanges = tracked.gitChanges if (globalStepDone > 0) - persistInitProgressSafely() + (dependencies.persistProgress ?? persistInitProgressSafely)() } return tracked.result } @@ -2373,9 +2574,14 @@ async function markStep(orgId: string, apikey: string, step: string, appId: stri */ async function saveAppIdToCapacitorConfig(appId: string) { try { + const configTarget = await getConfigForWrite() + const configDir = dirname(configTarget.path) await runTrackedInitMutation( () => updateConfigUpdater({ appId }), - { startDir: globalConfigLoadDir ?? (globalCapacitorConfigPath ? dirname(globalCapacitorConfigPath) : cwd()) }, + { + startDir: configDir, + scope: createInitGitChangeScope(configDir, [configTarget.path]), + }, ) pLog.info(`💾 Saved new app ID "${appId}" to CapacitorUpdater config`) } @@ -2399,7 +2605,10 @@ async function syncPendingAppIdToCapacitorConfig(appId: string) { } await runTrackedInitMutation( () => writeConfigUpdater(extConfig, true), - { startDir: globalConfigLoadDir ?? (globalCapacitorConfigPath ? dirname(globalCapacitorConfigPath) : cwd()) }, + { + startDir: dirname(extConfig.path), + scope: createInitGitChangeScope(dirname(extConfig.path), [extConfig.path]), + }, ) pLog.info(`💾 Synced pending onboarding app ID "${appId}" to capacitor config`) } @@ -2471,6 +2680,9 @@ async function runNativeResetCommand(platformRunner: string, nativePlatform: Pla }) if (!syncResult.success) throw syncResult.error ?? new Error(`cap sync ${nativePlatform} failed`) + }, { + startDir: cwd(), + scope: createInitGitChangeScope(cwd(), [], [path.resolve(nativePlatform)]), }) await delay(750) @@ -2625,7 +2837,11 @@ async function ensureCapacitorProjectReady( try { const initResult = await runTrackedInitMutation( () => spawnSync(pm.runner, ['cap', 'init', appName, appId], { stdio: 'pipe' as const }), - { isSuccess: result => !result.error && result.status === 0 }, + { + startDir: cwd(), + scope: getInitCapacitorConfigScope(cwd()), + isSuccess: isSuccessfulInitProcessResult, + }, ) if (initResult.error) throw initResult.error @@ -3347,7 +3563,11 @@ async function runUpdaterInstallCommand(pm: PackageManagerInfo, packageJsonPath: stdio: 'pipe', cwd: dirname(packageJsonPath), }), - { startDir: dirname(packageJsonPath), isSuccess: result => !result.error && result.status === 0 }, + { + startDir: dirname(packageJsonPath), + scope: getInitPackageMutationScope(pm.pm, packageJsonPath), + isSuccess: isSuccessfulInitProcessResult, + }, ) if (result.error || result.status !== 0) { const output = [formatSpawnOutput(result.stdout), formatSpawnOutput(result.stderr)] @@ -3483,14 +3703,19 @@ async function addUpdaterStep(orgId: string, apikey: string, appId: string) { s.start(`Updating config file`) delta = !!doDirectInstall const projectDir = dirname(path) + const configDir = getInitConfigLoadDir(projectDir) + const configTarget = await withTemporaryCwd(configDir, () => getConfigForWrite()) await runTrackedInitMutation( - () => withTemporaryCwd(getInitConfigLoadDir(projectDir), async () => { + () => withTemporaryCwd(configDir, async () => { if (doDirectInstall) { await updateConfigbyKey('SplashScreen', { launchAutoHide: false }) } await updateConfigUpdater(getInitUpdaterPluginConfig(appId, delta)) }), - { startDir: projectDir }, + { + startDir: projectDir, + scope: createInitGitChangeScope(projectDir, [configTarget.path]), + }, ) s.stop(`Config file updated ✅`) break @@ -3608,7 +3833,10 @@ async function addCodeStep(orgId: string, apikey: string, appId: string) { mkdirSync(dirname(filePath), { recursive: true }) } writeFileSync(filePath, newContent, 'utf8') - }, { startDir: projectDir }) + }, { + startDir: projectDir, + scope: createInitGitChangeScope(projectDir, [filePath]), + }) s.stop() globalCodeDiff = previewDiff setInitCodeDiff(globalCodeDiff) @@ -3787,7 +4015,14 @@ async function addEncryptionStep(orgId: string, apikey: string, appId: string) { const encryptionConfig = await withTemporaryCwd(getInitConfigLoadDir(projectDir), () => getConfigForWrite()) await runTrackedInitMutation( () => withTemporaryCwd(projectDir, () => createKeyInternal({ force: true, setupChannel: false }, true, encryptionConfig)), - { startDir: projectDir }, + { + startDir: projectDir, + scope: createInitGitChangeScope(projectDir, [ + join(projectDir, baseKeyV2), + join(projectDir, baseKeyPubV2), + encryptionConfig.path, + ]), + }, ) // Intentionally stop without a success message: the persistent // encryption summary panel renders on the next step and already shows @@ -3817,7 +4052,14 @@ async function addEncryptionStep(orgId: string, apikey: string, appId: string) { args: ['cap', 'sync'], cwd: projectDir, }), - { startDir: projectDir, isSuccess: result => result.success }, + { + startDir: projectDir, + scope: createInitGitChangeScope(projectDir, [], [ + path.resolve(projectDir, getPlatformDirFromCapacitorConfig(encryptionConfig.config, 'ios')), + path.resolve(projectDir, getPlatformDirFromCapacitorConfig(encryptionConfig.config, 'android')), + ]), + isSuccess: isSuccessfulInitCommandResult, + }, ) // Small dwell so the user can read the final state of the panel // (success banner or the last few lines of an error) before we @@ -4109,7 +4351,7 @@ async function ensureNativePlatformForBuild(platform: PlatformChoice, config: Ca continue } - if (!await runCapacitorPlatformAdd(platform, runner, projectDir)) + if (!await runCapacitorPlatformAdd(platform, runner, projectDir, missingDir)) pLog.warn(`Still could not add ${platform}.`) } } @@ -4208,6 +4450,7 @@ async function ensureUpdaterReadyBeforeSync(pm: PackageManagerInfo, orgId: strin async function runBuildAndSyncLoop( platform: PlatformChoice, + nativePlatformDir: string, buildCommand: string, buildAndSyncCommand: string, buildAndSyncCwd: string, @@ -4251,7 +4494,11 @@ async function runBuildAndSyncLoop( args: ['cap', 'sync', platform], cwd: buildAndSyncCwd, }), - { startDir: buildAndSyncCwd, isSuccess: result => result.success }, + { + startDir: buildAndSyncCwd, + scope: createInitGitChangeScope(buildAndSyncCwd, [], [path.resolve(buildAndSyncCwd, nativePlatformDir)]), + isSuccess: isSuccessfulInitCommandResult, + }, ) if (!syncResult.success) { await delay(3500) @@ -4285,7 +4532,7 @@ async function runBuildAndSyncLoop( return } } -async function runProjectBuildAndSync(appId: string, platform: PlatformChoice, orgId: string, apikey: string, pm: PackageManagerInfo): Promise { +async function runProjectBuildAndSync(appId: string, platform: PlatformChoice, orgId: string, apikey: string, pm: PackageManagerInfo, config?: CapacitorConfigSnapshot): Promise { const packageJsonPath = path.resolve(globalPathToPackageJson ?? join(findRoot(cwd()), PACKNAME)) const projectDir = dirname(packageJsonPath) const projectType = await findProjectType({ packageJsonPath }) @@ -4296,7 +4543,7 @@ async function runProjectBuildAndSync(appId: string, platform: PlatformChoice, o return handleMissingBuildScript(buildCommand, appId, platform, orgId, apikey, pm) const buildAndSyncCommand = `${pm.pm} run ${buildCommand} && ${pm.runner} cap sync ${platform}` - await runBuildAndSyncLoop(platform, buildCommand, buildAndSyncCommand, projectDir, packageJsonPath, pm, orgId, apikey) + await runBuildAndSyncLoop(platform, getPlatformDirFromCapacitorConfig(config, platform), buildCommand, buildAndSyncCommand, projectDir, packageJsonPath, pm, orgId, apikey) return 'completed' } @@ -4314,7 +4561,7 @@ async function buildProjectStep(orgId: string, apikey: string, appId: string, pl return } - const buildOutcome = await runProjectBuildAndSync(appId, platform, orgId, apikey, pm) + const buildOutcome = await runProjectBuildAndSync(appId, platform, orgId, apikey, pm, config) if (buildOutcome === 'skipped') return @@ -4954,7 +5201,8 @@ async function handleMissingPlatformSelection(orgId: string, apikey: string, ava } const platformToAdd = recoveryChoice === 'add-ios' ? 'ios' : 'android' - if (!await runCapacitorPlatformAdd(platformToAdd, pm.runner, projectDir)) + const nativePlatformDir = platformToAdd === 'ios' ? availablePlatforms.iosDir : availablePlatforms.androidDir + if (!await runCapacitorPlatformAdd(platformToAdd, pm.runner, projectDir, nativePlatformDir)) pLog.warn(`Still could not add ${platformToAdd}.`) } @@ -5121,7 +5369,10 @@ async function addCodeChangeStep(orgId: string, apikey: string, appId: string, p if (appliedChange) { await runTrackedInitMutation( () => writeFileSync(filePath, appliedChange.content, 'utf8'), - { startDir: projectDir }, + { + startDir: projectDir, + scope: createInitGitChangeScope(projectDir, [filePath]), + }, ) const displayPath = formatInitFilePath(filePath) s.stop(`✅ Made test changes to ${displayPath}`) @@ -5320,7 +5571,10 @@ async function maybeOfferAutoTestCleanup(orgId: string, apikey: string, appId: s else { await runTrackedInitMutation( () => writeFileSync(autoTestChange.filePath, revertedContent, 'utf8'), - { startDir: dirname(autoTestChange.filePath) }, + { + startDir: dirname(autoTestChange.filePath), + scope: createInitGitChangeScope(dirname(autoTestChange.filePath), [autoTestChange.filePath]), + }, ) pLog.success(`Reverted ${autoTestChange.displayPath} ✅`) reverted = true @@ -5789,8 +6043,10 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup } } else { + const configDir = getInitConfigLoadDir(selectedProjectDir) + const configTarget = await withTemporaryCwd(configDir, () => getConfigForWrite()) extConfig = await runTrackedInitMutation( - () => withTemporaryCwd(getInitConfigLoadDir(selectedProjectDir), () => updateConfigUpdater({ + () => withTemporaryCwd(configDir, () => updateConfigUpdater({ statsUrl: `${options.supaHost}/functions/v1/stats`, channelUrl: `${options.supaHost}/functions/v1/channel_self`, updateUrl: `${options.supaHost}/functions/v1/updates`, @@ -5799,7 +6055,10 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup localSupa: options.supaHost, localSupaAnon: options.supaAnon, })), - { startDir: selectedProjectDir }, + { + startDir: selectedProjectDir, + scope: createInitGitChangeScope(selectedProjectDir, [configTarget.path]), + }, ) } } @@ -5851,7 +6110,8 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup if (continueWithout === 'add-ios' || continueWithout === 'add-android') { const platformToAdd = continueWithout === 'add-ios' ? 'ios' : 'android' - const added = await runCapacitorPlatformAdd(platformToAdd, pm.runner, selectedProjectDir) + const nativePlatformDir = platformToAdd === 'ios' ? nativePlatforms.iosDir : nativePlatforms.androidDir + const added = await runCapacitorPlatformAdd(platformToAdd, pm.runner, selectedProjectDir, nativePlatformDir) if (!added) { const recoveryChoice = await pSelect({ message: `Could not add ${platformToAdd}. What do you want to do next?`, @@ -5878,7 +6138,7 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup } if (recoveryChoice === 'retry') { - const retried = await runCapacitorPlatformAdd(platformToAdd, pm.runner, selectedProjectDir) + const retried = await runCapacitorPlatformAdd(platformToAdd, pm.runner, selectedProjectDir, nativePlatformDir) if (!retried) { pLog.warn(`Still could not add ${platformToAdd}. Continuing without native platforms for now.`) } diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index 639dbcc3fa..1ec9133e09 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -22,12 +22,15 @@ import { getResumedOnboardingAccessError, getNativePlatformAvailability, injectInitCode, + isSuccessfulInitCommandResult, + isSuccessfulInitProcessResult, isOnlyAllowedInitAutoTestChange, mergeInitGitChanges, parseInitGitChanges, restoreInitProgressState, revertInitAutoTestChangeContent, runInheritedCommand, + runTrackedInitMutation, trackInitGitChanges, tryResumeOnboarding, } from '../src/init/command.ts' @@ -930,6 +933,183 @@ await tAsync('git mutation tracker retains existing fingerprints when capture is }) }) +await tAsync('scoped git tracking ignores a large unrelated dirty tree and retains saved out-of-scope records', async () => { + await withTempDirAsync(async (root) => { + const files = { 'package.json': '{"name":"example"}\n' } + for (let index = 0; index < 120; index += 1) + files[`src/dirty-${index}.ts`] = `export const value = ${index}\n` + initializeGitRepo(root, files) + for (let index = 0; index < 120; index += 1) + writeFileSync(join(root, `src/dirty-${index}.ts`), `export const userValue = ${index}\n`, 'utf8') + + const repoRoot = captureInitGitSnapshot(root, { exactPaths: [] })?.repoRoot + assert.ok(repoRoot) + const existing = { + version: 1, + repoRoot, + files: { + 'src/dirty-0.ts': { status: ' M', sha256: 'a'.repeat(64), mode: 0o100644 }, + }, + } + const tracked = await trackInitGitChanges(existing, () => { + writeFileSync(join(root, 'package.json'), '{"name":"capgo"}\n', 'utf8') + writeFileSync(join(root, 'src/dirty-1.ts'), 'export const changedAgain = true\n', 'utf8') + }, { + startDir: root, + scope: { exactPaths: ['package.json'] }, + }) + + assert.deepEqual(Object.keys(tracked.gitChanges?.files ?? {}).sort(), ['package.json', 'src/dirty-0.ts']) + assert.deepEqual(tracked.gitChanges?.files['src/dirty-0.ts'], existing.files['src/dirty-0.ts']) + assert.deepEqual(Object.keys(captureInitGitSnapshot(root, { exactPaths: ['package.json'] })?.files ?? {}), ['package.json']) + }) +}) + +await tAsync('unsafe git scopes fail closed without claiming paths', async () => { + await withTempDirAsync(async (root) => { + initializeGitRepo(root, { 'package.json': '{"name":"example"}\n' }) + const existing = { + version: 1, + repoRoot: captureInitGitSnapshot(root)?.repoRoot, + files: { + 'saved.txt': { status: '??', sha256: 'a'.repeat(64), mode: 0o100644 }, + }, + } + const unsafeScopes = [ + { exactPaths: ['/absolute.txt'] }, + { exactPaths: ['../escaping.txt'] }, + { exactPaths: ['unsafe\0name.txt'] }, + { directoryPrefixes: ['../../outside'] }, + ] + + for (const scope of unsafeScopes) { + assert.equal(captureInitGitSnapshot(root, scope), undefined) + const tracked = await trackInitGitChanges(existing, () => { + writeFileSync(join(root, 'package.json'), '{"name":"partial"}\n', 'utf8') + }, { startDir: root, scope }) + assert.deepEqual(tracked.gitChanges, existing) + } + }) +}) + +await tAsync('tracked init mutation persists changed process, structured, and void successes', async () => { + await withTempDirAsync(async (root) => { + initializeGitRepo(root, { + 'package.json': '{"name":"example"}\n', + 'capacitor.config.ts': 'export default {}\n', + 'src/main.ts': 'console.log(\'initial\')\n', + }) + restoreInitProgressState(1, undefined) + let persistCount = 0 + const dependencies = { persistProgress: () => { persistCount += 1 } } + + const processResult = await runTrackedInitMutation(() => { + writeFileSync(join(root, 'package.json'), '{"name":"capgo"}\n', 'utf8') + return { status: 0, error: undefined } + }, { + startDir: root, + scope: { exactPaths: ['package.json'] }, + isSuccess: isSuccessfulInitProcessResult, + }, dependencies) + assert.equal(processResult.status, 0) + assert.equal(persistCount, 1) + + const commandResult = await runTrackedInitMutation(() => { + writeFileSync(join(root, 'capacitor.config.ts'), 'export default { appId: \'com.test.app\' }\n', 'utf8') + return { success: true } + }, { + startDir: root, + scope: { exactPaths: ['capacitor.config.ts'] }, + isSuccess: isSuccessfulInitCommandResult, + }, dependencies) + assert.equal(commandResult.success, true) + + await runTrackedInitMutation(() => { + writeFileSync(join(root, 'src/main.ts'), 'console.log(\'capgo\')\n', 'utf8') + }, { + startDir: root, + scope: { exactPaths: ['src/main.ts'] }, + }, dependencies) + + assert.equal(persistCount, 3) + assert.deepEqual(Object.keys(getInitProgressStateForTesting().gitChanges?.files ?? {}).sort(), [ + 'capacitor.config.ts', + 'package.json', + 'src/main.ts', + ]) + beginFreshInitProgress() + }) +}) + +await tAsync('tracked init mutation leaves global state and persistence unchanged on every failure mode', async () => { + await withTempDirAsync(async (root) => { + initializeGitRepo(root, { 'package.json': '{"name":"example"}\n' }) + writeFileSync(join(root, 'package.json'), '{"name":"saved"}\n', 'utf8') + const saved = captureInitGitSnapshot(root) + assert.ok(saved) + const original = structuredClone(saved) + let persistCount = 0 + const dependencies = { persistProgress: () => { persistCount += 1 } } + + const operationError = new Error('operation failed') + restoreInitProgressState(1, saved) + await assert.rejects( + runTrackedInitMutation(() => { + writeFileSync(join(root, 'package.json'), '{"name":"partial-operation"}\n', 'utf8') + throw operationError + }, { startDir: root, scope: { exactPaths: ['package.json'] } }, dependencies), + error => error === operationError, + ) + assert.deepEqual(getInitProgressStateForTesting().gitChanges, original) + + restoreInitProgressState(1, saved) + const failedResult = await runTrackedInitMutation(() => { + writeFileSync(join(root, 'package.json'), '{"name":"partial-result"}\n', 'utf8') + return { success: false } + }, { + startDir: root, + scope: { exactPaths: ['package.json'] }, + isSuccess: isSuccessfulInitCommandResult, + }, dependencies) + assert.deepEqual(failedResult, { success: false }) + assert.deepEqual(getInitProgressStateForTesting().gitChanges, original) + + const predicateError = new Error('predicate failed') + restoreInitProgressState(1, saved) + await assert.rejects( + runTrackedInitMutation(() => ({ success: true }), { + startDir: root, + scope: { exactPaths: ['package.json'] }, + isSuccess: () => { throw predicateError }, + }, dependencies), + error => error === predicateError, + ) + assert.deepEqual(getInitProgressStateForTesting().gitChanges, original) + assert.equal(persistCount, 0) + beginFreshInitProgress() + }) +}) + +await tAsync('zero-delta tracked success keeps global state byte-equal without persisting', async () => { + await withTempDirAsync(async (root) => { + initializeGitRepo(root, { 'package.json': '{"name":"example"}\n' }) + writeFileSync(join(root, 'package.json'), '{"name":"saved"}\n', 'utf8') + const saved = captureInitGitSnapshot(root) + assert.ok(saved) + restoreInitProgressState(1, saved) + let persistCount = 0 + + await runTrackedInitMutation(() => undefined, { + startDir: root, + scope: { exactPaths: ['package.json'] }, + }, { persistProgress: () => { persistCount += 1 } }) + + assert.deepEqual(getInitProgressStateForTesting().gitChanges, saved) + assert.equal(persistCount, 0) + beginFreshInitProgress() + }) +}) + t('automatic onboarding mutations use narrow tracking windows and user-controlled work stays outside them', () => { const source = readFileSync(new URL('../src/init/command.ts', import.meta.url), 'utf8') const sourceBetween = (start, end) => { @@ -957,8 +1137,11 @@ t('automatic onboarding mutations use narrow tracking windows and user-controlle ['self-host config update', 'export async function initApp(', undefined, 1], ] - for (const [name, start, end, expectedCalls] of coverage) - assert.equal(trackedCallCount(sourceBetween(start, end)), expectedCalls, name) + for (const [name, start, end, expectedCalls] of coverage) { + const body = sourceBetween(start, end) + assert.equal(trackedCallCount(body), expectedCalls, name) + assert.equal(body.match(/\bscope:/g)?.length ?? 0, expectedCalls, `${name} scope`) + } assert.equal(trackedCallCount(sourceBetween('async function waitUntilSetupIsDone(', 'async function askForAppName(')), 0, 'manual setup wait') assert.equal(trackedCallCount(sourceBetween('async function waitForReadyConfirmation(', 'async function waitForReadyRetry(')), 0, 'manual ready wait') From 9ca4645a6ed80931b8f55381b55a497cd7a1a351 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 19:50:14 +0200 Subject: [PATCH 11/19] fix(cli): reset configured native platform path --- cli/src/init/command.ts | 73 ++++++++++++++++++++++++++++--- cli/test/test-init-guardrails.mjs | 44 ++++++++++++++++++- 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index e07c35de14..37ae03825d 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -151,6 +151,11 @@ export interface InitGitChangeScope { directoryPrefixes?: string[] } +export interface InitNativeResetTarget { + directory: string + scope: InitGitChangeScope +} + interface NormalizedInitGitChangeScope { exactPaths: string[] directoryPrefixes: string[] @@ -639,6 +644,47 @@ function createInitGitChangeScope(startDir: string, exactTargets: string[] = [], } } +function isPathInside(parentDir: string, targetPath: string, allowSame = false) { + const relativePath = path.relative(parentDir, targetPath) + return (allowSame || relativePath !== '') + && relativePath !== '..' + && !relativePath.startsWith(`..${path.sep}`) + && !path.isAbsolute(relativePath) +} + +export function resolveInitNativeResetTarget(projectDir: string, nativePlatformDir: string): InitNativeResetTarget | undefined { + try { + const repoRoot = getInitGitRepoRoot(projectDir) + const resolvedProjectDir = realpathSync(projectDir) + if (!repoRoot || !isPathInside(repoRoot, resolvedProjectDir, true)) + return undefined + + const directory = path.resolve(resolvedProjectDir, nativePlatformDir) + if (!isPathInside(resolvedProjectDir, directory)) + return undefined + + let existingAncestor = directory + while (!existsSync(existingAncestor)) { + const parentDir = dirname(existingAncestor) + if (parentDir === existingAncestor) + return undefined + existingAncestor = parentDir + } + if ((existingAncestor === directory && !lstatSync(directory).isDirectory()) + || !isPathInside(resolvedProjectDir, realpathSync(existingAncestor), true)) + return undefined + + const scope = createInitGitChangeScope(resolvedProjectDir, [], [directory]) + const normalizedScope = normalizeInitGitScope(scope) + if (!normalizedScope || normalizedScope.directoryPrefixes.length !== 1) + return undefined + return { directory, scope } + } + catch { + return undefined + } +} + const initPackageLockfiles: Record = { npm: ['package-lock.json', 'npm-shrinkwrap.json'], pnpm: ['pnpm-lock.yaml', 'shrinkwrap.yaml'], @@ -2656,19 +2702,31 @@ async function maybeCancelAfterRepeatedIosSyncFailures(failureCount: number, org await exitCanceledInitOnboarding(orgId, apikey) } -async function runNativeResetCommand(platformRunner: string, nativePlatform: PlatformChoice, successMessage: string, failureMessage: string): Promise { +async function runNativeResetCommand( + platformRunner: string, + nativePlatform: PlatformChoice, + projectDir: string, + nativePlatformDir: string, + successMessage: string, + failureMessage: string, +): Promise { const resetAdvice = getNativeProjectResetAdvice(platformRunner, nativePlatform) const resetSpinner = pSpinner() resetSpinner.start(`Running: ${resetAdvice.command}`) try { + const resetTarget = resolveInitNativeResetTarget(projectDir, nativePlatformDir) + if (!resetTarget) + throw new Error(`Cannot safely reset ${nativePlatformDir}: the native directory must stay inside the selected project`) + await runTrackedInitMutation(async () => { - rmSync(nativePlatform, { recursive: true, force: true }) + rmSync(resetTarget.directory, { recursive: true, force: true }) resetSpinner.stop() const addResult = await streamCommandInInitPanel({ title: `Recreating ${nativePlatform.toUpperCase()} native project`, runner: platformRunner, args: ['cap', 'add', nativePlatform], + cwd: projectDir, }) if (!addResult.success) throw addResult.error ?? new Error(`cap add ${nativePlatform} failed`) @@ -2677,12 +2735,13 @@ async function runNativeResetCommand(platformRunner: string, nativePlatform: Pla title: `Syncing ${nativePlatform.toUpperCase()} native project`, runner: platformRunner, args: ['cap', 'sync', nativePlatform], + cwd: projectDir, }) if (!syncResult.success) throw syncResult.error ?? new Error(`cap sync ${nativePlatform} failed`) }, { - startDir: cwd(), - scope: createInitGitChangeScope(cwd(), [], [path.resolve(nativePlatform)]), + startDir: projectDir, + scope: resetTarget.scope, }) await delay(750) @@ -2731,7 +2790,7 @@ async function waitForReadyRetry(message: string, orgId: string, apikey: string, await waitForReadyConfirmation(message, orgId, apikey, 'Type "ready" when the iOS folder is fixed.', placeholder) } -async function handleBrokenIosSync(platformRunner: string, details: string[], orgId: string, apikey: string, failureCount: number) { +async function handleBrokenIosSync(platformRunner: string, projectDir: string, nativePlatformDir: string, details: string[], orgId: string, apikey: string, failureCount: number) { const resetAdvice = getNativeProjectResetAdvice(platformRunner, 'ios') const { doctor } = getInitRecoveryCommands() logBrokenIosSync(details, resetAdvice.summary, resetAdvice.command, doctor) @@ -2745,7 +2804,7 @@ async function handleBrokenIosSync(platformRunner: string, details: string[], or await cancelCommand(runResetNow, orgId, apikey) if (runResetNow) { - await runNativeResetCommand(platformRunner, 'ios', 'iOS folder recreated and synced ✅', 'iOS folder reset failed ❌') + await runNativeResetCommand(platformRunner, 'ios', projectDir, nativePlatformDir, 'iOS folder recreated and synced ✅', 'iOS folder reset failed ❌') return } @@ -4522,7 +4581,7 @@ async function runBuildAndSyncLoop( if (syncValidation.shouldCheck && !syncValidation.valid) { iosSyncFailureCount += 1 spinner.stop('iOS sync check failed ❌') - await handleBrokenIosSync(pm.runner, syncValidation.details, orgId, apikey, iosSyncFailureCount) + await handleBrokenIosSync(pm.runner, buildAndSyncCwd, nativePlatformDir, syncValidation.details, orgId, apikey, iosSyncFailureCount) pLog.info(`Retrying build and sync for iOS (attempt ${iosSyncFailureCount + 1})`) continue } diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index 1ec9133e09..c137069ced 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { execSync, spawn } from 'node:child_process' -import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { @@ -28,6 +28,7 @@ import { mergeInitGitChanges, parseInitGitChanges, restoreInitProgressState, + resolveInitNativeResetTarget, revertInitAutoTestChangeContent, runInheritedCommand, runTrackedInitMutation, @@ -992,6 +993,44 @@ await tAsync('unsafe git scopes fail closed without claiming paths', async () => }) }) +await tAsync('native reset targets configured platform directories without escaping the project', async () => { + for (const [platformName, configuredPath] of [['ios', 'native/apple-app'], ['android', 'native/android-app']]) { + await withTempDirAsync(async (root) => { + const projectDir = join(root, 'project') + mkdirSync(projectDir) + initializeGitRepo(projectDir, { + 'package.json': '{"name":"example"}\n', + [`${configuredPath}/generated.txt`]: 'generated\n', + [`${platformName}/keep.txt`]: 'keep\n', + }) + const config = { [platformName]: { path: configuredPath } } + const availability = getNativePlatformAvailability(config, projectDir) + const nativePlatformDir = platformName === 'ios' ? availability.iosDir : availability.androidDir + const resetTarget = resolveInitNativeResetTarget(projectDir, nativePlatformDir) + + assert.ok(resetTarget) + assert.equal(resetTarget.directory, join(realpathSync(projectDir), configuredPath)) + assert.deepEqual(resetTarget.scope, { exactPaths: [], directoryPrefixes: [configuredPath] }) + + const tracked = await trackInitGitChanges(undefined, () => { + rmSync(resetTarget.directory, { recursive: true, force: true }) + }, { startDir: projectDir, scope: resetTarget.scope }) + + assert.equal(existsSync(join(projectDir, configuredPath)), false) + assert.equal(existsSync(join(projectDir, platformName, 'keep.txt')), true) + assert.deepEqual(Object.keys(tracked.gitChanges?.files ?? {}), [`${configuredPath}/generated.txt`]) + + const outsideDir = join(root, 'outside') + mkdirSync(outsideDir) + assert.equal(resolveInitNativeResetTarget(projectDir, '../outside'), undefined) + assert.equal(resolveInitNativeResetTarget(projectDir, outsideDir), undefined) + assert.equal(resolveInitNativeResetTarget(projectDir, '.'), undefined) + if (tryCreateTestSymlink(outsideDir, join(projectDir, 'native-link'))) + assert.equal(resolveInitNativeResetTarget(projectDir, 'native-link/ios'), undefined) + }) + } +}) + await tAsync('tracked init mutation persists changed process, structured, and void successes', async () => { await withTempDirAsync(async (root) => { initializeGitRepo(root, { @@ -1147,6 +1186,9 @@ t('automatic onboarding mutations use narrow tracking windows and user-controlle assert.equal(trackedCallCount(sourceBetween('async function waitForReadyConfirmation(', 'async function waitForReadyRetry(')), 0, 'manual ready wait') assert.equal(trackedCallCount(sourceBetween('async function runDeviceStep(', 'async function addCodeChangeStep(')), 0, 'cap run') assert.equal(trackedCallCount(sourceBetween('const buildResult = await streamCommandInInitPanel({', '// Keep the completed build output visible')), 0, 'project build') + const nativeResetBody = sourceBetween('async function runNativeResetCommand(', 'async function waitForReadyConfirmation(') + assert.match(nativeResetBody, /rmSync\(resetTarget\.directory,/) + assert.match(nativeResetBody, /scope: resetTarget\.scope/) }) await tAsync('live git cleanliness gate filters trusted changes without weakening the existing prompt', async () => { From fd5e4af9059ee12fb4af42fcf91daf7c4bb977d1 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 20:01:46 +0200 Subject: [PATCH 12/19] fix(cli): canonicalize native reset target --- cli/src/init/command.ts | 43 ++++++++++++++++++++++++------ cli/test/test-init-guardrails.mjs | 44 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 37ae03825d..84bd31afd7 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -654,29 +654,50 @@ function isPathInside(parentDir: string, targetPath: string, allowSame = false) export function resolveInitNativeResetTarget(projectDir: string, nativePlatformDir: string): InitNativeResetTarget | undefined { try { + if (!nativePlatformDir + || nativePlatformDir.includes('\0') + || path.isAbsolute(nativePlatformDir) + || path.win32.isAbsolute(nativePlatformDir) + || nativePlatformDir.split(/[\\/]/).includes('..')) + return undefined + const repoRoot = getInitGitRepoRoot(projectDir) const resolvedProjectDir = realpathSync(projectDir) if (!repoRoot || !isPathInside(repoRoot, resolvedProjectDir, true)) return undefined - const directory = path.resolve(resolvedProjectDir, nativePlatformDir) - if (!isPathInside(resolvedProjectDir, directory)) + const lexicalTarget = path.resolve(resolvedProjectDir, nativePlatformDir) + if (!isPathInside(resolvedProjectDir, lexicalTarget)) return undefined - let existingAncestor = directory + const unresolvedSuffix: string[] = [] + let existingAncestor = lexicalTarget while (!existsSync(existingAncestor)) { + unresolvedSuffix.unshift(path.basename(existingAncestor)) const parentDir = dirname(existingAncestor) if (parentDir === existingAncestor) return undefined existingAncestor = parentDir } - if ((existingAncestor === directory && !lstatSync(directory).isDirectory()) - || !isPathInside(resolvedProjectDir, realpathSync(existingAncestor), true)) + + const ancestorStats = lstatSync(existingAncestor) + if (existingAncestor === lexicalTarget && ancestorStats.isSymbolicLink()) + return undefined + const canonicalAncestor = realpathSync(existingAncestor) + if (!statSync(canonicalAncestor).isDirectory()) + return undefined + + const directory = path.resolve(canonicalAncestor, ...unresolvedSuffix) + if (!isPathInside(resolvedProjectDir, directory) + || !isPathInside(repoRoot, directory)) return undefined const scope = createInitGitChangeScope(resolvedProjectDir, [], [directory]) const normalizedScope = normalizeInitGitScope(scope) - if (!normalizedScope || normalizedScope.directoryPrefixes.length !== 1) + const repoRelativeDirectory = path.relative(repoRoot, directory).replaceAll(path.sep, '/') + if (!normalizedScope + || normalizedScope.directoryPrefixes.length !== 1 + || normalizedScope.directoryPrefixes[0] !== repoRelativeDirectory) return undefined return { directory, scope } } @@ -2710,11 +2731,17 @@ async function runNativeResetCommand( successMessage: string, failureMessage: string, ): Promise { + const resetTarget = resolveInitNativeResetTarget(projectDir, nativePlatformDir) const resetAdvice = getNativeProjectResetAdvice(platformRunner, nativePlatform) + const resetDisplayPath = resetTarget + ? path.relative(realpathSync(projectDir), resetTarget.directory).replaceAll(path.sep, '/') + : nativePlatform + const resetCommand = resetDisplayPath === nativePlatform + ? resetAdvice.command + : resetAdvice.command.replace(`rm -rf ${nativePlatform}`, `rm -rf ${resetDisplayPath}`) const resetSpinner = pSpinner() - resetSpinner.start(`Running: ${resetAdvice.command}`) + resetSpinner.start(`Running: ${resetCommand}`) try { - const resetTarget = resolveInitNativeResetTarget(projectDir, nativePlatformDir) if (!resetTarget) throw new Error(`Cannot safely reset ${nativePlatformDir}: the native directory must stay inside the selected project`) diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index c137069ced..6ac13aa0d8 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -1031,6 +1031,48 @@ await tAsync('native reset targets configured platform directories without escap } }) +await tAsync('native reset resolution canonicalizes directory ancestors and rejects ambiguous targets', async () => { + await withTempDirAsync(async (root) => { + const projectDir = join(root, 'project') + mkdirSync(projectDir) + initializeGitRepo(projectDir, { 'package.json': '{"name":"example"}\n' }) + const canonicalProjectDir = realpathSync(projectDir) + + writeFileSync(join(projectDir, 'blocking-file'), 'not a directory\n', 'utf8') + assert.equal(resolveInitNativeResetTarget(projectDir, 'blocking-file/ios'), undefined) + + const nonexistentTarget = resolveInitNativeResetTarget(projectDir, 'missing/nested/ios') + assert.deepEqual(nonexistentTarget, { + directory: join(canonicalProjectDir, 'missing/nested/ios'), + scope: { exactPaths: [], directoryPrefixes: ['missing/nested/ios'] }, + }) + + const internalTargetDir = join(projectDir, 'real-native') + mkdirSync(internalTargetDir) + if (tryCreateTestSymlink(internalTargetDir, join(projectDir, 'native-alias'))) { + const internalTarget = resolveInitNativeResetTarget(projectDir, 'native-alias/ios') + assert.deepEqual(internalTarget, { + directory: join(canonicalProjectDir, 'real-native/ios'), + scope: { exactPaths: [], directoryPrefixes: ['real-native/ios'] }, + }) + + const finalTargetDir = join(projectDir, 'final-native') + mkdirSync(finalTargetDir) + assert.equal(tryCreateTestSymlink(finalTargetDir, join(projectDir, 'final-native-link')), true) + assert.equal(resolveInitNativeResetTarget(projectDir, 'final-native-link'), undefined) + } + + const outsideDir = join(root, 'outside') + mkdirSync(outsideDir) + if (tryCreateTestSymlink(outsideDir, join(projectDir, 'external-alias'))) + assert.equal(resolveInitNativeResetTarget(projectDir, 'external-alias/ios'), undefined) + + assert.equal(resolveInitNativeResetTarget(projectDir, join(projectDir, 'absolute-ios')), undefined) + assert.equal(resolveInitNativeResetTarget(projectDir, 'native/../ios'), undefined) + assert.equal(resolveInitNativeResetTarget(projectDir, 'native\0ios'), undefined) + }) +}) + await tAsync('tracked init mutation persists changed process, structured, and void successes', async () => { await withTempDirAsync(async (root) => { initializeGitRepo(root, { @@ -1189,6 +1231,8 @@ t('automatic onboarding mutations use narrow tracking windows and user-controlle const nativeResetBody = sourceBetween('async function runNativeResetCommand(', 'async function waitForReadyConfirmation(') assert.match(nativeResetBody, /rmSync\(resetTarget\.directory,/) assert.match(nativeResetBody, /scope: resetTarget\.scope/) + assert.match(nativeResetBody, /path\.relative\(realpathSync\(projectDir\), resetTarget\.directory\)/) + assert.match(nativeResetBody, /resetSpinner\.start\(`Running: \$\{resetCommand\}`\)/) }) await tAsync('live git cleanliness gate filters trusted changes without weakening the existing prompt', async () => { From cf5bd3dd1ac01b609f1f2a553a48b1dc58d61d34 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 20:08:18 +0200 Subject: [PATCH 13/19] fix(cli): reject dangling native reset links --- cli/src/init/command.ts | 5 +++-- cli/test/test-init-guardrails.mjs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 84bd31afd7..f15f1e8c84 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -672,15 +672,16 @@ export function resolveInitNativeResetTarget(projectDir: string, nativePlatformD const unresolvedSuffix: string[] = [] let existingAncestor = lexicalTarget - while (!existsSync(existingAncestor)) { + let ancestorStats = lstatSync(existingAncestor, { throwIfNoEntry: false }) + while (!ancestorStats) { unresolvedSuffix.unshift(path.basename(existingAncestor)) const parentDir = dirname(existingAncestor) if (parentDir === existingAncestor) return undefined existingAncestor = parentDir + ancestorStats = lstatSync(existingAncestor, { throwIfNoEntry: false }) } - const ancestorStats = lstatSync(existingAncestor) if (existingAncestor === lexicalTarget && ancestorStats.isSymbolicLink()) return undefined const canonicalAncestor = realpathSync(existingAncestor) diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index 6ac13aa0d8..702654483f 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -1062,6 +1062,18 @@ await tAsync('native reset resolution canonicalizes directory ancestors and reje assert.equal(resolveInitNativeResetTarget(projectDir, 'final-native-link'), undefined) } + const danglingFinalLink = join(projectDir, 'dangling-final-link') + if (tryCreateTestSymlink(join(projectDir, 'missing-final-target'), danglingFinalLink)) { + assert.equal(resolveInitNativeResetTarget(projectDir, 'dangling-final-link'), undefined) + assert.equal(lstatSync(danglingFinalLink).isSymbolicLink(), true) + } + + const danglingAncestorLink = join(projectDir, 'dangling-ancestor-link') + if (tryCreateTestSymlink(join(projectDir, 'missing-ancestor-target'), danglingAncestorLink)) { + assert.equal(resolveInitNativeResetTarget(projectDir, 'dangling-ancestor-link/ios'), undefined) + assert.equal(lstatSync(danglingAncestorLink).isSymbolicLink(), true) + } + const outsideDir = join(root, 'outside') mkdirSync(outsideDir) if (tryCreateTestSymlink(outsideDir, join(projectDir, 'external-alias'))) From e859ca567fd732fd1c42d6b79c439542ddfed95d Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 20:38:40 +0200 Subject: [PATCH 14/19] fix(cli): harden onboarding git snapshots --- cli/src/init/command.ts | 242 +++++++++++++++++++++++------- cli/test/test-init-guardrails.mjs | 142 ++++++++++++++++++ 2 files changed, 330 insertions(+), 54 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index f15f1e8c84..5c466b9c39 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -131,9 +131,16 @@ interface GitRepoStatus { clean: boolean repoRoot?: string entries: string[] + fileEntries?: InitGitStatusEntry[] error?: string } +interface InitGitStatusEntry { + status: string + filePath: string + display: string +} + export interface InitGitChangeFingerprint { status: string sha256: string | null @@ -151,6 +158,20 @@ export interface InitGitChangeScope { directoryPrefixes?: string[] } +export interface InitGitSnapshotDependencies { + runStatus?: (repoRoot: string, args: readonly string[]) => Buffer | undefined + lstat?: (filePath: string) => { + dev: bigint + ino: bigint + size: bigint + mode: bigint + mtimeNs: bigint + ctimeNs: bigint + isFile: () => boolean + } + readFile?: (filePath: string) => Buffer +} + export interface InitNativeResetTarget { directory: string scope: InitGitChangeScope @@ -372,10 +393,9 @@ export function getGitRepoStatus(startDir = cwd()): GitRepoStatus { } } - const statusResult = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { + const statusResult = spawnSync('git', getInitGitStatusArgs(), { cwd: repoRoot, stdio: 'pipe', - encoding: 'utf8', }) if (statusResult.error) { @@ -398,17 +418,26 @@ export function getGitRepoStatus(startDir = cwd()): GitRepoStatus { } } - const entries = statusResult.stdout - ?.toString() - .split('\n') - .map(line => line.trimEnd()) - .filter(Boolean) ?? [] + const statusEntries = parseInitGitStatusOutput(statusResult.stdout) + if (!statusEntries) { + return { + inRepo: true, + clean: false, + repoRoot, + entries: [], + fileEntries: [], + error: 'Could not parse git status output.', + } + } + const fileEntries = statusEntries.map(({ status, filePath, display }) => ({ status, filePath, display })) + const entries = fileEntries.map(entry => entry.display) return { inRepo: true, clean: entries.length === 0, repoRoot, entries, + fileEntries, } } @@ -744,12 +773,11 @@ function isGitObjectId(value: string) { return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value) } -interface InitGitStatusRecord { - status: string - filePath: string +interface InitGitStatusRecord extends InitGitStatusEntry { headMode: string | null indexMode: string | null worktreeMode: string | null + originalPath?: string } function splitInitGitStatusFields(value: string, count: number) { @@ -766,32 +794,130 @@ function splitInitGitStatusFields(value: string, count: number) { return fields } -function parseInitGitStatusRecord(value: string): InitGitStatusRecord | undefined { +function normalizeInitGitStatus(rawStatus: string) { + const status = rawStatus.replaceAll('.', ' ') + return /^[ MADRCUT]{2}$/.test(status) ? status : undefined +} + +function hasValidInitGitStatusMetadata(modes: string[], objectIds: string[]) { + return modes.every(mode => /^[0-7]{6}$/.test(mode)) + && objectIds.every(isGitObjectId) +} + +function parseInitGitStatusRecord(value: string, originalPath?: string): InitGitStatusRecord | undefined { if (value.startsWith('? ')) { const filePath = value.slice(2) return filePath - ? { status: '??', filePath, headMode: null, indexMode: null, worktreeMode: null } + ? { status: '??', filePath, display: `?? ${filePath}`, headMode: null, indexMode: null, worktreeMode: null } : undefined } - if (!value.startsWith('1 ')) - return undefined - const fields = splitInitGitStatusFields(value, 9) - if (!fields) - return undefined - const [, rawStatus, submodule, headMode, indexMode, worktreeMode, headObjectId, indexObjectId, filePath] = fields - const status = rawStatus.replaceAll('.', ' ') - if (!filePath - || !/^[ MADRCUT]{2}$/.test(status) - || submodule !== 'N...' - || ![headMode, indexMode, worktreeMode].every(mode => /^[0-7]{6}$/.test(mode)) - || !isGitObjectId(headObjectId) - || !isGitObjectId(indexObjectId)) + if (value.startsWith('1 ')) { + const fields = splitInitGitStatusFields(value, 9) + if (!fields) + return undefined + const [, rawStatus, submodule, headMode, indexMode, worktreeMode, headObjectId, indexObjectId, filePath] = fields + const status = normalizeInitGitStatus(rawStatus) + if (!status || !filePath || submodule !== 'N...' + || !hasValidInitGitStatusMetadata([headMode, indexMode, worktreeMode], [headObjectId, indexObjectId])) + return undefined + return { status, filePath, display: `${status} ${filePath}`, headMode, indexMode, worktreeMode } + } + + if (value.startsWith('2 ')) { + const fields = splitInitGitStatusFields(value, 10) + if (!fields || !originalPath) + return undefined + const [, rawStatus, submodule, headMode, indexMode, worktreeMode, headObjectId, indexObjectId, score, filePath] = fields + const status = normalizeInitGitStatus(rawStatus) + if (!status || !filePath || submodule !== 'N...' || !/^[RC][0-9]+$/.test(score) + || !hasValidInitGitStatusMetadata([headMode, indexMode, worktreeMode], [headObjectId, indexObjectId])) + return undefined + return { + status, + filePath, + display: `${status} ${originalPath} -> ${filePath}`, + headMode, + indexMode, + worktreeMode, + originalPath, + } + } + + if (value.startsWith('u ')) { + const fields = splitInitGitStatusFields(value, 11) + if (!fields) + return undefined + const [, rawStatus, , stageOneMode, stageTwoMode, stageThreeMode, worktreeMode, stageOneObjectId, stageTwoObjectId, stageThreeObjectId, filePath] = fields + const status = normalizeInitGitStatus(rawStatus) + if (!status || !filePath + || !hasValidInitGitStatusMetadata( + [stageOneMode, stageTwoMode, stageThreeMode, worktreeMode], + [stageOneObjectId, stageTwoObjectId, stageThreeObjectId], + )) + return undefined + return { + status, + filePath, + display: `${status} ${filePath}`, + headMode: stageOneMode, + indexMode: stageTwoMode, + worktreeMode, + } + } + + return undefined +} + +function parseInitGitStatusOutput(output: Buffer): InitGitStatusRecord[] | undefined { + if (output.length > 0 && output.at(-1) !== 0) return undefined - return { status, filePath, headMode, indexMode, worktreeMode } + const rawEntries = output.length > 0 ? output.subarray(0, -1).toString('utf8').split('\0') : [] + const entries: InitGitStatusRecord[] = [] + for (let index = 0; index < rawEntries.length; index += 1) { + const rawEntry = rawEntries[index] + const isRenameOrCopy = rawEntry.startsWith('2 ') + const originalPath = isRenameOrCopy ? rawEntries[++index] : undefined + const parsedEntry = parseInitGitStatusRecord(rawEntry, originalPath) + if (!parsedEntry) + return undefined + entries.push(parsedEntry) + } + return entries +} + +function getInitGitStatusArgs(scope?: NormalizedInitGitChangeScope) { + const args = ['-c', 'status.renames=copies', 'status', '--porcelain=v2', '-z', '--untracked-files=all'] + if (scope) + args.push('--', ...scope.exactPaths.map(filePath => `:(top,literal)${filePath}`), ...scope.directoryPrefixes.map(prefix => `:(top,literal)${prefix}`)) + return args +} + +function runInitGitStatus(repoRoot: string, args: readonly string[]): Buffer | undefined { + const result = spawnSync('git', [...args], { + cwd: repoRoot, + stdio: 'pipe', + }) + return !result.error && result.status === 0 && result.stdout ? result.stdout : undefined } -export function captureInitGitSnapshot(startDir = cwd(), scope?: InitGitChangeScope): InitGitChanges | undefined { +function initGitFileMetadataMatches( + left: ReturnType>, + right: ReturnType>, +) { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mode === right.mode + && left.mtimeNs === right.mtimeNs + && left.ctimeNs === right.ctimeNs +} + +export function captureInitGitSnapshot( + startDir = cwd(), + scope?: InitGitChangeScope, + dependencies: InitGitSnapshotDependencies = {}, +): InitGitChanges | undefined { try { const repoRoot = getInitGitRepoRoot(startDir) if (!repoRoot) @@ -802,29 +928,19 @@ export function captureInitGitSnapshot(startDir = cwd(), scope?: InitGitChangeSc if (normalizedScope && normalizedScope.exactPaths.length === 0 && normalizedScope.directoryPrefixes.length === 0) return { version: 1, repoRoot, files: {} } - const statusArgs = ['-c', 'status.renames=copies', 'status', '--porcelain=v2', '-z', '--untracked-files=all'] - if (normalizedScope) { - statusArgs.push('--', ...normalizedScope.exactPaths.map(filePath => `:(top,literal)${filePath}`), ...normalizedScope.directoryPrefixes.map(prefix => `:(top,literal)${prefix}`)) - } - const statusResult = spawnSync('git', statusArgs, { - cwd: repoRoot, - stdio: 'pipe', - encoding: 'utf8', - }) - if (statusResult.error || statusResult.status !== 0) + const runStatus = dependencies.runStatus ?? runInitGitStatus + const lstat = dependencies.lstat ?? (filePath => lstatSync(filePath, { bigint: true })) + const readFile = dependencies.readFile ?? (filePath => readFileSync(filePath)) + const statusArgs = getInitGitStatusArgs(normalizedScope) + const statusOutput = runStatus(repoRoot, statusArgs) + if (!statusOutput) return undefined - - const statusOutput = statusResult.stdout?.toString() ?? '' - if (statusOutput && !statusOutput.endsWith('\0')) + const entries = parseInitGitStatusOutput(statusOutput) + if (!entries) return undefined - const entries = statusOutput ? statusOutput.slice(0, -1).split('\0') : [] const files: Record = {} for (const entry of entries) { - const parsedEntry = parseInitGitStatusRecord(entry) - if (!parsedEntry) - return undefined - - const { status, filePath, headMode, indexMode, worktreeMode } = parsedEntry + const { status, filePath, headMode, indexMode, worktreeMode } = entry const fingerprintKind = getInitGitFingerprintKind(status) if (!filePath || !fingerprintKind || files[filePath]) return undefined @@ -846,16 +962,26 @@ export function captureInitGitSnapshot(startDir = cwd(), scope?: InitGitChangeSc if (status !== '??' && (!isRegularGitBlobMode(indexMode) || !isRegularGitBlobMode(worktreeMode))) return undefined - const fileStats = lstatSync(absolutePath) - if (!fileStats.isFile()) + const beforeStats = lstat(absolutePath) + if (!beforeStats.isFile()) + return undefined + const contents = readFile(absolutePath) + const afterStats = lstat(absolutePath) + const mode = Number(afterStats.mode) + if (!afterStats.isFile() + || !initGitFileMetadataMatches(beforeStats, afterStats) + || !isSupportedInitGitFileMode(mode)) return undefined files[filePath] = { status, - sha256: createHash('sha256').update(readFileSync(absolutePath)).digest('hex'), - mode: fileStats.mode, + sha256: createHash('sha256').update(contents).digest('hex'), + mode, } } + const finalStatusOutput = runStatus(repoRoot, statusArgs) + if (!finalStatusOutput || !finalStatusOutput.equals(statusOutput)) + return undefined return { version: 1, repoRoot, files } } catch { @@ -1123,6 +1249,10 @@ function getNormalizedGitStatusEntryPath(entry: string) { return getGitStatusEntryPath(entry).split(path.sep).join('/') } +function getGitStatusFileEntries(status: GitRepoStatus) { + return status.fileEntries?.length === status.entries.length ? status.fileEntries : undefined +} + export function evaluateInitGitRepoState( status: GitRepoStatus, currentValue: InitGitChanges | undefined, @@ -1141,7 +1271,10 @@ export function evaluateInitGitRepoState( } const current = parseInitGitChangesValue(currentValue, true) - const statusPaths = status.entries.map(getNormalizedGitStatusEntryPath) + const fileEntries = getGitStatusFileEntries(status) + const statusPaths = fileEntries + ? fileEntries.map(entry => entry.filePath) + : status.entries.map(getNormalizedGitStatusEntryPath) const currentPaths = current ? Object.keys(current.files).sort() : [] const snapshotMatchesStatus = Boolean( current @@ -1175,7 +1308,9 @@ export function evaluateInitGitRepoState( const unsafePaths = new Set(classification.unsafePaths) return { - warningEntries: status.entries.filter(entry => unsafePaths.has(getNormalizedGitStatusEntryPath(entry))), + warningEntries: fileEntries + ? fileEntries.filter(entry => unsafePaths.has(entry.filePath)).map(entry => entry.display) + : status.entries.filter(entry => unsafePaths.has(getNormalizedGitStatusEntryPath(entry))), recognizedCount: classification.recognizedCount, nextSaved: classification.retained, shouldPersist: true, @@ -1193,8 +1328,7 @@ export function isOnlyAllowedInitAutoTestChange(status: GitRepoStatus, allowedCh try { const repoRoot = realpathSync.native(status.repoRoot) const allowedFilePath = realpathSync.native(path.resolve(allowedChange.filePath)) - const dirtyPaths = status.entries - .map(getGitStatusEntryPath) + const dirtyPaths = (getGitStatusFileEntries(status)?.map(entry => entry.filePath) ?? status.entries.map(getGitStatusEntryPath)) .filter(Boolean) .map(entryPath => realpathSync.native(path.resolve(repoRoot, entryPath))) diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index 702654483f..e55a905a8d 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -85,6 +85,13 @@ function initializeGitRepo(root, files) { execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) } +function getRawInitGitStatus(root) { + return execSync('git -c status.renames=copies status --porcelain=v2 -z --untracked-files=all', { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + }) +} + function tryCreateTestSymlink(target, filePath) { try { symlinkSync(target, filePath) @@ -521,6 +528,80 @@ t('git snapshot handles mixed tracked, untracked, and staged-deleted paths toget }) }) +t('git snapshot rejects file metadata changed during hashing', () => { + withTempDir((root) => { + initializeGitRepo(root, { 'tracked file ü.txt': 'initial\n' }) + const filePath = join(root, 'tracked file ü.txt') + writeFileSync(filePath, 'modified\n', 'utf8') + const statusOutput = getRawInitGitStatus(root) + const stable = lstatSync(filePath, { bigint: true }) + const changed = { + dev: stable.dev, + ino: stable.ino, + size: stable.size, + mode: stable.mode, + mtimeNs: stable.mtimeNs + 1n, + ctimeNs: stable.ctimeNs, + isFile: () => true, + } + let readCompleted = false + + const snapshot = captureInitGitSnapshot(root, undefined, { + runStatus: () => statusOutput, + lstat: () => readCompleted ? changed : stable, + readFile: (target) => { + const content = readFileSync(target) + readCompleted = true + return content + }, + }) + + assert.equal(snapshot, undefined) + }) +}) + +t('git snapshot rejects a changed second status result', () => { + withTempDir((root) => { + initializeGitRepo(root, { 'tracked file ü.txt': 'initial\n' }) + writeFileSync(join(root, 'tracked file ü.txt'), 'modified\n', 'utf8') + const statusOutput = getRawInitGitStatus(root) + let statusCalls = 0 + + const snapshot = captureInitGitSnapshot(root, undefined, { + runStatus: () => statusCalls++ === 0 + ? statusOutput + : Buffer.concat([statusOutput, Buffer.from('? changed after hashing.txt\0')]), + }) + + assert.equal(snapshot, undefined) + assert.equal(statusCalls, 2) + }) +}) + +t('git snapshot accepts stable metadata and status for raw whitespace and Unicode paths', () => { + withTempDir((root) => { + initializeGitRepo(root, { + 'tracked file.txt': 'initial\n', + 'tracked ü.txt': 'initial\n', + }) + writeFileSync(join(root, 'tracked file.txt'), 'modified\n', 'utf8') + writeFileSync(join(root, 'tracked ü.txt'), 'modified\n', 'utf8') + const statusOutput = getRawInitGitStatus(root) + let statusCalls = 0 + + const snapshot = captureInitGitSnapshot(root, undefined, { + runStatus: () => { + statusCalls += 1 + return statusOutput + }, + }) + + assert.ok(snapshot) + assert.deepEqual(Object.keys(snapshot.files).sort(), ['tracked file.txt', 'tracked ü.txt']) + assert.equal(statusCalls, 2) + }) +}) + t('git snapshot declines unsupported symlinks and renames', () => { withTempDir((root) => { execSync('git init', { cwd: root, stdio: 'ignore' }) @@ -541,6 +622,9 @@ t('git snapshot declines unsupported symlinks and renames', () => { execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) execSync('git mv before.txt after.txt', { cwd: root, stdio: 'ignore' }) + const status = getGitRepoStatus(root) + assert.deepEqual(status.entries, ['R before.txt -> after.txt']) + assert.deepEqual(status.fileEntries, [{ status: 'R ', filePath: 'after.txt', display: 'R before.txt -> after.txt' }]) assert.equal(captureInitGitSnapshot(root), undefined) }) }) @@ -1350,6 +1434,64 @@ await tAsync('live git cleanliness gate filters trusted changes without weakenin } }) +await tAsync('live git cleanliness gate recognizes raw whitespace and Unicode paths', async () => { + await withTempDirAsync(async (root) => { + initializeGitRepo(root, { + 'trusted file.txt': 'initial\n', + 'trusted ü.txt': 'initial\n', + }) + writeFileSync(join(root, 'trusted file.txt'), 'Capgo change\n', 'utf8') + writeFileSync(join(root, 'trusted ü.txt'), 'Capgo change\n', 'utf8') + const saved = captureInitGitSnapshot(root) + assert.ok(saved) + + const runGate = async () => { + const events = [] + await ensureGitRepoCleanBeforeInit(undefined, { + getStatus: () => getGitRepoStatus(root), + captureSnapshot: () => captureInitGitSnapshot(root), + isOnlyAllowedAutoTestChange: () => false, + persistProgress: () => {}, + log: { + error: message => events.push({ type: 'error', message }), + info: message => events.push({ type: 'info', message }), + success: message => events.push({ type: 'success', message }), + warn: message => events.push({ type: 'warn', message }), + }, + selectAction: async (prompt) => { + events.push({ type: 'prompt', prompt }) + return 'continue-dirty' + }, + cancelAction: async () => {}, + waitForRetry: async () => assert.fail('retry prompt was not expected'), + }) + return events + } + + try { + restoreInitProgressState(4, saved) + const exactEvents = await runGate() + assert.equal(exactEvents.some(event => event.type === 'prompt'), false) + assert.deepEqual(exactEvents.filter(event => event.type === 'info').map(event => event.message), [ + 'Resuming with uncommitted changes created by the previous Capgo onboarding run.', + ]) + + writeFileSync(join(root, 'unsafe ü file.txt'), 'user change\n', 'utf8') + restoreInitProgressState(4, saved) + const mixedEvents = await runGate() + assert.equal(mixedEvents.some(event => event.type === 'prompt'), true) + assert.deepEqual( + mixedEvents.filter(event => event.type === 'warn' && event.message.startsWith(' ')).map(event => event.message), + [' ?? unsafe ü file.txt'], + ) + assert.equal(mixedEvents.some(event => event.type === 'info' && event.message === '2 recognized Capgo changes were omitted from this warning.'), true) + } + finally { + beginFreshInitProgress() + } + }) +}) + t('production onboarding lifecycle transitions clear resumed fingerprint state', () => { const saved = { version: 1, From c55e22627f66273a5100a7ddccfaa3b35db5a9af Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 20:48:33 +0200 Subject: [PATCH 15/19] fix(cli): fail closed on progress persistence --- cli/src/init/command.ts | 24 +++++++++++------- cli/test/test-init-guardrails.mjs | 42 ++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 5c466b9c39..47b031670e 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -119,7 +119,7 @@ export interface InitGitCleanGateDependencies { getStatus?: () => GitRepoStatus captureSnapshot?: (startDir?: string) => InitGitChanges | undefined isOnlyAllowedAutoTestChange?: (status: GitRepoStatus, allowedChange?: InitAutoTestChange) => boolean - persistProgress?: () => void + persistProgress?: () => boolean log?: InitGitCleanGateLog selectAction?: (prompt: { message: string, options: ReturnType }) => Promise cancelAction?: (action: DirtyGitStatusAction | symbol) => Promise @@ -1429,22 +1429,26 @@ export async function ensureGitRepoCleanBeforeInit( return const decision = evaluateInitGitRepoState(status, captureSnapshot(status.repoRoot), globalInitGitChanges) + let persistenceSucceeded = false if (decision.shouldPersist) { globalInitGitChanges = decision.nextSaved - persistProgress() + persistenceSucceeded = persistProgress() } - for (const message of decision.infoMessages) + const canUseRecognition = decision.recognizedCount === 0 || persistenceSucceeded + const infoMessages = canUseRecognition ? decision.infoMessages : [] + const warningEntries = canUseRecognition ? decision.warningEntries : status.entries + for (const message of infoMessages) log.info(message) - if (decision.skipPrompt) + if (canUseRecognition && decision.skipPrompt) return warned = true log.warn(`Git repository is not clean: ${status.repoRoot}`) - for (const entry of decision.warningEntries.slice(0, 10)) { + for (const entry of warningEntries.slice(0, 10)) { log.warn(` ${entry}`) } - if (decision.warningEntries.length > 10) { - log.warn(` ...and ${decision.warningEntries.length - 10} more`) + if (warningEntries.length > 10) { + log.warn(` ...and ${warningEntries.length - 10} more`) } log.info('Clean, commit, or stash those changes before init continues, or continue anyway if you accept the risk.') @@ -2197,7 +2201,7 @@ export function getInitProgressStateForTesting() { function writeInitProgress() { if (globalStepDone <= 0) - return + return false const gitChanges = parseInitGitChanges(globalInitGitChanges) writeFileSync(getTmpObjectPath(), JSON.stringify({ @@ -2219,11 +2223,12 @@ function writeInitProgress() { nodeModulesPath: globalNodeModulesPath, ...(gitChanges ? { gitChanges } : {}), })) + return true } function persistInitProgressSafely() { try { - writeInitProgress() + return writeInitProgress() } catch (error) { try { @@ -2231,6 +2236,7 @@ function persistInitProgressSafely() { } catch { } + return false } } diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index e55a905a8d..0e42bf1ddd 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -1354,14 +1354,17 @@ await tAsync('live git cleanliness gate filters trusted changes without weakenin entries: [' M package.json', '?? src/user.ts'], } - const runGate = async ({ status, snapshot, savedValue, action = 'continue-dirty' }) => { + const runGate = async ({ status, snapshot, savedValue, action = 'continue-dirty', persistSucceeds = true }) => { restoreInitProgressState(4, savedValue) const events = [] await ensureGitRepoCleanBeforeInit(undefined, { getStatus: () => status, captureSnapshot: () => snapshot, isOnlyAllowedAutoTestChange: () => false, - persistProgress: () => events.push({ type: 'persist', state: getInitProgressStateForTesting() }), + persistProgress: () => { + events.push({ type: 'persist', state: getInitProgressStateForTesting() }) + return persistSucceeds + }, log: { error: message => events.push({ type: 'error', message }), info: message => events.push({ type: 'info', message }), @@ -1389,6 +1392,20 @@ await tAsync('live git cleanliness gate filters trusted changes without weakenin 'Resuming with uncommitted changes created by the previous Capgo onboarding run.', ]) + const failedExact = await runGate({ + status: { ...dirtyStatus, entries: [' M package.json'] }, + snapshot: saved, + savedValue: saved, + persistSucceeds: false, + }) + assert.equal(failedExact.events.some(event => event.type === 'prompt'), true) + assert.deepEqual( + failedExact.events.filter(event => event.type === 'warn' && event.message.startsWith(' ')).map(event => event.message), + [' M package.json'], + ) + assert.equal(failedExact.events.some(event => event.type === 'info' && event.message.includes('previous Capgo onboarding run')), false) + assert.equal(failedExact.events.findIndex(event => event.type === 'persist') < failedExact.events.findIndex(event => event.type === 'prompt'), true) + const mixed = await runGate({ status: dirtyStatus, snapshot: current, savedValue: saved }) const mixedPrompt = mixed.events.find(event => event.type === 'prompt') assert.deepEqual(mixedPrompt?.prompt, { @@ -1407,6 +1424,15 @@ await tAsync('live git cleanliness gate filters trusted changes without weakenin assert.equal(mixed.state.gitChanges?.files['src/user.ts'], undefined) assert.equal(mixed.events.some(event => event.type === 'warn' && event.message === 'Continuing with dirty git status. This is not recommended.'), true) + const failedMixed = await runGate({ status: dirtyStatus, snapshot: current, savedValue: saved, persistSucceeds: false }) + assert.deepEqual( + failedMixed.events.filter(event => event.type === 'warn' && event.message.startsWith(' ')).map(event => event.message), + [' M package.json', ' ?? src/user.ts'], + ) + assert.equal(failedMixed.events.some(event => event.type === 'info' && event.message.includes('recognized Capgo')), false) + assert.equal(failedMixed.events.findIndex(event => event.type === 'persist') < failedMixed.events.findIndex(event => event.type === 'prompt'), true) + assert.deepEqual(Object.keys(failedMixed.state.gitChanges?.files ?? {}), ['package.json']) + for (const [name, savedValue] of [ ['missing fingerprints', undefined], ['malformed fingerprints', { ...saved, files: [] }], @@ -1428,6 +1454,16 @@ await tAsync('live git cleanliness gate filters trusted changes without weakenin assert.equal(clean.events.some(event => event.type === 'prompt'), false) assert.deepEqual(clean.events.filter(event => event.type === 'persist').map(event => event.state.gitChanges), [undefined]) assert.deepEqual(clean.state, { stepDone: 4, gitChanges: undefined }) + + const failedClean = await runGate({ + status: { inRepo: true, clean: true, repoRoot, entries: [] }, + snapshot: undefined, + savedValue: saved, + persistSucceeds: false, + }) + assert.equal(failedClean.events.some(event => event.type === 'prompt'), false) + assert.equal(failedClean.events.some(event => event.type === 'persist'), true) + assert.deepEqual(failedClean.state, { stepDone: 4, gitChanges: undefined }) } finally { beginFreshInitProgress() @@ -1451,7 +1487,7 @@ await tAsync('live git cleanliness gate recognizes raw whitespace and Unicode pa getStatus: () => getGitRepoStatus(root), captureSnapshot: () => captureInitGitSnapshot(root), isOnlyAllowedAutoTestChange: () => false, - persistProgress: () => {}, + persistProgress: () => true, log: { error: message => events.push({ type: 'error', message }), info: message => events.push({ type: 'info', message }), From f56585873172dc8920c4345a80b9d8db2c7b7e75 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 20:49:37 +0200 Subject: [PATCH 16/19] docs(cli): align onboarding fingerprint design --- .../2026-08-11-cli-onboarding-git-fingerprints-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md b/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md index 84acbde9de..d63e2fbbbc 100644 --- a/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md +++ b/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md @@ -130,17 +130,17 @@ Keep the production change in the existing onboarding command file unless a pre- - Small wrapper calls only at automatic mutation sites. - A conditional branch inside the existing dirty-Git warning flow. -Do not introduce a service, database change, new prompt, package-manager mapping, or broad onboarding refactor. +Do not introduce a service, database change, new prompt, or broad onboarding refactor. Keep the package-manager knowledge local to the tracker and limited to selecting the manifest and known lockfiles that an automatic install may claim. ## Testing -Use three compact tests rather than a full onboarding test suite: +Use focused guardrail tests rather than a full end-to-end onboarding suite. Keep the original three behavioral groups, and add compact regressions where conservative attribution or destructive-path safety depends on an edge case: 1. **Attribution regression:** `src/main.ts` is dirty before a tracked operation changes `package.json` and a lockfile; only the package files are recorded. 2. **Resume classification:** table-driven cases cover an exact match, changed fingerprint, additional path, and absent saved data. 3. **Conservative failure:** a failed snapshot or automatic operation records no trusted changes. -Include staged, deleted, and mode-changed fingerprints as additional rows in the same classification table; do not create separate fixtures or tests for them. Do not add a full end-to-end onboarding test for this isolated behavior. +Include staged, deleted, and mode-changed fingerprints as rows in the same classification table. Cover raw whitespace/Unicode Git paths, malformed progress, persistence failures, scoped automatic operations, and native-reset path/symlink safety with focused fixtures. Do not add a full end-to-end onboarding test for this isolated behavior. ## Alternatives Considered From bd9e81f3110384c7daec597e76591cec51006d5a Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 21:59:16 +0200 Subject: [PATCH 17/19] fix(cli): harden onboarding git guardrails --- cli/src/init/command.ts | 44 +++++++++--- cli/test/test-init-guardrails.mjs | 108 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 47b031670e..79eeb6d0c4 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -170,6 +170,15 @@ export interface InitGitSnapshotDependencies { isFile: () => boolean } readFile?: (filePath: string) => Buffer + limits?: { + maxEntries: number + maxTotalBytes: number + } +} + +const defaultInitGitSnapshotLimits = { + maxEntries: 10_000, + maxTotalBytes: 256 * 1024 * 1024, } export interface InitNativeResetTarget { @@ -774,6 +783,7 @@ function isGitObjectId(value: string) { } interface InitGitStatusRecord extends InitGitStatusEntry { + submodule: string | null headMode: string | null indexMode: string | null worktreeMode: string | null @@ -804,11 +814,15 @@ function hasValidInitGitStatusMetadata(modes: string[], objectIds: string[]) { && objectIds.every(isGitObjectId) } +function isValidInitGitSubmoduleState(value: string) { + return value === 'N...' || /^S[C.][M.][U.]$/.test(value) +} + function parseInitGitStatusRecord(value: string, originalPath?: string): InitGitStatusRecord | undefined { if (value.startsWith('? ')) { const filePath = value.slice(2) return filePath - ? { status: '??', filePath, display: `?? ${filePath}`, headMode: null, indexMode: null, worktreeMode: null } + ? { status: '??', filePath, display: `?? ${filePath}`, submodule: null, headMode: null, indexMode: null, worktreeMode: null } : undefined } @@ -818,10 +832,10 @@ function parseInitGitStatusRecord(value: string, originalPath?: string): InitGit return undefined const [, rawStatus, submodule, headMode, indexMode, worktreeMode, headObjectId, indexObjectId, filePath] = fields const status = normalizeInitGitStatus(rawStatus) - if (!status || !filePath || submodule !== 'N...' + if (!status || !filePath || !isValidInitGitSubmoduleState(submodule) || !hasValidInitGitStatusMetadata([headMode, indexMode, worktreeMode], [headObjectId, indexObjectId])) return undefined - return { status, filePath, display: `${status} ${filePath}`, headMode, indexMode, worktreeMode } + return { status, filePath, display: `${status} ${filePath}`, submodule, headMode, indexMode, worktreeMode } } if (value.startsWith('2 ')) { @@ -830,13 +844,14 @@ function parseInitGitStatusRecord(value: string, originalPath?: string): InitGit return undefined const [, rawStatus, submodule, headMode, indexMode, worktreeMode, headObjectId, indexObjectId, score, filePath] = fields const status = normalizeInitGitStatus(rawStatus) - if (!status || !filePath || submodule !== 'N...' || !/^[RC][0-9]+$/.test(score) + if (!status || !filePath || !isValidInitGitSubmoduleState(submodule) || !/^[RC][0-9]+$/.test(score) || !hasValidInitGitStatusMetadata([headMode, indexMode, worktreeMode], [headObjectId, indexObjectId])) return undefined return { status, filePath, display: `${status} ${originalPath} -> ${filePath}`, + submodule, headMode, indexMode, worktreeMode, @@ -848,9 +863,9 @@ function parseInitGitStatusRecord(value: string, originalPath?: string): InitGit const fields = splitInitGitStatusFields(value, 11) if (!fields) return undefined - const [, rawStatus, , stageOneMode, stageTwoMode, stageThreeMode, worktreeMode, stageOneObjectId, stageTwoObjectId, stageThreeObjectId, filePath] = fields + const [, rawStatus, submodule, stageOneMode, stageTwoMode, stageThreeMode, worktreeMode, stageOneObjectId, stageTwoObjectId, stageThreeObjectId, filePath] = fields const status = normalizeInitGitStatus(rawStatus) - if (!status || !filePath + if (!status || !filePath || !isValidInitGitSubmoduleState(submodule) || !hasValidInitGitStatusMetadata( [stageOneMode, stageTwoMode, stageThreeMode, worktreeMode], [stageOneObjectId, stageTwoObjectId, stageThreeObjectId], @@ -860,6 +875,7 @@ function parseInitGitStatusRecord(value: string, originalPath?: string): InitGit status, filePath, display: `${status} ${filePath}`, + submodule, headMode: stageOneMode, indexMode: stageTwoMode, worktreeMode, @@ -931,18 +947,24 @@ export function captureInitGitSnapshot( const runStatus = dependencies.runStatus ?? runInitGitStatus const lstat = dependencies.lstat ?? (filePath => lstatSync(filePath, { bigint: true })) const readFile = dependencies.readFile ?? (filePath => readFileSync(filePath)) + const limits = dependencies.limits ?? defaultInitGitSnapshotLimits + if (!Number.isSafeInteger(limits.maxEntries) || limits.maxEntries < 0 + || !Number.isSafeInteger(limits.maxTotalBytes) || limits.maxTotalBytes < 0) + return undefined const statusArgs = getInitGitStatusArgs(normalizedScope) const statusOutput = runStatus(repoRoot, statusArgs) if (!statusOutput) return undefined const entries = parseInitGitStatusOutput(statusOutput) - if (!entries) + if (!entries || entries.length > limits.maxEntries) return undefined const files: Record = {} + const maxTotalBytes = BigInt(limits.maxTotalBytes) + let totalBytes = 0n for (const entry of entries) { - const { status, filePath, headMode, indexMode, worktreeMode } = entry + const { status, filePath, submodule, headMode, indexMode, worktreeMode } = entry const fingerprintKind = getInitGitFingerprintKind(status) - if (!filePath || !fingerprintKind || files[filePath]) + if (!filePath || !fingerprintKind || (submodule !== null && submodule !== 'N...') || files[filePath]) return undefined const absolutePath = path.resolve(repoRoot, filePath) @@ -965,6 +987,9 @@ export function captureInitGitSnapshot( const beforeStats = lstat(absolutePath) if (!beforeStats.isFile()) return undefined + const nextTotalBytes = totalBytes + beforeStats.size + if (beforeStats.size < 0n || nextTotalBytes > maxTotalBytes) + return undefined const contents = readFile(absolutePath) const afterStats = lstat(absolutePath) const mode = Number(afterStats.mode) @@ -977,6 +1002,7 @@ export function captureInitGitSnapshot( sha256: createHash('sha256').update(contents).digest('hex'), mode, } + totalBytes = nextTotalBytes } const finalStatusOutput = runStatus(repoRoot, statusArgs) diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index 0e42bf1ddd..c3c28aeb9d 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -602,6 +602,63 @@ t('git snapshot accepts stable metadata and status for raw whitespace and Unicod }) }) +t('scoped git snapshot rejects an entry count above its hashing limit', () => { + withTempDir((root) => { + initializeGitRepo(root, { + 'native/one.txt': 'initial one\n', + 'native/two.txt': 'initial two\n', + }) + writeFileSync(join(root, 'native', 'one.txt'), 'changed one\n', 'utf8') + writeFileSync(join(root, 'native', 'two.txt'), 'changed two\n', 'utf8') + let readCount = 0 + + const snapshot = captureInitGitSnapshot(root, { directoryPrefixes: ['native'] }, { + limits: { maxEntries: 1, maxTotalBytes: 1024 }, + readFile: (target) => { + readCount += 1 + return readFileSync(target) + }, + }) + + assert.equal(snapshot, undefined) + assert.equal(readCount, 0) + }) +}) + +t('scoped git snapshot rejects total file bytes above its hashing limit', () => { + withTempDir((root) => { + initializeGitRepo(root, { + 'native/one.txt': 'initial one\n', + 'native/two.txt': 'initial two\n', + }) + writeFileSync(join(root, 'native', 'one.txt'), '1234', 'utf8') + writeFileSync(join(root, 'native', 'two.txt'), '5678', 'utf8') + + const snapshot = captureInitGitSnapshot(root, { directoryPrefixes: ['native'] }, { + limits: { maxEntries: 2, maxTotalBytes: 7 }, + }) + + assert.equal(snapshot, undefined) + }) +}) + +t('scoped git snapshot fingerprints a small tree within its hashing limits', () => { + withTempDir((root) => { + initializeGitRepo(root, { + 'native/one.txt': 'initial one\n', + 'native/two.txt': 'initial two\n', + }) + writeFileSync(join(root, 'native', 'one.txt'), '1234', 'utf8') + writeFileSync(join(root, 'native', 'two.txt'), '5678', 'utf8') + + const snapshot = captureInitGitSnapshot(root, { directoryPrefixes: ['native'] }, { + limits: { maxEntries: 2, maxTotalBytes: 8 }, + }) + + assert.deepEqual(Object.keys(snapshot?.files ?? {}).sort(), ['native/one.txt', 'native/two.txt']) + }) +}) + t('git snapshot declines unsupported symlinks and renames', () => { withTempDir((root) => { execSync('git init', { cwd: root, stdio: 'ignore' }) @@ -1528,6 +1585,57 @@ await tAsync('live git cleanliness gate recognizes raw whitespace and Unicode pa }) }) +await tAsync('live git cleanliness gate warns for a dirty submodule without retrying', async () => { + await withTempDirAsync(async (root) => { + const moduleSource = join(root, 'module-source') + const projectRoot = join(root, 'project') + mkdirSync(moduleSource) + mkdirSync(projectRoot) + initializeGitRepo(moduleSource, { 'tracked.txt': 'initial\n' }) + initializeGitRepo(projectRoot, { 'README.md': 'project\n' }) + execSync(`git -c protocol.file.allow=always submodule add "${moduleSource}" vendor/module`, { cwd: projectRoot, stdio: 'ignore' }) + execSync('git commit -m "add submodule"', { cwd: projectRoot, stdio: 'ignore' }) + writeFileSync(join(projectRoot, 'vendor', 'module', 'tracked.txt'), 'dirty\n', 'utf8') + + const status = getGitRepoStatus(projectRoot) + assert.equal(status.error, undefined) + assert.equal(status.clean, false) + assert.deepEqual(status.entries, [' M vendor/module']) + assert.equal(captureInitGitSnapshot(projectRoot), undefined) + + const warnings = [] + let promptCount = 0 + try { + beginFreshInitProgress() + await ensureGitRepoCleanBeforeInit(undefined, { + getStatus: () => getGitRepoStatus(projectRoot), + captureSnapshot: () => captureInitGitSnapshot(projectRoot), + isOnlyAllowedAutoTestChange: () => false, + persistProgress: () => true, + log: { + error: message => warnings.push(`error:${message}`), + info: () => {}, + success: () => {}, + warn: message => warnings.push(message), + }, + selectAction: async () => { + promptCount += 1 + return 'continue-dirty' + }, + cancelAction: async () => {}, + waitForRetry: async () => assert.fail('dirty submodules must not enter the git-error retry path'), + }) + } + finally { + beginFreshInitProgress() + } + + assert.equal(promptCount, 1) + assert.equal(warnings.includes(' M vendor/module'), true) + assert.equal(warnings.some(message => message.startsWith('error:')), false) + }) +}) + t('production onboarding lifecycle transitions clear resumed fingerprint state', () => { const saved = { version: 1, From 0ad364c7b884ce5e708986bcbfcf722981b6b07a Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 12 Aug 2026 08:12:11 +0200 Subject: [PATCH 18/19] fix(cli): address onboarding fingerprint review --- cli/src/init/command.ts | 43 +- cli/test/test-init-guardrails.mjs | 378 +++++++++--------- ...-cli-onboarding-git-fingerprints-design.md | 7 +- 3 files changed, 224 insertions(+), 204 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 79eeb6d0c4..d189a6419a 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -661,24 +661,35 @@ function getInitGitRepoRoot(startDir = cwd()): string | undefined { } } -function createInitGitChangeScope(startDir: string, exactTargets: string[] = [], directoryTargets: string[] = []): InitGitChangeScope { - const repoRoot = getInitGitRepoRoot(startDir) - if (!repoRoot) - return { exactPaths: [], directoryPrefixes: [] } - - const toRepoRelativePath = (target: string) => { - const relativePath = path.relative(repoRoot, path.resolve(startDir, target)).replaceAll(path.sep, '/') - if (!relativePath || relativePath === '..' || relativePath.startsWith('../') || path.posix.isAbsolute(relativePath)) - return undefined - return relativePath +export function createInitGitChangeScope(startDir: string, exactTargets: string[] = [], directoryTargets: string[] = []): InitGitChangeScope { + try { + const lexicalStartDir = path.resolve(startDir) + const resolvedStartDir = realpathSync(lexicalStartDir) + const repoRoot = getInitGitRepoRoot(resolvedStartDir) + if (!repoRoot) + return { exactPaths: [], directoryPrefixes: [] } + + const toRepoRelativePath = (target: string) => { + const lexicalTarget = path.resolve(lexicalStartDir, target) + const resolvedTarget = path.isAbsolute(target) && isPathInside(lexicalStartDir, lexicalTarget, true) + ? path.resolve(resolvedStartDir, path.relative(lexicalStartDir, lexicalTarget)) + : path.resolve(resolvedStartDir, target) + const relativePath = path.relative(repoRoot, resolvedTarget).replaceAll(path.sep, '/') + if (!relativePath || relativePath === '..' || relativePath.startsWith('../') || path.posix.isAbsolute(relativePath)) + return undefined + return relativePath + } + const exactPaths = exactTargets.map(toRepoRelativePath) + const directoryPrefixes = directoryTargets.map(toRepoRelativePath) + if (exactPaths.some(value => !value) || directoryPrefixes.some(value => !value)) + return { exactPaths: [], directoryPrefixes: [] } + return { + exactPaths: exactPaths as string[], + directoryPrefixes: directoryPrefixes as string[], + } } - const exactPaths = exactTargets.map(toRepoRelativePath) - const directoryPrefixes = directoryTargets.map(toRepoRelativePath) - if (exactPaths.some(value => !value) || directoryPrefixes.some(value => !value)) + catch { return { exactPaths: [], directoryPrefixes: [] } - return { - exactPaths: exactPaths as string[], - directoryPrefixes: directoryPrefixes as string[], } } diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index c3c28aeb9d..cbd78092ca 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -10,6 +10,7 @@ import { beginFreshInitProgress, captureInitGitSnapshot, classifyInitGitChanges, + createInitGitChangeScope, declineInitProgressResume, discardResumedInitProgress, ensureGitRepoCleanBeforeInit, @@ -250,6 +251,39 @@ t('native platform availability honors custom Capacitor platform directories', ( }) }) +t('git mutation scopes canonicalize symlinked working directories and fail closed', () => { + withTempDir((root) => { + const repoRoot = join(root, 'repo') + const projectDir = join(repoRoot, 'packages', 'app') + mkdirSync(projectDir, { recursive: true }) + initializeGitRepo(repoRoot, { + 'packages/app/capacitor.config.ts': 'export default {}\n', + 'packages/app/package.json': '{"name":"app"}\n', + }) + + assert.deepEqual(createInitGitChangeScope(join(root, 'missing'), ['package.json']), { + exactPaths: [], + directoryPrefixes: [], + }) + + const linkedProjectDir = join(root, 'linked-app') + if (!tryCreateTestSymlink(projectDir, linkedProjectDir)) + return + + assert.deepEqual(createInitGitChangeScope(linkedProjectDir, [ + 'package.json', + join(linkedProjectDir, 'capacitor.config.ts'), + ], [join(linkedProjectDir, 'ios')]), { + exactPaths: ['packages/app/package.json', 'packages/app/capacitor.config.ts'], + directoryPrefixes: ['packages/app/ios'], + }) + assert.deepEqual(createInitGitChangeScope(linkedProjectDir, [join(root, 'outside.txt')]), { + exactPaths: [], + directoryPrefixes: [], + }) + }) +}) + t('git status helper detects clean and dirty repos', () => { withTempDir((root) => { execSync('git init', { cwd: root, stdio: 'ignore' }) @@ -406,19 +440,9 @@ t('auto css onboarding changes preserve leading css header rules', () => { t('resume allowlist only accepts the exact cli-managed test diff', () => { withTempDir((root) => { - execSync('git init', { cwd: root, stdio: 'ignore' }) - execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) - execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) - // Hermetic against host gitconfig: a global commit.gpgsign=true would make - // the temp-repo commit below fail (no pinentry in non-interactive runs). - execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) - - mkdirSync(join(root, 'src'), { recursive: true }) - const filePath = join(root, 'src', 'main.css') const original = 'body { color: red; }\n' - writeFileSync(filePath, original, 'utf8') - execSync('git add src/main.css', { cwd: root, stdio: 'ignore' }) - execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + initializeGitRepo(root, { 'src/main.css': original }) + const filePath = join(root, 'src', 'main.css') const applied = applyInitAutoTestChange(filePath, original) assert.ok(applied) @@ -444,17 +468,11 @@ t('resume allowlist only accepts the exact cli-managed test diff', () => { t('git fingerprints attribute only files changed during the onboarding mutation window', () => { withTempDir((root) => { - execSync('git init', { cwd: root, stdio: 'ignore' }) - execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) - execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) - execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) - - mkdirSync(join(root, 'src'), { recursive: true }) - writeFileSync(join(root, 'src', 'main.ts'), 'console.log(\'initial\')\n', 'utf8') - writeFileSync(join(root, 'package.json'), '{"name":"example","dependencies":{}}\n', 'utf8') - writeFileSync(join(root, 'package-lock.json'), '{"name":"example","lockfileVersion":3}\n', 'utf8') - execSync('git add src/main.ts package.json package-lock.json', { cwd: root, stdio: 'ignore' }) - execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + initializeGitRepo(root, { + 'src/main.ts': 'console.log(\'initial\')\n', + 'package.json': '{"name":"example","dependencies":{}}\n', + 'package-lock.json': '{"name":"example","lockfileVersion":3}\n', + }) writeFileSync(join(root, 'src', 'main.ts'), 'console.log(\'user edit\')\n', 'utf8') const before = captureInitGitSnapshot(root) @@ -478,13 +496,7 @@ t('git fingerprints attribute only files changed during the onboarding mutation t('git snapshot fingerprints a deleted regular file with null content and mode', () => { withTempDir((root) => { - execSync('git init', { cwd: root, stdio: 'ignore' }) - execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) - execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) - execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) - writeFileSync(join(root, 'deleted.txt'), 'tracked\n', 'utf8') - execSync('git add deleted.txt', { cwd: root, stdio: 'ignore' }) - execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + initializeGitRepo(root, { 'deleted.txt': 'tracked\n' }) unlinkSync(join(root, 'deleted.txt')) @@ -500,15 +512,10 @@ t('git snapshot fingerprints a deleted regular file with null content and mode', t('git snapshot handles mixed tracked, untracked, and staged-deleted paths together', () => { withTempDir((root) => { - execSync('git init', { cwd: root, stdio: 'ignore' }) - execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) - execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) - execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) - mkdirSync(join(root, 'src'), { recursive: true }) - writeFileSync(join(root, 'src', 'tracked file.ts'), 'initial\n', 'utf8') - writeFileSync(join(root, 'old file ü.txt'), 'delete me\n', 'utf8') - execSync('git add .', { cwd: root, stdio: 'ignore' }) - execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + initializeGitRepo(root, { + 'src/tracked file.ts': 'initial\n', + 'old file ü.txt': 'delete me\n', + }) unlinkSync(join(root, 'old file ü.txt')) execSync('git add -u', { cwd: root, stdio: 'ignore' }) @@ -670,13 +677,7 @@ t('git snapshot declines unsupported symlinks and renames', () => { }) withTempDir((root) => { - execSync('git init', { cwd: root, stdio: 'ignore' }) - execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) - execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) - execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) - writeFileSync(join(root, 'before.txt'), 'tracked\n', 'utf8') - execSync('git add before.txt', { cwd: root, stdio: 'ignore' }) - execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + initializeGitRepo(root, { 'before.txt': 'tracked\n' }) execSync('git mv before.txt after.txt', { cwd: root, stdio: 'ignore' }) const status = getGitRepoStatus(root) @@ -688,16 +689,12 @@ t('git snapshot declines unsupported symlinks and renames', () => { t('git snapshot declines a tracked symlink changed into a regular file', () => { withTempDir((root) => { - execSync('git init', { cwd: root, stdio: 'ignore' }) - execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) - execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) - execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + initializeGitRepo(root, { 'target.txt': 'target\n' }) execSync('git config core.symlinks true', { cwd: root, stdio: 'ignore' }) - writeFileSync(join(root, 'target.txt'), 'target\n', 'utf8') if (!tryCreateTestSymlink('target.txt', join(root, 'link.txt'))) return - execSync('git add target.txt link.txt', { cwd: root, stdio: 'ignore' }) - execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + execSync('git add link.txt', { cwd: root, stdio: 'ignore' }) + execSync('git commit -m "add symlink"', { cwd: root, stdio: 'ignore' }) unlinkSync(join(root, 'link.txt')) writeFileSync(join(root, 'link.txt'), 'now regular\n', 'utf8') @@ -708,17 +705,13 @@ t('git snapshot declines a tracked symlink changed into a regular file', () => { t('git snapshot checks tracked index mode when core.symlinks is disabled', () => { withTempDir((root) => { - execSync('git init', { cwd: root, stdio: 'ignore' }) - execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) - execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) - execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + initializeGitRepo(root, { 'target.txt': 'target\n' }) execSync('git config core.symlinks true', { cwd: root, stdio: 'ignore' }) - writeFileSync(join(root, 'target.txt'), 'target\n', 'utf8') const linkPath = join(root, 'link.txt') if (!tryCreateTestSymlink('target.txt', linkPath)) return - execSync('git add target.txt link.txt', { cwd: root, stdio: 'ignore' }) - execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) + execSync('git add link.txt', { cwd: root, stdio: 'ignore' }) + execSync('git commit -m "add symlink"', { cwd: root, stdio: 'ignore' }) execSync('git config core.symlinks false', { cwd: root, stdio: 'ignore' }) unlinkSync(linkPath) execSync('git checkout -- link.txt', { cwd: root, stdio: 'ignore' }) @@ -732,14 +725,8 @@ t('git snapshot checks tracked index mode when core.symlinks is disabled', () => t('git snapshot forces rename detection when repository status config disables it', () => { withTempDir((root) => { - execSync('git init', { cwd: root, stdio: 'ignore' }) - execSync('git config user.email "test@example.com"', { cwd: root, stdio: 'ignore' }) - execSync('git config user.name "Test User"', { cwd: root, stdio: 'ignore' }) - execSync('git config commit.gpgsign false', { cwd: root, stdio: 'ignore' }) + initializeGitRepo(root, { 'before.txt': 'tracked\n' }) execSync('git config status.renames false', { cwd: root, stdio: 'ignore' }) - writeFileSync(join(root, 'before.txt'), 'tracked\n', 'utf8') - execSync('git add before.txt', { cwd: root, stdio: 'ignore' }) - execSync('git commit -m "init"', { cwd: root, stdio: 'ignore' }) execSync('git mv before.txt after.txt', { cwd: root, stdio: 'ignore' }) assert.equal(captureInitGitSnapshot(root), undefined) @@ -1236,42 +1223,45 @@ await tAsync('tracked init mutation persists changed process, structured, and vo restoreInitProgressState(1, undefined) let persistCount = 0 const dependencies = { persistProgress: () => { persistCount += 1 } } - - const processResult = await runTrackedInitMutation(() => { - writeFileSync(join(root, 'package.json'), '{"name":"capgo"}\n', 'utf8') - return { status: 0, error: undefined } - }, { - startDir: root, - scope: { exactPaths: ['package.json'] }, - isSuccess: isSuccessfulInitProcessResult, - }, dependencies) - assert.equal(processResult.status, 0) - assert.equal(persistCount, 1) - - const commandResult = await runTrackedInitMutation(() => { - writeFileSync(join(root, 'capacitor.config.ts'), 'export default { appId: \'com.test.app\' }\n', 'utf8') - return { success: true } - }, { - startDir: root, - scope: { exactPaths: ['capacitor.config.ts'] }, - isSuccess: isSuccessfulInitCommandResult, - }, dependencies) - assert.equal(commandResult.success, true) - - await runTrackedInitMutation(() => { - writeFileSync(join(root, 'src/main.ts'), 'console.log(\'capgo\')\n', 'utf8') - }, { - startDir: root, - scope: { exactPaths: ['src/main.ts'] }, - }, dependencies) - - assert.equal(persistCount, 3) - assert.deepEqual(Object.keys(getInitProgressStateForTesting().gitChanges?.files ?? {}).sort(), [ - 'capacitor.config.ts', - 'package.json', - 'src/main.ts', - ]) - beginFreshInitProgress() + try { + const processResult = await runTrackedInitMutation(() => { + writeFileSync(join(root, 'package.json'), '{"name":"capgo"}\n', 'utf8') + return { status: 0, error: undefined } + }, { + startDir: root, + scope: { exactPaths: ['package.json'] }, + isSuccess: isSuccessfulInitProcessResult, + }, dependencies) + assert.equal(processResult.status, 0) + assert.equal(persistCount, 1) + + const commandResult = await runTrackedInitMutation(() => { + writeFileSync(join(root, 'capacitor.config.ts'), 'export default { appId: \'com.test.app\' }\n', 'utf8') + return { success: true } + }, { + startDir: root, + scope: { exactPaths: ['capacitor.config.ts'] }, + isSuccess: isSuccessfulInitCommandResult, + }, dependencies) + assert.equal(commandResult.success, true) + + await runTrackedInitMutation(() => { + writeFileSync(join(root, 'src/main.ts'), 'console.log(\'capgo\')\n', 'utf8') + }, { + startDir: root, + scope: { exactPaths: ['src/main.ts'] }, + }, dependencies) + + assert.equal(persistCount, 3) + assert.deepEqual(Object.keys(getInitProgressStateForTesting().gitChanges?.files ?? {}).sort(), [ + 'capacitor.config.ts', + 'package.json', + 'src/main.ts', + ]) + } + finally { + beginFreshInitProgress() + } }) }) @@ -1284,43 +1274,46 @@ await tAsync('tracked init mutation leaves global state and persistence unchange const original = structuredClone(saved) let persistCount = 0 const dependencies = { persistProgress: () => { persistCount += 1 } } + try { + const operationError = new Error('operation failed') + restoreInitProgressState(1, saved) + await assert.rejects( + runTrackedInitMutation(() => { + writeFileSync(join(root, 'package.json'), '{"name":"partial-operation"}\n', 'utf8') + throw operationError + }, { startDir: root, scope: { exactPaths: ['package.json'] } }, dependencies), + error => error === operationError, + ) + assert.deepEqual(getInitProgressStateForTesting().gitChanges, original) - const operationError = new Error('operation failed') - restoreInitProgressState(1, saved) - await assert.rejects( - runTrackedInitMutation(() => { - writeFileSync(join(root, 'package.json'), '{"name":"partial-operation"}\n', 'utf8') - throw operationError - }, { startDir: root, scope: { exactPaths: ['package.json'] } }, dependencies), - error => error === operationError, - ) - assert.deepEqual(getInitProgressStateForTesting().gitChanges, original) - - restoreInitProgressState(1, saved) - const failedResult = await runTrackedInitMutation(() => { - writeFileSync(join(root, 'package.json'), '{"name":"partial-result"}\n', 'utf8') - return { success: false } - }, { - startDir: root, - scope: { exactPaths: ['package.json'] }, - isSuccess: isSuccessfulInitCommandResult, - }, dependencies) - assert.deepEqual(failedResult, { success: false }) - assert.deepEqual(getInitProgressStateForTesting().gitChanges, original) - - const predicateError = new Error('predicate failed') - restoreInitProgressState(1, saved) - await assert.rejects( - runTrackedInitMutation(() => ({ success: true }), { + restoreInitProgressState(1, saved) + const failedResult = await runTrackedInitMutation(() => { + writeFileSync(join(root, 'package.json'), '{"name":"partial-result"}\n', 'utf8') + return { success: false } + }, { startDir: root, scope: { exactPaths: ['package.json'] }, - isSuccess: () => { throw predicateError }, - }, dependencies), - error => error === predicateError, - ) - assert.deepEqual(getInitProgressStateForTesting().gitChanges, original) - assert.equal(persistCount, 0) - beginFreshInitProgress() + isSuccess: isSuccessfulInitCommandResult, + }, dependencies) + assert.deepEqual(failedResult, { success: false }) + assert.deepEqual(getInitProgressStateForTesting().gitChanges, original) + + const predicateError = new Error('predicate failed') + restoreInitProgressState(1, saved) + await assert.rejects( + runTrackedInitMutation(() => ({ success: true }), { + startDir: root, + scope: { exactPaths: ['package.json'] }, + isSuccess: () => { throw predicateError }, + }, dependencies), + error => error === predicateError, + ) + assert.deepEqual(getInitProgressStateForTesting().gitChanges, original) + assert.equal(persistCount, 0) + } + finally { + beginFreshInitProgress() + } }) }) @@ -1332,15 +1325,18 @@ await tAsync('zero-delta tracked success keeps global state byte-equal without p assert.ok(saved) restoreInitProgressState(1, saved) let persistCount = 0 + try { + await runTrackedInitMutation(() => undefined, { + startDir: root, + scope: { exactPaths: ['package.json'] }, + }, { persistProgress: () => { persistCount += 1 } }) - await runTrackedInitMutation(() => undefined, { - startDir: root, - scope: { exactPaths: ['package.json'] }, - }, { persistProgress: () => { persistCount += 1 } }) - - assert.deepEqual(getInitProgressStateForTesting().gitChanges, saved) - assert.equal(persistCount, 0) - beginFreshInitProgress() + assert.deepEqual(getInitProgressStateForTesting().gitChanges, saved) + assert.equal(persistCount, 0) + } + finally { + beginFreshInitProgress() + } }) }) @@ -1355,26 +1351,26 @@ t('automatic onboarding mutations use narrow tracking windows and user-controlle } const trackedCallCount = body => body.match(/\brunTrackedInitMutation\s*\(/g)?.length ?? 0 const coverage = [ - ['updater dependency install', 'function runUpdaterInstallCommand(', 'function logUpdaterInstallStateDetails(', 1], - ['Capacitor package installs and init', 'async function maybeRunCapacitorInit(', 'async function runCapacitorPlatformAdd(', 3], - ['Capacitor platform add', 'async function runCapacitorPlatformAdd(', 'async function runCreateAppTemplate(', 1], - ['app-ID config update', 'async function saveAppIdToCapacitorConfig(', 'async function syncPendingAppIdToCapacitorConfig(', 1], - ['pending app-ID config sync', 'async function syncPendingAppIdToCapacitorConfig(', 'function logBrokenIosSync(', 1], - ['native reset delete/add/sync sequence', 'async function runNativeResetCommand(', 'async function waitForReadyConfirmation(', 1], - ['pending-app Capacitor init', 'async function ensureCapacitorProjectReady(', 'async function selectPendingOnboardingApp(', 1], - ['updater config update', 'async function addUpdaterStep(', 'async function addCodeStep(', 1], - ['source-code injection write', 'async function addCodeStep(', 'async function addEncryptionStep(', 1], - ['key creation and encryption sync', 'async function addEncryptionStep(', 'async function streamCommandInInitPanel(', 2], - ['primary automatic native sync', 'async function runBuildAndSyncLoop(', 'async function runProjectBuildAndSync(', 1], - ['updater test write', 'async function addCodeChangeStep(', 'function getSuggestedCleanupBundleVersion(', 1], - ['updater test cleanup write', 'async function maybeOfferAutoTestCleanup(', 'async function uploadStep(', 1], - ['self-host config update', 'export async function initApp(', undefined, 1], + ['updater dependency install', 'function runUpdaterInstallCommand(', 'function logUpdaterInstallStateDetails('], + ['Capacitor package installs and init', 'async function maybeRunCapacitorInit(', 'async function runCapacitorPlatformAdd('], + ['Capacitor platform add', 'async function runCapacitorPlatformAdd(', 'async function runCreateAppTemplate('], + ['app-ID config update', 'async function saveAppIdToCapacitorConfig(', 'async function syncPendingAppIdToCapacitorConfig('], + ['pending app-ID config sync', 'async function syncPendingAppIdToCapacitorConfig(', 'function logBrokenIosSync('], + ['native reset delete/add/sync sequence', 'async function runNativeResetCommand(', 'async function waitForReadyConfirmation('], + ['pending-app Capacitor init', 'async function ensureCapacitorProjectReady(', 'async function selectPendingOnboardingApp('], + ['updater config update', 'async function addUpdaterStep(', 'async function addCodeStep('], + ['source-code injection write', 'async function addCodeStep(', 'async function addEncryptionStep('], + ['key creation and encryption sync', 'async function addEncryptionStep(', 'async function streamCommandInInitPanel('], + ['primary automatic native sync', 'async function runBuildAndSyncLoop(', 'async function runProjectBuildAndSync('], + ['updater test write', 'async function addCodeChangeStep(', 'function getSuggestedCleanupBundleVersion('], + ['updater test cleanup write', 'async function maybeOfferAutoTestCleanup(', 'async function uploadStep('], + ['self-host config update', 'export async function initApp(', undefined], ] - for (const [name, start, end, expectedCalls] of coverage) { + for (const [name, start, end] of coverage) { const body = sourceBetween(start, end) - assert.equal(trackedCallCount(body), expectedCalls, name) - assert.equal(body.match(/\bscope:/g)?.length ?? 0, expectedCalls, `${name} scope`) + assert.ok(trackedCallCount(body) >= 1, name) + assert.match(body, /\brunTrackedInitMutation\s*\([\s\S]*?\bscope:/, `${name} scope`) } assert.equal(trackedCallCount(sourceBetween('async function waitUntilSetupIsDone(', 'async function askForAppName(')), 0, 'manual setup wait') @@ -1648,10 +1644,15 @@ t('production onboarding lifecycle transitions clear resumed fingerprint state', ['discarded resumed onboarding', () => discardResumedInitProgress({ clearCodeDiff: () => {}, clearEncryptionSummary: () => {} })], ] - for (const [name, transition] of transitions) { - restoreInitProgressState(4, saved) - transition() - assert.deepEqual(getInitProgressStateForTesting(), { stepDone: 0, gitChanges: undefined }, name) + try { + for (const [name, transition] of transitions) { + restoreInitProgressState(4, saved) + transition() + assert.deepEqual(getInitProgressStateForTesting(), { stepDone: 0, gitChanges: undefined }, name) + } + } + finally { + beginFreshInitProgress() } }) @@ -1663,27 +1664,32 @@ await tAsync('resume fallback clears fingerprints when post-confirmation restora } let stateBeforeFailure - const resumed = await tryResumeOnboarding('test-key', {}, process.cwd(), {}, undefined, { - readProgress: () => JSON.stringify({ - step_done: 1, - orgId: 'org-id', - orgName: 'Saved org', - gitChanges: saved, - }), - validateAccess: async () => undefined, - selectResume: async () => 'yes', - afterProgressRestored: () => { - stateBeforeFailure = getInitProgressStateForTesting() - throw new Error('post-restore failure') - }, - clearCodeDiff: () => {}, - clearEncryptionSummary: () => {}, - log: { error: () => {}, info: () => {}, warn: () => {} }, - }) + try { + const resumed = await tryResumeOnboarding('test-key', {}, process.cwd(), {}, undefined, { + readProgress: () => JSON.stringify({ + step_done: 1, + orgId: 'org-id', + orgName: 'Saved org', + gitChanges: saved, + }), + validateAccess: async () => undefined, + selectResume: async () => 'yes', + afterProgressRestored: () => { + stateBeforeFailure = getInitProgressStateForTesting() + throw new Error('post-restore failure') + }, + clearCodeDiff: () => {}, + clearEncryptionSummary: () => {}, + log: { error: () => {}, info: () => {}, warn: () => {} }, + }) - assert.deepEqual(stateBeforeFailure, { stepDone: 1, gitChanges: saved }) - assert.equal(resumed, undefined) - assert.deepEqual(getInitProgressStateForTesting(), { stepDone: 0, gitChanges: undefined }) + assert.deepEqual(stateBeforeFailure, { stepDone: 1, gitChanges: saved }) + assert.equal(resumed, undefined) + assert.deepEqual(getInitProgressStateForTesting(), { stepDone: 0, gitChanges: undefined }) + } + finally { + beginFreshInitProgress() + } }) await tAsync('command settlement preserves ENOENT instead of close code -2', async () => { diff --git a/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md b/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md index d63e2fbbbc..f5b13782e9 100644 --- a/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md +++ b/docs/superpowers/specs/2026-08-11-cli-onboarding-git-fingerprints-design.md @@ -51,7 +51,10 @@ The field is optional. A progress file without it retains the current conservati Add one reusable helper that runs an automatic mutation between two Git snapshots. It returns the operation result unchanged and accepts a success predicate for APIs that report failure without throwing: ```ts -await trackInitGitChanges(() => automaticMutation(), result => result.success) +await runTrackedInitMutation( + () => automaticMutation(), + { startDir, scope, isSuccess: result => result.success }, +) ``` The helper: @@ -83,7 +86,7 @@ Do not wrap user prompts, manual-install/manual-edit waits, project build script Keep the existing progress payload and file. Extract its current serialization into a small shared writer used by: - `markStepDone()`, which updates `step_done` and writes progress as today. -- `trackInitGitChanges()`, which writes an updated optional `gitChanges` field while preserving the last completed step. +- `runTrackedInitMutation()`, which writes an updated optional `gitChanges` field while preserving the last completed step. If no resumable progress has been established yet, tracked fingerprints stay in memory and are included by the next normal `markStepDone()` call. This avoids creating a step-zero progress record that the current resume logic would reject. From 72917bcdf1447f2353dd3b301d698dd288486ed4 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 12 Aug 2026 08:42:32 +0200 Subject: [PATCH 19/19] fix(cli): bound onboarding fingerprint reads --- cli/src/init/command.ts | 44 +++++++++++++++++++++++++++---- cli/test/test-init-guardrails.mjs | 30 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index d189a6419a..5fe3604e57 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -1,4 +1,4 @@ -import type { Buffer } from 'node:buffer' +import { Buffer } from 'node:buffer' import type { ExistingOrganizationApp, Options, PendingOnboardingApp } from '../api/app' import type { UploadReporter } from '../bundle/upload' import type { Organization } from '../utils' @@ -6,7 +6,7 @@ import type { SupportedPackageManager } from './command-execution' import type { InitCodeDiff, InitEncryptionPhase, InitEncryptionSummary } from './runtime' import { spawn, spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs' import path, { dirname, join } from 'node:path' import { chdir, cwd, env, exit, platform, stderr, stdin, stdout } from 'node:process' import { canParse, format, increment, lessThan, parse } from '@std/semver' @@ -940,6 +940,39 @@ function initGitFileMetadataMatches( && left.ctimeNs === right.ctimeNs } +function hashInitGitFile( + filePath: string, + expectedSize: bigint, + readFile?: NonNullable, +) { + const expectedBytes = Number(expectedSize) + const hash = createHash('sha256') + if (readFile) { + const contents = readFile(filePath) + return contents.length === expectedBytes ? hash.update(contents).digest('hex') : undefined + } + + const buffer = Buffer.allocUnsafe(64 * 1024) + const file = openSync(filePath, 'r') + try { + let bytesRead = 0 + while (bytesRead < expectedBytes) { + const chunkSize = Math.min(buffer.length, expectedBytes - bytesRead) + const currentRead = readSync(file, buffer, 0, chunkSize, null) + if (currentRead === 0) + return undefined + hash.update(buffer.subarray(0, currentRead)) + bytesRead += currentRead + } + if (readSync(file, buffer, 0, 1, null) !== 0) + return undefined + return hash.digest('hex') + } + finally { + closeSync(file) + } +} + export function captureInitGitSnapshot( startDir = cwd(), scope?: InitGitChangeScope, @@ -957,7 +990,6 @@ export function captureInitGitSnapshot( const runStatus = dependencies.runStatus ?? runInitGitStatus const lstat = dependencies.lstat ?? (filePath => lstatSync(filePath, { bigint: true })) - const readFile = dependencies.readFile ?? (filePath => readFileSync(filePath)) const limits = dependencies.limits ?? defaultInitGitSnapshotLimits if (!Number.isSafeInteger(limits.maxEntries) || limits.maxEntries < 0 || !Number.isSafeInteger(limits.maxTotalBytes) || limits.maxTotalBytes < 0) @@ -1001,7 +1033,9 @@ export function captureInitGitSnapshot( const nextTotalBytes = totalBytes + beforeStats.size if (beforeStats.size < 0n || nextTotalBytes > maxTotalBytes) return undefined - const contents = readFile(absolutePath) + const sha256 = hashInitGitFile(absolutePath, beforeStats.size, dependencies.readFile) + if (!sha256) + return undefined const afterStats = lstat(absolutePath) const mode = Number(afterStats.mode) if (!afterStats.isFile() @@ -1010,7 +1044,7 @@ export function captureInitGitSnapshot( return undefined files[filePath] = { status, - sha256: createHash('sha256').update(contents).digest('hex'), + sha256, mode, } totalBytes = nextTotalBytes diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index cbd78092ca..1322b0490a 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -567,6 +567,36 @@ t('git snapshot rejects file metadata changed during hashing', () => { }) }) +t('git snapshot rejects injected contents that grow beyond the pre-read file size', () => { + withTempDir((root) => { + initializeGitRepo(root, { 'tracked.txt': 'initial\n' }) + const filePath = join(root, 'tracked.txt') + writeFileSync(filePath, 'modified\n', 'utf8') + let readCount = 0 + + const snapshot = captureInitGitSnapshot(root, undefined, { + readFile: (target) => { + readCount += 1 + return Buffer.concat([readFileSync(target), Buffer.from('growth')]) + }, + }) + + assert.equal(snapshot, undefined) + assert.equal(readCount, 1) + }) +}) + +t('git snapshot preserves the sha256 for exact-size file contents', () => { + withTempDir((root) => { + initializeGitRepo(root, { 'tracked.txt': 'initial\n' }) + writeFileSync(join(root, 'tracked.txt'), 'modified\n', 'utf8') + + const snapshot = captureInitGitSnapshot(root) + + assert.equal(snapshot?.files['tracked.txt']?.sha256, '4487e24377581c1a43c957c7700c8b49920de7b8500c05590cee74996ef73f42') + }) +}) + t('git snapshot rejects a changed second status result', () => { withTempDir((root) => { initializeGitRepo(root, { 'tracked file ü.txt': 'initial\n' })