@cellajs/cli 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config/types.ts"],"sourcesContent":["/**\n * Configuration types: the public cella/cella.config.ts surface, its runtime\n * schema, and the internal types the services share.\n */\n\nimport { z } from 'zod';\n\n// Public API for cella/cella.config.ts.\n\nconst PACKAGE_JSON_KEYS = [\n 'dependencies',\n 'devDependencies',\n 'peerDependencies',\n 'optionalDependencies',\n 'scripts',\n 'engines',\n 'packageManager',\n 'overrides',\n 'exports',\n 'pnpm',\n] as const;\n\n/** Valid package.json keys that can merge from upstream */\nexport type PackageJsonKey = (typeof PACKAGE_JSON_KEYS)[number];\n\n/**\n * Settings: all configurable options for the cella CLI.\n * Hover over each property for documentation.\n */\nexport interface SyncSettings {\n /** Upstream repository URL (e.g., 'git@github.com:cellajs/cella.git') */\n upstreamUrl: string;\n\n /**\n * Upstream branch, used for `upstreamTrack: 'branch'` and for GitHub file links.\n * Defaults to 'main'.\n */\n upstreamBranch?: string;\n\n /**\n * How to track upstream cella. Defaults to 'release'.\n * - 'release' (default): upgrade to a published cella release tag (`0.18.1`, with or without `v`). Stable and\n * reviewable: each bump maps to a changelog. Uses the latest release.\n * - 'branch': follow the bleeding-edge tip of `upstreamBranch`. For cella\n * maintainers and forks doing active development on top of unreleased changes.\n */\n upstreamTrack?: 'release' | 'branch';\n\n /**\n * Trunk branch that `pnpm cella upgrade` cuts the temporary branch from and that you open the\n * squash-merge PR into. Defaults to 'main'. Cutting fresh from trunk each cycle keeps every\n * PR limited to that cycle's upstream delta (no accumulation) and `main` linear.\n */\n releaseBase?: string;\n\n /** Which package.json keys merge from upstream (default: ['dependencies', 'devDependencies']). `type` always merges. */\n packageJsonKeys?: PackageJsonKey[];\n\n /** Merge the `packageJsonKeys` of every package.json after the upgrade merge (default: true). */\n mergePackageJson?: boolean;\n\n /** @deprecated Renamed to `packageJsonKeys`. */\n packageJsonSync?: PackageJsonKey[];\n\n /** @deprecated Renamed to `mergePackageJson`. */\n syncWithPackages?: boolean;\n\n /**\n * How to link files in CLI output.\n * - 'commit' (default): Link to the commit that changed the file on GitHub.\n * - 'file': Link to the file in the repo at the upstream branch on GitHub.\n */\n fileLinkMode?: 'commit' | 'file';\n}\n\n/**\n * Configuration for a downstream fork repository that cella interacts with.\n *\n * Cella is the upstream/source-of-truth. For each fork it can:\n * - pull contributions FROM the fork's `pullBranch` (contributions service)\n * - run the normal temporary-branch upgrade flow inside the fork (forks service)\n */\nexport interface ForkConfig {\n /** Display name for the fork (also used as the contrib/<name> branch slug) */\n name: string;\n /** Local path to the fork repository (relative to this config or absolute) */\n localPath: string;\n /**\n * Git remote URL of the fork (e.g. 'git@github.com:org/fork.git').\n * When set, the contributions service fetches the fork's `pullBranch` from this\n * remote (the authoritative committed ref), so the comparison does not depend on\n * the local checkout being up to date. The local clone is still used to read the\n * fork's owned-folder territory when available.\n */\n remoteUrl?: string;\n /** Fork branch that cella pulls contributions from (contributions service) */\n pullBranch: string;\n}\n\n/** User-configurable sync options for cella/cella.config.ts. */\nexport interface CellaCliConfig {\n /** Core sync settings */\n settings: SyncSettings;\n\n /**\n * File overrides for controlling sync behavior per folder/file.\n */\n overrides?: {\n /**\n * Paths the fork fully owns: never synced (existing or new).\n * Exact paths or directory prefixes, not globs: 'bench' matches 'bench/' and\n * everything under it, 'README.md' matches that exact file.\n * Local territory: upstream cannot add, modify, or delete anything under these.\n */\n ignored?: string[];\n\n /**\n * Paths pinned to the fork: the fork copy wins whole-file.\n * Exact paths or directory prefixes, not globs: 'bench' matches 'bench/' and\n * everything under it, 'README.md' matches that exact file.\n * Upstream changes to a pinned file are dropped, not merged: analyze lists them under\n * \"protected but behind upstream\" to adopt by hand.\n * If fork deleted a pinned file, deletion is respected.\n */\n pinned?: string[];\n };\n\n /**\n * Downstream fork repositories that cella interacts with (for upstream template repos).\n * When configured, the 'forks' and 'contributions' services appear in the CLI menu.\n */\n forks?: ForkConfig[];\n}\n\n/**\n * Helper function for defining cella/cella.config.ts with type checking.\n */\nexport function defineConfig(config: CellaCliConfig): CellaCliConfig {\n return config;\n}\n\n/**\n * Strict runtime schema mirroring {@link CellaCliConfig}.\n *\n * The TS interfaces above stay the source of truth (and keep their hover docs for\n * config authors). This schema is what's enforced at load time: `.strict()` rejects\n * unknown keys, so a typo like `ignoredFolders` for `ignored` fails closed; a silent\n * fallback would drop sync protection (tsx does not typecheck the config).\n *\n * The `satisfies z.ZodType<CellaCliConfig>` assertion keeps schema and interface in\n * lockstep: if either drifts (a renamed/removed/mis-typed field), TypeScript errors here.\n */\nexport const cellaConfigSchema = z\n .object({\n settings: z\n .object({\n upstreamUrl: z.string().min(1),\n upstreamBranch: z.string().min(1).optional(),\n upstreamTrack: z.enum(['release', 'branch']).optional(),\n releaseBase: z.string().min(1).optional(),\n packageJsonKeys: z.array(z.enum(PACKAGE_JSON_KEYS)).optional(),\n mergePackageJson: z.boolean().optional(),\n packageJsonSync: z.array(z.enum(PACKAGE_JSON_KEYS)).optional(),\n syncWithPackages: z.boolean().optional(),\n fileLinkMode: z.enum(['commit', 'file']).optional(),\n })\n .strict(),\n overrides: z\n .object({\n ignored: z.array(z.string()).optional(),\n pinned: z.array(z.string()).optional(),\n })\n .strict()\n .optional(),\n forks: z\n .array(\n z\n .object({\n name: z.string().min(1),\n localPath: z.string().min(1),\n remoteUrl: z.string().optional(),\n pullBranch: z.string().min(1),\n })\n .strict(),\n )\n .optional(),\n })\n .strict() satisfies z.ZodType<CellaCliConfig>;\n\n// Internal types for the services.\n\n/** Sync services available in the CLI */\nexport type SyncService = 'analyze' | 'upgrade' | 'align' | 'audit' | 'forks' | 'contributions' | 'stats';\n\n/** Where a command shows in the menu and in `--help`; a gate shows in `--help` only, since scripts and CI run it. */\nexport type CommandGroup = 'develop' | 'security' | 'upstream' | 'gate';\n\nexport type CommandOption = [flags: string, description: string];\n\n/**\n * A command that runs without the upgrade config: no `cella.config.ts`, no override validation. It declares its own\n * options, parses them into its own shape and returns its exit code.\n */\nexport interface ToolCommand<Options = unknown> {\n name: string;\n group: Exclude<CommandGroup, 'upstream'>;\n description: string;\n /** The jobs its first operand names, the default first; the menu asks for one. Empty: the command has one job. */\n jobs: [name: string, description: string][];\n /**\n * The words it takes after the job, in commander's notation such as `[scenario]` or `[paths...]`; none when unset.\n * A command takes these or a passthrough, never both.\n */\n operands?: CommandOption;\n options?: CommandOption[];\n /** What the words after `--` are for, in `--help`. */\n passthrough?: CommandOption;\n parse(input: { job?: string; operands: string[]; opts: Record<string, unknown>; passthrough: string[] }): Options;\n run(forkPath: string, options: Options): Promise<number>;\n}\n\n/** Analyze output scope for interactive and machine-readable flows */\nexport type AnalyzeScope = 'all' | 'risk' | 'protected';\n\n/** Runtime configuration with all resolved values */\nexport interface RuntimeConfig extends CellaCliConfig {\n /** Resolved fork repository path */\n forkPath: string;\n\n /** Full upstream remote ref (e.g., 'cella-upstream/development') */\n upstreamRef: string;\n\n /** Selected sync service */\n service: SyncService;\n\n /** Non-interactive list output (for LLM/agent usage) */\n list: boolean;\n\n /** Machine-readable JSON output (for tooling/agent usage) */\n json: boolean;\n\n /** Scope of files returned in analyze list/json output */\n scope?: AnalyzeScope;\n\n /** Show verbose output */\n verbose: boolean;\n\n /** Pre-selected fork name (skips fork selection prompt) */\n fork?: string;\n\n /**\n * Override upstream tracking for this run (analyze/upgrade), ignoring `settings.upstreamTrack`.\n * 'branch' follows the upstream branch tip (bleeding edge); 'release' uses a release tag.\n */\n track?: 'release' | 'branch';\n\n /**\n * Pin the upstream ref for this run (analyze/upgrade), overriding `track`: a commit sha, a release\n * tag or an upstream branch name. Must be on the upstream branch or in an upstream release.\n */\n ref?: string;\n\n /**\n * Merge with the fork's CLI config as it stands (upgrade service). Without it, a new merge stops\n * before anything changes when upstream changed its own `overrides` or `packageJsonKeys` in ways\n * the fork config does not follow.\n */\n keepConfig?: boolean;\n\n /**\n * Lift every pinned entry for this run (analyze, sync): managed files stay pinned, and the merge\n * uses the full upstream history, so upstream changes that earlier syncs dropped come back too.\n */\n unpinned?: boolean;\n\n /** Count what the current branch changed since it left this ref, by kind of file (stats service) */\n since?: string;\n\n /** Print the `since` table as markdown for a pull request description (stats service) */\n md?: boolean;\n\n /** List every file instead of collapsing large groups (analyze), or every upstream alignment (align) */\n all?: boolean;\n\n /** Print one alignment's README (align service) */\n show?: string;\n\n /** Write one alignment's folder where its codemod can run (align service) */\n extract?: string;\n\n /** Run one alignment's codemod, keeping files identical to upstream as they are (align service) */\n run?: string;\n\n /** Arguments after `--`, passed to the codemod as given (align service, with `run`) */\n runArgs?: string[];\n\n /** Alignment ids to record as handled (align service) */\n mark?: string[];\n}\n\n/** File status after analysis */\nexport type FileStatus =\n | 'identical' // No changes needed\n | 'ahead' // Fork is ahead, protected (pinned/ignored)\n | 'local' // Local file (never existed upstream)\n | 'drifted' // Fork is ahead, NOT protected (at risk)\n | 'behind' // Upstream has changes to sync\n | 'diverged' // Both changed, will merge\n | 'pinned' // Fork wins on conflict\n | 'ignored' // Excluded from sync entirely\n | 'deleted' // Fork deleted, will stay deleted\n | 'renamed'; // Upstream renamed file\n\n/** Statuses the sync applies changes for: a staged sync holds at least one file with one of these. */\nexport const SYNC_APPLIED_STATUSES: readonly FileStatus[] = ['behind', 'diverged', 'renamed', 'ignored', 'pinned'];\n\n/** Commit metadata for a single log entry */\nexport interface CommitRangeEntry {\n hash: string;\n message: string;\n date: string;\n /** Unix epoch seconds of the commit */\n timestamp?: number;\n}\n\n/** Analyzed file with status and metadata */\nexport interface AnalyzedFile {\n path: string;\n status: FileStatus;\n /** True if file is in ignored list */\n isIgnored: boolean;\n /** True if file is in pinned list */\n isPinned: boolean;\n /** True if file exists in fork */\n existsInFork: boolean;\n /** True if file exists in upstream */\n existsInUpstream: boolean;\n /** Relative date when file was last changed (since merge-base) */\n changedAt?: string;\n /** Unix epoch seconds of the last change, for sorting */\n changedTs?: number;\n /** Short commit hash of the last change */\n changedCommit?: string;\n /** For diverged files: relative date of upstream change */\n upstreamChangedAt?: string;\n /** For diverged files: Unix epoch seconds of the upstream change, for sorting */\n upstreamChangedTs?: number;\n /** For diverged files: short commit hash of the upstream change */\n upstreamCommit?: string;\n /** For renamed files: the original path before rename */\n renamedFrom?: string;\n /**\n * Protected (pinned/ignored) files only: both sides changed since the merge-base. The fork\n * side wins whole-file, so upstream's changes to this path are dropped by the sync.\n */\n upstreamChanged?: boolean;\n /** For `upstreamChanged` and `upstreamOnly` files: lines upstream changed since the merge-base (undefined for binary) */\n upstreamChangedLines?: number;\n /**\n * Ignored files only: upstream changed, added or deleted the file since the merge-base and the\n * fork did not touch it. Ignored paths never sync, so without this flag the change stays unseen\n * (new config keys, version bumps). Managed files and generated output (`isGeneratedFile`) are\n * never flagged.\n */\n upstreamOnly?: boolean;\n /**\n * Pinned `ahead` files only: lines present upstream but absent from the fork (undefined for\n * binary). Independent of the sync point, so it also shows upstream content dropped by an\n * earlier sync, or removed on purpose; only a diff can tell.\n */\n upstreamLinesAbsent?: number;\n}\n\n/** Summary counts by status */\nexport interface AnalysisSummary {\n /** Changed package/config files that are handled separately from normal sync categories */\n managed: number;\n identical: number;\n ahead: number;\n local: number;\n drifted: number;\n behind: number;\n diverged: number;\n pinned: number;\n ignored: number;\n deleted: number;\n renamed: number;\n}\n\n/** Merge result from merge-engine */\nexport interface MergeResult {\n success: boolean;\n /** Upstream branch name for file links */\n upstreamBranch?: string;\n /** Resolved concrete upstream ref that was merged (branch ref or release tag ref) */\n upstreamRef?: string;\n /** Upstream release tag synced (when tracking releases) */\n upstreamTag?: string;\n files: AnalyzedFile[];\n summary: AnalysisSummary;\n conflicts: string[];\n /** Upstream GitHub URL base for commit links */\n upstreamGitHubUrl?: string;\n /** Upstream commit info */\n upstreamCommit?: CommitRangeEntry;\n /** Upstream commits since the last sync point */\n upstreamCommitCount?: number;\n /** Files that were auto-merged by git (diverged without remaining conflicts) */\n autoMergedFiles?: string[];\n /**\n * Protected (pinned/ignored) files resolved to the fork side although upstream also changed\n * them: upstream's hunks were dropped, not merged. Review each against upstream.\n */\n protectedConflicts?: string[];\n /** `<merge-base>..<upstream>` range of short SHAs: what upstream changed since the last sync, for the review page. */\n upstreamDiffRange?: string;\n /** How upstream's own CLI config changed since the merge-base, where the fork config does not follow. */\n upstreamOverrides?: UpstreamOverridesReport;\n /** Files flagged `upstreamOnly`, grouped by the `ignored` entry they fall under (config order). */\n ignoredUpstreamChanges?: IgnoredUpstreamGroup[];\n /**\n * Upstream alignments: how many the incoming ref holds, which ones upstream added since the\n * sync point, and the ones open in the fork after the run. A sync opens the arrivals; a dry run\n * leaves them out of `open`.\n */\n alignments?: { total: number; arrived: string[]; open: string[] };\n}\n\n/** Entries of one CLI config list (`pinned`, `ignored` or `packageJsonKeys`) that the fork config does not mirror. */\nexport interface OverrideListChanges {\n /** Entries upstream added since the merge-base that the fork list lacks. */\n added: string[];\n /** Entries upstream removed since the merge-base that the fork list still carries. */\n removed: string[];\n}\n\n/**\n * Upstream CLI config changes the fork should review. The CLI config is managed (never merged),\n * so new upstream `pinned`/`ignored` entries and `packageJsonKeys` only reach the fork by\n * hand. `unreadable` means upstream's config was missing or unparsable at one side (`base` =\n * merge-base, `incoming` = upstream ref), so nothing was compared.\n */\nexport type UpstreamOverridesReport =\n | {\n kind: 'changes';\n pinned: OverrideListChanges;\n ignored: OverrideListChanges;\n packageJsonKeys: OverrideListChanges;\n }\n | { kind: 'unreadable'; side: 'base' | 'incoming' };\n\n/** Upstream-only changes under one `ignored` entry. */\nexport interface IgnoredUpstreamGroup {\n /** The `ignored` entry (configured or derived from an app-owned module) the files fall under. */\n entry: string;\n /** Changed paths under the entry, sorted. */\n paths: string[];\n /** Paths the fork lacks (new upstream files). */\n added: number;\n /** Paths upstream deleted (the fork keeps them). */\n deleted: number;\n}\n"],"mappings":";AAKA,SAAS,SAAS;AAIlB,IAAM,oBAAoB;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAqHO,SAAS,aAAa,QAAwC;AACnE,SAAO;AACT;AAaO,IAAM,oBAAoB,EAC9B,OAAO;AAAA,EACN,UAAU,EACP,OAAO;AAAA,IACN,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IAC7B,gBAAgB,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,IAC3C,eAAe,EAAE,KAAK,CAAC,WAAW,QAAQ,CAAC,EAAE,SAAS;AAAA,IACtD,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,IACxC,iBAAiB,EAAE,MAAM,EAAE,KAAK,iBAAiB,CAAC,EAAE,SAAS;AAAA,IAC7D,kBAAkB,EAAE,QAAQ,EAAE,SAAS;AAAA,IACvC,iBAAiB,EAAE,MAAM,EAAE,KAAK,iBAAiB,CAAC,EAAE,SAAS;AAAA,IAC7D,kBAAkB,EAAE,QAAQ,EAAE,SAAS;AAAA,IACvC,cAAc,EAAE,KAAK,CAAC,UAAU,MAAM,CAAC,EAAE,SAAS;AAAA,EACpD,CAAC,EACA,OAAO;AAAA,EACV,WAAW,EACR,OAAO;AAAA,IACN,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA,IACtC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA,EACvC,CAAC,EACA,OAAO,EACP,SAAS;AAAA,EACZ,OAAO,EACJ;AAAA,IACC,EACG,OAAO;AAAA,MACN,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACtB,WAAW,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MAC3B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,MAC/B,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IAC9B,CAAC,EACA,OAAO;AAAA,EACZ,EACC,SAAS;AACd,CAAC,EACA,OAAO;","names":[]}
1
+ {"version":3,"sources":["../src/config/types.ts"],"sourcesContent":["/**\n * Configuration types: the public cella/cella.config.ts surface, its runtime\n * schema, and the internal types the services share.\n */\n\nimport { z } from 'zod';\n\n// Public API for cella/cella.config.ts.\n\nconst PACKAGE_JSON_KEYS = [\n 'dependencies',\n 'devDependencies',\n 'peerDependencies',\n 'optionalDependencies',\n 'scripts',\n 'engines',\n 'packageManager',\n 'overrides',\n 'exports',\n 'pnpm',\n] as const;\n\n/** Valid package.json keys that can merge from upstream */\nexport type PackageJsonKey = (typeof PACKAGE_JSON_KEYS)[number];\n\n/**\n * Settings: all configurable options for the cella CLI.\n * Hover over each property for documentation.\n */\nexport interface SyncSettings {\n /** Upstream repository URL (e.g., 'git@github.com:cellajs/cella.git') */\n upstreamUrl: string;\n\n /**\n * Upstream branch, used for `upstreamTrack: 'branch'` and for GitHub file links.\n * Defaults to 'main'.\n */\n upstreamBranch?: string;\n\n /**\n * How to track upstream cella. Defaults to 'release'.\n * - 'release' (default): upgrade to a published cella release tag (`0.18.1`, with or without `v`). Stable and\n * reviewable: each bump maps to a changelog. Uses the latest release.\n * - 'branch': follow the bleeding-edge tip of `upstreamBranch`. For cella\n * maintainers and forks doing active development on top of unreleased changes.\n */\n upstreamTrack?: 'release' | 'branch';\n\n /**\n * Trunk branch that `pnpm cella upgrade` cuts the temporary branch from and that you open the\n * squash-merge PR into. Defaults to 'main'. Cutting fresh from trunk each cycle keeps every\n * PR limited to that cycle's upstream delta (no accumulation) and `main` linear.\n */\n releaseBase?: string;\n\n /** Which package.json keys merge from upstream (default: ['dependencies', 'devDependencies']). `type` always merges. */\n packageJsonKeys?: PackageJsonKey[];\n\n /** Merge the `packageJsonKeys` of every package.json after the upgrade merge (default: true). */\n mergePackageJson?: boolean;\n\n /** @deprecated Renamed to `packageJsonKeys`. */\n packageJsonSync?: PackageJsonKey[];\n\n /** @deprecated Renamed to `mergePackageJson`. */\n syncWithPackages?: boolean;\n\n /**\n * How to link files in CLI output.\n * - 'commit' (default): Link to the commit that changed the file on GitHub.\n * - 'file': Link to the file in the repo at the upstream branch on GitHub.\n */\n fileLinkMode?: 'commit' | 'file';\n}\n\n/**\n * Configuration for a downstream fork repository that cella interacts with.\n *\n * Cella is the upstream/source-of-truth. For each fork it can:\n * - pull contributions FROM the fork's `pullBranch` (contributions service)\n * - run the normal temporary-branch upgrade flow inside the fork (forks service)\n */\nexport interface ForkConfig {\n /** Display name for the fork (also used as the contrib/<name> branch slug) */\n name: string;\n /** Local path to the fork repository (relative to this config or absolute) */\n localPath: string;\n /**\n * Git remote URL of the fork (e.g. 'git@github.com:org/fork.git').\n * When set, the contributions service fetches the fork's `pullBranch` from this\n * remote (the authoritative committed ref), so the comparison does not depend on\n * the local checkout being up to date. The local clone is still used to read the\n * fork's owned-folder territory when available.\n */\n remoteUrl?: string;\n /** Fork branch that cella pulls contributions from (contributions service) */\n pullBranch: string;\n}\n\n/** User-configurable sync options for cella/cella.config.ts. */\nexport interface CellaCliConfig {\n /** Core sync settings */\n settings: SyncSettings;\n\n /**\n * File overrides for controlling sync behavior per folder/file.\n */\n overrides?: {\n /**\n * Paths the fork fully owns: never synced (existing or new).\n * Exact paths or directory prefixes, not globs: 'bench' matches 'bench/' and\n * everything under it, 'README.md' matches that exact file.\n * Local territory: upstream cannot add, modify, or delete anything under these.\n */\n ignored?: string[];\n\n /**\n * Paths pinned to the fork: the fork copy wins whole-file.\n * Exact paths or directory prefixes, not globs: 'bench' matches 'bench/' and\n * everything under it, 'README.md' matches that exact file.\n * Upstream changes to a pinned file are dropped, not merged: analyze lists them under\n * \"protected but behind upstream\" to adopt by hand.\n * If fork deleted a pinned file, deletion is respected.\n */\n pinned?: string[];\n };\n\n /**\n * Downstream fork repositories that cella interacts with (for upstream template repos).\n * When configured, the 'forks' and 'contributions' services appear in the CLI menu.\n */\n forks?: ForkConfig[];\n}\n\n/**\n * Helper function for defining cella/cella.config.ts with type checking.\n */\nexport function defineConfig(config: CellaCliConfig): CellaCliConfig {\n return config;\n}\n\n/**\n * Strict runtime schema mirroring {@link CellaCliConfig}.\n *\n * The TS interfaces above stay the source of truth (and keep their hover docs for\n * config authors). This schema is what's enforced at load time: `.strict()` rejects\n * unknown keys, so a typo like `ignoredFolders` for `ignored` fails closed; a silent\n * fallback would drop sync protection (tsx does not typecheck the config).\n *\n * The `satisfies z.ZodType<CellaCliConfig>` assertion keeps schema and interface in\n * lockstep: if either drifts (a renamed/removed/mis-typed field), TypeScript errors here.\n */\nexport const cellaConfigSchema = z\n .object({\n settings: z\n .object({\n upstreamUrl: z.string().min(1),\n upstreamBranch: z.string().min(1).optional(),\n upstreamTrack: z.enum(['release', 'branch']).optional(),\n releaseBase: z.string().min(1).optional(),\n packageJsonKeys: z.array(z.enum(PACKAGE_JSON_KEYS)).optional(),\n mergePackageJson: z.boolean().optional(),\n packageJsonSync: z.array(z.enum(PACKAGE_JSON_KEYS)).optional(),\n syncWithPackages: z.boolean().optional(),\n fileLinkMode: z.enum(['commit', 'file']).optional(),\n })\n .strict(),\n overrides: z\n .object({\n ignored: z.array(z.string()).optional(),\n pinned: z.array(z.string()).optional(),\n })\n .strict()\n .optional(),\n forks: z\n .array(\n z\n .object({\n name: z.string().min(1),\n localPath: z.string().min(1),\n remoteUrl: z.string().optional(),\n pullBranch: z.string().min(1),\n })\n .strict(),\n )\n .optional(),\n })\n .strict() satisfies z.ZodType<CellaCliConfig>;\n\n// Internal types for the services.\n\n/** Sync services available in the CLI */\nexport type SyncService = 'analyze' | 'upgrade' | 'align' | 'audit' | 'forks' | 'contributions' | 'stats';\n\n/** Where a command shows in the menu and in `--help`; a gate shows in `--help` only, since scripts and CI run it. */\nexport type CommandGroup = 'develop' | 'security' | 'upstream' | 'gate';\n\nexport type CommandOption = [flags: string, description: string];\n\n/**\n * A command that runs without the upgrade config: no `cella.config.ts`, no override validation. It declares its own\n * options, parses them into its own shape and returns its exit code.\n */\nexport interface ToolCommand<Options = unknown> {\n name: string;\n group: Exclude<CommandGroup, 'upstream'>;\n description: string;\n /** The jobs its first operand names, the default first; the menu asks for one. Empty: the command has one job. */\n jobs: [name: string, description: string][];\n /**\n * The words it takes after the job, in commander's notation such as `[scenario]` or `[paths...]`; none when unset.\n * A command takes these or a passthrough, never both.\n */\n operands?: CommandOption;\n options?: CommandOption[];\n /** What the words after `--` are for, in `--help`. */\n passthrough?: CommandOption;\n parse(input: { job?: string; operands: string[]; opts: Record<string, unknown>; passthrough: string[] }): Options;\n run(forkPath: string, options: Options): Promise<number>;\n}\n\n/** Analyze output scope for interactive and machine-readable flows */\nexport type AnalyzeScope = 'all' | 'risk' | 'protected';\n\n/** Runtime configuration with all resolved values */\nexport interface RuntimeConfig extends CellaCliConfig {\n /** Resolved fork repository path */\n forkPath: string;\n\n /** Full upstream remote ref (e.g., 'cella-upstream/development') */\n upstreamRef: string;\n\n /** Selected sync service */\n service: SyncService;\n\n /** Non-interactive list output (for LLM/agent usage) */\n list: boolean;\n\n /** Machine-readable JSON output (for tooling/agent usage) */\n json: boolean;\n\n /** Scope of files returned in analyze list/json output */\n scope?: AnalyzeScope;\n\n /** Show verbose output */\n verbose: boolean;\n\n /** Pre-selected fork name (skips fork selection prompt) */\n fork?: string;\n\n /**\n * Override upstream tracking for this run (analyze/upgrade), ignoring `settings.upstreamTrack`.\n * 'branch' follows the upstream branch tip (bleeding edge); 'release' uses a release tag.\n */\n track?: 'release' | 'branch';\n\n /**\n * Pin the upstream ref for this run (analyze/upgrade), overriding `track`: a commit sha, a release\n * tag or an upstream branch name. Must be on the upstream branch or in an upstream release.\n */\n ref?: string;\n\n /**\n * Merge with the fork's CLI config as it stands (upgrade service). Without it, a new merge stops\n * before anything changes when upstream changed its own `overrides` or `packageJsonKeys` in ways\n * the fork config does not follow.\n */\n keepConfig?: boolean;\n\n /**\n * Lift every pinned entry for this run (analyze, sync): managed files stay pinned, and the merge\n * uses the full upstream history, so upstream changes that earlier syncs dropped come back too.\n */\n unpinned?: boolean;\n\n /** Count what the current branch changed since it left this ref, by kind of file (stats service) */\n since?: string;\n\n /** Print the `since` table as markdown for a pull request description (stats service) */\n md?: boolean;\n\n /** List every file instead of collapsing large groups (analyze), or every upstream alignment (align) */\n all?: boolean;\n\n /** Print one alignment's README (align service) */\n show?: string;\n\n /** Write one alignment's folder where its codemod can run (align service) */\n extract?: string;\n\n /** Run one alignment's codemod, keeping files identical to upstream as they are (align service) */\n run?: string;\n\n /** Arguments after `--`, passed to the codemod as given (align service, with `run`) */\n runArgs?: string[];\n\n /** Alignment ids to record as handled (align service) */\n mark?: string[];\n}\n\n/** File status after analysis */\nexport type FileStatus =\n | 'identical' // No changes needed\n | 'ahead' // Fork is ahead, protected (pinned/ignored)\n | 'local' // Local file (never existed upstream)\n | 'drifted' // Fork is ahead, NOT protected (at risk)\n | 'behind' // Upstream has changes to sync\n | 'diverged' // Both changed, will merge\n | 'pinned' // Fork wins on conflict\n | 'ignored' // Excluded from sync entirely\n | 'deleted' // Fork deleted, will stay deleted\n | 'renamed'; // Upstream renamed file\n\n/** Statuses the sync applies changes for: a staged sync holds at least one file with one of these. */\nexport const SYNC_APPLIED_STATUSES: readonly FileStatus[] = ['behind', 'diverged', 'renamed', 'ignored', 'pinned'];\n\n/** Commit metadata for a single log entry */\nexport interface CommitRangeEntry {\n hash: string;\n message: string;\n date: string;\n /** Unix epoch seconds of the commit */\n timestamp?: number;\n}\n\n/** Analyzed file with status and metadata */\nexport interface AnalyzedFile {\n path: string;\n status: FileStatus;\n /** True if file is in ignored list */\n isIgnored: boolean;\n /** True if file is in pinned list */\n isPinned: boolean;\n /** True if file exists in fork */\n existsInFork: boolean;\n /** True if file exists in upstream */\n existsInUpstream: boolean;\n /** Relative date when file was last changed (since merge-base) */\n changedAt?: string;\n /** Unix epoch seconds of the last change, for sorting */\n changedTs?: number;\n /** Short commit hash of the last change */\n changedCommit?: string;\n /** For diverged files: relative date of upstream change */\n upstreamChangedAt?: string;\n /** For diverged files: Unix epoch seconds of the upstream change, for sorting */\n upstreamChangedTs?: number;\n /** For diverged files: short commit hash of the upstream change */\n upstreamCommit?: string;\n /** For renamed files: the original path before rename */\n renamedFrom?: string;\n /**\n * Protected (pinned/ignored) files only: both sides changed since the merge-base. The fork\n * side wins whole-file, so upstream's changes to this path are dropped by the sync.\n */\n upstreamChanged?: boolean;\n /** For `upstreamChanged` and `upstreamOnly` files: lines upstream changed since the merge-base (undefined for binary) */\n upstreamChangedLines?: number;\n /**\n * Ignored files only: upstream changed, added or deleted the file since the merge-base and the\n * fork did not touch it. Ignored paths never sync, so without this flag the change stays unseen\n * (new config keys, version bumps). Managed files and generated output (`isGeneratedFile`) are\n * never flagged.\n */\n upstreamOnly?: boolean;\n /**\n * Pinned `ahead` files only: lines present upstream but absent from the fork (undefined for\n * binary). Independent of the sync point, so it also shows upstream content dropped by an\n * earlier sync, or removed on purpose; only a diff can tell.\n */\n upstreamLinesAbsent?: number;\n}\n\n/** Summary counts by status */\nexport interface AnalysisSummary {\n /** Changed package/config files that are handled separately from normal sync categories */\n managed: number;\n identical: number;\n ahead: number;\n local: number;\n drifted: number;\n behind: number;\n diverged: number;\n pinned: number;\n ignored: number;\n deleted: number;\n renamed: number;\n}\n\n/** Merge result from merge-engine */\nexport interface MergeResult {\n success: boolean;\n /** Upstream branch name for file links */\n upstreamBranch?: string;\n /** Resolved concrete upstream ref that was merged (branch ref or release tag ref) */\n upstreamRef?: string;\n /** Upstream release tag synced (when tracking releases) */\n upstreamTag?: string;\n files: AnalyzedFile[];\n summary: AnalysisSummary;\n conflicts: string[];\n /** Upstream GitHub URL base for commit links */\n upstreamGitHubUrl?: string;\n /** Upstream commit info */\n upstreamCommit?: CommitRangeEntry;\n /** Upstream commits since the last sync point */\n upstreamCommitCount?: number;\n /** Files that were auto-merged by git (diverged without remaining conflicts) */\n autoMergedFiles?: string[];\n /**\n * Protected (pinned/ignored) files resolved to the fork side although upstream also changed\n * them: upstream's hunks were dropped, not merged. Review each against upstream.\n */\n protectedConflicts?: string[];\n /** `<merge-base>..<upstream>` range of short SHAs: what upstream changed since the last sync, for the review page. */\n upstreamDiffRange?: string;\n /** How upstream's own CLI config changed since the merge-base, where the fork config does not follow. */\n upstreamOverrides?: UpstreamOverridesReport;\n /** Files flagged `upstreamOnly`, grouped by the `ignored` entry they fall under (config order). */\n ignoredUpstreamChanges?: IgnoredUpstreamGroup[];\n /**\n * Upstream alignments: how many the incoming ref holds, which ones upstream added since the\n * sync point, and the ones open in the fork after the run. A sync opens the arrivals; a dry run\n * leaves them out of `open`.\n */\n alignments?: { total: number; arrived: string[]; open: string[] };\n}\n\n/** Entries of one CLI config list (`pinned`, `ignored` or `packageJsonKeys`) that the fork config does not mirror. */\nexport interface OverrideListChanges {\n /** Entries upstream added since the merge-base that the fork list lacks. */\n added: string[];\n /** Entries upstream removed since the merge-base that the fork list still carries. */\n removed: string[];\n /** Entries upstream renamed that the fork never carried under either path: informational, never a stop. */\n moved?: { from: string; to: string }[];\n}\n\n/**\n * Upstream CLI config changes the fork should review. The CLI config is managed (never merged),\n * so new upstream `pinned`/`ignored` entries and `packageJsonKeys` only reach the fork by\n * hand. `unreadable` means upstream's config was missing or unparsable at one side (`base` =\n * merge-base, `incoming` = upstream ref), so nothing was compared.\n */\nexport type UpstreamOverridesReport =\n | {\n kind: 'changes';\n pinned: OverrideListChanges;\n ignored: OverrideListChanges;\n packageJsonKeys: OverrideListChanges;\n }\n | { kind: 'unreadable'; side: 'base' | 'incoming' };\n\n/** Upstream-only changes under one `ignored` entry. */\nexport interface IgnoredUpstreamGroup {\n /** The `ignored` entry (configured or derived from an app-owned module) the files fall under. */\n entry: string;\n /** Changed paths under the entry, sorted. */\n paths: string[];\n /** Paths the fork lacks (new upstream files). */\n added: number;\n /** Paths upstream deleted (the fork keeps them). */\n deleted: number;\n}\n"],"mappings":";AAKA,SAAS,SAAS;AAIlB,IAAM,oBAAoB;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAqHO,SAAS,aAAa,QAAwC;AACnE,SAAO;AACT;AAaO,IAAM,oBAAoB,EAC9B,OAAO;AAAA,EACN,UAAU,EACP,OAAO;AAAA,IACN,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IAC7B,gBAAgB,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,IAC3C,eAAe,EAAE,KAAK,CAAC,WAAW,QAAQ,CAAC,EAAE,SAAS;AAAA,IACtD,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,IACxC,iBAAiB,EAAE,MAAM,EAAE,KAAK,iBAAiB,CAAC,EAAE,SAAS;AAAA,IAC7D,kBAAkB,EAAE,QAAQ,EAAE,SAAS;AAAA,IACvC,iBAAiB,EAAE,MAAM,EAAE,KAAK,iBAAiB,CAAC,EAAE,SAAS;AAAA,IAC7D,kBAAkB,EAAE,QAAQ,EAAE,SAAS;AAAA,IACvC,cAAc,EAAE,KAAK,CAAC,UAAU,MAAM,CAAC,EAAE,SAAS;AAAA,EACpD,CAAC,EACA,OAAO;AAAA,EACV,WAAW,EACR,OAAO;AAAA,IACN,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA,IACtC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA,EACvC,CAAC,EACA,OAAO,EACP,SAAS;AAAA,EACZ,OAAO,EACJ;AAAA,IACC,EACG,OAAO;AAAA,MACN,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACtB,WAAW,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MAC3B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,MAC/B,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IAC9B,CAAC,EACA,OAAO;AAAA,EACZ,EACC,SAAS;AACd,CAAC,EACA,OAAO;","names":[]}
package/dist/index.js CHANGED
@@ -234,7 +234,7 @@ import process6 from "process";
234
234
  // package.json
235
235
  var package_default = {
236
236
  name: "@cellajs/cli",
237
- version: "0.6.6",
237
+ version: "0.6.7",
238
238
  private: false,
239
239
  license: "MIT",
240
240
  description: "CLI for managing your monorepo and upgrading it with upstream Cella.",
@@ -1319,6 +1319,9 @@ function printUpstreamOverrideChanges(result) {
1319
1319
  for (const entry of report[list2].removed) {
1320
1320
  lines.push(` ${colors_default.red("\u2212")} ${list2}: ${entry}${colors_default.dim(" \xB7 dropped upstream, still in your config")}`);
1321
1321
  }
1322
+ for (const { from, to } of report[list2].moved ?? []) {
1323
+ lines.push(colors_default.dim(` ~ ${list2}: ${from} \u2192 ${to} \xB7 renamed upstream, your config never had it`));
1324
+ }
1322
1325
  }
1323
1326
  console.info(
1324
1327
  `${warningMark} ${colors_default.yellow(`upstream changed its CLI config \xB7 ${plural(lines.length, "entry", "entries")} to review`)}`
@@ -1403,8 +1406,8 @@ async function resolveTestbed(forkPath, data = "seed") {
1403
1406
  throw new Error("shared/src/local-database.ts names no testbed database: upgrade the app from cella first");
1404
1407
  }
1405
1408
  const composeEnv = databases.composeEnv();
1406
- const envFile = join4(forkPath, "backend/.env");
1407
- const fileEnv = existsSync4(envFile) ? parseEnv(readFileSync2(envFile, "utf8")) : {};
1409
+ const envFile = [join4(forkPath, ".env"), join4(forkPath, "backend/.env")].find((path) => existsSync4(path));
1410
+ const fileEnv = envFile ? parseEnv(readFileSync2(envFile, "utf8")) : {};
1408
1411
  const portOffset = facts.devPortOffset + PORT_SHIFT;
1409
1412
  return {
1410
1413
  forkPath,
@@ -1413,7 +1416,7 @@ async function resolveTestbed(forkPath, data = "seed") {
1413
1416
  databasePort,
1414
1417
  portOffset,
1415
1418
  env: {
1416
- // The seed creates the admin that backend/.env names, and a command signs in as that user
1419
+ // The seed creates the admin that the env file names, and a command signs in as that user
1417
1420
  ...fileEnv.ADMIN_EMAIL ? { ADMIN_EMAIL: fileEnv.ADMIN_EMAIL } : {},
1418
1421
  ...getEnvSnapshot(),
1419
1422
  ...data === "showcase" ? { ADMIN_EMAIL: showcaseAdmin(forkPath) } : {},
@@ -3324,7 +3327,7 @@ var seconds = (since) => `${((Date.now() - since) / 1e3).toFixed(1)}s`;
3324
3327
  async function runAudit(run, scope, testbed, options) {
3325
3328
  const startedAt = Date.now();
3326
3329
  const email = options.email ?? testbed.env.ADMIN_EMAIL;
3327
- if (!email) throw new Error("no audit user: pass --email, or set ADMIN_EMAIL in backend/.env");
3330
+ if (!email) throw new Error("no audit user: pass --email, or set ADMIN_EMAIL in .env");
3328
3331
  const builtAt = statSync(join10(run.forkPath, "frontend/dist/index.html"), { throwIfNoEntry: false })?.mtime;
3329
3332
  printStep("auditing", `${testbed.frontendUrl}, frontend build of ${builtAt?.toLocaleString() ?? "unknown date"}`);
3330
3333
  const states = options.states.length > 0 ? scope.states.filter((state) => options.states.includes(state.id)) : scope.states;
@@ -3722,7 +3725,7 @@ var a11yCommand = {
3722
3725
  options: [
3723
3726
  ["--states <ids>", "audit: these states only, comma separated; writes ledger-partial.json, never the ledger"],
3724
3727
  ["--workers <n>", "audit: states audited at the same time (default 4)"],
3725
- ["--email <address>", "audit: the audit user (default ADMIN_EMAIL in backend/.env)"],
3728
+ ["--email <address>", "audit: the audit user (default ADMIN_EMAIL in .env)"],
3726
3729
  ["--publish", "report: put the PDF where the app serves it and print what the statement needs"],
3727
3730
  ["--draft", "report: publish while rows still need a person; the statement calls the results provisional"],
3728
3731
  ["--file <path>", "decide: a JSON list of decisions"],
@@ -5406,7 +5409,7 @@ async function spinnersGone(page, quiet = 1e3, cap = 45e3) {
5406
5409
  var hasMfaScript = (forkPath) => Boolean(JSON.parse(readFileSync13(join21(forkPath, "backend/package.json"), "utf8")).scripts?.["showcase:mfa"]);
5407
5410
  async function shoot(forkPath, list2, testbed, options) {
5408
5411
  const email = options.email ?? testbed.env.ADMIN_EMAIL;
5409
- if (!email) throw new Error("no shot user: pass --email, or set ADMIN_EMAIL in backend/.env");
5412
+ if (!email) throw new Error("no shot user: pass --email, or set ADMIN_EMAIL in .env");
5410
5413
  const todo = options.ids.length > 0 ? list2.shots.filter((shot2) => options.ids.includes(shot2.id)) : list2.shots;
5411
5414
  const user = mintSession(email, { forkPath, env: testbed.env });
5412
5415
  const scratch = mkdtempSync(join21(tmpdir(), "cella-screenshots-"));
@@ -5535,7 +5538,7 @@ var screenshotsCommand = {
5535
5538
  operands: ["[ids...]", "the shots to take (default: all)"],
5536
5539
  options: [
5537
5540
  ["--out <dir>", "write into this folder, a trial run that leaves the working tree alone"],
5538
- ["--email <address>", "the shot user (default the showcase admin, else ADMIN_EMAIL in backend/.env)"]
5541
+ ["--email <address>", "the shot user (default the showcase admin, else ADMIN_EMAIL in .env)"]
5539
5542
  ],
5540
5543
  parse: ({ operands, opts }) => ({
5541
5544
  ids: operands,
@@ -7052,13 +7055,27 @@ function parseOverrideLists(source) {
7052
7055
  }
7053
7056
  }
7054
7057
  function diffOverrideLists(base, incoming, fork) {
7058
+ const basenameOf2 = (entry) => entry.slice(entry.lastIndexOf("/") + 1);
7055
7059
  const diff = (key) => {
7056
7060
  const baseSet = new Set(base[key].map(normalizeEntry).filter(Boolean));
7057
7061
  const incomingSet = new Set(incoming[key].map(normalizeEntry).filter(Boolean));
7058
7062
  const forkEntries = fork[key].map(normalizeEntry).filter(Boolean);
7063
+ const newEntries = [...incomingSet].filter((entry) => !baseSet.has(entry) && !isUnderAnyFolder(entry, forkEntries));
7064
+ const renameSources = [...baseSet].filter(
7065
+ (entry) => !incomingSet.has(entry) && !forkEntries.includes(entry) && !isUnderAnyFolder(entry, forkEntries)
7066
+ );
7067
+ const moved = [];
7068
+ const added = newEntries.filter((entry) => {
7069
+ const sourceAt = renameSources.findIndex((source) => basenameOf2(source) === basenameOf2(entry));
7070
+ if (sourceAt === -1) return true;
7071
+ moved.push({ from: renameSources[sourceAt], to: entry });
7072
+ renameSources.splice(sourceAt, 1);
7073
+ return false;
7074
+ });
7059
7075
  return {
7060
- added: [...incomingSet].filter((entry) => !baseSet.has(entry) && !isUnderAnyFolder(entry, forkEntries)),
7061
- removed: [...baseSet].filter((entry) => !incomingSet.has(entry) && forkEntries.includes(entry))
7076
+ added,
7077
+ removed: [...baseSet].filter((entry) => !incomingSet.has(entry) && forkEntries.includes(entry)),
7078
+ ...moved.length > 0 ? { moved } : {}
7062
7079
  };
7063
7080
  };
7064
7081
  const diffKeys = () => {
@@ -7086,9 +7103,17 @@ async function compareUpstreamOverrides(repoPath, mergeBaseRef, incomingRef, for
7086
7103
  packageJsonKeys: forkConfig.settings.packageJsonKeys ?? DEFAULT_PACKAGE_JSON_KEYS
7087
7104
  };
7088
7105
  const changes = diffOverrideLists(base, incoming, fork);
7089
- const count3 = Object.values(changes).reduce((n, list2) => n + list2.added.length + list2.removed.length, 0);
7106
+ const count3 = Object.values(changes).reduce(
7107
+ (n, list2) => n + list2.added.length + list2.removed.length + (list2.moved?.length ?? 0),
7108
+ 0
7109
+ );
7090
7110
  return count3 > 0 ? { kind: "changes", ...changes } : void 0;
7091
7111
  }
7112
+ function hasBlockingOverrideChanges(report) {
7113
+ if (report.kind !== "changes") return false;
7114
+ const lists = [report.pinned, report.ignored, report.packageJsonKeys];
7115
+ return lists.some((list2) => list2.added.length > 0 || list2.removed.length > 0);
7116
+ }
7092
7117
 
7093
7118
  // src/utils/overrides.ts
7094
7119
  function normalizeEntry(entry) {
@@ -7137,8 +7162,9 @@ async function validateOverrides(config, forkPath, upstreamRef) {
7137
7162
  const path = normalizeEntry(entry);
7138
7163
  const ignoredEntry = ignored.find((folder) => isUnderAnyFolder(path, [folder]));
7139
7164
  if (/[*?]/.test(entry)) warnings.push(`pin uses glob (not supported, use a path or folder): ${entry}`);
7140
- else if (!existsSync21(join28(forkPath, path))) warnings.push(`pin not found: ${entry}`);
7141
- else if (ignoredEntry) warnings.push(`pin does nothing, '${normalizeEntry(ignoredEntry)}' is ignored: ${entry}`);
7165
+ else if (!existsSync21(join28(forkPath, path))) {
7166
+ if (!upstream || !await fileExistsAtRef(forkPath, upstream, path)) warnings.push(`pin not found: ${entry}`);
7167
+ } else if (ignoredEntry) warnings.push(`pin does nothing, '${normalizeEntry(ignoredEntry)}' is ignored: ${entry}`);
7142
7168
  else if (!upstream) continue;
7143
7169
  else if (await fileExistsAtRef(forkPath, upstream, path)) pinsUpstreamHas.push(entry);
7144
7170
  else warnings.push(`pin does nothing, upstream no longer has it: ${entry}`);
@@ -7159,6 +7185,7 @@ async function validateOverrides(config, forkPath, upstreamRef) {
7159
7185
  warnings.push(`ignored entry uses glob (not supported, use a path or folder): ${entry}`);
7160
7186
  } else if (!existsSync21(join28(forkPath, path))) {
7161
7187
  if (upstreamRef && await fileExistsAtRef(forkPath, upstreamRef, path)) continue;
7188
+ if (isGeneratedFile(path)) continue;
7162
7189
  warnings.push(`ignored entry not found: ${entry}`);
7163
7190
  }
7164
7191
  }
@@ -7489,6 +7516,109 @@ import { existsSync as existsSync22 } from "fs";
7489
7516
  import { rm as rm2 } from "fs/promises";
7490
7517
  import { join as join29 } from "path";
7491
7518
 
7519
+ // src/utils/audit-utils.ts
7520
+ import { execFile as execFile2 } from "child_process";
7521
+ import { promisify as promisify2 } from "util";
7522
+ var execFileAsync2 = promisify2(execFile2);
7523
+ function outdatedPackageName(key) {
7524
+ const name = key.split(" ")[0];
7525
+ const at = name.indexOf("@", 1);
7526
+ return at === -1 ? name : name.slice(0, at);
7527
+ }
7528
+ function getRepoUrl(repositoryUrl) {
7529
+ if (!repositoryUrl) return null;
7530
+ let url = repositoryUrl;
7531
+ if (url.startsWith("git+")) {
7532
+ url = url.slice(4);
7533
+ }
7534
+ if (url.endsWith(".git")) {
7535
+ url = url.slice(0, -4);
7536
+ }
7537
+ if (url.startsWith("git://")) {
7538
+ url = url.replace("git://", "https://");
7539
+ }
7540
+ return url;
7541
+ }
7542
+ function isGitHubRepoUrl(repoUrl) {
7543
+ if (!repoUrl) return false;
7544
+ try {
7545
+ const parsed2 = new URL(repoUrl);
7546
+ return parsed2.hostname === "github.com" || parsed2.hostname === "www.github.com";
7547
+ } catch {
7548
+ return false;
7549
+ }
7550
+ }
7551
+ function getReleasesUrl(repoUrl) {
7552
+ if (!isGitHubRepoUrl(repoUrl)) return null;
7553
+ return `${repoUrl}/releases`;
7554
+ }
7555
+ async function runPnpm(args, cwd) {
7556
+ try {
7557
+ const { stdout, stderr } = await execFileAsync2("pnpm", args, {
7558
+ encoding: "utf8",
7559
+ maxBuffer: 10 * 1024 * 1024,
7560
+ cwd
7561
+ });
7562
+ return { ok: true, stdout, stderr };
7563
+ } catch (error) {
7564
+ const { stdout = "", stderr = "" } = error;
7565
+ return { ok: false, stdout, stderr: stdout || stderr ? stderr : String(error) };
7566
+ }
7567
+ }
7568
+ async function runPnpmJson(args, cwd) {
7569
+ try {
7570
+ return JSON.parse((await runPnpm([...args, "--loglevel=error"], cwd)).stdout);
7571
+ } catch {
7572
+ return null;
7573
+ }
7574
+ }
7575
+ async function runPnpmAudit(cwd) {
7576
+ const result = await runPnpmJson(["audit", "--json"], cwd);
7577
+ if (!result || typeof result.advisories !== "object" || result.advisories === null) return null;
7578
+ return result;
7579
+ }
7580
+ async function readReleaseAgeMinutes(cwd) {
7581
+ const minutes = Number.parseInt((await runPnpm(["config", "get", "minimumReleaseAge"], cwd)).stdout, 10);
7582
+ return Number.isNaN(minutes) ? 0 : minutes;
7583
+ }
7584
+ async function fetchPublishTimes(name, cwd) {
7585
+ const times = await runPnpmJson(["view", name, "time", "--json"], cwd);
7586
+ return times && typeof times === "object" && !("error" in times) ? times : null;
7587
+ }
7588
+ function editJsonText(content, change) {
7589
+ const json = JSON.parse(content);
7590
+ change(json);
7591
+ const indent = content.match(/^[ \t]+(?=")/m)?.[0] ?? " ";
7592
+ return `${JSON.stringify(json, null, indent)}${content.endsWith("\n") ? "\n" : ""}`;
7593
+ }
7594
+ function rewriteSpecifier(spec, latest) {
7595
+ const match = spec.match(/^([\^~]?)\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?$/);
7596
+ return match ? `${match[1]}${latest}` : null;
7597
+ }
7598
+ var vulnIcons = {
7599
+ critical: colors_default.red("\u25CF"),
7600
+ high: colors_default.red("\u25CF"),
7601
+ moderate: colors_default.yellow("\u25CF"),
7602
+ low: colors_default.blue("\u25CF"),
7603
+ info: colors_default.gray("\u25CF")
7604
+ };
7605
+ function getVulnIcon(severity) {
7606
+ return vulnIcons[severity] ?? colors_default.gray("\u25CF");
7607
+ }
7608
+ function middleTruncate(str2, maxLen) {
7609
+ if (str2.length <= maxLen) return str2;
7610
+ const ellipsis = "\u2026";
7611
+ const charsToShow = maxLen - ellipsis.length;
7612
+ const frontChars = Math.ceil(charsToShow / 2);
7613
+ const backChars = Math.floor(charsToShow / 2);
7614
+ return str2.slice(0, frontChars) + ellipsis + str2.slice(-backChars);
7615
+ }
7616
+ function formatDependents(dependents) {
7617
+ if (dependents.length === 0) return "";
7618
+ if (dependents.length === 1) return dependents[0];
7619
+ return `${dependents[0]} +${dependents.length - 1}`;
7620
+ }
7621
+
7492
7622
  // src/utils/versions.ts
7493
7623
  function parseComparableVersion(version) {
7494
7624
  const trimmed = version.trim();
@@ -7552,6 +7682,10 @@ async function readUpstreamCliRange(repoPath, ref) {
7552
7682
  return null;
7553
7683
  }
7554
7684
  }
7685
+ function releaseAgeExcludeHint(range) {
7686
+ const version = range.match(/\d+\S*/)?.[0] ?? range;
7687
+ return ` pnpm delays fresh releases (minimumReleaseAge): if the install is refused, add '${CLI_PACKAGE}@${version}' to minimumReleaseAgeExclude in pnpm-workspace.yaml.`;
7688
+ }
7555
7689
  function cliVersionMismatch(running, range) {
7556
7690
  if (/^\s*(<|>(?!=))/.test(range)) return null;
7557
7691
  const required = parseComparableVersion(range);
@@ -7567,6 +7701,81 @@ function cliVersionMismatch(running, range) {
7567
7701
  function isReportableIgnored(filePath) {
7568
7702
  return !isManagedFile(filePath) && !isGeneratedFile(filePath);
7569
7703
  }
7704
+ var basenameOf = (path) => path.slice(path.lastIndexOf("/") + 1);
7705
+ var dirnameOf = (path) => path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
7706
+ function directoryMoves(changes) {
7707
+ const votes = /* @__PURE__ */ new Map();
7708
+ for (const [newPath, change] of changes) {
7709
+ if (change.status !== "R" || !change.oldPath || basenameOf(change.oldPath) !== basenameOf(newPath)) continue;
7710
+ const from = dirnameOf(change.oldPath);
7711
+ const counts = votes.get(from) ?? /* @__PURE__ */ new Map();
7712
+ counts.set(dirnameOf(newPath), (counts.get(dirnameOf(newPath)) ?? 0) + 1);
7713
+ votes.set(from, counts);
7714
+ }
7715
+ const moves = /* @__PURE__ */ new Map();
7716
+ for (const [from, counts] of votes) {
7717
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
7718
+ if (ranked.length === 1 || ranked[0][1] > ranked[1][1]) moves.set(from, ranked[0][0]);
7719
+ }
7720
+ return moves;
7721
+ }
7722
+ var joinDir = (dir, name) => dir ? `${dir}/${name}` : name;
7723
+ function repairIncomingRenames(changes, context) {
7724
+ const dirMoves = directoryMoves(changes);
7725
+ const snapshot = new Map(changes);
7726
+ const crossed = [];
7727
+ const keptTargets = /* @__PURE__ */ new Set();
7728
+ const renameSources = /* @__PURE__ */ new Set();
7729
+ for (const [newPath, change] of changes) {
7730
+ if (change.status !== "R" || !change.oldPath) continue;
7731
+ renameSources.add(change.oldPath);
7732
+ if (basenameOf(change.oldPath) === basenameOf(newPath)) keptTargets.add(newPath);
7733
+ else crossed.push({ from: newPath, oldPath: change.oldPath, change });
7734
+ }
7735
+ const desired = /* @__PURE__ */ new Map();
7736
+ for (const pair of crossed) {
7737
+ const name = basenameOf(pair.oldPath);
7738
+ const mappedDir = dirMoves.get(dirnameOf(pair.oldPath));
7739
+ const candidates = [
7740
+ ...mappedDir === void 0 ? [] : [joinDir(mappedDir, name)],
7741
+ joinDir(dirnameOf(pair.from), name)
7742
+ ];
7743
+ const target = candidates.find(
7744
+ (path) => context.incomingPaths.has(path) && !context.basePaths.has(path) && !keptTargets.has(path)
7745
+ );
7746
+ desired.set(pair.from, target ?? pair.from);
7747
+ }
7748
+ const targets = [...desired.values()];
7749
+ if (new Set(targets).size === targets.length) {
7750
+ const targetSet = new Set(targets);
7751
+ for (const pair of crossed) {
7752
+ const target = desired.get(pair.from);
7753
+ if (target === pair.from) continue;
7754
+ changes.set(target, {
7755
+ status: "R",
7756
+ oldPath: pair.oldPath,
7757
+ baseHash: pair.change.baseHash,
7758
+ targetHash: snapshot.get(target)?.targetHash ?? null
7759
+ });
7760
+ }
7761
+ for (const pair of crossed) {
7762
+ if (desired.get(pair.from) === pair.from || targetSet.has(pair.from)) continue;
7763
+ changes.set(pair.from, { status: "A", baseHash: null, targetHash: snapshot.get(pair.from)?.targetHash ?? null });
7764
+ }
7765
+ }
7766
+ for (const oldPath of context.localPaths) {
7767
+ if (context.incomingPaths.has(oldPath) || !context.basePaths.has(oldPath)) continue;
7768
+ if (renameSources.has(oldPath) || !context.isProtected(oldPath)) continue;
7769
+ const mappedDir = dirMoves.get(dirnameOf(oldPath));
7770
+ if (mappedDir === void 0) continue;
7771
+ const target = joinDir(mappedDir, basenameOf(oldPath));
7772
+ if (!context.incomingPaths.has(target) || context.basePaths.has(target) || changes.get(target)?.status !== "A")
7773
+ continue;
7774
+ changes.set(target, { status: "R", oldPath, baseHash: null, targetHash: changes.get(target)?.targetHash ?? null });
7775
+ changes.delete(oldPath);
7776
+ renameSources.add(oldPath);
7777
+ }
7778
+ }
7570
7779
  async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predicates, onProgress) {
7571
7780
  onProgress?.("collecting file hashes (batch)...");
7572
7781
  const [forkHashes, upstreamHashes, baseHashes] = await Promise.all([
@@ -7576,6 +7785,12 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
7576
7785
  ]);
7577
7786
  const upstreamChanges = await getFileChanges(repoPath, mergeBaseRef, incomingRef);
7578
7787
  const forkChanges = await getFileChanges(repoPath, mergeBaseRef, localRef);
7788
+ repairIncomingRenames(upstreamChanges, {
7789
+ basePaths: new Set(baseHashes.keys()),
7790
+ incomingPaths: new Set(upstreamHashes.keys()),
7791
+ localPaths: new Set(forkHashes.keys()),
7792
+ isProtected: (path) => predicates.isIgnored(path) || predicates.isPinned(path)
7793
+ });
7579
7794
  const upstreamRenames = /* @__PURE__ */ new Map();
7580
7795
  for (const [newPath, change] of upstreamChanges) {
7581
7796
  if (change.status === "R" && change.oldPath) {
@@ -7660,7 +7875,7 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
7660
7875
  status = "ignored";
7661
7876
  } else if (!inFork && inUpstream) {
7662
7877
  const locallyDeleted = baseHash !== null && upstreamHash === baseHash;
7663
- status = fileIsPinned || locallyDeleted ? "deleted" : "behind";
7878
+ status = fileIsPinned && baseHash !== null || locallyDeleted ? "deleted" : "behind";
7664
7879
  } else if (inFork && !inUpstream) {
7665
7880
  if (baseHash !== null) {
7666
7881
  status = fileIsPinned ? "ahead" : "behind";
@@ -7772,6 +7987,9 @@ async function readOriginGitHubUrl(forkPath) {
7772
7987
  function isProtectedFile(file) {
7773
7988
  return file.isIgnored || file.isPinned;
7774
7989
  }
7990
+ function isPinnedFirstArrival(file) {
7991
+ return file.isPinned && !file.isIgnored && !file.existsInFork && file.existsInUpstream && file.status === "behind";
7992
+ }
7775
7993
  function syncPredicates(config) {
7776
7994
  return {
7777
7995
  isIgnored: (path) => isIgnored(path, config),
@@ -7792,7 +8010,7 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
7792
8010
  const batchRemovePaths = [];
7793
8011
  const movedProtected = [];
7794
8012
  for (const file of analyzedFiles) {
7795
- if (file.status === "identical" || file.status === "ahead") continue;
8013
+ if (file.status === "identical" || file.status === "ahead" || isPinnedFirstArrival(file)) continue;
7796
8014
  if (file.status === "pinned" && file.renamedFrom) movedProtected.push({ from: file.renamedFrom, to: file.path });
7797
8015
  else if (file.isIgnored || file.isPinned) {
7798
8016
  (file.existsInFork ? batchRestorePaths : batchRemovePaths).push(file.path);
@@ -7817,14 +8035,17 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
7817
8035
  }
7818
8036
  for (const file of analyzedFiles) {
7819
8037
  const { path: filePath, status, existsInFork } = file;
7820
- if (status === "identical" || status === "ahead" || isProtectedFile(file)) continue;
8038
+ if (status === "identical" || status === "ahead" || isProtectedFile(file) && !isPinnedFirstArrival(file))
8039
+ continue;
7821
8040
  if (status === "diverged") {
7822
8041
  onProgress?.(`\u2192 ${filePath}: using git merge result (diverged)`);
7823
8042
  continue;
7824
8043
  }
7825
8044
  if (status === "behind") {
7826
8045
  if (!existsInFork) {
7827
- onProgress?.(`\u2192 ${filePath}: adding from upstream (new file)`);
8046
+ onProgress?.(
8047
+ `\u2192 ${filePath}: adding from upstream (${isPinnedFirstArrival(file) ? "pinned, upstream's copy is your first" : "new file"})`
8048
+ );
7828
8049
  await checkoutFromRef(forkPath, upstreamRef, [filePath]);
7829
8050
  } else if (!file.existsInUpstream) {
7830
8051
  onProgress?.(`\u2192 ${filePath}: removing (deleted in upstream)`);
@@ -7963,7 +8184,11 @@ async function prepareUpstream(config, onProgress, onStep) {
7963
8184
  config.upstreamRef = upstreamRef;
7964
8185
  const cliRange = await readUpstreamCliRange(forkPath, upstreamRef);
7965
8186
  const cliMismatch = cliRange ? cliVersionMismatch(VERSION, cliRange) : null;
7966
- if (cliMismatch) throw new Error(cliMismatch);
8187
+ if (cliMismatch && cliRange) {
8188
+ const delayed = await readReleaseAgeMinutes(forkPath) > 0;
8189
+ throw new Error(delayed ? `${cliMismatch}
8190
+ ${releaseAgeExcludeHint(cliRange)}` : cliMismatch);
8191
+ }
7967
8192
  onProgress?.("checking sync base...");
7968
8193
  await ensureSyncBase(forkPath, "HEAD", upstreamRef);
7969
8194
  const upstreamGitHubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
@@ -8006,7 +8231,7 @@ async function assertSyncConfigFollowed(config, ctx) {
8006
8231
  const report = await compareUpstreamOverrides(config.forkPath, ctx.mergeBase, ctx.upstreamRef, config).catch(
8007
8232
  () => void 0
8008
8233
  );
8009
- if (report?.kind === "changes") throw new UpstreamConfigChangedError(report);
8234
+ if (report && hasBlockingOverrideChanges(report)) throw new UpstreamConfigChangedError(report);
8010
8235
  }
8011
8236
  function resultMeta(config, ctx) {
8012
8237
  return {
@@ -9069,109 +9294,6 @@ import { readFileSync as readFileSync19 } from "fs";
9069
9294
  import { basename as basename9, join as join35, relative as relative4 } from "path";
9070
9295
  import { checkbox, Separator } from "@inquirer/prompts";
9071
9296
 
9072
- // src/utils/audit-utils.ts
9073
- import { execFile as execFile2 } from "child_process";
9074
- import { promisify as promisify2 } from "util";
9075
- var execFileAsync2 = promisify2(execFile2);
9076
- function outdatedPackageName(key) {
9077
- const name = key.split(" ")[0];
9078
- const at = name.indexOf("@", 1);
9079
- return at === -1 ? name : name.slice(0, at);
9080
- }
9081
- function getRepoUrl(repositoryUrl) {
9082
- if (!repositoryUrl) return null;
9083
- let url = repositoryUrl;
9084
- if (url.startsWith("git+")) {
9085
- url = url.slice(4);
9086
- }
9087
- if (url.endsWith(".git")) {
9088
- url = url.slice(0, -4);
9089
- }
9090
- if (url.startsWith("git://")) {
9091
- url = url.replace("git://", "https://");
9092
- }
9093
- return url;
9094
- }
9095
- function isGitHubRepoUrl(repoUrl) {
9096
- if (!repoUrl) return false;
9097
- try {
9098
- const parsed2 = new URL(repoUrl);
9099
- return parsed2.hostname === "github.com" || parsed2.hostname === "www.github.com";
9100
- } catch {
9101
- return false;
9102
- }
9103
- }
9104
- function getReleasesUrl(repoUrl) {
9105
- if (!isGitHubRepoUrl(repoUrl)) return null;
9106
- return `${repoUrl}/releases`;
9107
- }
9108
- async function runPnpm(args, cwd) {
9109
- try {
9110
- const { stdout, stderr } = await execFileAsync2("pnpm", args, {
9111
- encoding: "utf8",
9112
- maxBuffer: 10 * 1024 * 1024,
9113
- cwd
9114
- });
9115
- return { ok: true, stdout, stderr };
9116
- } catch (error) {
9117
- const { stdout = "", stderr = "" } = error;
9118
- return { ok: false, stdout, stderr: stdout || stderr ? stderr : String(error) };
9119
- }
9120
- }
9121
- async function runPnpmJson(args, cwd) {
9122
- try {
9123
- return JSON.parse((await runPnpm([...args, "--loglevel=error"], cwd)).stdout);
9124
- } catch {
9125
- return null;
9126
- }
9127
- }
9128
- async function runPnpmAudit(cwd) {
9129
- const result = await runPnpmJson(["audit", "--json"], cwd);
9130
- if (!result || typeof result.advisories !== "object" || result.advisories === null) return null;
9131
- return result;
9132
- }
9133
- async function readReleaseAgeMinutes(cwd) {
9134
- const minutes = Number.parseInt((await runPnpm(["config", "get", "minimumReleaseAge"], cwd)).stdout, 10);
9135
- return Number.isNaN(minutes) ? 0 : minutes;
9136
- }
9137
- async function fetchPublishTimes(name, cwd) {
9138
- const times = await runPnpmJson(["view", name, "time", "--json"], cwd);
9139
- return times && typeof times === "object" && !("error" in times) ? times : null;
9140
- }
9141
- function editJsonText(content, change) {
9142
- const json = JSON.parse(content);
9143
- change(json);
9144
- const indent = content.match(/^[ \t]+(?=")/m)?.[0] ?? " ";
9145
- return `${JSON.stringify(json, null, indent)}${content.endsWith("\n") ? "\n" : ""}`;
9146
- }
9147
- function rewriteSpecifier(spec, latest) {
9148
- const match = spec.match(/^([\^~]?)\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?$/);
9149
- return match ? `${match[1]}${latest}` : null;
9150
- }
9151
- var vulnIcons = {
9152
- critical: colors_default.red("\u25CF"),
9153
- high: colors_default.red("\u25CF"),
9154
- moderate: colors_default.yellow("\u25CF"),
9155
- low: colors_default.blue("\u25CF"),
9156
- info: colors_default.gray("\u25CF")
9157
- };
9158
- function getVulnIcon(severity) {
9159
- return vulnIcons[severity] ?? colors_default.gray("\u25CF");
9160
- }
9161
- function middleTruncate(str2, maxLen) {
9162
- if (str2.length <= maxLen) return str2;
9163
- const ellipsis = "\u2026";
9164
- const charsToShow = maxLen - ellipsis.length;
9165
- const frontChars = Math.ceil(charsToShow / 2);
9166
- const backChars = Math.floor(charsToShow / 2);
9167
- return str2.slice(0, frontChars) + ellipsis + str2.slice(-backChars);
9168
- }
9169
- function formatDependents(dependents) {
9170
- if (dependents.length === 0) return "";
9171
- if (dependents.length === 1) return dependents[0];
9172
- return `${dependents[0]} +${dependents.length - 1}`;
9173
- }
9174
-
9175
9297
  // src/utils/local-tools.ts
9176
9298
  import { execFile as execFile3 } from "child_process";
9177
9299
  import { existsSync as existsSync24, realpathSync as realpathSync2 } from "fs";
@@ -10921,9 +11043,18 @@ function readSearchable(filePath) {
10921
11043
  return "";
10922
11044
  }
10923
11045
  }
11046
+ var codeFilePattern = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
11047
+ var proseFilePattern = /\.(?:md|mdx|markdown)$/i;
11048
+ function stripComments(text2) {
11049
+ return text2.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
11050
+ }
10924
11051
  function fileSource(forkPath, path) {
10925
11052
  let text2;
10926
- return { path, label: path, text: () => text2 ??= readSearchable(join37(forkPath, path)) };
11053
+ const read = () => {
11054
+ const content = readSearchable(join37(forkPath, path));
11055
+ return codeFilePattern.test(path) ? stripComments(content) : content;
11056
+ };
11057
+ return { path, label: path, text: () => text2 ??= read() };
10927
11058
  }
10928
11059
  async function readScriptsAtRef(forkPath, ref, path) {
10929
11060
  try {
@@ -10976,7 +11107,7 @@ async function loadForkUsage(forkPath, upstreamRef, baseRef) {
10976
11107
  const sources = [];
10977
11108
  for (const path of diff.split("\0").filter(Boolean)) {
10978
11109
  if (isPackageJson(path)) sources.push(...await scriptSources(forkPath, upstreamRef, baseRef, path));
10979
- else if (!isManagedFile(path)) sources.push(fileSource(forkPath, path));
11110
+ else if (!isManagedFile(path) && !proseFilePattern.test(path)) sources.push(fileSource(forkPath, path));
10980
11111
  }
10981
11112
  const findIn = (scoped, patterns) => scoped.find((source) => patterns.some((pattern) => pattern.test(source.text())))?.label;
10982
11113
  return {