diff --git a/.ai/instructions.md b/.ai/instructions.md index 71fcd03bd..25dfe9734 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -92,7 +92,7 @@ Supporting: **Types** (`src/types/`) for value objects, context objects, and dom - **Temp directories** — always use `withDirPath()` wrapper, never `tmp-promise` directly - **`authKey` typed as `string | null = null`**, not `undefined` - **Exit via `outro(result)`** — sets `process.exitCode`; never call `process.exit()` directly -- **`ActionResult` variants** — `success()`, `failed()`, `cancelled()`, `stopped()` (for long-running server commands); exit codes 0 / 1 / 130 respectively +- **`ActionResult` variants** — `success()`, `failed()`, `cancelled()`, `stopped()` (for long-running server commands), `actionRequired()` (the developer has something left to do, such as conflicts to resolve; yellow outro); exit codes 0 / 1 / 130 / 130 / 1 respectively - **Constructor pattern**: `private readonly` properties, `public readonly execute = async (...) => { ... }` - **`static cmdTxt`** on every Command using `format.cmd(...)` for example rendering - **Commands use `export default class`** — oclif requires default export; actions, prompts, and services use named exports (`export class`) @@ -102,7 +102,14 @@ Supporting: **Types** (`src/types/`) for value objects, context objects, and dom - **Reuse infra** — network, file I/O, OS calls, sleep, time: use existing services, Node.js standard libraries and built-in JS APIs. - **Readability is king** — simple names, simple domain models, composed with simple functions that turn one type into another instead of passing primitives around. - **Validate once, up front** — no repeated runtime type checks; later code works with guaranteed types. -- **Telemetry** — After `outro(result)`, commands optionally track failures via `result.mapAll(() => {}, async () => { await new TelemetryService(configDir).trackEvent(new SomeFailedEvent(...), shell) }, () => {})`. Event classes extend `DomainEvent` (`src/types/events/`). Only the failure callback is populated; success/cancel are no-ops. +- **Async `Result`s are `Promise>`** — an `async` body that `await`s each step and returns early on `isErr()`. No `ResultAsync`, `safeTry`, `okAsync` or `errAsync` in new code: a signature never names `ResultAsync`, and a throwing promise becomes a `Result` through `try`/`catch` around the `await`. +- **No private instance methods** — a class has `private readonly` fields and public methods; a helper is a `private static` method or a module-level function. +- **No middlemen** — a method does something with each argument (reads it, transforms it, or combines it with the object's own state); no method only forwards to another call, and no layer re-exposes a service one to one. +- **Functional core, imperative shell** — a step reads every fact it needs first, decides as a pure function over that data, then does its I/O. `execute` is the script of the run and the only place that speaks; prompts word the data they are handed and decide nothing; a value with several outcomes (`ShareHook`, `SnapshotFetch`, `ActionResult`) offers `match` over named handlers, so a caller that leaves one out fails to compile; contexts answer data and never print. +- **Good code is not more functions** — a step's logic stays together where it reads as one thing; a function is split out only when it is used twice, when it separates a decision from I/O, or when its name says something the reader would otherwise work out. A one-line call does not get a wrapper. +- **One prompts class per action** — wording is repeated per class rather than shared through a prompt module. +- **Flag descriptions match the docs** — a flag's `description` (which the generated README carries) and its row in apimatic-docs' CLI Reference (`v4/src/content/apimatic-cli/cli-reference.mdx`) say the same words; change both, or flag the mismatch. +- **Telemetry** — After `outro(result)`, commands optionally track failures via `result.match({ success: () => {}, failed: async () => { await new TelemetryService(configDir).trackEvent(new SomeFailedEvent(...), shell) }, cancelled: () => {}, actionRequired: () => {} })`. Event classes extend `DomainEvent` (`src/types/events/`). Only the `failed` handler is populated; the others are no-ops. ## Branching diff --git a/.ai/plans/custom-code-phases.md b/.ai/plans/custom-code-phases.md new file mode 100644 index 000000000..ec3ddfab4 --- /dev/null +++ b/.ai/plans/custom-code-phases.md @@ -0,0 +1,577 @@ +# Custom code, built in vertical slices + +Status: planned on `asadali214/custom-code-redesign`, cut from `dev` at `dc3235a1` on +2026-10-30. Nothing from the first attempt is reused as code; it is reference only. + +What the feature does is written down, step by step, in the doc "Custom-code split plan" +(https://claude.ai/artifact/4ocLd2wGVmgRWfmh2Scme7). This file says in what order it is built, +one behaviour at a time, so each phase can be run and reviewed on its own. + +## 1. How a phase works + +- Each phase delivers one behaviour a developer can run, with the tests that prove it. A phase + creates only the code that behaviour needs: a class appears the first time a slice has + something to hold, a method the first time a slice calls it, a prompt the first time it is said. +- A phase starts from the working tree as the reviewer left it. Nothing is committed by the + agent; the reviewer reads, tailors or refactors the code, and the next phase builds on that. +- A phase ends with `tsc` on `src` and `test`, ESLint, Prettier on the touched files, the + phase's tests, and one full run of the suite. +- Where a placement is unclear, the agent asks before writing, with options and their + trade-offs. Section 4 lists the questions each phase is expected to raise, so the reviewer + can answer them ahead of time. + +References, on the branch `asadali214/custom-code-rewrite`: + +| Commit | Holds | Used for | +|---|---|---| +| `533bcf1a` | the flags, the git service, value objects, prompts wording, publish changes, docs | the behaviour and the wording to reproduce; the git plumbing that is known to work | +| `92988f2f` | the whole run as one file, with every behaviour test | the tests each phase ports: `test/actions/sdk/generate-destination.test.ts`, `generate-write.test.ts`, `test/types/sdk-repository-context-merge.test.ts`, `sdk-repository-context.test.ts`, `sdk-context.test.ts`, `test/infrastructure/git-service.test.ts`, `test/prompts/sdk/generate.test.ts`, `test/types/sdk/*.test.ts` | + +A ported test keeps its name and its assertions; only the seam it drives changes to the new +design. + +## 2. Rules for the code + +These come from the review of the first attempt and apply to every phase. + +- **Functional core, imperative shell.** A step reads every fact it needs first, decides as a + pure function over that data, then does its I/O. `execute` is the script of the run and the + only place that speaks; the prompts word the data they are handed and decide nothing. +- **No private instance methods.** A class has `private readonly` fields and public methods. + A helper is a `private static` method or a module-level function. +- **No middlemen.** A method does something with each argument: reads it, transforms it, or + combines it with the object's own state. No method only forwards to another call, and no + layer re-exposes a service one to one. +- **Good code is not more functions.** A step's logic stays together where it reads as one + thing. A function is split out only when it is used twice, when it separates a decision from + I/O, or when its name says something the reader would otherwise have to work out. A one-line + call does not get a wrapper. +- **The repository's conventions** stay: `Promise` with `await` and early returns, + typed paths, `ActionResult` as a plain outcome, one prompts class per action, comments only + for a why. + +## 3. The phases + +| Phase | A developer can | Ends as | +|---|---|---| +| 0 | generate into the SDK directory itself, with the old flags gone | Success, Failed, Cancelled | +| 1 | make the SDK directory a repository with `--git-init`, and have the first generation recorded | + git looked for only when wanted | +| 2 | regenerate and have their edits merged, when nothing conflicts | + conflicts listed, not yet asked about | +| 3 | choose how a conflict is settled, or resolve it by hand | + Action required | +| 4 | rerun after leaving conflicts, and be asked what to do with them | + refusals of a repository mid-operation | +| 5 | regenerate in a clone, which fetches the snapshot it lacks | | +| 6 | push, and have the snapshots shared by a hook | | +| 7 | publish an SDK that keeps edits, uploading only what git would commit | | +| 8 | read the docs for all of it | | + +### Phase 0: the SDK directory is the destination itself + +The breaking change on its own, before any git: what a run is given, and where it writes. + +Behaviour: + +1. `sdk generate` takes `--stability`, `--destination`, `--force`, `--input`, `--auth-key`. + `--api-version` and `--zip` are gone, and with them the versioned build lookup; + `--codegen-version` too (2026-11-03), on both SDK commands: v4 is the one generator, so + neither the flag nor the record names it. +2. The SDK directory is the `--destination` itself, else `/sdk/`. One + place derives it, for both SDK commands. +3. The run refuses a destination that contains `src`, since the write empties it. +4. Without a terminal, the overwrite question is not asked: the run fails, naming `--force`. +5. `sdk publish` follows: it passes the generation it was given and the directory the project + derived. A dry run generates into the SDK directory, not a numbered folder under Downloads. + +Touches: the generate command and action; the publish command and the two publish actions; +`SdkContext` reduced to the directory; `ProjectContext.sdkDirectory(language, destination)`; +`terminal.isInteractive()`; the flag description for `--force` on the SDK commands; the README +regenerated. Removed: `BuildConfig`, `versionToBuild`, the Downloads helper, the `zip` save. + +Tests: dev's `test/actions/sdk/generate.test.ts` reshaped, plus from `92988f2f`: "writes into +the destination itself", "refuses … as the destination", "stops before generating when the +developer declines to overwrite"; `project-context.test.ts` "sdk" cases; `publish.test.ts` +"runs a dry run on the SDK directory the project names"; `publish-flags.test.ts`. + +### Phase 1: the first run records + +With `--git-init`, the SDK directory becomes a repository of its own and the generation is +recorded in it under the pristine ref, so the next run has a base. + +Behaviour: + +1. Git is looked for only when the SDK directory has a `.git` of its own, a folder or a + worktree's file, or `--git-init` was given. Otherwise the SDK is saved as in phase 0, and the + run ends with the Next steps box: rerun with `--git-init` before changing anything. +2. Git absent or not answering: warn that it is not available. Older than 2.38: warn, naming the + version found. Either way the SDK is saved as in phase 0, `.git` included in what the save + removes, and no next steps are given. +3. A repository counts only when its root is the SDK directory itself. One that only encloses + it is treated as none, and nothing is written into it. Git that will not say what the + repository is: Failed, before generating. +4. `--git-init`, or a repository of its own: after the save, `git init` on `main` in the + directory, say so, record. The save removes any `.git`, so a found repository is made afresh; + "Found a git repository" and a save that keeps `.git` come with phase 2. A failed init is a + warning: the SDK is written, nothing recorded. +5. Recording: the generation as a tree of every file, committed under `refs/apimatic/pristine` + with the CLI as author and chained onto the last snapshot; an identical generation keeps the + last commit. A failure here is a warning, since the SDK is on disk. Nothing else is written: + the record file arrives with the phase that first reads it. + +Touches: `GitService` with its runner (git's messages in English, nothing else set) and the +methods `version` (a `GitVersion`, none for no git or one that does not answer), `topLevel`, +`init` (git creates the directory and its parents), `treeOf` every file through a temporary +index, `commitTree`, `resolveRef`, `treeOfCommit`, `updateRef`, each failing with git's words as +a string, which the prompts take as `reason`; `GitVersion` total, with `none`, `isFound` and +`isSupported`; `CommitId`, `TreeId`; `GitContext` on the SDK directory: `hasGitDirectory` (a +`.git` of its own, read without git), `isRepository(git)` (as that git sees it; false, with no +git run, without a `.git` or a git that can serve), `recordSnapshot`, with the pristine ref a constant of the module +(since Phase 4's refactor, the questions a run asks of a repository, the operation under way, the +unmerged paths, the record and its snapshot, are answered together by `inspect` as a `RepositoryState`); +`SdkContext` as in phase 0; no run flag reaches a context, and `execute` combines the facts: the +version is read only with a `.git` of its own or `--git-init`, and the run records only with a +usable git; the `--git-init` flag; the prompts for the two warnings, the initialised message, the +Next steps, the failed init, a failed recording, and a repository git would not inspect. + +Left for the phase that first reads them: `.apimatic/generation.json` (phase 2) and its +attributes (phase 5), trees with line endings normalised and the runner's `-c` settings +(phase 2), `GIT_DIR` cleared for a run from a hook (phase 6), the ESLint rule for the pure +modules (phase 8). + +Tests: `generate-destination.test.ts` (git absent or old, not looked for, the repository cases, +the recording and its failures); `sdk-context.test.ts` as in phase 0; `git-context.test.ts` +("hasGitDirectory", "isRepository", "recordSnapshot"); `git-service.test.ts` (`version`, +`topLevel`, `init`, `treeOf`, the commit, ref and tree round trip); the prompts tests for the +messages added; `git-version` (none included) and `object-id` value-object tests. Helpers: +`test/helpers/sdk-generation.ts` (a stubbed generation) and `test/helpers/snapshots.ts` (a +commit described and its files, through git itself). + +### Phase 2: a regeneration merges + +The run finds the record, merges the fresh generation over the directory with git, and writes +only what changed. Conflicts are found and listed, but not yet asked about: a run that meets +one fails with the list, as a run without a terminal will always do. + +Behaviour: + +1. Inspect the repository: the record, whether it is for this generation, whether the commit + it names is here. No record, or another generation: record afresh after the overwrite + question, with no further warning. The commit missing and no remote: say the snapshot is + not available, then record afresh. Under `--force`, a recorded SDK the run cannot merge into + is replaced, with a warning after the write that the customizations were not carried over. + "Found a git repository" is said only by the first run to record in it, the one that finds + no record; a later run, merging or recording afresh, no longer says it. + A run that merges does not ask to overwrite, since it writes only what changed; the question + stays for every save. The merge needs `.git` to survive the write: the save that keeps it and + the check that counts every entry but `.git` are `GitContext.save` and `hasFilesBesidesGit`, + and `SdkContext` is gone. +2. Snapshot the generation (every file) and the directory as it is (its ignore rules honoured, + except that every path the base holds goes in), both with line endings normalised. +3. Merge the two over the base with `merge-tree`, renames paired both ways. From git's merge + and diffs, know every file to write and remove, each conflict and its kind, and what + happened to every other file: added, updated, merged, removed, kept. +4. Keep the directory as it is, byte for byte, under `refs/apimatic/previous`. +5. Remove before writing. Write markers relabelled "Your changes" and "Generated by APIMatic", + each file with its own line ending (else the generator's), and skip a file whose bytes would + not change. Remove the folders a removal leaves empty, below the SDK directory. +6. A write that fails names the file, and the directory is put back from the previous + snapshot; if that fails too, the restore command is given. Failed. +7. Record the new generation chained on the base; an identical generation keeps the last + commit, and `.apimatic/generation.json` names it, written only when changed. Then the + summary: counts of added, updated, merged and removed files, and the merged and kept files + by name. +8. With conflicts: list them with their kinds and how to rerun; nothing written. Failed. + +Slices: 2.1, done, the record and the save that keeps `.git`: `GenerationRecord` and +`.apimatic/generation.json` written by every recording (the snapshot's message names the +generation) and read before the question, "Found" only on the first record, the +snapshot-missing warning without a remote, init before the save again, the hidden-entries +count, `GitContext.save`, `hasFilesBesidesGit`, `generationRecord` (none when there is no record, the file written by `recordContents`) and `hasSnapshot` (folded into `inspect` with Phase 4's refactor), `SdkContext` +deleted. 2.2, done, the merge: items 2, 3, 7 and 8 and the writes and removals of item 5, the +question skipped, the `--force` replacement and its warning; `GitContext.merge` (both trees, +`merge-tree`, the diffs, the plan) and `write` (removals, then writes whose bytes differ); +`GitService` with the `-c` settings on every call, normalised trees, `treeOfDirectory`, +`listTree`, `readBlobs`, `mergeTree`, `diffTrees`; `RelativeFilePath`, `BlobId`, `TreeEntry`, +the `merge-tree` output types and parser, `ValueMap`, `MergePlan` with `from` and +`hasConflicts`; the `customCodeFailed`, `conflictsNotAsked`, `sdkNotWritten`, `sdkMerged`, +`recordedSdkReplaced` and `snapshotNotRecordedAfterMerge` prompts. 2.3, done, the write made +safe and exact: `GitContext.keepPrevious` (the directory as it is, byte for byte, under +`refs/apimatic/previous`), a step of the run before `write`, which lands the plan (its bytes read +by `merge`) and names the step that failed; the run then puts every path the plan names back +through `restore` and says whether the directory is back, with the restore command when it is not; +`WriteProblem` is the file, operation and reason, `removeEmptyDirectories` after a removal, each file +written with its own line ending else the generator's, the binary rule in `file-content`, and +`GitService.treeOfDirectoryAsIs`. + +Touches: `GenerationRecord` and the record file, read by the inspection and written after every +recording; `SnapshotRef` with the previous ref; the runner's `-c` settings and the attributes +file that store trees with line endings normalised; `GitService.hasCommit`, `listTree`, `tree` +of the working tree (normalised and as is), `readBlobs` (contents in the order asked), `mergeTree`, `diff`; `BlobId`, +`RelativeFilePath`, `ValueMap`, the `merge-tree` and `diff-tree` output types and parsers; +`FileService.getBytes`, `writeBytes`, +`removeEmptyDirectories`; the merge plan and the summary as pure data; binary and line-ending +rules; the `recordedSdkReplaced`, `snapshotMissing` (no remote), `sdkMerged`, +`conflictsNotAsked`, `sdkNotWritten` and `customCodeFailed` prompts. + +Tests from `92988f2f`: generate-destination "records over an SDK generated at another +stability …", "records over an SDK whose snapshot the repository does not hold …", +"replaces, with --force, …" (both), "merges against the recorded snapshot …"; +generate-write "merge" group except the two marker cases, and "refuses without a terminal, +naming the conflicts"; the merge table except the marker and settlement cases ("merges your +edit …", "adds a file …", "accepts one with the same", the renames without markers, "how the +plan lands on disk", "a write that fails partway", the content rules except "labels the +markers …" and the binary conflict, "two spec versions" for the files they keep); +sdk-repository-context "inspect" cases for no record, another generation and a missing +snapshot; git-service `mergeTree`, `diff`, `listTree`, `readBlobs`, "keeps every byte …"; +`merge-plan.test.ts` for writes, deletes and the summary; `file-content` rules; +`generation-record` and `snapshot-ref` value-object tests; sdk-repository-context "the +generation record" (written beside the snapshot, absent when it cannot be read); git-service +"stores a CRLF file with LF …". + +### Phase 3: conflicts are asked about + +Behaviour: + +1. In a terminal, before anything is written: list the conflicts and ask. "I'll resolve them + myself", "Resolve using generator changes", "Resolve using my changes", "Cancel this run". + Backing out cancels, with nothing written and no previous ref saved. +2. `--force` answers for the generator without asking. +3. A chosen side settles each marked file block by block as it is written, keeping every line + outside the markers; a file one side deleted, or a binary one, is settled whole: the + generator's version where it has one, else yours stays. Say which files were resolved and for + which side. +4. Resolving by hand: the markers stay, the conflicted paths go into git's index at stages 1, + 2 and 3 as a merge would leave them, and the steps follow the summary: resolve in a merge + tool or by hand, files with markers and without listed apart, then rerun. If the index could + not be marked, warn and leave the merge tool out. +5. The run ends as Action required: a yellow outro, exit 1, so a chained command does not run. + +Done, in one slice. `ActionResult.actionRequired()` and `isActionRequired()`, the yellow outro, +and `match({ success, failed, cancelled, actionRequired })` in place of `mapAll`; +`GitService.stageConflicts` (a stage-0 entry dropped first, then every stage merge-tree +reported, through `update-index --index-info`), reached through `GitContext.stageConflicts(plan)` +like every other git operation on the SDK directory; `merge.directoryRenames=conflict` pinned +beside `merge.conflictStyle`, so a file added inside a directory the generator renamed follows +it whatever the developer's global config says; `MergedText` in `conflict-markers.ts` (git's +labels read, `id` and `id:path` alike, relabelled with the CLI's, settled for a side line by +line, a malformed sequence leaving the file whole); `conflict.ts` with `Settlement` (how one +conflict lands for a side: what is written and what the summary calls it) and three conflicts +behind one interface, each carrying the stage entries git kept for its path, `MarkedConflict` +for the files git marked, `WholeConflict` for a file a side deleted or a binary one (the +generator's version where it has one, else yours stays), `PlacedConflict` for the other shapes +(git's placement stands); `MergePlan.taking(side)` and `stages` derived from the conflicts, +built from the stage-3 blobs `GitContext.merge` reads beside the merged tree. The only switch +on a conflict's kind is where git's facts become objects. A path git names in a conflict +message that the merge leaves nowhere (neither staged nor written), such as the old name of a +file the generator renamed and you deleted, or of one the merge moved into a directory the +generator renamed, is left out of the conflicts and of the summary: it is no file to settle. +The `conflictChoice` (answering a side, `asPlaced` for the files as git left them, or `cancel`, +which backing out answers too), `conflictsResolved`, `conflictSteps`, `conflictsNotStaged` and +`runCancelled` prompts; `conflictsNotAsked` titled "N files need your attention" and saying how +to rerun. Phase 7's item 3 was pulled forward: `sdk publish` says `conflictsNotPublished` and +ends as Action required when the generation does. + +Touches: `ActionResult.actionRequired()` and `isActionRequired()`, the yellow outro and the +fourth `match` handler; `GitService.stageConflicts` and `GitContext.stageConflicts`; +`merge.directoryRenames` pinned; conflict kinds completed (both changed, +both added, deleted by you, no longer generated, binary, other), settling markers for a side, +the plan with a side taken; the `conflictChoice`, `conflictsResolved`, `conflictSteps`, +`conflictsNotStaged` and `runCancelled` prompts. + +Tests from `92988f2f`: generate-write "the question when the merge conflicts" group, "writes +markers …", "leaves the merge tool out …"; the merge table's marker cases, "settled for a side" +group, the binary conflict and the two-spec conflicts; `conflict-markers.test.ts`, +`settlement.test.ts` (folded into `merge-plan.test.ts`, since the settlement is +`MergePlan.taking`), `merge-plan.test.ts` for `taking`; `action-result.test.ts`, +`format.test.ts`; the prompts tests about the conflicts; `conflict.test.ts` for the three +conflicts and `Settlement`; git-service `stageConflicts`; publish "publishes nothing, and ends +as Action required …". + +### Phase 4: the next run after conflicts were left + +Behaviour: + +1. Before the record is read, git's unmerged entries below the SDK directory are listed. Any + means the last run's conflicts are unresolved, whatever the record says. +2. Without a terminal and without `--force`: list them, Action required. With `--force`: settle + them for the generator. In a terminal: ask. "I'll finish resolving them" ends as Action + required; backing out cancels; either side settles. +3. Settling reads what is on disk and the two sides git kept in the index: markers still there + settle in place; a file one side lacks, or a binary one, takes the chosen side whole, the + generator's side keeping yours where it has none; text whose markers are gone was resolved + by hand and stays. The files then leave the unmerged state. +4. Inspect again, once; anything still unmerged is listed and the run ends as Action required. + Otherwise the run goes on to generate and merge. +5. A repository in the middle of a merge, rebase, cherry-pick or revert of the developer's own + is refused first, naming the operation and how to abort it. + +Touches: `GitService.unmergedStages`, `unstage`, `operationInProgress`; `GitOperation`; the +settling of an unresolved file as a pure rule; the `unresolvedChoice`, `conflictsUnresolved`, +`resolveLater`, `conflictsNotSettled` prompts and the operation-in-progress wording. + +Tests from `92988f2f`: generate-destination "ends as Action required without a terminal …", +"refuses an unmerged file before looking at the record …", the "question while the last run's +conflicts are unresolved" group, "refuses while the repository is in the middle of a merge …"; +sdk-repository-context "settling what the last run left unresolved", the "inspect" cases about +unmerged files and the operation in progress; git-service `unmergedStages`; +`unresolved-file.test.ts`; `repository-facts.test.ts` for the order of the facts. + +Slices: 4.1, done, what is in the way: `GitOperation` (a value, none-able) and +`GitService.operationInProgress`, `unmergedStages`, and the block in the action before the record is +read that refuses an operation in progress and lists what is unmerged as Action required; the +`operationInProgress` and `conflictsUnresolved` prompts. 4.2, done, settling what was left: +`UnresolvedFile.settledFor` answering a `Settlement`, `GitContext.settlementFor` as the plan over the +index's two sides and the disk, landed by the same `write` as a merge and followed by +`GitService.unstage`, the question (`unresolvedChoice`, backing out cancels) with `--force` answering +for the generator, and the second look; the `resolveLater` and `conflictsNotSettled` prompts, and +`conflictsResolved` over paths for this run's conflicts and the last run's alike. Refactored with +Phase 3 afterwards: `GitContext.inspect` answers every question a run asks of the repository as one +`RepositoryState`; every plan carries the bytes that reach the disk, line endings decided by `merge` +and `settlementFor`, so `write(plan)` lands any plan; the prompts render what they are handed +(`sdkMerged` given its counts, `conflictSteps` and `conflictStepsByHand` picked by the action). +`repository-facts.test.ts` has `repository-state.test.ts` as its counterpart. + +### Phase 5: a clone merges against a snapshot it fetches + +Behaviour: + +1. When the record names a commit the repository lacks and there is a remote (the branch's, + else `origin`), fetch `refs/apimatic/pristine` from it, with no prompts, a batch-mode ssh + and a 30-second limit. +2. The fetched chain is adopted when there is none here or it continues the local one, kept + when it is behind, and joined with a two-parent commit when the two diverged. +3. The commit here now: merge. Still absent: say the remote lacks it, or why the fetch failed, + then the overwrite question and a fresh record. +4. `--destination` naming a clone of an SDK repository regenerates over the clone. + +Touches: `GitService.remoteOf`, `fetchRef`, `isAncestor`, the remote options of the runner; +`RemoteName`; the chain reconciliation as a pure rule; `snapshotMissing` for the absent and +failed cases; `.apimatic/.gitattributes` beside the record, merging it as binary and marking +it generated, so a pull that meets a teammate's record leaves no markers in it. + +Tests from `92988f2f`: generate-destination "merges in a clone only once the pristine refs +were fetched by hand …", "merges over a clone of an SDK repository named as the destination +…"; sdk-repository-context "sharing" group; git-service "remotes" group except the hooks +cases, "leaves an SSH client the developer configured …", "tells an ancestor from a stranger"; +`pristine-chain.test.ts`; sdk-repository-context "is written with attributes that merge it as +binary …" and "stays yours … when a pull meets a teammate's record". + +Done, in one slice. `GitService.remoteOf`, `fetchRef` (a remote call: no terminal prompt, +`credential.interactive` off, the default ssh in batch mode unless the developer's own client is +configured, a 30-second limit) and `isAncestor`, with `commitTree` taking any number of parents; +`RemoteName`; `RecordedSnapshot` on the `RepositoryState`, the snapshot the record names with +whether it is held and, once the remote was tried, where else it was looked for as a `SnapshotFetch` +(none, absent, failed); `GitContext.fetchSnapshot`, a step of the run after the refusals and the +unresolved block, which brings the remote's chain over and meets it with the local one in +`meetChains` (adopted, kept or joined; the three outcomes are proven through git in the context's +tests, which stand in for `pristine-chain.test.ts`); `snapshotMissing` wording each outcome through +`match` on the fetch; `.apimatic/.gitattributes` written beside the record by every recording, and +the whole `.apimatic/` directory kept out of "yours" by `merge`. + +### Phase 6: every push shares the snapshots + +Behaviour: + +1. After every recording, the script `apimatic-pre-push` is written into git's own hooks + directory, executable, and the `pre-push` hook git runs is made to call it: a new shell hook + when there is none, the call right after the shebang of an existing shell hook, and nothing + when the call is there already. +2. With `core.hooksPath` set, the CLI writes nothing there. It names the file git runs (beside + husky's `_`) and the one line to add, with a commit step when that file is in the + repository. A hook in another language is left alone and named the same way. +3. The script pushes the pristine ref with `--no-verify`; when rejected, it fetches the + remote's chain to a tracking ref, joins, and pushes again; a second failure prints one line + in the server's words and never fails the push. +4. The run says once that it wrote the hook, or what one step is still needed, or that the hook + could not be written. Later runs are silent. + +Touches: `GIT_DIR`, `GIT_WORK_TREE` and `GIT_INDEX_FILE` cleared from the environment of every +git call, since a hook runs the CLI with its own repository in the environment; +`GitService.hooksDirectory`, `hooksPathConfigured`, `hooksRunFrom`; +`FileService.makeExecutable`; the script and the marked block as constants; the hook decision +as a pure rule; the `shareHookInstalled`, `shareHookNeedsOneStep` and `shareHookNotInstalled` +prompts, and the recording notice that chooses one message. + +Tests from `92988f2f`: generate-write "the hook that shares snapshots" group; +sdk-repository-context "the hook that shares snapshots" group (writes once, elsewhere, under +the home directory, quiet once it calls, another language, shares on push, through husky, a +clone that never ran the CLI, joins at push time, the server's words, ahead of an existing +hook); git-service hooks cases; `share-hook.test.ts`, `recording-notice.test.ts`. + +Done, in one slice. `ShareHook` (`src/types/sdk/share-hook.ts`) holds the call line, the markers +and the script's name beside the rule, `found`, which reads the facts first (the hook as found on +disk, git's own hooks directory, where git runs hooks from, the repository) and answers one of five +outcomes, each holding the one call to its handler for `match` (calling, written, elsewhere, +notShell, notWritten) and, through `toWrite`, the content for the hook file when this run writes +it; `fileIn` names the `pre-push` beside husky's `_`. `GitContext.shareOnPush`, a step of the run +after each recording, writes the script (its text beside the attributes in the context, joined line +by line, standing aside when the push carries the ref itself, which it reads from a copy of stdin +the call line keeps and hands back to the rest of the hook, 2026-11-03) and the hook as the rule says, and +answers how the hook was left, a hook that could not be written included, since nothing about it +stops the run. `GitService.hooksDirectory` and +`hooksRunFrom`, with `GIT_DIR`, `GIT_WORK_TREE` and `GIT_INDEX_FILE` cleared from every call; +`FileService.makeExecutable`; the one `shareHook` prompt wording each outcome, a hook already +calling the script as nothing. `recording-notice.test.ts` has no counterpart: the action picks +nothing, the value does. + +### Phase 7: `sdk publish` keeps edits too + +Behaviour: + +1. `sdk publish` run with flags always passes `--git-init` and never asks. Run without flags, or + with `--git-init` alone, it is the step-by-step experience, which asks for the SDK directory + after the language and passes the flag on. +2. The upload holds what git would commit: not `.git`, not `.apimatic`, and not what the SDK's + own ignore rules keep out. Outside a repository of its own, everything but `.git` and + `.apimatic`. +3. A generation that ends as Action required publishes nothing, and says the SDK still has + conflicts. Done in phase 3. + +Touches: `GitService.ignoredBelow`; `RelativeDirectoryPath` and `RelativePaths`; +`ZipService.archiveWithout` and `TempContext.zipWithout`; the `--git-init` flag on publish and +the interactive-mode rule; the `conflictsNotPublished`, `dryRunNotice` and +`destinationContainsSource` wording on the publish prompts. + +Tests from `92988f2f`: `publish.test.ts` ("uploads the SDK without the CLI's record …", +"publishes nothing, and ends as Action required …", "reports an SDK it could not zip …"), +`publish-flags.test.ts`; sdk-context "archive" group; git-service `ignoredBelow`; +`zip-service.test.ts` for the exclusions; `relativeDirectoryPath.test.ts`. + +Done, in one slice. `RelativeDirectoryPath` (`src/types/file/`) beside `RelativeFilePath`, with +`RelativePaths` holding files and directories below one root; `ZipService.archive` and +`TempContext.zip` take what to leave out, nothing by default, the zip service's helpers as module +functions; `GitService.ignoredBelow`, `ls-files` for what the ignore rules keep out below a +directory, a directory ignored whole as one entry. `GitContext.archiveForUpload(git, tempDirectory)` +stands beside `save` as its mirror: a save keeps `.git`, an upload leaves `.git` and the record out +and, in a repository of its own, what git's ignore rules keep out (`keptOutOfUpload`), read with any +git found, one too old to merge included (2026-11-03); outside one, or with no git at all, git is +not asked. The publish action reads git's version as generate does, when the directory has a +`.git`, and hands it over, so a `.git` with no git uploads everything but the two. `sdk +publish` takes `--git-init`, passed through in guided mode (`SdkPublish.isGuided`: no flags, or +`--git-init` alone) and always on with flags; `FlagsProvider.sdkForce` words `--force` for both SDK +commands as the docs do; `dryRunNotice` says what the directory holds and what an upload leaves +out; `sdkNotZipped` takes the reason. + +### Phase 8: docs and leftovers + +1. `CONTEXT.md` gains the terms: SDK directory, pristine snapshot, generation record, custom + code. +2. The design document `.ai/plans/custom-code.md` from `533bcf1a` comes back with sections 9 and + 10 rewritten for the layout the phases produced; the decisions and the lab cases stay. +3. The `--git-init` flag's description becomes "Track the SDK directory with its own git + repository so your customizations survive regeneration." and the README is regenerated; the + convention notes land in the `.ai` instructions and skills, and an ESLint rule keeps the pure + modules under `src/types/git/` free of file and process access, if wanted. +4. Optional, to decide at review: the cleanups the first attempt carried that are not this + feature. The plugin action and the not-found hooks reading the terminal check instead of + `process.stdin.isTTY`; the unused methods on the config, plugin, portal, spec and file + classes; the error map on `ServiceError`. + +Done, in one slice. `CONTEXT.md` names the SDK directory, the pristine snapshot, the generation +record and custom code; `.ai/plans/custom-code.md` is back from `533bcf1a` and made true for this +branch (its phases in §8, the files in §9, the architecture in §10, every name the first attempt +had replaced, a reversed decision amended with its date); the four SDK flag descriptions say what +their rows in the docs' CLI Reference say, `--force` through `FlagsProvider.sdkForce`, both +commands carry the docs' sentence on keeping your changes, the README names git under +Requirements and is regenerated; the conventions these phases followed are in +`.ai/instructions.md` and the skills; an ESLint rule keeps `src/types/sdk`, `src/types/git` and +`src/types/file` free of file, process and infrastructure imports; `terminal.isInteractive()` is +the one TTY check (the plugin action, the not-found hooks and portal serve read it, the plugin +prompts' `canAsk` gone); `PluginConfigContext.hasNoSourceRepository`, which only a test called, is +gone, as is `ServiceError`'s error map, which nothing read. + +## 4. Questions each phase will raise + +Each is asked when its phase reaches it, with these options. An answer written here ahead of +time saves the question. + +**Phase 0: the run's inputs.** The action takes `force` today. + +- One `RunMode` value (`force`, whether a question can be asked, later `gitInit`), built by + the command. For: one argument, and the publish actions build it the same way. Against: a + type for three flags. +- Separate parameters. For: nothing new. Against: every caller changes with every phase. + +**Phase 1: where the inspection lives.** Answered, after three reviews of the first cut: +`GitContext` is git on the SDK directory: `hasGitDirectory` (disk), `isRepository(git)` (as the +git it is handed sees it; false, with no git run, when there is no `.git` or that git cannot +serve) and `recordSnapshot`; `GitService.version` answers a `GitVersion`, none for no git, and +`execute` reads it only when the run needs git, holding `GitVersion.none()` otherwise, so every +fact in the action is a `const` and no run flag reaches a context. A second cut had +`repository()` fold the availability into its answer, which gave two types the same +`gitUnavailable` case; a third had `availability` and `isRepository` side by side, with two +`let`s in the action to carry what each branch learnt; handing the version to `isRepository` +removed both. + +**Phase 1: the repository as a fact.** Answered for now: `GitContext` is built on the directory +whether or not it is a repository, and `isRepository(git)` answers which; the root is the SDK +directory itself. To revisit at the end of phase 2, when the merge, the record and the +settlement show whether the seam between the two contexts holds. + +**Phase 2: what a merge answers with.** Git's merge becomes a plan of writes, removals, +conflicts and the summary. + +- One class, the plan, holding all of it with the operations on it (take a side, which paths + carry markers). For: the merge result is one thing the summary, the settlement and the write + all read. Against: it is the largest pure type in the feature. +- Plain records and functions over them. For: nothing to hold. Against: six functions taking + the same record. + +**Phase 2: how a write is undone.** The step that lands the plan also puts files back when a +write fails. + +- Landing and undoing in one place, since the undo needs what landing touched. For: the + invariant lives in one function. Against: it is the longest function in the shell. +- Landing returns what it touched, and a separate step restores. For: shorter pieces. Against: + a step that only exists for the failure path. + +**Phase 3: the end state.** Conflicts left for the developer need a fourth `ActionResult`. + +- A new `ActionRequired` kind with exit 1 and a yellow outro, as the product review chose. For: + not a success, since the SDK will not build; not a failure, since the run did what was + chosen. Against: every `mapAll` caller gains a callback. Taken, with `mapAll` renamed + `match` over named handlers, so a caller that leaves one out fails to compile. +- Reuse `failed` with a message. For: no new kind. Against: `sdk publish` could not tell a + conflict from a failure, and the outro would be red for a run that did what was asked. + +**Phase 6: where the hook text lives.** The script and the marked block are constants that the +pure decision and the installer both read. + +- Beside the hook decision in `src/types/sdk/`. For: the decision tests read them. Against: a + shell script as a TypeScript constant among pure functions. +- In infrastructure, beside the git service. For: it is git's hooks directory. Against: the + decision would import infrastructure, which the ESLint rule forbids. + +Answered: the script's text in `git-context.ts` beside the record's attributes, where the shell +writes it and the pristine ref is; the call line, the markers and the script's name in +`share-hook.ts` with the rule, which is all the rule and the prompt read. + +**Phase 7: where the upload rule lives.** The upload holds what git would commit, without `.git` +or the record. + +- On `GitContext` beside `save`, as `archiveForUpload(git, tempDirectory)`. For: the two file + rules of the directory (what a save keeps, what an upload leaves out) share one class and its + constants, and the repository check is there; the publish action reads git's version as + generate does and hands it over. Against: the name `GitContext` undersells a class that also + saves and zips the directory. +- An `SdkContext` over `GitContext`, as the injection branch had. Against: the split Phase 1 + folded; the archive still needs the repository check and the ignore rules from the other class. +- The context asking git itself, with no version handed in. Against: a `.git` without a usable + git fails the upload where generate only warned. + +Answered: the first, one context. + +**Phase 8: the leftovers.** Four cleanups the first attempt carried that are not this feature. + +- All four: the ESLint purity rule over the three pure directories, one terminal check, the + unused method a reference scan finds, `ServiceError` without its error map. For: what the + review already judged lands with the feature. Against: files outside the feature in its PR. +- The rule and the terminal check only. Against: two known leftovers stay. +- None. Against: the review's findings are lost. + +Answered: all four, with `--force` brought to the docs' row as well as `--git-init`, and the git +sentence on both commands and under the README's Requirements. + +## 5. Out of scope + +As the first attempt decided: an ignore list for frozen files, patch files, code regions, +portal downloads that include custom code, and any push by the CLI itself. Telemetry for +conflicts is not sent; the `ActionResult` carries no data. diff --git a/.ai/plans/custom-code.md b/.ai/plans/custom-code.md new file mode 100644 index 000000000..de6f94f92 --- /dev/null +++ b/.ai/plans/custom-code.md @@ -0,0 +1,633 @@ +# Plan: keep your edits when regenerating an SDK + +Status: implemented on branch `asadali214/custom-code-redesign`, cut from `dev` at +`dc3235a1`, in nine vertical slices (phases 0 to 8 of `custom-code-phases.md`) between +2026-10-30 and 2026-11-03, after a first attempt on `asadali214/custom-code-injection` (PR #445) +whose review decided the rules in section 2; PR #465 into `dev`. Section 8 lists the phases and +their commits, section 9 the files, section 10 the architecture, layer by layer; section 11 lists +what still needs a decision. The design was tested against Speakeasy's generator first; section 7 +records what that lab measured and what this plan does on the same cases, and the APIMatic +Customization Lab replays them on the real CLI. + +## 1. Goal and scope + +A developer edits a generated SDK, any file and any line, and regenerates it with the same +command; their edits survive, the generator's changes arrive, and the two are merged file by +file with git's three-way merge. Nothing to configure, no markers or regions to stay inside, +no commit required before regenerating. + +What changes for the developer: + +- **Git is required.** The CLI uses the git on `PATH`, 2.38 or newer, for the snapshots and + the merge. It looks for git only when the SDK directory has a repository of its own (a + `.git` in it) or `--git-init` will make one; anywhere else there is nothing for git to keep, + so a machine without git sees nothing about it. Without it `sdk generate` behaves exactly + as before, writes no `.git` and no `.apimatic/`, and warns: "git was not found, so your + customizations to the generated SDK will not persist across regenerations". +- **First run**: as before, a spec and a language; the SDK lands in `/sdk/`, + or in the `--destination` itself when one is named, with no language directory added below it. + If that directory is not a git repository of its own, `--git-init` makes it one; without the + flag the SDK is written without one, and the run ends with next steps for a developer who + wants to customize it: rerun with `--git-init` before changing anything, then make the + changes. A repository + that only encloses the directory, such as the project's, is never used. +- **A clone**: `--destination` naming a clone of an SDK repository regenerates over the clone, + and merges when the clone holds a generation record. A destination holding the project's + `src` is refused, since the SDK directory is emptied before a write that does not merge. +- **Regenerate in place**: the same command. Generator changes are merged into edited files, + new files are added, files the generator stopped producing are removed when unedited. +- **Conflicts are explicit**: overlapping changes are listed before anything is written, and a + terminal run asks how to settle them (6.1); `--force` takes the generator's changes. Resolving + by hand leaves git-style markers, put in git's index for a merge tool. Resolve, rerun. +- **Teams**: every recorded run makes sure a `pre-push` hook is in place, and from then on every + push of a branch also pushes the snapshot refs; a clone fetches them before merging. The CLI + never pushes (3.2). +- **No base, no merge**: an SDK directory with no generation record, or whose recorded + snapshot is missing, gets the "overwrite?" question as before; the run then records a + snapshot so the next one merges. + +Terms, also in `CONTEXT.md`: the **SDK directory** is the directory the CLI writes into (the +`--destination` as named, else `sdk/`), input and output of every later run; the **repository** is the SDK +directory's own git repository, rooted at it; a **pristine snapshot** is one generation's output exactly as the +server returned it, stored as a commit under a hidden ref; the **generation record** is +`/.apimatic/generation.json`, naming the snapshot the directory was last written from; +**custom code** is whatever differs between the SDK directory on disk and its snapshot. + +Out of scope: an ignore list for files the developer wants frozen, patch files, code regions, +portal downloads that include custom code (section 11), and any push by the CLI itself. + +## 2. Decisions + +| Ask | Decision | Why | +|---|---|---| +| Where the merge runs | In the CLI, against the working tree. The server is unchanged. | Nothing is uploaded or downloaded beyond the SDK zip, the merge sees uncommitted edits, and the design matches Speakeasy's, which the lab showed working. | +| When it merges | Always, whenever git is available, the SDK directory is a git repository of its own and has a generation record. No switch in `apimatic.json`. | One rule to explain: "with git installed, your edits are kept". A switch would be read only after the first edit is already lost. | +| Git is a hard requirement | The system git, 2.38 or newer, found on `PATH`, looked for only when the SDK directory has a `.git` of its own or `--git-init` will make one (2026-10-30; every run looked before). Absent or older: today's flow, no `.git` or `.apimatic/` written, one warning. No bundled fallback engine. | Git's merge is the reference everyone else measures against: rename detection, modify/delete and add/add semantics, binary handling. Generators that embed a library write their own diff3 (Speakeasy, isomorphic-git); tools that accept git as a prerequisite shell out to it (JHipster, Copier, Cruft); none reimplements rename detection. 2.38 (October 2022) is the first with `merge-tree --write-tree`. | +| No repository | `--git-init` makes the SDK directory a repository: `git init -b main` there after the generation succeeds and before the first write, reported afterwards. Without it the SDK is written without a repository, exit 0, however the run started, and a "Next steps" box after the write says how to keep edits, should the developer make any: rerun with `--git-init` before changing anything (it needs git 2.38 or newer), then make the changes (decided 2026-10-29 on the product review, replacing the question of 2026-10-27; the steps replaced a warning printed before the upload the same day, and were worded on 2026-10-30 not to assume the SDK will be edited). `sdk publish` always passes it when run with flags, and takes `--git-init` in its step-by-step experience. A non-empty directory without a repository is asked "overwrite?" first. | Without a snapshot of the first generation there is nothing to merge against later, but a repository nobody asked for inside `sdk/` trips `git add .` once the project becomes a repository of its own, and CI should never get one by surprise. A flag says so up front, where the question could not be answered in CI at all. | +| Only the SDK directory's own repository | A repository counts only when its root is the SDK directory. One that encloses it from above, the project's or any other, is treated as none: nothing is written into it, no refs, no hook (decided 2026-10-29 on the product review). | The CLI should not keep refs and a hook in a repository the developer keeps for something else. The cost: inside a project repository, `--git-init` makes the SDK directory an embedded repository, which the project then tracks as a gitlink rather than as files. | +| Repository found but no record | Ask "overwrite?" as before (skipped by `--force`); then record a snapshot, saying once that the repository was found. | An SDK generated by an older CLI, copied in, or built from the portal has no base. | +| Record for another generation | A record for another stability (or generator) is "no base": the overwrite question, then a fresh snapshot. No warning about what the directory holds (removed 2026-10-29 on the product review). | Merging a beta generation over a stable base would be a merge of everything; replacing is what the developer asked for. | +| Remote | Read only by the CLI, and only when a record names a commit the repository lacks. Writing is the developer's `git push`, through a `pre-push` hook the CLI installs (decided 2026-10-22, replacing the push inside the CLI). | A snapshot pushed by the CLI reached the remote before the branch that records it, and never when the push failed or the remote came later; the hook ties the snapshot to the push of the branch, so the two arrive together, every time. | +| Merge engine | `git merge-tree --write-tree` between two temporary commits (yours and the generation) that share the pristine snapshot as parent, run with the CLI's own `-c` settings. The CLI reads the result and writes it into the SDK directory. | No merge code to maintain; git supplies the three-way merge, rename detection and every conflict shape, without touching the working tree, the index or any branch. | +| Branches | None. Snapshots are commits under `refs/apimatic/…`, never checked out, never on a branch. | The CLI should not create or switch branches in someone's repository. The base for a run is the one the committed record names, so each branch carries its own. | +| Moves and renames | Git's rename detection, both ways, at its usual 50% similarity. | The lab's case 6 lost edits because Speakeasy relies on a header comment; git pairs by content. | +| Same path added by both | A conflict, unless the contents are identical. | Git semantics; Speakeasy overwrites the developer's file silently. | +| Generator drops an edited file | A conflict: the file stays, listed as "no longer generated". | Lab case 7: Speakeasy deletes it with no warning. | +| Developer deleted a file the generator changed | A conflict: the new version is written back and listed. Needs sign-off, section 10. | Git semantics. The alternative, remembering the deletion, is silent and recurs on docs files. | +| `--force` | Keeps its meaning: never ask. On conflicts, this run's or those the last run left unresolved, it takes the generator's changes, as "Resolve using generator changes" does (decided 2026-10-29 on the product review). A recorded SDK it cannot merge into, whose record names another generation or a missing snapshot, is replaced, with one warning after the write that the customizations were not carried over (decided 2026-10-29, replacing the refusal). | CI runs already pass it, and a forced run should end with an SDK that builds. What a replacement drops, the developer's commits still hold. | +| Undo | Not offered by the CLI (decided 2026-10-23). The SDK directory as it was before the run is still kept under `refs/apimatic/previous`, overwritten each run, for a developer to restore by hand. | The first question can cancel before anything is written, so a way back is needed only after a run that could not ask; the ref covers that at the cost of one commit per run. | +| Conflicts in git's index | The conflicted paths are put into the repository's index at stages 1, 2 and 3. | `git status` says "both modified" and `git mergetool` opens them, as after a merge git ran itself. | +| One action | `GenerateAction` is the whole run: no `SdkPrepareAction`, no `SdkWriteAction` (decided 2026-10-22). | Each phase had one caller and could not run alone, and passing `SdkDestination` between actions through `ActionResult.getValue()` put data on a result that is meant to carry only an outcome. | +| Functional core, imperative shell | `execute` is the script of the run, read top to bottom, and the only place that speaks: every question and message is a `this.prompts.*` call in it, every fact a `const`, and the action has no other method. The prompts word the data they are handed and never derive a fact or a policy of their own; where a message depends on an outcome, the core answers with a value that carries it, and the prompt calls its `match` with one handler per outcome (`SnapshotFetch`, `ShareHook`; amended 2026-10-28 on review, and 2026-11-03 for the redesign, where the discriminated unions became such values). The I/O is on one context, `GitContext` (`inspect`, `fetchSnapshot`, `merge`, `settlementFor`, `write`, `recordSnapshot`, `shareOnPush`, `archiveForUpload`); the decisions are pure values in `src/types/sdk/`: `RepositoryState`, `RecordedSnapshot`, `MergePlan.taking` and `settled`, `UnresolvedFile.settledFor`, `ShareHook.found` (decided 2026-10-22). | The reader of `execute` sees what is asked and said, and when, without a hop; what a repository state or a conflict choice means is testable without git or a terminal; the context stays silent, so it can be composed. | +| Telemetry | None for now. `SdkMergeConflictedEvent`, `ConflictOutcome` and the result's payload are gone; `execute` returns a plain `ActionResult` (decided 2026-10-22). | An `ActionResult` carries no data. Conflict counts can come back later through a callback the command hands the action, as `sdk publish` does with `onPublishSdkError`. | +| One context | `GitContext` is git on the SDK directory, built on the directory whether or not it is a repository: `hasGitDirectory` reads the disk, `isRepository(git)` answers as the git it is handed sees it (false, with no git run, without a `.git` of its own or a git that can serve), and every other method takes the directory as its root (decided 2026-10-30 on the redesign's Phase 1 review, replacing the two classes of 2026-10-22, `SdkContext` over `SdkRepositoryContext`). | The two-class seam made every git method ask for a root that may not be there, or carry a found repository through the action. With `inspect` answering "not a repository" as one state of a `RepositoryState`, the action reads one value, every fact in it is a `const`, and no run flag reaches a context. | + +## 3. What is saved, and where + +Two hidden refs in the SDK directory's own repository plus one small file inside it (one SDK +directory per repository since 2026-10-29, so the refs carry no path). + +| Item | Holds | Written | Shared | +|---|---|---|---| +| `refs/apimatic/pristine` | A chain of commits, one per generation. Each tree is that generation's output exactly as unzipped: no `.git`, no `.apimatic/`, no developer files. | After every successful write, with plumbing: the generated directory is added to a temporary index (`GIT_INDEX_FILE`, `--work-tree`), then `write-tree`, `commit-tree` and `update-ref`. Author "APIMatic CLI ", message `apimatic: pristine typescript v4 stable`. | Pushed to the remote after every recorded run; fetched by a run whose record names a commit the repository lacks (3.2). | +| `refs/apimatic/previous` | One commit, no parent: the SDK directory as it was on disk the moment before the last run wrote to it (`.git` excluded). | Before the merge writes anything. Overwritten each run. | No. Local safety net: `git restore --source=refs/apimatic/previous --worktree -- .`, run in the SDK directory, puts the files back, record included (verified in the lab, case 15); files the run added stay and `git status` lists them. | +| `/.apimatic/generation.json` | `schemaVersion`, `language`, `stability`, `pristine` (the commit the directory was written from); nothing that changes from run to run, so a regeneration that changed nothing leaves it untouched (`generatedAt` and `cliVersion` dropped 2026-10-27 on review; `codegenVersion` dropped with the flag 2026-11-03, v4 being the one generator). The files a run leaves in conflict are not in it: they are git's unmerged index entries, the only state that says when the developer has resolved them. | After the pristine commit exists, so the two never disagree; `.apimatic/.gitattributes` (`generation.json merge=binary linguist-generated=true`) first, each only when its content changed. | Yes: an ordinary tracked file, committed with the SDK. It is what makes a branch, a worktree or a clone merge against the right base. The attributes keep a pull where two clones both regenerated from writing markers into it (section 11). | + +### 3.1 Which snapshot is the base + +The commit named in the generation record on disk, never the ref's tip. The tip only says +where the chain continues. If the named commit is absent locally, the CLI fetches the ref from +the remote (3.2) and looks again; if it is still absent, it says so and why (no remote, the +remote lacks it, or the fetch failed and how), and offers to overwrite instead, which records a +fresh snapshot. The commit id and the ref are not shown: nothing in the message is for the +developer to act on beyond the question that follows. + +### 3.2 Sharing through a pre-push hook + +The snapshot reaches the remote at the same moment the record does: when the developer pushes +a branch. After every recorded run the CLI writes two things (`GitContext.shareOnPush`): the script, +`apimatic-pre-push`, into git's own hooks directory (`git rev-parse --git-common-dir`, so +worktrees share it, whatever `core.hooksPath` says; `SHARE_HOOK_SCRIPT` in +`src/types/git-context.ts` beside the record's attributes, rewritten only when its content +changed), and a three-line marked block in `pre-push` that calls it (`# >>> apimatic snapshots` +… `# <<< apimatic snapshots`): a fresh hook when there is none, right after the shebang of an +existing shell hook (so an early `exit` below cannot skip it), and left alone when the block is +there. `ShareHook.found` (`src/types/sdk/share-hook.ts`) decides which from what is on disk and +answers one of five outcomes, which the one `shareHook` prompt words. The run that writes the +call into the hook says so, once; later runs are silent about it (decided 2026-10-27 on review). +The CLI never pushes. + +The call is one line that finds the script through git, does nothing where the script is not +there (a clone that never ran the CLI) and never fails the push: +`d="$(git rev-parse --git-common-dir)" && f="$d/hooks/apimatic-pre-push" && [ -f "$f" ] && { t="$d/apimatic-pre-push.$$"; cat >"$t" && sh "$f" "$@" <"$t"; [ -r "$t" ] && command exec <"$t"; rm -f "$t"; } || true`. +Git lists the refs being pushed on stdin and the script reads them, so the line keeps a copy in +the git directory, runs the script on the copy, then hands the copy to the rest of the hook as its +stdin and removes it (2026-11-03): a hook that reads the refs itself, git-lfs's or git's own +sample, still sees them. `command exec` rather than `exec`, and one `&&` chain, keep a `sh -e` +hook from exiting on a redirection error or a git missing from its PATH. That is what makes the +other cases a one-step note rather than a warning with nothing to do. + +The script, for the remote git passes as `$1`: nothing when the push carries the ref itself (git +lists what is pushed on the hook's stdin, so pushing it by hand still works); else push +`refs/apimatic/pristine`, when there is one, with `--no-verify` (so the push inside the hook does not re-enter the hook) and +`GIT_TERMINAL_PROMPT=0`; when that is rejected, fetch the remote's chain with +`+refs/apimatic/pristine:refs/apimatic/remotes//pristine`, fast-forward or join the +local chain with a two-parent commit (`apimatic: pristine chains joined`), and push again. A second failure prints one line in the server's words; the script +always exits 0, and runs in its own `sh`, so a hook's `set -e` cannot be tripped by it. The +common push costs one extra push of small refs; the fetch happens only when another machine +pushed first. + +A tracking name, not the local chain, is what the fetch writes to: a plain +`refs/apimatic/pristine` refspec made a diverged chain reject the fetch and `git pull` skip +its merge, and a forced one would strand unpushed snapshots. + +When `core.hooksPath` is set, the CLI does not write the hook (husky regenerates `.husky/_` on +install, and the team's versioned `.husky/pre-push` is theirs). The run shows a next-steps note +naming the file that hook runs (`.husky/pre-push` for a hooks path ending in `_`, else +`/pre-push`) and the line to add; once that file mentions the script, the note +stops. The same note, without the commit step, when the existing `.git/hooks/pre-push` is not +a shell script. + +The CLI's only remote operation is `GitContext.fetchSnapshot`, a step after the refusals and the +unresolved block, when the record names a commit the repository lacks: from the branch's remote (`branch..remote`) else `origin`, without +prompts, with a 30-second timeout, and joining the local chain when it had diverged. A +snapshot that is nowhere is reported as missing and the run asks before replacing. + +### 3.3 Rules + +- Only a repository rooted at the SDK directory holds its snapshots. An SDK directory inside + another SDK's repository is, for the CLI, a directory without one (decided 2026-10-29, + replacing the refusal of a nested SDK directory, which per-path refs needed). +- `.git/` is never part of a snapshot. `.apimatic/` is in a previous snapshot (so a restore + by hand puts the record back too) but never in a pristine one, and the merge leaves it + alone since only the disk side has it. +- A snapshot of the disk honours the SDK's own `.gitignore` (so a `node_modules` or `dist` + never enters a tree), except that every path the pristine snapshot holds is added + regardless, so a generated file an ignore rule happens to match is still seen and never + mistaken for one the developer deleted. +- The developer's git configuration is bypassed: every git call runs with + `-c core.autocrlf=false`, `-c merge.conflictStyle=merge`, `-c merge.renormalize=true`, + raised rename limits, `gc.auto=0`, a temporary attributes file holding `* text=auto`, and + `LC_ALL=C`, with `GIT_DIR`, `GIT_WORK_TREE` and `GIT_INDEX_FILE` cleared from the environment, + so two machines produce the same trees and the same markers, git's messages stay parseable, + and a hook that runs the CLI cannot hand it another repository. +- Snapshots are ordinary git objects: deduplicated, packed by the developer's own `git gc`, + invisible to `git log` and to a plain `git push`. +- `.apimatic/` never ships inside a published package: the three v4 generators' manifests are + whitelists that do not name it (verified 2026-10-20, section 10). + +## 4. One run of `apimatic sdk generate` + +### 4.1 Before this plan + +The destination was checked before anything was uploaded, and a non-empty one was emptied +before the fresh SDK was copied in. Nothing on disk survived a regeneration. + +### 4.2 With this plan + +```mermaid +flowchart TD + A([apimatic sdk generate -l typescript]) --> G0{.git in the SDK directory, or --git-init?} + G0 -- no --> N1([Write without a repository; Next steps say how to keep edits; git never asked]) + G0 -- yes --> V{git 2.38+ on PATH?} + V -- no --> V1([Warn: customizations will not persist; today's flow, no .git, no .apimatic]) + V -- yes --> D{Generation record and its pristine commit found?} + D -- no, directory empty --> C1 + D -- no, directory not empty --> F{Overwrite? / --force} + F -- no --> X([Cancelled, nothing touched]) + F -- yes --> C1[Zip src/ and upload; poll; download the SDK zip into a temp directory] + C1 --> R1[git init in the SDK directory with --git-init, when it is not a repository of its own] + R1 --> E[Write the SDK; an overwrite empties the directory but keeps any existing .git] + D -- yes --> G{Files still unmerged from the last run?} + G -- yes --> Y([Asked in a terminal, settled for the generator under --force, else Action required: resolve those files first]) + G -- no --> C2[Zip src/ and upload; poll; download the SDK zip into a temp directory] + C2 --> H[Save disk state to refs/apimatic/previous] + H --> I[git merge-tree of yours and the generation over the pristine base; write the result in place] + E --> J[Record pristine commit; write generation.json] + I --> J + J --> K{Conflicts?} + K -- no --> S([Success: summary of files]) + K -- yes --> T([Success: the developer resolves by hand; markers, and what to do]) +``` + +The run is one action, `GenerateAction.execute`, read top to bottom; every question and +message is in it (section 10). It validates as before, then reads what git says about the SDK +directory, before the upload: + +1. A `.git` in the SDK directory, a folder or a worktree's file, looked for on disk + (`GitContext.hasGitDirectory`). Without one and without `--git-init`, git is never run and the + version held is `GitVersion.none()` (decided 2026-10-30 on the product review; until then every + run began with `git --version`). Otherwise `GitService.version`: absent, or older than 2.38, + warns that customizations will not persist, and the run writes without a repository, no `.git` + and no `.apimatic/` added. +2. `GitContext.inspect(git, language, stability)` answers one `RepositoryState`: not a repository + (no `.git` of its own, a git that cannot serve, or git's top level above the directory), else + the operation it is in the middle of (a merge, rebase, cherry-pick or revert is refused), the + paths git still holds as unmerged, whether a record is there, and the snapshot the record + names for this generation with whether it is held (`RecordedSnapshot`). +3. Unmerged files: asked about, settled for the generator under `--force`, or refused without a + terminal (6.1). Then `GitContext.fetchSnapshot` brings a snapshot the clone lacks from its + remote (3.2). A snapshot held: `merge` against it. No repository, no record, a record for + another generation, or a snapshot still missing: a save, recorded when the directory is or + becomes a repository of its own. +4. The overwrite question is asked before a save that would remove anything from the directory, + never before a merge, and never with `--force`. Every entry but `.git` counts, hidden ones + included, since the save keeps only `.git`: a repository of the developer's own that holds + only `.github` or `.husky` is asked about, one that holds only `.git` is not + (`GitContext.hasFilesBesidesGit`, `FileService.hasEntriesExcluding`). + +It asks the overwrite question, uploads, polls and downloads into a temp directory as +before, and puts the SDK in place the way the facts say: + +- A save: `GitContext.save` replaces what the directory holds, except its `.git`; with + `--git-init` and no repository of its own, `GitService.init` first (after the generation + succeeded, so a failed one leaves no repository behind), then `recordSnapshot` and + `shareOnPush`; "Found a git repository" when the repository was already there and held no + record. Without the flag, the save alone, with the Next steps. A failure to record is a + warning: the SDK is on disk, and the next run asks before replacing it. With `--force`, a + directory whose record names another generation or a missing snapshot is replaced all the + same, and the run warns after the write that the customizations were not carried over + (`recordedSdkReplaced`): `--force` answers for the developer. +- A merge: `GitContext.merge` (the generated tree, the disk tree, `merge-tree`) into a + `MergePlan`; its conflicts settled by the answer to the question (6.1, `MergePlan.taking`); + `keepPrevious`, then `write` (the previous ref, then the writes and deletions: only paths whose + content differs between the disk and the merged tree; the directory is never emptied, and a + write that fails is undone by `restore`); `recordSnapshot` and `shareOnPush`; the summary. + With conflicts: the stages into the index, the steps, and Action required. + +## 5. The merge, file by file + +Three trees: base (the pristine snapshot), yours (the SDK directory on disk, snapshotted as +in 3.3) and new (the fresh generation). Yours and new are written as temporary commits whose +parent is the base, so `git merge-tree --write-tree yours new` finds the base itself and +returns a merged tree, the conflicted paths with their stages, and one informational line +per conflict naming its kind. The CLI does none of the deciding: + +| Base | Yours | New | Action | +|---|---|---|---| +| absent | absent | present | Write it (new generated file). | +| absent | present | present | Identical: nothing. Different: conflict add/add, markers around both versions. | +| present | = base | present | Write the new version (you never edited it). | +| present | edited | = base | Keep yours (the generator did not change it). | +| present | edited | = yours | Nothing. | +| present | edited | changed | Three-way line merge. Non-overlapping: write the merged file. Overlapping: conflict markers. | +| present | absent | = base | Stays deleted. | +| present | absent | changed | Conflict "deleted by you, changed by the generator": the new version is written back and listed. Section 10. | +| present | = base | absent | Delete it (the generator no longer produces it). | +| present | edited | absent | Conflict "no longer generated": your file stays and is listed. | +| present | absent | absent | Nothing. | +| absent | present | absent | Your own file: never touched. | + +Renames: git's detection on both sides at its default 50% similarity, with `merge.renameLimit` +raised. The generator renamed a file: your edits are merged into it at the new name and the +old name is removed. You moved a file: the generator's change to the old name is merged into +your file where it is now. A file both sides renamed differently is a rename conflict git +reports; the CLI lists it with git's message, and both candidates stay on disk. + +Content: trees are stored with `* text=auto` and merged with `merge.renormalize`, so a CRLF +file and its LF counterpart compare as equal and merge line by line; a file the merge changes +is written with LF, a file it leaves alone keeps its ending. Binary files are git's binary +files: unedited ones are replaced, edited ones are kept, and an edited one the generator also +changed is a conflict that keeps yours. + +## 6. Conflicts + +The merge finishes for every file; a conflict in one file does not stop the others. A line +conflict is written into the file, with git's labels (the temporary commit ids) replaced as +the file is written: + +``` +<<<<<<< Your changes + * Price in USD, VAT included. +======= + * Retail price in US dollars. +>>>>>>> Generated by APIMatic +``` + +The other kinds carry no markers: a file no longer generated stays as yours; a file you +deleted comes back in its new version; a binary keeps yours. All are listed before the +question in a block, "N files need your attention", each with what happened to it and +nothing about how it will be written (decided 2026-10-29 on the product review). When the +developer resolves by hand, the steps follow the summary: resolve them in a merge tool +(`git mergetool`, or the editor's merge view) or by hand, naming the files with markers (keep +what is right, remove the markers, stage each) and those without (decide whether each stays +or goes, stage that); then rerun the command. The rerun asks how to resolve whatever is still +unresolved, so the steps do not offer that themselves (2026-10-30). The merge tool is left out +when the conflicts could not be put in the index. Git's own tools apply throughout: +`git status` lists the files as both modified, `git mergetool` opens them, `git add` marks +them resolved. The steps do not name +`refs/apimatic/previous`; that ref is the safety net of section 3, named only when a write +failed and could not be undone. + +The run ends as Action required (`ActionResult.actionRequired()`, closed in yellow), exit 1 +(decided 2026-10-29, replacing the success of the product review; named Conflicted until +2026-10-30, renamed so the state is not tied to conflicts): not a success, since the SDK holds +markers and will not build, and not a failure, since the run did what was chosen. The exit +code is the one `git merge` gives conflicts, so a command chained after the run does not run. +`sdk publish` stops on such a generation before zipping, so markers are never uploaded or +recorded as published, and ends as Action required too. A run without a terminal never writes +markers; it stops with the list, failed, exit 1. Nothing is lost: every other file is already +updated, the pristine snapshot is recorded, the index holds the conflicted paths. + +The next run: while git still holds any file below the SDK directory as unmerged +(`GitService.unmergedStages`, `ls-files -u`), the run stops before generating and names them +(or asks, 6.1). That is git's own convention: the three stages stay in the index until +`git add` (or a mergetool, or an IDE's accept button) marks the file resolved, and `git commit` +refuses until then. The CLI reads nothing else: not the marker text, which a developer may +legitimately write and which an unstaged resolution still lacks, and not a list in the record, +which would carry a local, transient state in a committed file. Once resolved and staged, no +commit is needed. With the spec unchanged the rerun changes nothing, because new equals base. +With a changed spec it merges onto your resolution. + +### 6.1 The questions + +Conflicts are settled before anything is written. The plan is computed first (generated +tree, disk tree, `merge-tree`); when it has conflicts, a terminal is attached and `--force` +is absent, the action lists the conflicted files with their kind and asks +(`SdkGeneratePrompts.conflictChoice`, a `select`): + +- **I'll resolve them myself**: the run goes on as above, markers, the steps (ending with "Or + rerun and let the CLI resolve what is left, using either side."), success. `--force` answers + "Resolve using generator changes" instead, without asking; a run without a terminal stops + with the list and writes nothing. +- **Resolve using generator changes**: every marked block is settled for the generator's + side as the file is written (`MergePlan.taking`, the same marker rule the next run uses on a + file left unresolved, `UnresolvedFile.settledFor`), so only the overlapping hunks go the generator's way and your other edits + in the same file survive; a file one side deleted, or a binary one, is settled whole by + `MergePlan.taking('generated')`: the generator's version where it has one, else yours + stays, so a customized file the generator dropped is never thrown away by this answer. +- **Resolve using my changes**: the mirror image: every marked block keeps your lines and the + generator's other changes in the same file still apply; a file left whole stays as it is on + disk, which is your side (`MergePlan.taking('yours')`), so a file you deleted stays deleted + and a customized file the generator dropped stays. The next run sees your lines as your + edits and keeps them. +- **Cancel this run**, or backing out of the question: nothing written, nothing recorded, no + previous ref saved; the result is cancelled. The previous ref is saved only after the + decision, so this answer leaves no trace. + +For either side the plan is taken for that side (`MergePlan.taking(side)`), which remembers +the files the merge left in conflict (`conflictedPaths`) so the run can name them, under "Resolved +using …", once they are written; it records a clean snapshot and succeeds. The merge runs +once: `merge-tree -X ours|theirs` would need git 2.43, and settling the markers as the file +is written gives the same result on 2.38. + +The next run, while git holds any of the SDK's files as unmerged, asks before generating +(`SdkGeneratePrompts.unresolvedChoice`); `--force` settles them for the generator without +asking, and a run without a terminal names them and ends as Action required, exit 1: + +- **I'll finish resolving them**: nothing done; the run ends as Action required, exit 1 + (decided 2026-10-29, replacing the cancellation of the product review, which replaced a + failure). + Backing out of the question cancels, exit 130, as it does at every other question. +- **Resolve remaining conflicts using generator changes** and **… using my changes**: + `GitContext.settlementFor(side, paths)` reads the two sides git kept in the + index (`GitService.unmergedStages`, stage 2 yours, stage 3 the generator's) and settles each + file through the pure `UnresolvedFile.settledFor`: markers still in it are settled in place for the side, + keeping every line around them (what you already resolved in that file included); without + markers, a file one side deleted stays or goes with the side asked for, a binary file takes + that side's version, and a text file whose markers are all gone was resolved by hand and + stays. The plan is written (`GitContext.write`) and every file taken out of the unmerged + state (`GitService.unstage`); no network needed. + +There is no undo and no cancel on this question (decided 2026-10-23): the first question +already offers the cancel, before anything is written. After either side, the directory is +inspected again, once; a file still unmerged after that is reported, never asked about +twice, and the run ends as Action required. The run then generates and merges from the settled state. + +Nothing is reported to telemetry (section 2). Whether a question can be asked is +`terminal.isInteractive()` (`process.stdin.isTTY`, `src/infrastructure/terminal.ts`), which the +command reads and hands the action as `canAsk`; the actions' tests pass it directly. + +CI: with `--force` nothing is asked; conflicts are settled for the generator and listed under +"Resolved using the generator's changes", and a recorded SDK the run cannot merge into is +replaced, with a warning. Without it, conflicts fail the job with the list. A CI checkout +fetches the snapshot it needs by itself; its push back usually fails for lack of a write +token, which is a warning. Opening pull requests is not the CLI's job; an Action that commits +the marked files to a branch for review is a separate piece of work. + +## 7. Expected behaviour on the lab's cases + +The eleven Speakeasy runs from the lab notebook, plus three the lab did not cover. + +| # | Case | Speakeasy (measured) | This plan | +|---|---|---|---| +| 1 | Edit with nothing configured | lost unless the prompt is answered Yes | kept; there is nothing to configure | +| 2 | Edits and spec changes in different places | kept | kept | +| 3 | Same line changed by both | conflict markers, exit 1 | asked first; by hand: conflict markers, stages in the index for a merge tool, pre-run state kept | +| 4 | Developer adds new files | kept | kept | +| 5 | Developer deletes a generated file | stays deleted | stays deleted while the generator leaves it alone; conflict once the generator changes it (section 10) | +| 6 | Developer moves a generated file | lost: rewritten from scratch | kept: matched by content, merged at the new path | +| 7 | Generator drops an edited file | lost: deleted silently | conflict: file kept and listed | +| 8 | `.genignore` frozen file | drifts from the spec | not offered; an ignore list is a possible follow-up | +| 9 | Patch files | replayed every run | not offered; edits in place cover the need | +| 10 | Code regions | kept inside markers, lost outside | not offered; every line is editable | +| 11 | SDK hooks registration file | written once | unchanged: hooks are generator features, merged like any file | +| 12 | Both add the same path with different content | developer's file overwritten | conflict add/add | +| 13 | Generator renames an edited file | lost | kept: edits follow the rename | +| 14 | Fresh clone, regenerate | fails unless the hidden refs were pushed | merges by itself: the generating run pushed the ref, the clone fetches it; a clear message with the commands and the overwrite question only when the push had failed | + +Every row of section 5 and this table is a test over real repositories in +`test/types/git-context.test.ts` (the merge table), `test/actions/sdk/generate-destination.test.ts` +(row 14 with a bare remote) and `test/actions/sdk/generate-merge.test.ts`, the last two driving +`GenerateAction` with a stubbed generation service (`test/helpers/sdk-generation.ts`). + +## 8. Phases, as delivered + +The first attempt (`asadali214/custom-code-injection`, PR #445, 2026-10-20 to 2026-10-30) was +reviewed into the rules of section 2 and the sequence of `custom-code-phases.md`, and rebuilt +from `dev` as vertical slices on `asadali214/custom-code-redesign`: each phase one behaviour a +developer can run, with the tests that prove it. The first attempt's history stays on +`asadali214/custom-code-rewrite` (`533bcf1a`, `92988f2f`), as reference only. + +| Phase | Commit | A developer can | +|---|---|---| +| 0 | `d8a90a39` | generate into the SDK directory itself: `--destination` is the directory, `--api-version` and `--zip` are gone. | +| 1 | `bc516636` | make the SDK directory a repository with `--git-init`, and have the first generation recorded: `GitService`, `GitContext` (`hasGitDirectory`, `isRepository`, `inspect`, `save`, `recordSnapshot`), `GenerationRecord`, `RepositoryState`; git looked for only when wanted. | +| 2 | `82fd92db`, `7fcd38ff` | regenerate and have their edits merged, when nothing conflicts: `GitContext.merge`, `keepPrevious`, `write`, `restore`; `MergePlan`, the conflict kinds, markers and line endings; conflicts listed, not yet asked about. | +| 3 | `b2af733c`, `2e65e272` | choose how a conflict is settled, or resolve it by hand: `conflictChoice`, `MergePlan.taking`, the stages into the index, the steps; `ActionResult.actionRequired`; `sdk publish` stops on conflicts. | +| 4 | `b2892d93` | rerun after leaving conflicts, and be asked what to do with them: `unresolvedChoice`, `GitContext.settlementFor`, `UnresolvedFile`; a merge, rebase, cherry-pick or revert in progress refused. | +| 5 | `0eec5e8d` | regenerate in a clone, which fetches the snapshot it lacks: `GitContext.fetchSnapshot`, `RecordedSnapshot`, `SnapshotFetch`, the chains met; `.apimatic/.gitattributes`. | +| 6 | `48eaccf7` | push, and have the snapshots shared by a hook: `ShareHook`, `GitContext.shareOnPush`, the script, `hooksDirectory` and `hooksRunFrom`, the environment cleared. | +| 7 | this file | publish an SDK that keeps edits, uploading only what git would commit: `--git-init` on `sdk publish`, `GitContext.archiveForUpload`, `GitService.ignoredBelow`, `RelativeDirectoryPath`. | +| 8 | this file | read the docs for all of it: the terms, this document, the flag descriptions as the docs' CLI Reference has them, the conventions in `.ai`, the ESLint rule, the leftovers. | + +Each phase ended with `tsc` on `src` and `test`, ESLint, Prettier on the touched files, its tests +and one full run of the suite, and was committed once reviewed. PR #465 into `dev`, to be +squash-merged, title `feat(sdk): keep your edits when regenerating an SDK`. + +## 9. Files + +| File | Role | +|---|---| +| `src/infrastructure/git-service.ts` | the system git behind every snapshot, the merge (its output read into stages and messages), the index, the remote (no prompts, 30 s), the hooks directories and what the ignore rules keep out; every call with the same `-c` settings and `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE` cleared; every method a `Promise>` carrying git's own words | +| `src/types/git-context.ts` | `GitContext`, the SDK directory under git: what the disk and git say (`hasGitDirectory`, `hasFilesBesidesGit`, `isRepository`, `generationRecord`, `inspect`), what the remote holds (`fetchSnapshot`), what a run plans (`merge`, `settlementFor`), how a plan lands (`save`, `archiveForUpload`, `keepPrevious`, `write`, `restore`), the snapshots it keeps (`recordSnapshot`) and how they are shared (`shareOnPush`); the refs, the record's attributes and the hook script as its constants | +| `src/types/sdk/repository-state.ts`, `recorded-snapshot.ts`, `snapshot-fetch.ts` | what `inspect` answers: the operation under way, the unresolved paths, whether a record is there, and the snapshot it names with whether it is held and, once the remote was tried, where else it was looked for | +| `src/types/sdk/merge-plan.ts`, `conflict.ts`, `conflict-markers.ts`, `unresolved-file.ts`, `file-content.ts` | pure: `MergePlan` built from git's merge and diffs, `taking(side)`, `settled`, the conflict kinds and their labels, the markers and their resolution for either side, how an unresolved file is settled from its stages, line endings | +| `src/types/sdk/share-hook.ts` | `ShareHook`: the call line, the markers, the script's name, and the rule over what is on disk, answering one of five outcomes for `match` | +| `src/types/sdk/generation-record.ts`, `write-problem.ts`, `stability-choice.ts`, `generate.ts` | the record file's content, a failed write named, the stability a flag chose, the languages and levels | +| `src/types/git/*` | git's output as types: `CommitId`, `TreeId`, `BlobId`, `GitVersion`, `GitOperation`, `RemoteName`, the merge-tree output, tree entries | +| `src/types/file/relativeFilePath.ts`, `relativeDirectoryPath.ts`, `relative-paths.ts` | paths as git spells them inside a tree, each kind its own type | +| `src/actions/sdk/generate.ts`, `src/prompts/sdk/generate.ts` | the run, its questions and its wording; `execute` the only place that speaks | +| `src/actions/sdk/publish.ts`, `publish/*.ts`, `src/prompts/sdk/publish.ts` | the publish runs the same generation with `--git-init` (always with flags, as asked in guided mode), stops on conflicts, uploads `archiveForUpload`, and its dry run says what the directory holds | +| `src/types/temp-context.ts`, `src/infrastructure/zip-service.ts` | a zip that leaves paths out | +| `src/infrastructure/file-service.ts` | bytes written only when changed, entries counted but `.git`, a hook made executable | +| `src/infrastructure/terminal.ts` | whether a question can be asked at all, read by the commands | +| `src/commands/sdk/generate.ts`, `publish.ts` | the flags (`--git-init`, `--force` as `FlagsProvider.sdkForce`), the docs' sentence on keeping your changes, guided mode on publish | +| `eslint.config.js` | `src/types/sdk`, `src/types/git` and `src/types/file` import no `fs`, process or infrastructure | +| `test/…` | mirrors each file above; the behaviour tests drive `GenerateAction` and `SdkPublishAction` with a stubbed generation service (`test/helpers/sdk-generation.ts`) over real repositories, under an empty global git config wherever hooks or ignore rules are read | + +## 10. Architecture + +Where each piece lives, by layer, and what calls what. Arrows are calls (or writes, where +labelled); the dashed subgraph is outside the CLI. This is the code, not the developer's flow +(that is section 4.2). + +```mermaid +flowchart TB + subgraph CMD["Commands — src/commands/sdk/"] + C["SdkGenerate.run() · SdkPublish.run()
flags → the action → outro(result); publish: isGuided(argv) picks the step-by-step action"] + end + subgraph ACT["Actions — src/actions/sdk/"] + G["GenerateAction.execute(project, sdk, language, stability, force, canAsk, gitInit, packageSettings?) → ActionResult
reads top to bottom and speaks every prompt itself, with no other method on the action
the source checks · needsGit → GitService.version, else GitVersion.none() · GitContext.inspect → RepositoryState · an operation under way refused · unresolved: unresolvedChoice (the generator's side under --force, Action required without a terminal) → settlementFor → write → unstage → inspect again · fetchSnapshot → snapshotMissing · the overwrite question before a save
withDirPath: buildZip → generateSdk → unzip → merge (conflictChoice, taking) → keepPrevious → write (restore on failure) → recordSnapshot → shareOnPush → sdkMerged; conflicts left → stageConflicts, conflictSteps, Action required · or init? → save → recordSnapshot → shareOnPush → sdkGenerated, gitInitSteps"] + P["SdkPublishAction.execute(…, force, canAsk, gitInit, …)
GenerateAction → conflictsNotPublished on Action required · dry run: dryRunNotice, open · else GitContext.archiveForUpload(git, temp) → publishSdkPackage → poll"] + end + subgraph PR["Prompts — src/prompts/sdk/ (wording only; a value with outcomes is worded through its match)"] + GP["SdkGeneratePrompts
gitUnavailable · gitVersionNotSupported · repositoryNotInspected · operationInProgress · conflictsUnresolved · unresolvedChoice · resolveLater · conflictsNotSettled · conflictsResolved
snapshotMissing(match) · cannotAskToOverwrite · overwriteSdk · destinationDirNotEmpty · srcDirNotZipped · generateSdk · sdkNotUnzipped
customCodeFailed · conflictsNotAsked · conflictChoice · runCancelled · sdkNotWritten · sdkRestored · sdkNotRestored · snapshotNotRecordedAfterMerge · shareHook(match) · sdkUnchanged · sdkMerged · conflictsNotStaged · conflictSteps · conflictStepsByHand
repositoryNotInitialised · repositoryInitialised · repositoryFound · recordedSdkReplaced · snapshotNotRecorded · sdkGenerated · gitInitSteps"] + PP["SdkPublishPrompts
conflictsNotPublished · dryRunNotice · sdkNotZipped · publishSdk · publishingRunningNotice · publishingLogsMessage · pollPublishingStatus"] + end + subgraph TY["Types — src/types/"] + GC["GitContext (git-context.ts)
hasGitDirectory · hasFilesBesidesGit · isRepository(git) · generationRecord · inspect → RepositoryState · fetchSnapshot → RecordedSnapshot · merge → MergePlan · settlementFor → MergePlan
save · archiveForUpload · keepPrevious · write · restore · recordSnapshot · shareOnPush → ShareHook
constants: the two refs, .apimatic/generation.json and its attributes, the hook script, what an upload keeps out"] + RS["RepositoryState · RecordedSnapshot · SnapshotFetch (sdk/)
notARepository · of(operation, unresolved, recorded, base, held) · isRecorded · hasUnresolved · mergeBase · toFetch · brought · stillMissing · match"] + MP["MergePlan (sdk/merge-plan.ts)
from(inputs) · writes/deletes/conflicts/files · taking(side) · settled · conflictedPaths · markedPaths · unmarkedPaths · stages
conflict.ts · conflict-markers.ts · unresolved-file.ts (settledFor) · file-content.ts"] + SH["ShareHook (sdk/share-hook.ts)
SHARE_HOOK_CALL · found(facts) → calling | written | elsewhere | notShell | notWritten · fileIn · scriptIn · match · toWrite"] + GR["GenerationRecord (sdk/generation-record.ts)
generation.json: language, stability, pristine commit · baseFor"] + GT["git/ · CommitId · TreeId · BlobId · GitVersion · GitOperation · RemoteName · merge-tree (stages, changes)"] + RF["file/ · RelativeFilePath · RelativeDirectoryPath · RelativePaths · FilePath · DirectoryPath"] + TC["TempContext · zip(directory, excluding) · unzip · save"] + end + subgraph INF["Infrastructure — src/infrastructure/"] + GS["GitService (git-service.ts), every method a Promise carrying git's own words
version · topLevel · init · ignoredBelow · treeOf · treeOfDirectory · treeOfDirectoryAsIs · commitTree · updateRef · resolveRef · hasCommit · treeOfCommit · listTree · readBlobs
mergeTree · stageConflicts · operationInProgress · unmergedStages · unstage · diffTrees · diffTreesPairingRenames
remoteOf · fetchRef (no prompts, 30 s) · isAncestor · hooksDirectory · hooksRunFrom"] + FS["FileService · ZipService"] + TU["terminal.isInteractive (read by the commands)"] + end + subgraph OUT["Outside the CLI (dashed)"] + HK[".git/hooks/apimatic-pre-push (sh), called from .git/hooks/pre-push or the developer's own hook
push refs/apimatic/pristine · on rejection fetch into refs/apimatic/remotes/‹remote›/pristine, join, push again"] + RM[(remote
refs/apimatic/pristine)] + end + C --> G + C --> P + C --> TU + P --> G + P --> PP + G --> GP + G --> GC + P --> GC + G --> GS + GC --> GS + GC --> FS + GC --> TC + GC --> RS + GC --> MP + GC --> GR + GC --> SH + GP --> RS + GP --> SH + MP --> GT + GS --> GT + GS --> RF + GC --> RF + GC -- writes script + call --> HK + HK -- git push --> RM + GS -- fetchRef --> RM + style OUT stroke-dasharray: 5 5 +``` + +What each layer owns: + +| Layer | Holds | Never does | +|---|---|---| +| Command | flags, intro/outro, whether a question can be asked (`terminal.isInteractive()`, handed down as `canAsk`) | git, files, questions, telemetry (none for now) | +| Actions | the imperative shell: `execute` with the order of the steps, every fact a `const`, every question and message spoken through `this.prompts` in it, and no other method on the action | wording, git commands, file I/O, data on the `ActionResult`, deciding what a state means, speaking through anything but its own prompts | +| Prompts | words and `select`/`confirm` for data they are handed; a value with outcomes (`SnapshotFetch`, `ShareHook`) worded through its `match`, one handler per outcome | derive a fact or a policy, know git refs | +| Types | the functional core: values that know their outcomes (`RepositoryState`, `RecordedSnapshot`, `MergePlan`, `UnresolvedFile`, `ShareHook`), and one context, `GitContext`, the SDK directory under git, which reads every fact first and answers data | print, call the API, ask | +| Infrastructure | `git`, the file system, zips | know what a snapshot or a conflict means | +| Outside | the hook script in the developer's `.git/hooks`, the remote's refs | need the CLI to run | + +## 11. Needs a decision + +- **Deleted by you, changed by the generator.** Conflict (this plan) or remembered deletion + as Speakeasy does. The conflict recurs on docs files that change with every version bump; + the memory is silent. +- **`git init` is a flag.** Decided 2026-10-29 on the product review, replacing the question + of 2026-10-27, which had replaced the silent init: `--git-init` makes the SDK directory a + repository, and without it every run writes without one and ends with the steps that make one. The silent init left a + commit-less repository inside `sdk/` that broke `git add .` once the project became + a repository, and CI got a repository nobody asked for; the question could not be answered + in CI at all. +- **No `directory()` on the SDK's context.** Decided on review (2026-10-27): `ProjectContext.sdkDirectory(language, destination)` is the one place a `--destination` becomes a path, for both SDK commands, and the action names the directory by what it was given, never by a second spelling. +- **The record merges as binary.** Decided 2026-10-23 after testing with two clones: both + regenerated, so both changed `generatedAt`, and the second pull hit a text conflict in the + record. `.apimatic/.gitattributes` sets `merge=binary`: git writes no markers, leaves the + puller's record in place as valid JSON and lists it as both modified, so `git add` accepts + it; either record names a snapshot a run can merge from. Under `git pull --rebase` the + record left in place is the upstream one, equally valid. Weighed and set aside: `merge=union` + (no conflict, but invalid JSON), a custom "keep ours" driver (silent, but it needs a + `git config` line in every clone, and a clone without it falls back to markers). The + fields that changed on every run, `generatedAt` and `cliVersion`, were dropped on review + (2026-10-27): nothing read them, and a regeneration that changed nothing still showed the + record as modified. `linguist-generated` collapses the record in GitHub's pull request diffs. +- **Unresolved conflicts are git's unmerged entries**, decided 2026-10-23 on review: the + CLI asks the index (`ls-files -u` below the SDK directory) and nothing else, so a file + counts as resolved exactly when `git add` has marked it so, as after any merge. A + developer who discards the index (`git reset`, `git stash`) with markers still in the + files has told git the files are resolved; the next merge then sees the marker lines as + their edit and flags them again, so nothing is lost silently. +- **Packages must exclude `.apimatic/`.** No change needed, checked 2026-10-20: all three v4 + manifests are whitelists (`package.json` `files`, the `.csproj`'s explicit `Pack="true"` + items, `[tool.poetry]` `packages`), and `npm pack`, `dotnet pack` and `python -m build` on + projects with a planted `.apimatic/generation.json` packed none of it. +- **Portal downloads.** `portal generate` ships the server's SDK zips, so a portal's download + never contains custom code. Zipping the merged SDK directory from disk is a follow-up. +- **Sharing by the CLI.** Decided 2026-10-21, reversing the earlier "no remote": the manual + push and fetch was the step developers would forget and the one a clone could not do. The + alternative weighed was a tracked `.apimatic/pristine/` copy (travels with every branch, but + doubles the working tree and every diff); the remote push keeps the tree clean and makes the + failure modes (no rights, no credential, offline) warnings with a manual fallback. +- **No `--offline` flag.** Decided 2026-10-21: the cases it guarded (a read-only remote, a + policy against tool pushes, a dead network) are per-repository, not per-run, and every one + of them is already a warning that does not fail the run. If an opt-out is ever needed, a git + config read by `remoteOf` fits better than a flag. +- **The hook, not the CLI, pushes.** Decided 2026-10-22, replacing the push inside the CLI + from the day before: a snapshot pushed by the CLI left the remote without it whenever the + push failed or the remote was attached later, and nothing but another `sdk generate` would + send it. Tying it to the developer's own push makes the snapshot and the record that names + it arrive together. Weighed and dropped: a fetch refspec in the remote's config (the hook and + the CLI fetch for themselves), writing into a `core.hooksPath` directory (husky wipes it), + a fetch hook (git has none; `post-merge` fires only on pulls that merge, and a clone has no + hooks yet). +- **Conflicts ask.** Decided 2026-10-22, reworded 2026-10-23 on review: the first run offers + resolve by hand, + resolve using generator changes or using my changes (hunk by hunk: each marked block is + settled for the side as the file is written, which `merge-tree -X` would also do but only + from git 2.43; never the whole file or SDK, since dropping what merged cleanly is more than + asked), or cancel the run. The run after, while files are still unresolved, offers to + finish resolving or to resolve what remains for either side; undo and cancel are not on it. + `--force` answers for the generator on both questions (2026-10-29 on the product review, + replacing markers as its default), so a forced run ends with an SDK that builds. No + `--on-conflict` flag: nothing else was asked for. +- **No `--zip` flag.** Decided 2026-10-21: `sdk generate --zip` wrote the archive the server + sent and nothing else, which custom code could never apply to, and nobody needs the CLI to + zip a directory. Removed outright rather than hidden and deprecated. The server still delivers + a zip, which the CLI unpacks into a temp directory; `portal generate --zip` is untouched. + +## 12. Relation to other work + +The codegen-v2 server-side plan (custom code as a git merge the server performs, with the +`.git` zipped up and down each run) is superseded by this one; the server's `/api/generate` +needs none of it, and the beta CLI never uploads a source tree. The v3 CLI flow +(`--track-changes`, `save-changes`, `merge-source-tree`) was removed from `dev` in #427 with +its `git-service.ts` and the isomorphic-git dependency; the file name returns here for the +system-git wrapper, and no library is added. Speakeasy's `persistentEdits` has the same shape +(snapshots as hidden refs in the developer's repository, per-file three-way merge on the +working tree, markers plus a failed exit on conflict), with the four gaps the lab found +closed: moves, generator renames, add/add, and dropped edited files. diff --git a/.ai/skills/action.md b/.ai/skills/action.md index d815cfa12..473bfedee 100644 --- a/.ai/skills/action.md +++ b/.ai/skills/action.md @@ -8,6 +8,8 @@ Actions live at `src/actions/` and are the single use-case orchestrators for eac - **DO** use named export: `export class {PascalName}Action`. - **DO** use `public readonly execute = async (...): Promise =>` (arrow function property). +- **DO** make `execute` the script of the run, read top to bottom, and the only place that speaks: every question and message is a `this.prompts.*` call in it, every fact a `const`, and the action has no other method. A helper is a `private static` method or a module-level function, never a private instance method. +- **DO** `await` each `Result` a context or service answers with and return early on `isErr()` — never chain `andThen` or `asyncAndThen` on a promise, and never import `ResultAsync`. - **DO** initialize prompts as a field: `private readonly prompts = new {Name}Prompts()`. - **DO** initialize services as `private readonly` fields (inline with `new`) when the service takes no constructor args. If a service needs `configDir`, declare without initializer and assign in the constructor body after `this.configDir` is set. - **DO** use the full constructor pattern when the action calls an API: `constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null)`. @@ -72,7 +74,7 @@ Actions live at `src/actions/` and are the single use-case orchestrators for eac | Standard action (auth + services + withDirPath) | `src/actions/api/validate.ts` | | Standard action (multiple params + overwrite guard) | `src/actions/api/transform.ts` | | Standard action (Context + TempContext + service) | `src/actions/portal/generate.ts` | -| Standard action (SDK generation + version selection) | `src/actions/sdk/generate.ts` | +| Standard action (SDK generation + git-backed regeneration) | `src/actions/sdk/generate.ts` | | Standard action (gate + services + withDirPath) | `src/actions/portal/generate.ts` | | Minimal action (configDir only, no services) | `src/actions/auth/logout.ts` | | Minimal action (configDir + commandMetadata, no auth) | `src/actions/auth/status.ts` | diff --git a/.ai/skills/command.md b/.ai/skills/command.md index 740308c65..2b030c252 100644 --- a/.ai/skills/command.md +++ b/.ai/skills/command.md @@ -84,6 +84,7 @@ These three layers are always created together and mirror each other in path str - **DO** use typed path objects (`DirectoryPath`, `FilePath`, `FileName`, `UrlPath`) instead of raw strings. - **DO** use `ActionResult.success(value?)`, `ActionResult.failed(message?)`, or `ActionResult.cancelled()` as return values. - **DO** use neverthrow `Result` for service returns — check with `.isErr()` / `.isOk()`. +- **DO** type an async `Result` as `Promise>` and `await` it before checking — never `ResultAsync`, `safeTry`, `okAsync` or `errAsync`. ### General — DON'T @@ -128,7 +129,7 @@ These three layers are always created together and mirror each other in path str | Action with Result error handling | `src/actions/api/validate.ts` | | Action with simpler constructor (no authKey) | `src/actions/auth/status.ts` | | Action that delegates to sub-actions | `src/actions/quickstart.ts` | -| Prompts with spinner + confirm + select | `src/prompts/sdk/generate.ts` | +| Prompts with spinner + confirm + noteWrapped | `src/prompts/sdk/generate.ts` | | Prompts with error display methods | `src/prompts/portal/generate.ts` | | Prompts with format helpers | `src/prompts/api/transform.ts` | | Prompts with noteWrapped + getTree | `src/prompts/quickstart.ts` | diff --git a/.ai/skills/context.md b/.ai/skills/context.md index 57b9b4798..488e0ccdb 100644 --- a/.ai/skills/context.md +++ b/.ai/skills/context.md @@ -25,10 +25,11 @@ Context objects live at `src/types/` and encapsulate path derivation, validation - **DON'T** expose derived file names — methods like `getScriptFileName()` leak internal naming logic. - **DON'T** add public properties for internal state — constructor parameters are `private readonly`, not exposed. - **DON'T** use `console.log` or any prompt output — contexts are silent. -- **DON'T** use `Result` for a plain file operation — return `boolean`, `undefined` or the value. A context returns a `Result` only when it does network I/O (rare) or reports a structured problem the prompts switch on, as `PortalSourceContext.resolve()` does with `PortalSourceProblem`. +- **DON'T** use `Result` for a plain file operation — return `boolean`, `undefined` or the value. A context returns a `Result` only when it does network I/O (rare) or reports a structured problem the prompts switch on, as `PortalSourceContext.resolve()` does with `PortalSourceProblem`. An async one is `Promise>`, written with `await` and early returns — never `ResultAsync` or `safeTry`. - **DON'T** use raw string paths — always wrap in `DirectoryPath`, `FilePath`, `FileName`. - **DON'T** add methods that only use infrastructure services (`fileService`, `zipService`) without touching domain-specific private fields — these are stateless utilities, not context behavior. Every public method must use at least one constructor-derived private field (e.g., `sdkDirectory`, `language`). If a method takes all its inputs as parameters and never reads context state, it belongs in a service or a different context. -- **DON'T** embed the context's own domain subject in method names — if the class is `SdkContext`, a method named `cleanUpSdkDirectory()` is redundant. Prefer concise behavioral verbs: `cleanUp()`, `getChanges()`, `save()`. +- **DON'T** embed the context's own domain subject in method names — if the class is `PortalContext`, a method named `cleanUpPortalDirectory()` is redundant. Prefer concise behavioral verbs: `cleanUp()`, `getChanges()`, `save()`. +- **DON'T** keep a private instance method — a helper is a `private static` method or a module-level function, and every public method does something with each of its arguments. - **DON'T** pass internal paths through callback parameters — `onSomething: (path: DirectoryPath) => void` is the same leakage as a public getter. If the caller needs the path, return it as the result of the operation (`Promise` etc.). ### Variant-specific rules @@ -83,8 +84,8 @@ Context objects live at `src/types/` and encapsulate path derivation, validation | Input context (validate + file ops) | `src/types/spec-context.ts` | Good — zip detection internal | | Temp context (zip + save stream) | `src/types/temp-context.ts` | Good — UUID naming internal | | Temp context (download + resolve) | `src/types/resource-context.ts` | Good — URL/file decision internal | -| Composite (delegates to other contexts) | `src/types/project-context.ts` | Good — hands back `PortalSourceContext`, `PluginConfigContext` and `SdkContext`; knows the layout so no caller joins `src` | -| Output context (leaky — avoid pattern) | `src/types/sdk-context.ts` | Avoid — exposes `sdkLanguageDirectory`, has methods that only use infrastructure services without touching domain state | +| Composite (delegates to other contexts) | `src/types/project-context.ts` | Good — hands back `PortalSourceContext` and `PluginConfigContext`, and derives the SDK directory; knows the layout so no caller joins `src` | +| Directory under git (facts, plans, snapshots) | `src/types/git-context.ts` | Good — one directory, git and the disk; every method reads the directory, and answers values (`RepositoryState`, `MergePlan`, `ShareHook`) the prompts word | | Input context (validate + typed result) | `src/types/portal-source-context.ts` | Good — reports problems, exposes no paths | --- @@ -96,7 +97,7 @@ Use when creating a new Context class. Choose the variant that matches the domai ### What to determine 1. **Context name** — lowercase hyphenated (e.g., `portal`, `sdk`, `build`). File will be `{name}-context.ts` -2. **Class name** — PascalCase (e.g., `PortalContext`, `SdkContext`) +2. **Class name** — PascalCase (e.g., `PortalContext`, `GitContext`) 3. **Variant** — one of: - `output` — save/exists pattern for command outputs (portals, SDKs, transformed files) - `input` — validate/read pattern for user-provided input directories diff --git a/.ai/skills/event.md b/.ai/skills/event.md index 587d23f8f..c01007696 100644 --- a/.ai/skills/event.md +++ b/.ai/skills/event.md @@ -73,7 +73,7 @@ export class {PascalName}Event extends DomainEvent { - **DO** place the file at `src/types/events/{kebab-name}.ts`. - **DO** use `.js` extension in the import: `import { DomainEvent } from "./domain-event.js"`. - **DO** fire events from the Command layer only — never from Actions or Services. -- **DO** fire success events inside the `mapAll` success callback (first arg); failure events in the failure callback (second arg). +- **DO** fire success events inside the `success` handler of `result.match(...)`; failure events in its `failed` handler. ### DON'T @@ -92,7 +92,7 @@ export class {PascalName}Event extends DomainEvent { - [ ] `message` and `commandName` are `private static readonly ... as const` (self-contained variant) - [ ] Named export (not default) - [ ] Import uses `.js` extension: `"./domain-event.js"` -- [ ] Fired from Command layer only, inside `result.mapAll(...)` callback +- [ ] Fired from Command layer only, inside a `result.match(...)` handler --- diff --git a/.ai/skills/prompt.md b/.ai/skills/prompt.md index abb56730b..6c4836ef8 100644 --- a/.ai/skills/prompt.md +++ b/.ai/skills/prompt.md @@ -25,6 +25,7 @@ Prompts live at `src/prompts/` and are the sole terminal UI layer for each comma - **DON'T** use a constructor — no parameters, no fields. - **DON'T** put business logic in Prompts — only UI rendering and user interaction. This is a wrapper around `@clack/prompts`, nothing more. +- **DON'T** decide anything from what a value means — a prompt handed a value with several outcomes (`ShareHook`, `SnapshotFetch`) calls its `match` with one handler per outcome and words each; the deciding was done where the value was made. - **DON'T** use `console.log` — use `log.*` from `@clack/prompts`. - **DON'T** skip the `isCancel()` guard on interactive prompts — omitting it causes a crash when the user presses Ctrl+C. - **DON'T** return the raw `symbol` value from interactive prompts — always guard with `isCancel()` first. @@ -65,7 +66,7 @@ Prompts live at `src/prompts/` and are the sole terminal UI layer for each comma | Simple (log + spinner, no interactive prompts) | `src/prompts/auth/login.ts` | | Simple (log-only display methods) | `src/prompts/auth/status.ts` | | Standard (confirm + log + spinner) | `src/prompts/portal/generate.ts` | -| Standard (confirm + select + log + spinner) | `src/prompts/sdk/generate.ts` | +| Standard (confirm + log + spinner + noteWrapped) | `src/prompts/sdk/generate.ts` | | Standard (log + spinner + structured display) | `src/prompts/api/validate.ts` | | Delegation (select flow + welcome message) | `src/prompts/quickstart.ts` | | Wizard (multi-step, noteWrapped, getTree) | `src/prompts/portal/quickstart.ts` | diff --git a/.ai/skills/service.md b/.ai/skills/service.md index da9137688..5ebf27a15 100644 --- a/.ai/skills/service.md +++ b/.ai/skills/service.md @@ -8,6 +8,7 @@ Services live at `src/infrastructure/services/` and are the only layer that make - **DO** use named export: `export class {PascalName}Service`. - **DO** return `Promise>` from all public methods — use `ok()` and `err()` from neverthrow. +- **DO** write the body as `async`/`await`: wrap the call in `try`/`catch`, `await` each step, and return `err(...)` early when a step `isErr()`. - **DO** catch `ProblemDetailsError` first (for 400/403 with structured error messages), then fall back to `handleServiceError(error)`. - **DO** close file streams in `finally` blocks when using `FileWrapper` or `getStream()`. - **DO** resolve auth as: `getAuthInfo(configDir.toString())` → `createAuthorizationHeader()` → `apiClientFactory.createApiClient()`. @@ -23,6 +24,7 @@ Services live at `src/infrastructure/services/` and are the only layer that make ### DON'T - **DON'T** throw — always return `err(...)` wrapped in `ServiceError`. +- **DON'T** return or chain `ResultAsync` — no `ResultAsync`, `safeTry`, `okAsync` or `errAsync`; the return type is `Promise>` and a step is checked with `isErr()`, not composed with `andThen`. - **DON'T** use `console.log` — services are silent; errors communicated via `Result`. - **DON'T** use raw string paths — use `DirectoryPath`, `FilePath`, `UrlPath`, `FileName`. - **DON'T** use `axios.create()` as a class field — create a fresh instance per call in `axiosInstance()` method. diff --git a/.ai/skills/value-object.md b/.ai/skills/value-object.md index e5b297858..62f49644d 100644 --- a/.ai/skills/value-object.md +++ b/.ai/skills/value-object.md @@ -12,6 +12,7 @@ Value objects live at `src/types/file/` and wrap a single primitive (`string`, ` - **DO** have methods that produce derived values return rich types — a method extracting a leaf name returns `FileName`, not `string`. - **DO** compose rich types through each other — `FilePath` holds `DirectoryPath` and `FileName` as objects; both are passed as objects in constructors and methods. - **DO** use `static create()` returning `T | undefined` for construction from user input or external data that can fail validation. +- **DO** use `static fromGit()` returning `T | undefined` for a value git printed (an id, a path, a ref): it validates as `create()` does, and the service turns `undefined` into an `err`, never a throw. - **DO** use `static createInput()` for the flag-value-to-rich-type pattern when a sensible fallback exists (e.g. `DirectoryPath.default`). - **DO** use `static readonly` for well-known singleton instances (e.g., `DirectoryPath.default`). - **DO** access the private field directly inside methods — write `this.directoryPath` not `this.toString()`. @@ -75,7 +76,7 @@ Anything not in this table is a violation. In particular: domain methods, contex ### Context class exposure rule -Context classes (`PortalContext`, `SdkContext`, etc.) may expose a rich-type field via a public getter or method **only** when the caller needs it for **logging or prompting display** — never for path computation, I/O, or construction of other paths. Add an inline comment when you do: +Context classes (`PortalContext`, `GitContext`, etc.) may expose a rich-type field via a public getter or method **only** when the caller needs it for **logging or prompting display** — never for path computation, I/O, or construction of other paths. Add an inline comment when you do: ```ts // Exposed for display in prompts — callers must not use this for path computation @@ -93,7 +94,6 @@ These patterns exist in the current codebase and are flagged for awareness: | File | Issue | |---|---| | `src/types/file/directoryPath.ts:31` | `leafName()` returns `string` — should return `FileName` | -| `src/types/sdk-context.ts` | `sdkLanguageDirectory` getter exposes `DirectoryPath` — needs justification comment if kept | --- @@ -125,7 +125,7 @@ These patterns exist in the current codebase and are flagged for awareness: | Rich predicate (`hasExtension`) and transform (`normalize`) returning rich type | `src/types/file/fileName.ts` | | Composed value object — holds two rich types, `static create()` splits at boundary | `src/types/file/filePath.ts` | | Correct boundary unwrapping — all `.toString()` calls at `fs.*` / `path.*` sites | `src/infrastructure/file-service.ts` | -| Context with flagged public getter (display use) | `src/types/sdk-context.ts` | +| Values git printed, through `static fromGit()` | `src/types/file/relativeFilePath.ts`, `src/types/git/commit-id.ts` | --- diff --git a/.github/workflows/portal_tests.yml b/.github/workflows/portal_tests.yml index 59f23579b..b3b9bd85c 100644 --- a/.github/workflows/portal_tests.yml +++ b/.github/workflows/portal_tests.yml @@ -166,6 +166,17 @@ jobs: PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}\.playwright-browsers run: pnpm test:visual + # The nav.json layouts that build. Each scenario generates its own copy + # of the suite's petstore build with this CLI and serves it on its + # worker's port (8090-8092), three at a time, so this needs neither the + # generate step above nor its server, and runs even if they failed. + - name: visual — nav.json + if: ${{ !cancelled() }} + working-directory: tests + env: + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}\.playwright-browsers + run: pnpm test:nav:visual + # Functional runs against the suite's auth build, whose playground calls # the API Tester (tests/api-tester/) on localhost:3000. # Playwright starts the API Tester itself (webServer in playwright.config.ts), diff --git a/CONTEXT.md b/CONTEXT.md index 09ad52aa5..e0c61861f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -55,3 +55,21 @@ _Avoid_: Example name, variant **Unplaced sample**: A code sample whose endpoint no spec in the source directory declares. _Avoid_: Orphan sample, missing operation + +### SDK regeneration + +**SDK directory**: +The directory `sdk generate` and `sdk publish` write an SDK into, and read back on every later run: the `--destination` itself when one is named, else `/sdk/`. +_Avoid_: Destination, output directory, language directory + +**Pristine snapshot**: +One generation's output exactly as the server returned it, stored as a commit under the hidden ref `refs/apimatic/pristine` of the SDK directory's own repository, never on a branch; the base of the next merge. +_Avoid_: Generated snapshot, baseline, original + +**Generation record**: +`/.apimatic/generation.json`, committed with the SDK, naming the pristine snapshot the directory was last written from. Written with `.apimatic/.gitattributes` beside it, so git merges it as binary and GitHub collapses it in pull requests. +_Avoid_: Lock file, manifest, metadata file + +**Custom code**: +Whatever differs between the SDK directory on disk and its pristine snapshot: the developer's edits, additions, deletions and moves, kept by merging the next generation over them. +_Avoid_: Customizations (in code), user changes, overrides diff --git a/README.md b/README.md index e191ded28..a1bf9676b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ Node.js 22.14 or newer, for every command. Install with `@2`: on an older Node, With `@2` npm only warns, so the install succeeds and the CLI then refuses to run, naming the version it found. +Git 2.38 or newer on your `PATH`, for `sdk generate` and `sdk publish` to keep your changes to a +generated SDK across regenerations. Without it the commands still generate, and warn that your +customizations will not persist. + # Getting Started To get started with APIMatic's CLI using a step by step wizard, run the following command: @@ -409,37 +413,37 @@ Generate an SDK for your API ``` USAGE - $ apimatic sdk generate -l csharp|java|php|python|ruby|typescript|go [-d ] [--api-version ] - [--zip] [--codegen-version v4] [--stability stable|beta] [-i ] [-f] [-k ] + $ apimatic sdk generate -l csharp|java|php|python|ruby|typescript|go [-d ] [--stability stable|beta] + [--git-init] [-i ] [-f] [-k ] FLAGS - -d, --destination= [default: /sdk/ | /sdk//] path where - the SDK will be generated - -f, --force overwrite changes without asking for user consent. - -i, --input= [default: ./] path to the parent directory containing the 'src' directory, which - includes API specifications and configuration files. - -k, --auth-key= override current authentication state with an authentication key. - -l, --language=