diff --git a/.changeset/ux-mvp-cli-flows.md b/.changeset/ux-mvp-cli-flows.md new file mode 100644 index 0000000..a28bc53 --- /dev/null +++ b/.changeset/ux-mvp-cli-flows.md @@ -0,0 +1,28 @@ +--- +'@enbox/gitd': patch +--- + +Add the first gitd UX MVP slice for local, edge-run repository workflows. + +The CLI now exposes `gitd doctor`, `gitd repair`, and `gitd helper ...` +commands, keeps local helper guidance separate from public GitTransport +publishing, and records repo context during `gitd init` and `gitd clone` so +later commands can infer owner, repo, branch, and identity without extra flags. + +First-run write commands now resolve a concrete identity before connecting the +Enbox agent, create the implicit `default` identity in interactive terminals, +and keep non-interactive `GITD_PASSWORD` flows working for scripts. Public +read-only clones can use a hidden local public-read cache when no identity is +configured. + +Contributor PR creation now infers base/head branch context, can publish the +canonical contributor branch with `--push`, and prompts interactively when a +remote-owner PR needs that publish step. Maintainer merges, role changes, and +moderation commands print concise authority summaries before writing records. + +The local helper lockfile now records session-style metadata used by `gitd +helper status`, `gitd auth sessions`, and `gitd auth revoke helper`. Doctor and +repair cover configured DWN reachability, `did::` origins, dangling bare-repo +`HEAD` refs, and duplicate `squash` SQLite migration drift. The repo protocol +also includes `$squash` `repo/viewSnapshot` records for maintainer/moderator +issue, PR, moderation, and report checkpoints. diff --git a/README.md b/README.md index 020dfd3..807b110 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,12 @@ A decentralized git forge built on [DWN](https://github.com/enboxorg/enbox) prot # install curl -fsSL https://gitd.sh/install | bash +# read a public repo, no identity setup needed +gitd clone did:dht:abc123/my-project + # create a repo, push code, open a PR — all addressed by DID -gitd setup +gitd auth login gitd init my-project -git clone did::did:dht:abc123/my-project # ... make changes ... git push gitd pr create "Add feature" @@ -31,8 +33,11 @@ gitd pr merge a1b2c3d curl -fsSL https://gitd.sh/install | bash ``` -The installer installs the published `@enbox/gitd` package with Bun, and -bootstraps Bun first if it is not already available. +The installer installs the published `@enbox/gitd` package with Bun, configures +Git's DID remote helper, and bootstraps Bun first if it is not already +available. After it finishes, `gitd clone did:dht:/` can read a +public repo without identity setup. Run `gitd auth login` when you want to +create repos, push, open PRs, or write issues. Or install directly with Bun: @@ -51,10 +56,12 @@ This installs three commands: ## Quick Start ```bash -gitd setup # configure git for DID remotes +gitd clone did:dht:abc/my-repo # read a public repo +gitd auth login # create or unlock an identity for writes gitd init my-repo # create repo record + bare git repo -gitd serve # start git transport server -git clone did::did:dht:abc/my-repo +gitd helper status # local helper should auto-start as needed +gitd auth sessions # inspect the active helper session +gitd auth reset default # archive a broken local identity profile ``` ## CLI Reference @@ -73,6 +80,7 @@ gitd issue close a1b2c3d ```bash gitd pr create "Add feature" +gitd pr create "Add feature" --push # also publish contributor branch gitd pr list gitd pr show a1b2c3d gitd pr checkout a1b2c3d @@ -117,7 +125,35 @@ gitd whoami # show connected DID ## Git Transport -`gitd serve` runs a smart HTTP git server with DID-based authentication. +`gitd helper` manages the local Git/DWN helper that native Git uses for DID +remotes. It normally starts automatically when `gitd init`, `gitd clone`, +`git push`, or `git fetch` needs it. + +`gitd helper status` shows the active profile, DID, repo cache path, local +capabilities, expiry policy, and repos this helper session has seen. `gitd auth +sessions` shows the same helper as a local Enbox session; `gitd auth revoke +helper` stops it. + +If a local identity profile is broken or you forgot its unlock password, use +`gitd auth reset ` to remove it from gitd config and move its local +profile data into `~/.enbox/profile-backups`. + +Use `gitd clone did:dht:/` for the friendlier clone path. On a +fresh machine, public clones use a hidden local public-read cache instead of +asking you to create an identity. Native Git also works with +`git clone did::did:dht:/` for public DWN-backed repos; `gitd +clone` is still the best first clone path because it records repo context and +can print clearer recovery hints. + +If you later want to write from a repo cloned through the public-read cache, +run `gitd auth login` and then `gitd auth use ` inside that repo. +Read-only commands continue to work without the write identity. + +`gitd publish --public-url ` runs the public smart HTTP Git transport with +DID-based authentication and registers a `GitTransport` endpoint. +`gitd serve --public-url ` is the lower-level equivalent. Bare `gitd +serve` remains a compatibility alias for starting the local helper, but normal +local Git work should use `gitd helper` or rely on automatic startup. - Clone and push via native git protocol - Pushers prove DID ownership; server checks DWN role records @@ -148,7 +184,8 @@ gitd web --port 3000 See [ARCHITECTURE.md](./ARCHITECTURE.md) for protocol and system design, [PLAN.md](./PLAN.md) for the full roadmap, or [REPO_LEVEL_MVP_PLAN.md](./REPO_LEVEL_MVP_PLAN.md) for the focused public -repo-level GitHub replacement plan and E2E MVP contract. +repo-level GitHub replacement plan and E2E MVP contract. See +[UX_MVP_PLAN.md](./UX_MVP_PLAN.md) for the target Git/GH-like CLI experience. ## Development diff --git a/REPO_LEVEL_MVP_PLAN.md b/REPO_LEVEL_MVP_PLAN.md index 3db4cb9..dd7cd5d 100644 --- a/REPO_LEVEL_MVP_PLAN.md +++ b/REPO_LEVEL_MVP_PLAN.md @@ -272,9 +272,11 @@ current tree: branch, branch ref-update state, squashed branch checkpoint state, and branch bundle records to the owner DWN after a remote-owner push accepted by Bob's local helper. -- `src/repo.ts` now includes immutable `repo/moderationEvent` records, and - `src/cli/commands/mod.ts` writes block/unblock, lock/unlock, - hide/unhide/delete comment, report resolution, and interaction-limit events. +- `src/repo.ts` now includes immutable `repo/moderationEvent` records plus + `$squash` `repo/viewSnapshot` checkpoint records for reduced issue, PR, + moderation, and report views. `src/cli/commands/mod.ts` writes + block/unblock, lock/unlock, hide/unhide/delete comment, report resolution, + and interaction-limit events. Block events are enforced by push authorization and by CLI issue/PR write paths. CLI issue/PR reads consume lock and comment visibility events when rendering discussion views, and CLI issue/PR comment writes reject locked @@ -376,9 +378,12 @@ These decisions should be treated as settled for the CLI E2E MVP: in canonical views. Hard deletion remains owner-only emergency behavior and should still leave a moderation audit record where possible. -9. **Block enforcement.** Blocking a DID also revokes that DID's contributor or - moderator role records in the repo. Unblocking does not restore roles - automatically. +9. **Block enforcement.** Blocking a DID creates an authoritative moderation + overlay that prevents canonical pushes and issue/PR writes even if role + grant records remain. When the repo owner issues the block locally, gitd + also deletes matching contributor or moderator role records. Moderators can + block interaction, but they do not gain role-management authority. + Unblocking does not restore deleted roles automatically. 10. **Report records.** Canonical report records are limited to contributors, moderators, maintainers, and owners. Outside reports stay actor-owned and diff --git a/UX_MVP_PLAN.md b/UX_MVP_PLAN.md new file mode 100644 index 0000000..dfad271 --- /dev/null +++ b/UX_MVP_PLAN.md @@ -0,0 +1,845 @@ +# gitd UX MVP Plan + +This plan sketches the CLI experience gitd should aim for once the repo-level +MVP is functionally solid. The goal is to make gitd feel closer to `git` and +`gh`: a few predictable commands, automatic local helper management, and +actionable recovery when local state is broken. + +The core product rule: users should not need to understand profiles, lockfiles, +DWN migration state, helper daemons, Git remote helpers, credential helpers, or +default-branch repair to create, push, clone, and collaborate on a public repo. + +## Current UX Problems + +The recent end-to-end smoke test exposed the practical friction: + +- The install flow can succeed while old wrapper/symlink state breaks Git. +- `gitd setup` is a separate mental step even though curl install should leave + Git ready to use. +- `gitd auth login` can create the profile but leave the process hanging. +- Users are asked for a "Vault password" repeatedly without clear session + behavior. +- `gitd serve` is both a local helper and a public Git transport server, which + makes the common local-helper case feel like server administration. +- Background startup failures hide the actual cause behind a timeout. +- Profile selection leaks into normal commands through `GITD_PROFILE=default`. +- Fresh public clones need a local helper/cache path; the first UX slice avoids + explicit identity setup for `gitd clone`, but true unauthenticated DWN reads + remain open. +- Failed local SQLite migrations, stale daemon locks, and dangling bare-repo + `HEAD` refs require manual repair commands. +- Clone warnings like "remote HEAD refers to nonexistent ref" are Git-level + symptoms, not gitd-level guidance. + +## UX Principles + +- Install should produce a working `gitd` and working `git clone did::...`. +- The first command that needs identity should guide the user through identity + setup; read-only public clone should not force identity setup if avoidable. +- The local helper should be automatic and boring. Users should not run + `gitd serve` for ordinary local work. +- `gitd serve` should mean "publish a GitTransport endpoint", not "make local + git operations possible". +- Every fatal error should include the failing subsystem, likely cause, and one + direct recovery command. +- Profiles should be invisible until the user has more than one identity. +- The CLI should prefer wizards for ambiguous setup, but not for routine + repeated operations. +- Native Git should still work. `gitd clone` is the polished path; `git clone + did::...` remains compatible. + +## Current Implementation Status + +- Done: helper/session visibility, auth reset, public-read clone cache, native + public `git clone did::...` fallback, write guards for the public-read cache, + doctor/repair commands, profile-aware helper startup, clearer post-init + guidance, and high-authority action summaries. +- Done: `gitd serve --public-url` now stays on the public GitTransport path, + while bare `gitd serve` is only a compatibility alias for the local helper and + tells users to prefer `gitd helper start`. +- Done: a fresh-author e2e smoke test now covers setup-equivalent wrapper + installation, implicit `default` identity creation, `gitd init`, native + `git push`, and native `git clone` without manual exports, manual setup, or + manual helper start. +- Done: first-run write commands now launch an identity setup wizard in + interactive terminals before connecting the Enbox agent. Non-interactive + flows that set `GITD_PASSWORD` keep the implicit `default` identity behavior, + and local-helper startup paths create the first identity in the foreground so + recovery phrases are not hidden in daemon logs. +- Done: `gitd pr create` has an interactive contributor-branch publish prompt + for remote-owner PRs; scripts can keep using `--push` or copy the printed + refspec from non-interactive output. +- Done: fresh-reader public clone is covered against a passive remote DWN with + a separate local home, no configured identity, no `GITD_PROFILE`, and no + `GITD_PASSWORD`. +- Done: contributor-machine PR creation is covered against a passive remote DWN + for `gitd pr create --push`, proving gitd can compute and publish the + contributor ref without a manual refspec. +- Done: issue and repo role commands now have command-specific TTY prompt + helpers for missing required input, while non-interactive calls keep explicit + usage errors. +- Done: the interactive PR publish-confirmation branch is covered through an + injectable confirmation helper, including accepted and declined decisions. +- Done: final verification and plan audit completed for the e2e UX MVP. + +## Wallet And Dapp Parity + +The Enbox wallet and Focus Boards demo suggest a useful product model for gitd: + +- The wallet owns identity, unlock state, protocol setup, permission grants, + temporary sessions, sync, and revocation. +- The dapp owns product intent: "connect", "show boards", "write task", or + "read profile". +- The dapp requests scoped access in human terms, and the wallet translates that + into DWN protocol setup, delegate DID creation, grants, and session metadata. +- Sessions are temporary and visible later in the wallet's permissions view. +- Protocol setup is described as housekeeping, separate from the actual access + being granted. + +For gitd CLI use, the same boundary should be: + +- `gitd` CLI is the local dapp surface. +- `gitd helper` is the local capability adapter for Git transport, DWN restore, + pack generation, cache repair, and background sync. +- The Enbox agent/wallet owns the signing identity, unlock password, DID + documents, local DWN, remote DWN sync, protocol setup, grants, and revocation. +- The canonical repo owner DWN is the source of truth for repo records, + protected branches, repo roles, issues, PRs, moderation events, and accepted + checkpoints. +- Contributor DWNs can hold contributor-owned branches, forks, and out-of-band + issue/patch submissions until accepted or indexed. +- Optional public indexers and public GitTransport endpoints improve discovery + and fetch performance, but they should not be required to hold maintainer keys + or run as authoritative delegates. + +Concrete lessons from the current wallet and dapp repos: + +- The dapp requests protocols and scopes up front, then the wallet handles DID + selection, DWN registration, protocol installation, delegate DID creation, + grant creation, session metadata, and revocation. +- The dapp keeps a local restored session and only asks the wallet again when + the session is missing, stale, or has insufficient scopes. +- The wallet treats protocol setup as a preparatory step before granting + access, not as the access itself. +- Read/write intent is expressed at the product layer. The user sees app-level + nouns, while the SDK maps those nouns to protocol definitions and permission + scopes. +- Delegate sessions include enough metadata for users to understand and revoke + them later: app name, origin/transport, selected DID, grants, expiry, and + connected delegate. + +For the CLI MVP, gitd should borrow those boundaries without adding a browser +approval dependency: + +- `gitd auth login` is the local wallet setup/unlock path. +- `gitd helper` is the restored dapp runtime for native Git operations. +- Helper startup should ensure forge protocols, DWN registration, sync, and + local Git transport readiness before native Git gets involved. +- The active local profile can stand in for a delegate session while we are + focused on local CLI use. +- Session metadata should still be recorded early, so later wallet-approved + delegate sessions can replace the local-profile session source without + changing repo records or Git remote behavior. + +That means the common CLI should not ask the user to manage delegates directly. +It should ask for intent: + +```bash +gitd auth login +gitd init demo +gitd clone did:dht:/demo +gitd pr create +gitd mod add +``` + +Then gitd maps that intent onto scoped authority: + +| User action | Authority needed | UX target | +|---|---|---| +| Clone public repo | Public read of repo, refs, branch bundles, issues, PRs | No identity ceremony if possible | +| Create repo | Own identity, repo protocol setup, refs protocol setup | First-run identity wizard if missing | +| Push protected branch | Maintainer role or owner identity | Native `git push` works; clear denial if not authorized | +| Push contributor branch | Contributor role and branch namespace owned by caller DID | Auto-publish to `users//` | +| Create PR | Contributor role, patch record, revision bundle | `gitd pr create` infers base/head and pushes if needed | +| Open/comment issue | Contributor, moderator, triager, or maintainer role | Direct write only for repo-level contributors | +| Moderate | Moderator or maintainer role | Explicit `gitd mod ...` commands with audit event | +| Merge/squash protected branch | Maintainer role or owner identity | Confirm branch and strategy before writing checkpoint | +| Squash own branch | Branch owner/contributor role | Automatic helper compaction, no extra prompt | + +The CLI can present permission prompts in the same style as DWeb Connect, but +with git-native nouns: + +```text +gitd wants to connect as liran + +It will be able to: + - Read public repository data + - Add or edit contributor branches for did:dht:... + - Create pull requests on repos where this identity is a contributor + +Access lasts until the helper stops. +``` + +For the local CLI MVP, this prompt can be collapsed into the first +`gitd auth login` wizard. For future dapps, it should be a wallet approval flow +with the same temporary/revocable session behavior the web wallet already uses. + +### Helper As Local Dapp Runtime + +The helper should behave like Focus Boards' restored dapp session, except it is +local and Git-facing: + +- On startup, restore the Enbox session for the selected profile. +- Ensure gitd forge protocols are installed and included in sync scope. +- Register or refresh remote DWN tenant access when needed. +- Start local DWN sync for the repo protocols. +- Expose a local GitTransport endpoint only on loopback by default. +- Cache public bundle restores and repo metadata. +- Mint short-lived push credentials for native Git. +- Stop or lock when the wallet/agent session locks. + +The helper should never be explained as a "server" in the normal path. It is a +local dapp runtime. `gitd publish` or `gitd serve --public-url` is the separate +advanced path for publishing a public GitTransport endpoint. + +### Grant And Session Shape + +Gitd can model CLI permissions as session metadata even before a full DWeb +Connect flow exists: + +- Session identity: profile name, connected DID, optional delegate DID. +- Client metadata: `gitd` version, platform, shell, local repo path, Git remote. +- Scope summary: public read, contributor branch write, patch write, + issue/comment write, maintainer write, moderation write. +- Expiry: helper lifetime for MVP; later a configurable TTL similar to the + wallet's 24-hour dapp sessions. +- Revocation: `gitd auth sessions`, `gitd auth revoke `, and wallet + permissions UI when available. + +The first CLI slice records this in the helper lockfile as a local session: +session id, profile, owner DID, repo cache path, helper capabilities, start +time, expiry policy, version, and bounded repo contexts observed through +`gitd init` and `gitd clone`. `gitd helper status` and `gitd auth sessions` +make it visible; `gitd auth revoke helper` stops the helper as the MVP +revocation mechanism. Later wallet-approved delegate sessions can replace this +local profile-backed session without changing the repo records or native Git +remote behavior. + +For native CLI use, the session can be backed by the local Enbox profile instead +of a separate delegate DID. For browser or third-party dapp use, it should use +wallet-approved delegate DIDs and permission grants so a web app never holds the +maintainer's primary keys. + +### Protocol Setup UX + +The wallet separates "protocol setup" from "permission." Gitd should do the +same: + +- `gitd auth login` prepares the forge protocols for the identity. +- `gitd init` prepares repo, refs, issues, patches, moderation, and sync scope. +- `gitd doctor` reports stale/missing protocol setup as repairable + housekeeping. +- User-facing prompts should say "prepare repository data" or "prepare gitd + protocols", not "grant Protocols.Configure". + +This also gives a clean explanation for first-run latency: gitd is preparing +identity, protocols, DWN registration, and sync before Git starts pushing data. + +### Public Repos First + +The wallet/dapp model still works for public repos: + +- Public repo records and branch bundles can be read by anyone. +- Public clone should use the smallest authority possible. +- If the current Enbox stack requires an agent for DWN reads, gitd should create + an implicit local reader session without recovery-phrase ceremony. +- Write actions still require an explicit identity and role. +- Moderation/maintainer actions should always show the actor DID and target repo + before committing the record. + +### Future Web/Dapp Shape + +We are not designing the web UI now, but the dapp pattern implies a reasonable +future: + +- A web gitd UI can request wallet access for repo social records: issues, PRs, + comments, labels, moderation events, repo metadata. +- Heavy Git pack operations can go through a local helper bridge on loopback, + because browser Git pack IO and local working-tree integration are different + from normal DWN record reads. +- A public indexer can support discovery, search, and read-heavy pages without + holding user keys. +- A hosted public GitTransport can serve public bundles, but maintainer writes + should still be signed at the edge by the maintainer's wallet/agent/helper. + +## Target Happy Paths + +### Install + +```bash +curl -fsSL https://gitd.sh/install | bash +``` + +Expected result: + +```text +Installed gitd 0.x.y +Git DID remotes configured +Run: gitd auth login +``` + +No manual `export PATH`, no manual credential helper command, no separate +`gitd setup` unless the user asks for repair/diagnostics. + +### First Identity + +```bash +gitd auth login +``` + +Target prompts: + +```text +Name this identity [default]: +Create a password to unlock gitd: +Save this recovery phrase: +... +Identity ready +``` + +Implementation notes: + +- Use "identity" and "unlock password" in user-facing copy; avoid "vault" unless + the command is explicitly diagnostic. +- Exit cleanly after success. +- Store `default` as the global default when it is the first profile. +- Offer import from recovery phrase in the same wizard. + +### Create And Push A Repo + +```bash +mkdir demo +cd demo +gitd init demo +echo "hello" > README.md +git add README.md +git commit -m "initial commit" +git push -u origin main +``` + +Target behavior: + +- `gitd init` creates identity first if missing. +- It creates the repo record and bare repo with `HEAD` pointed at the chosen + default branch. +- It initializes the local Git repo when needed. +- It configures `origin`. +- It starts or wakes the local helper automatically. +- `git push` works without `GITD_PROFILE`, `gitd serve`, or manual credential + helper setup. + +Target output from `gitd init`: + +```text +Created repo demo +Remote: did::did:dht:.../demo + +Next: + git add . + git commit -m "initial commit" + git push -u origin main +``` + +No public-server deployment guidance in this common local path. Put that behind +`gitd publish` or `gitd serve --public-url`. + +### Clone A Public Repo + +```bash +gitd clone did:dht:abc/demo +``` + +Target behavior: + +- Works on a fresh machine after install. +- If public read can be done without a persistent identity, use a read-only + public helper mode. +- If Enbox currently requires an agent for DWN reads, create/use an implicit + local "reader" profile and explain only if setup fails. +- Auto-start the helper. +- If the restored repo has a dangling `HEAD`, repair it before Git sees it. +- Store `enbox.owner`, `enbox.repo`, and active profile in the cloned repo. + +Target output: + +```text +Cloning did:dht:abc/demo +Resolved via DWN +Restored branch main +Checked out demo +``` + +Native Git remains valid: + +```bash +git clone did::did:dht:abc/demo +``` + +But `gitd clone` should be the documented path because it can diagnose and +repair more cleanly than Git's remote helper protocol allows. + +### Open A PR + +```bash +git switch -c feature +# edit, commit +gitd pr create "Add feature" +``` + +Target behavior: + +- Detect current repo, owner, default branch, and local branch. +- Infer the base branch from `--base`, the repo record, local `enbox.defaultBranch`, + remote `origin/HEAD`, or conventional local branches. +- If the current branch is not in the canonical contributor namespace, offer to + publish it as `refs/heads/users//`. +- Push the branch when requested with `--push`; later make this an interactive + yes/no prompt in TTY sessions. +- Create the PR record. +- Print a short result with PR id and checkout command. + +No required `--repo`, `--head`, or `--base` for the common case. + +### Maintainer Review And Merge + +```bash +gitd pr list +gitd pr checkout +gitd pr merge +git push origin main +``` + +Target behavior: + +- `pr checkout` creates a local branch with a predictable name. +- `pr merge` performs the Git merge locally and records the merge result. +- Protected branch push authorization remains enforced by the helper. +- If `git push origin main` would fail because the helper is down, auto-start + it and retry where possible. + +## Command Surface Changes + +### Keep + +- `gitd auth login` +- `gitd auth list` +- `gitd auth use` +- `gitd init` +- `gitd clone` +- `gitd repo ...` +- `gitd issue ...` +- `gitd pr ...` +- `gitd mod ...` + +### Add + +```bash +gitd doctor +gitd repair +gitd helper status +gitd helper logs +gitd helper stop +gitd publish --public-url https://git.example.com +``` + +`gitd doctor` should be read-only and explain: + +- installed version +- latest available version when known +- PATH and wrapper status +- Git credential helper status +- active identity/profile +- local helper status +- default DWN endpoint reachability +- repo context when inside a Git repo +- remote `did::` parse and resolution when origin is a DID remote + +`gitd repair` should be idempotent and safe: + +- rewrite command wrappers +- restore credential helper config +- remove stale daemon locks +- repair known SQLite migration-marker drift +- repair bare repo `HEAD` when a clear default branch exists +- print what it changed + +### Reframe + +`gitd serve` should become an advanced/public transport command: + +```bash +gitd publish --public-url https://git.example.com +``` + +Internally this can still call the current serve implementation. The user model +should be: + +- local helper: automatic background process +- public GitTransport server: explicit publish/deploy action + +## Error UX + +Every error should follow this shape: + +```text +gitd: could not start local helper +Reason: DWN SQLite migration state is inconsistent: squash column already exists + +Try: + gitd repair + +Details: + /home/liran/.enbox/profiles/default/gitd/daemon.log +``` + +Specific known cases: + +| Symptom | Better message | Recovery | +|---|---|---| +| `duplicate column name: squash` | Local DWN store migration marker is out of sync | `gitd repair` | +| `remote HEAD refers to nonexistent ref` | Restored repo has no default branch HEAD | auto-repair before clone; otherwise `gitd repair` | +| daemon timeout | Helper did not become healthy; show last log lines | `gitd helper logs`, `gitd repair` | +| wrong password | Could not unlock identity; offer reset/import | `gitd auth reset default` | +| missing helper binary | Install wrappers are broken | `gitd repair` | +| no profile | No identity configured | launch `auth login` wizard | + +## Profile UX + +Profiles are necessary but should not dominate the default path. + +Rules: + +- First identity is `default`. +- Commands use the repo's configured `enbox.profile` when inside a repo. +- Otherwise commands use global default profile. +- `GITD_PROFILE` remains a power-user override. +- If multiple profiles exist and the command is ambiguous, prompt once and then + store the choice where appropriate. + +Target commands: + +```bash +gitd auth login # create/import identity +gitd auth switch work # set global default +gitd auth use work # set current repo profile +gitd auth reset default # archive local profile state, keep backup +``` + +## Helper Lifecycle + +The helper should behave like an implementation detail: + +- Auto-start when `gitd init`, `gitd clone`, `git push`, `git fetch`, or a repo + command needs it. +- Health checks should wait long enough for first-run Enbox startup. +- Startup should expose progress in foreground/wizard flows: + - unlocking identity + - opening local DWN + - syncing protocols + - starting Git transport +- Background startup timeout should include the last 20 log lines. +- Helper lockfiles must be profile-scoped and version-aware. +- Old helpers should restart automatically after upgrade. + +Implementation cut: + +- Rename user-facing lifecycle commands to `gitd helper ...`. +- Keep `gitd serve ...` as an alias for now. +- Increase or make adaptive the first-run daemon startup timeout. +- Add structured helper startup events to the daemon log. + +## Install UX + +Installer responsibilities: + +- install Bun if missing +- install/update `@enbox/gitd` +- write command wrappers without following symlinks +- put `~/.gitd/bin` on PATH for common shells +- run `gitd setup` or equivalent internal setup automatically +- verify: + - `gitd --version` + - `git-remote-did` wrapper + - credential helper + - `git config --global credential.helper` + +Installer final output should include only next action, not test/debug exports. + +```text +gitd 0.x.y installed +Git DID remotes configured + +Next: + gitd auth login +``` + +## Repo Creation UX + +`gitd init ` should handle: + +- missing identity +- local git repo initialization +- default branch +- remote setup +- local helper startup +- bare repo default `HEAD` +- clear next steps + +Avoid printing deployment guidance unless the user passes `--publish`, +`--public-url`, or asks for `gitd publish`. + +Potential wizard when run outside a Git repo with no obvious intent: + +```text +Create repo demo? + Local path: /home/me/demo + Visibility: public + Default branch: main + Remote: origin +``` + +But the default non-interactive path should remain: + +```bash +gitd init demo +``` + +## Public Clone Without Identity + +This is the biggest UX opportunity. + +Current behavior needs a helper backed by an Enbox profile because clone/fetch +uses local DWN APIs and local git cache restore. For public repos, target one +of these: + +1. True unauthenticated read path: + - resolve DID + - read published DWN records directly + - restore to a temp/local cache + - no vault prompt + +2. Implicit reader identity: + - create a local read-only profile automatically + - no recovery phrase ceremony until the user wants to push, open PRs, or + create issues + +Option 1 is cleaner if Enbox APIs support it. Option 2 is acceptable for an +MVP if the prompt is avoided. + +Implemented first slice: `gitd clone did:dht:/` creates or reuses +a hidden `public-reader` profile only when no explicit, repo, environment, or +default identity is selected. The generated unlock secret is stored outside the +normal identity list under `~/.enbox/public-reader.json`, passed only to the +Git helper process, and the cloned repo records `enbox.profile=public-reader` +so later `git fetch` can wake the same local helper without a password prompt. +Native `git clone did::did:dht:/` now also uses the hidden +public-reader helper for public DWN-backed repos when no normal identity is +selected, but it cannot write repo context into `.git/config`; `gitd clone` +remains the polished first-clone path. +Write operations are blocked with a clear recovery path: `gitd auth login`, +then `gitd auth use ` inside the cloned repo. + +Covered by `tests/e2e-passive-dwn-sync.spec.ts` for the remote-DWN reader +variant: Alice publishes public repo metadata and branch bundles through a +passive DWN endpoint; a separate fresh reader home with no configured identity +clones with `gitd clone`, uses the hidden public-read cache, and checks out the +repo without a recovery phrase or unlock-password ceremony. + +## E2E MVP Line + +The end-to-end CLI MVP should not require the full browser DWeb Connect flow. +The useful wallet/dapp ideas to adopt now are the boundaries and language: + +- Treat the CLI as the dapp asking for intent. +- Treat the helper as a local runtime, not as a public service. +- Let the Enbox agent own identity, protocol setup, sync, and signing. +- Show user-facing permission summaries for surprising or high-authority + actions. +- Keep sessions revocable and inspectable, even if the first implementation is + "active while the helper is unlocked." + +Do not block the CLI MVP on: + +- QR/popup wallet approval. +- Browser-to-helper bridges. +- Hosted public indexers. +- Hosted public GitTransport. +- Delegating maintainer authority to a remote service. + +For the MVP, a successful local session can be the active Enbox profile unlocked +by `gitd auth login` or the first command that needs write authority. A future +browser/dapp can swap that session source for wallet-approved delegate grants +without changing the repo, refs, issues, patches, or moderation records. + +## Suggested Implementation Phases + +### Phase 1: Repair And Diagnostics + +- Add `gitd doctor`. +- Add `gitd repair`. +- Add structured helper startup failures with last log lines. +- Move current manual recovery knowledge into code: + - wrapper repair + - stale lock cleanup + - SQLite migration-marker repair + - bare HEAD repair + +This phase turns support conversations into executable checks. + +### Phase 2: Automatic Local Helper + +- Make `gitd init`, `gitd clone`, `gitd pr`, `gitd issue`, and `gitd repo` + consistently auto-start the helper. +- Make native `git clone did::...` and `git push` work without exported + profile vars by relying on repo/global profile config. +- Rename lifecycle UX to `gitd helper`. +- Keep extending helper session metadata from the MVP lockfile shape toward + explicit grant-backed scopes when wallet-approved delegates are introduced. +- Keep `gitd serve` for public transport and backwards compatibility. + +### Phase 3: First-Run Wizards + +- Polish `gitd auth login`. +- Add missing-identity prompts to commands that need identity. Implemented + slice: interactive first-run write commands and local-helper startup paths + show an identity setup wizard; scripted `GITD_PASSWORD` flows remain + non-interactive. +- Add command-specific wizards for common missing issue/repo input. Implemented: + `gitd issue create`, `issue show`, `issue comment`, `issue close`, + `issue reopen`, and repo role add/remove commands can prompt in TTY sessions; + scripts still receive usage errors. +- Add human-readable permission summaries for contributor branch writes, + maintainer branch writes, and moderation actions. Implemented slices: + `gitd pr merge` prints actor DID, repo, base/head, strategy, and commit count + before mutating the local branch and writing merge records; repo role changes + and moderation events print actor, target repo, target DID/record, and reason + before writing role or audit records. +- Add `gitd auth reset`. Implemented: resets remove the identity from config, + stop its helper, and archive the local profile directory under + `~/.enbox/profile-backups`. +- Add `gitd init` and `gitd clone` success summaries. + +### Phase 4: Public Read Mode + +- Make public clone/fetch possible without explicit identity setup. + Implemented first slices for `gitd clone` and native `git clone did::...`: + hidden local `public-reader` profile/cache when no normal identity exists. +- Block write commands from the hidden reader with an `auth login`/`auth use` + recovery message instead of prompting for an implementation-detail password. +- Cache restored public repos under a deterministic helper cache. +- Keep write operations gated by identity setup. + +### Phase 5: GH-Like Collaboration + +- Make `gitd pr create` infer branch/base, compute contributor namespace, and + publish it with `--push`. Implemented slices: base/head inference, + contributor namespace calculation, explicit `--push`, and an interactive + publish prompt for TTY sessions; non-interactive runs still print the exact + `git push` refspec. Covered by passive-DWN E2E for `gitd pr create --push` + from a contributor clone without a manual branch refspec, and by unit tests + for accepted/declined interactive publish confirmations. +- Make `gitd pr checkout` and `gitd pr merge` require minimal arguments. +- Add concise list/show formatting for issues, PRs, moderators, and roles. + +### Later: Wallet/Dapp Bridge + +- Design a DWeb Connect request shape for gitd repo records and contributor + branch writes. +- Decide whether web git pack operations need a local helper bridge or a + read-only public GitTransport. +- Add wallet permission display names for gitd protocols and scopes. +- Add session/revocation UI in the wallet for gitd helper and web dapp sessions. + +## Acceptance Scenarios + +### Fresh Author Machine + +```bash +curl -fsSL https://gitd.sh/install | bash +gitd auth login +mkdir demo && cd demo +gitd init demo +echo hello > README.md +git add README.md +git commit -m "initial commit" +git push -u origin main +``` + +Passes if no exports, manual setup, manual helper start, or repair commands are +needed. + +Covered by `tests/e2e-ux-mvp.spec.ts` for the local author-machine variant: +setup-equivalent wrapper installation, implicit first identity, init, push, and +clone all run through the native Git helper path without exported profile or +password during Git operations. + +### Fresh Reader Machine + +```bash +curl -fsSL https://gitd.sh/install | bash +gitd clone did:dht:/demo +cat demo/README.md +``` + +Passes if public clone works without identity ceremony. + +Covered by `tests/e2e-passive-dwn-sync.spec.ts` against a passive remote DWN, +using a separate reader home and no `GITD_PROFILE` or `GITD_PASSWORD`. + +### Fresh Contributor Machine + +```bash +curl -fsSL https://gitd.sh/install | bash +gitd auth login +gitd clone did:dht:/demo +cd demo +git switch -c feature +git commit --allow-empty -m "feature" +gitd pr create "Feature" +``` + +Passes if gitd pushes the right contributor branch and creates the PR without +manual refspecs. + +Covered by `tests/e2e-passive-dwn-sync.spec.ts` for the passive remote-DWN +contributor workflow with `gitd pr create --push`, and by +`tests/pr-helpers.spec.ts` for the interactive publish prompt decisions. + +### Broken Local State + +```bash +gitd doctor +gitd repair +gitd doctor +``` + +Passes if known broken install/profile/helper states are diagnosed and repaired +without manual file surgery. + +Covered by `tests/doctor.spec.ts`, `tests/repair.spec.ts`, +`tests/dwn-sqlite.spec.ts`, and daemon lifecycle coverage in the full suite. + +## Open Questions + +- Can Enbox expose a clean unauthenticated public-read DWN path, or do we need + to keep the implicit reader profile beyond the CLI MVP? +- Should OS keychain integration be in the MVP, or is one password prompt per + helper session acceptable? +- Should the local CLI use the primary profile directly for MVP writes, or + should it create a same-device delegate session from the start? +- Should `gitd publish` replace most `gitd serve --public-url` docs now, or + after local helper UX is stable? +- Should install automatically run identity setup when launched in an + interactive shell, or should it stop at "Run `gitd auth login`"? +- What is the exact repo URL we want users to share: `did:dht:.../repo`, + `did::did:dht:.../repo`, or a future `gitd.id//` resolver? diff --git a/install.sh b/install.sh index 0bf9f72..26595fb 100755 --- a/install.sh +++ b/install.sh @@ -207,7 +207,14 @@ main() { printf '==> Installed to %s\n' "$INSTALL_DIR" "${INSTALL_DIR}/gitd" --version - printf 'Run: gitd setup\n' + + printf '==> Configuring Git DID remotes\n' + if ! "${INSTALL_DIR}/gitd" setup --bin-dir "$INSTALL_DIR" --quiet; then + printf 'warning: gitd installed, but Git DID remote setup did not complete.\n' >&2 + printf 'Run: gitd repair\n' >&2 + fi + + printf 'Next: gitd auth login\n' } main "$@" diff --git a/schemas/repo/view-snapshot.json b/schemas/repo/view-snapshot.json new file mode 100644 index 0000000..b7cdfd0 --- /dev/null +++ b/schemas/repo/view-snapshot.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://enbox.id/schemas/forge/view-snapshot", + "title": "ViewSnapshotData", + "description": "$squash checkpoint for reduced issue, PR, moderation, or report views.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["issue", "pr", "moderation", "report"] + }, + "targetId": { + "type": "string" + }, + "reducerVersion": { + "type": "string" + }, + "lastRecordId": { + "type": "string" + }, + "recordCount": { + "type": "integer", + "minimum": 0 + }, + "state": { + "type": "object" + }, + "actorDid": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": ["kind", "state", "actorDid", "createdAt"], + "additionalProperties": false +} diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 23b259a..acbd5dd 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -73,6 +73,8 @@ export type AgentContext = { wiki : TypedEnbox; org : TypedEnbox; enbox : Enbox; + /** Release CLI-owned agent/auth resources after one-shot commands. */ + close? : () => Promise; /** Test/adapter hook for sending store:false records to another DID. */ sendRecord? : (record: any, targetDid: string) => Promise; }; @@ -286,8 +288,13 @@ export async function connectAgent(options: ConnectOptions): Promise | undefined; + const close = (): Promise => { + closePromise ??= closeAgentResources(enbox, auth, session.agent); + return closePromise; + }; - return bindProtocols(enbox, session.did, session.recoveryPhrase); + return bindProtocols(enbox, session.did, session.recoveryPhrase, close); } // --------------------------------------------------------------------------- @@ -394,6 +401,7 @@ async function bindProtocols( enbox: Enbox, did: string, recoveryPhrase?: string, + close?: () => Promise, ): Promise { const repo = enbox.using(ForgeRepoProtocol); const refs = enbox.using(ForgeRefsProtocol); @@ -425,6 +433,24 @@ async function bindProtocols( return { did, repo, refs, issues, patches, ci, releases, registry, social, notifications, wiki, org, enbox, + close, recoveryPhrase, }; } + +async function closeAgentResources(enbox: Enbox, auth: AuthManager, agent: unknown): Promise { + try { + await enbox.disconnect(); + } finally { + try { + await auth.shutdown(); + } finally { + await closeLocalDwn(agent); + } + } +} + +async function closeLocalDwn(agent: unknown): Promise { + const dwn = (agent as { dwn?: { _dwn?: { close?: () => Promise } } })?.dwn?._dwn; + await dwn?.close?.(); +} diff --git a/src/cli/command-input.ts b/src/cli/command-input.ts new file mode 100644 index 0000000..8e0626c --- /dev/null +++ b/src/cli/command-input.ts @@ -0,0 +1,61 @@ +/** + * Small helpers for command-specific wizards. + * + * Commands still accept normal positional arguments for scripts. In an + * interactive terminal, missing required positionals can be prompted without + * accidentally treating flag values such as `--repo demo` as command input. + * + * @module + */ + +export type InteractivePromptOptions = { + stdinIsTTY?: boolean; + stdoutIsTTY?: boolean; +}; + +export function shouldPromptForMissingInput( + value: string | undefined, + options: InteractivePromptOptions = { + stdinIsTTY : process.stdin.isTTY, + stdoutIsTTY : process.stdout.isTTY, + }, +): boolean { + return !value && Boolean(options.stdinIsTTY && options.stdoutIsTTY); +} + +export function positionalArgs(args: readonly string[], flagsWithValue: ReadonlySet): string[] { + const values: string[] = []; + let consumeNext = false; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (consumeNext) { + consumeNext = false; + continue; + } + + if (arg === '--') { + values.push(...args.slice(i + 1)); + break; + } + + if (arg.startsWith('--')) { + if (!arg.includes('=') && flagsWithValue.has(arg)) { + consumeNext = true; + } + continue; + } + + if (arg.startsWith('-') && arg !== '-') { + if (flagsWithValue.has(arg)) { + consumeNext = true; + } + continue; + } + + values.push(arg); + } + + return values; +} diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index a6e3d36..402ad0c 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -4,25 +4,35 @@ * Usage: * gitd auth Show current identity info * gitd auth login Create or import an identity - * gitd auth list List all profiles - * gitd auth use Set profile for current repo - * gitd auth use --global Set default profile - * gitd auth export [profile] Export portable identity + * gitd auth list List all identities + * gitd auth switch Set default identity + * gitd auth use Set identity for current repo + * gitd auth use --global Set default identity + * gitd auth sessions List active local helper sessions + * gitd auth revoke helper Revoke the active helper session + * gitd auth reset [identity] Archive and remove local identity state + * gitd auth export [identity] Export portable identity * gitd auth import Import from recovery phrase - * gitd auth logout [profile] Remove a profile + * gitd auth logout [identity] Remove an identity * * @module */ import type { AgentContext } from '../agent.js'; +import { join } from 'node:path'; +import { existsSync, mkdirSync, renameSync } from 'node:fs'; + import * as p from '@clack/prompts'; import { connectAgent } from '../agent.js'; +import { flagValue } from '../flags.js'; +import { daemonStatus, stopDaemon } from '../../daemon/lifecycle.js'; import { enboxHome, listProfiles, profileDataPath, + profilesDir, readConfig, resolveProfile, setGitConfigProfile, @@ -30,6 +40,8 @@ import { writeConfig, } from '../../profiles/config.js'; +const DEFAULT_IDENTITY_NAME = 'default'; + // --------------------------------------------------------------------------- // Sub-command dispatch // --------------------------------------------------------------------------- @@ -45,12 +57,125 @@ export async function authCommand(ctx: AgentContext | null, args: string[]): Pro switch (sub) { case 'login': return authLogin(); case 'list': return authList(); + case 'switch': return authSwitch(args.slice(1)); case 'use': return authUse(args.slice(1)); + case 'sessions': return authSessions(args.slice(1)); + case 'revoke': return authRevoke(args.slice(1)); + case 'reset': return authReset(args.slice(1)); case 'logout': return authLogout(args.slice(1)); default: return authInfo(ctx); } } +type AuthSessionStatus = ReturnType; + +function authExpiryLabel(policy: string | undefined): string | undefined { + if (policy === 'helper-lifetime') { return 'when helper stops'; } + return policy; +} + +function authRepoContextLine(context: NonNullable[number]): string { + const branch = context.defaultBranch ? ` (${context.defaultBranch})` : ''; + const path = context.path ? ` at ${context.path}` : ''; + return `${context.ownerDid}/${context.repo}${branch}${path}`; +} + +export function formatAuthSessionsStatus( + status: AuthSessionStatus, + fallbackProfileName?: string, +): string[] { + if (!status.running) { + return [ + 'No active helper session.', + 'Run `gitd helper start` to start one.', + ]; + } + + const lines = [ + 'Active helper session', + ` ID: ${status.sessionId ?? 'helper'}`, + ` Profile: ${status.profileName ?? fallbackProfileName ?? 'default'}`, + ]; + + if (status.ownerDid) { + lines.push(` DID: ${status.ownerDid}`); + } + if (status.port) { + lines.push(` Port: ${status.port}`); + } + if (status.reposPath) { + lines.push(` Repos: ${status.reposPath}`); + } + if (status.capabilities?.length) { + lines.push(' Capabilities:'); + for (const capability of status.capabilities) { + lines.push(` - ${capability}`); + } + } else if (status.dwnHelper) { + lines.push(' Capabilities: dwn-restore'); + } + const expiry = authExpiryLabel(status.expiryPolicy); + if (expiry) { + lines.push(` Expires: ${expiry}`); + } + if (status.repoContexts?.length) { + lines.push(' Seen repos:'); + for (const context of status.repoContexts) { + lines.push(` - ${authRepoContextLine(context)}`); + } + } + + lines.push(' Revoke: gitd auth revoke helper'); + return lines; +} + +function authProfileName(args: string[]): string | undefined { + return resolveProfile(flagValue(args, '--profile')) ?? undefined; +} + +export function firstAuthPositionalArg(args: string[]): string | undefined { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('--profile=')) { continue; } + if (arg === '--profile') { + i++; + continue; + } + if (!arg.startsWith('-')) { return arg; } + } + return undefined; +} + +function authSessions(args: string[]): void { + const profileName = authProfileName(args); + for (const line of formatAuthSessionsStatus(daemonStatus({ profileName }), profileName)) { + console.log(line); + } +} + +function authRevoke(args: string[]): void { + const target = firstAuthPositionalArg(args); + const profileName = authProfileName(args); + const status = daemonStatus({ profileName }); + + if (!target) { + p.log.error('Usage: gitd auth revoke helper'); + process.exit(1); + } + + if (target !== 'helper' && target !== status.sessionId) { + p.log.error(`Unknown helper session "${target}". Run \`gitd auth sessions\`.`); + process.exit(1); + } + + const stopped = stopDaemon({ profileName }); + if (stopped) { + p.log.success('Helper session revoked.'); + } else { + p.log.warn('No active helper session.'); + } +} + // --------------------------------------------------------------------------- // auth (no subcommand) — show current identity // --------------------------------------------------------------------------- @@ -66,7 +191,7 @@ async function authInfo(ctx: AgentContext | null): Promise { const entry = config.profiles[profileName]; - p.log.info(`Profile: ${profileName}${config.defaultProfile === profileName ? ' (default)' : ''}`); + p.log.info(`Identity: ${profileName}${config.defaultProfile === profileName ? ' (default)' : ''}`); p.log.info(`DID: ${entry.did}`); p.log.info(`Created: ${entry.createdAt}`); p.log.info(`Data: ${profileDataPath(profileName)}`); @@ -82,6 +207,8 @@ async function authInfo(ctx: AgentContext | null): Promise { async function authLogin(): Promise { p.intro('Identity setup'); + const existingProfiles = listProfiles(); + const defaultName = suggestIdentityName(existingProfiles); const action = await p.select({ message : 'What would you like to do?', @@ -97,13 +224,11 @@ async function authLogin(): Promise { } const name = await p.text({ - message : 'Name this profile:', - placeholder : 'personal', + message : 'Name this identity:', + defaultValue : defaultName, + placeholder : defaultName, validate(val) { - if (!val?.trim()) { return 'Profile name is required.'; } - if (!/^[a-zA-Z0-9_-]+$/.test(val)) { return 'Only letters, numbers, hyphens, and underscores.'; } - const existing = listProfiles(); - if (existing.includes(val)) { return `Profile "${val}" already exists.`; } + return validateIdentityNameInput(val, existingProfiles, defaultName); }, }); @@ -112,10 +237,10 @@ async function authLogin(): Promise { return; } - const profileName = (name as string).trim(); + const profileName = normalizeIdentityNameInput(name as string, defaultName); const password = await p.password({ - message: 'Choose a password for your vault:', + message: 'Create a password to unlock gitd:', validate(val) { if (!val || (val as string).length < 4) { return 'Password must be at least 4 characters.'; } }, @@ -148,7 +273,7 @@ async function authLogin(): Promise { } const spin = p.spinner(); - spin.start('Creating identity...'); + spin.start(action === 'import' ? 'Importing identity...' : 'Creating identity...'); try { const dataPath = profileDataPath(profileName); @@ -167,8 +292,9 @@ async function authLogin(): Promise { spin.stop('Identity created!'); - p.log.success(`DID: ${result.did}`); - p.log.success(`Profile: ${profileName} (${dataPath})`); + p.log.success(`DID: ${result.did}`); + p.log.success(`Identity: ${profileName}`); + p.log.info(`Data: ${dataPath}`); if (result.recoveryPhrase) { p.log.warn(''); @@ -178,17 +304,51 @@ async function authLogin(): Promise { p.log.warn('This phrase can recover your identity if you lose your password.'); p.log.warn('Store it securely — it will NOT be shown again.'); } + + try { + await result.close?.(); + } catch (closeErr) { + p.log.warn(`Identity was created, but local resources could not be released cleanly: ${(closeErr as Error).message}`); + } } catch (err) { spin.stop('Failed.'); - p.log.error(`Failed to create identity: ${(err as Error).message}`); + p.log.error(`Failed to ${action === 'import' ? 'import' : 'create'} identity: ${(err as Error).message}`); process.exit(1); } - p.outro('You\'re all set! Run `gitd whoami` to verify.'); + p.outro('Identity ready. Run `gitd whoami` to verify.'); +} + +export function suggestIdentityName(existingProfiles: readonly string[]): string { + if (!existingProfiles.includes(DEFAULT_IDENTITY_NAME)) { + return DEFAULT_IDENTITY_NAME; + } + + let index = 2; + while (existingProfiles.includes(`identity-${index}`)) { + index++; + } + return `identity-${index}`; +} + +export function normalizeIdentityNameInput(value: string | undefined, defaultName: string): string { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : defaultName; +} + +export function validateIdentityNameInput( + value: string | undefined, + existingProfiles: readonly string[], + defaultName = suggestIdentityName(existingProfiles), +): string | undefined { + const normalized = normalizeIdentityNameInput(value, defaultName); + if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) { return 'Only letters, numbers, hyphens, and underscores.'; } + if (existingProfiles.includes(normalized)) { return `Identity "${normalized}" already exists.`; } + return undefined; } // --------------------------------------------------------------------------- -// auth list — list all profiles +// auth list — list all identities // --------------------------------------------------------------------------- function authList(): void { @@ -196,11 +356,11 @@ function authList(): void { const names = Object.keys(config.profiles); if (names.length === 0) { - p.log.warn('No profiles found. Run `gitd auth login` to create one.'); + p.log.warn('No identities found. Run `gitd auth login` to create one.'); return; } - p.log.info(`Profiles (${enboxHome()}):\n`); + p.log.info(`Identities (${enboxHome()}):\n`); for (const name of names) { const entry = config.profiles[name]; @@ -211,8 +371,139 @@ function authList(): void { } } +export function setDefaultIdentity(name: string): string { + const config = readConfig(); + if (!config.profiles[name]) { + throw new Error(`Identity "${name}" not found. Run \`gitd auth list\` to see available identities.`); + } + + config.defaultProfile = name; + writeConfig(config); + return `Default identity set to "${name}".`; +} + +type ResetIdentityResult = { + name : string; + backupPath? : string; + defaultProfile? : string; +}; + +export function resetIdentityLocally( + name: string, + now = new Date(), +): ResetIdentityResult { + const config = readConfig(); + if (!config.profiles[name]) { + throw new Error(`Identity "${name}" not found. Run \`gitd auth list\` to see available identities.`); + } + + const backupPath = archiveIdentityDirectory(name, now); + + delete config.profiles[name]; + if (config.defaultProfile === name) { + const remaining = Object.keys(config.profiles); + config.defaultProfile = remaining[0] ?? ''; + } + writeConfig(config); + + return { + name, + ...(backupPath ? { backupPath } : {}), + ...(config.defaultProfile ? { defaultProfile: config.defaultProfile } : {}), + }; +} + +function archiveIdentityDirectory(name: string, now: Date): string | undefined { + const source = join(profilesDir(), name); + if (!existsSync(source)) { + return undefined; + } + + const backupRoot = join(enboxHome(), 'profile-backups'); + mkdirSync(backupRoot, { recursive: true }); + + const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_'); + const timestamp = now.toISOString().replace(/[:.]/g, '-'); + let target = join(backupRoot, `${safeName}-${timestamp}`); + let suffix = 2; + while (existsSync(target)) { + target = join(backupRoot, `${safeName}-${timestamp}-${suffix}`); + suffix++; + } + + renameSync(source, target); + return target; +} + +// --------------------------------------------------------------------------- +// auth switch — set global default identity +// --------------------------------------------------------------------------- + +async function authSwitch(args: string[]): Promise { + const name = args[0]; + + if (!name) { + p.log.error('Usage: gitd auth switch '); + process.exit(1); + } + + try { + p.log.success(setDefaultIdentity(name)); + } catch (err) { + p.log.error((err as Error).message); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// auth reset — archive local identity state +// --------------------------------------------------------------------------- + +async function authReset(args: string[]): Promise { + const name = firstAuthPositionalArg(args) ?? resolveProfile() ?? ''; + const yes = args.includes('--yes') || args.includes('-y'); + + if (!name) { + p.log.error('No identity specified and no default identity found.'); + p.log.info('Usage: gitd auth reset [identity] [--yes]'); + process.exit(1); + } + + if (!yes) { + const confirmed = await p.confirm({ + message: `Reset identity "${name}"? Local profile data will be moved to a backup.`, + }); + + if (p.isCancel(confirmed) || !confirmed) { + p.cancel('Cancelled.'); + return; + } + } + + stopDaemon({ profileName: name }); + + try { + const result = resetIdentityLocally(name); + p.log.success(`Identity "${result.name}" reset.`); + if (result.backupPath) { + p.log.info(`Backup: ${result.backupPath}`); + } else { + p.log.info('No local profile directory existed to back up.'); + } + if (result.defaultProfile) { + p.log.info(`Default identity is now "${result.defaultProfile}".`); + } else { + p.log.info('No default identity is configured.'); + } + p.log.info('Run `gitd auth login` to create or import an identity.'); + } catch (err) { + p.log.error((err as Error).message); + process.exit(1); + } +} + // --------------------------------------------------------------------------- -// auth use — set active profile +// auth use — set active identity // --------------------------------------------------------------------------- async function authUse(args: string[]): Promise { @@ -220,33 +511,31 @@ async function authUse(args: string[]): Promise { const isGlobal = args.includes('--global'); if (!name) { - p.log.error('Usage: gitd auth use [--global]'); + p.log.error('Usage: gitd auth use [--global]'); process.exit(1); } const config = readConfig(); if (!config.profiles[name]) { - p.log.error(`Profile "${name}" not found. Run \`gitd auth list\` to see available profiles.`); + p.log.error(`Identity "${name}" not found. Run \`gitd auth list\` to see available identities.`); process.exit(1); } if (isGlobal) { - config.defaultProfile = name; - writeConfig(config); - p.log.success(`Default profile set to "${name}".`); + p.log.success(setDefaultIdentity(name)); } else { try { setGitConfigProfile(name); - p.log.success(`Profile "${name}" set for this repository.`); + p.log.success(`Identity "${name}" set for this repository.`); } catch { - p.log.error('Not in a git repository. Use --global to set the default profile.'); + p.log.error('Not in a git repository. Use --global to set the default identity.'); process.exit(1); } } } // --------------------------------------------------------------------------- -// auth logout — remove a profile +// auth logout — remove an identity // --------------------------------------------------------------------------- async function authLogout(args: string[]): Promise { @@ -257,18 +546,18 @@ async function authLogout(args: string[]): Promise { } if (!name) { - p.log.error('No profile specified and no default profile found.'); + p.log.error('No identity specified and no default identity found.'); process.exit(1); } const config = readConfig(); if (!config.profiles[name]) { - p.log.error(`Profile "${name}" not found.`); + p.log.error(`Identity "${name}" not found.`); process.exit(1); } const confirm = await p.confirm({ - message: `Remove profile "${name}"? This will delete all local data for this identity.`, + message: `Remove identity "${name}"? This keeps local data on disk unless you delete it manually.`, }); if (p.isCancel(confirm) || !confirm) { @@ -284,7 +573,7 @@ async function authLogout(args: string[]): Promise { } writeConfig(config); - p.log.success(`Profile "${name}" removed.`); + p.log.success(`Identity "${name}" removed.`); p.log.info(`Data directory preserved at: ${profileDataPath(name)}`); p.log.info('Delete it manually if you want to free disk space.'); } diff --git a/src/cli/commands/clone.ts b/src/cli/commands/clone.ts index 3424b95..3369a2f 100644 --- a/src/cli/commands/clone.ts +++ b/src/cli/commands/clone.ts @@ -5,17 +5,42 @@ * 1. Validates the DID/repo argument * 2. Spawns `git clone did::/` with inherited stdio * - * Usage: gitd clone / [git-clone-args...] + * Usage: gitd clone / [--profile ] [git-clone-args...] * * @module */ +import { resolve } from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; +import { recordLockfileRepoContext } from '../../daemon/lockfile.js'; +import { resolveProfile } from '../../profiles/config.js'; +import { + getOrCreatePublicReaderPassword, + PUBLIC_READER_PROFILE, +} from '../../profiles/public-reader.js'; + // --------------------------------------------------------------------------- // Argument helpers // --------------------------------------------------------------------------- +type CloneTarget = { + did : string; + repo : string; +}; + +type CloneCommandArgs = { + gitArgs : string[]; + profileName?: string; +}; + +type CloneProfileSelection = { + profileName?: string; + env : NodeJS.ProcessEnv; + publicReader : boolean; + publicReaderCreated : boolean; +}; + const CLONE_OPTIONS_WITH_VALUE = new Set([ '--also-filter-submodules', '--branch', @@ -45,6 +70,68 @@ export function gitCloneArgs(args: string[]): string[] { return args[0] === '--' ? args.slice(1) : args; } +/** Parse the user-facing clone target into DID and repo components. */ +export function parseCloneTarget(target: string): CloneTarget { + const slashIdx = target.indexOf('/'); + if (slashIdx === -1 || !target.startsWith('did:')) { + throw new Error(`Invalid target: "${target}"\nExpected format: did::/`); + } + + const did = target.slice(0, slashIdx); + const repo = target.slice(slashIdx + 1); + + if (did.split(':').length < 3) { + throw new Error(`Invalid DID: "${did}"\nExpected format: did::`); + } + + if (!repo) { + throw new Error('Missing repository name after DID.\nExpected format: did::/'); + } + + return { did, repo }; +} + +/** + * Split gitd-owned clone flags from arguments that should pass through to + * native `git clone`. + */ +export function parseCloneCommandArgs(args: string[]): CloneCommandArgs { + const gitArgs: string[] = []; + let profileName: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--') { + gitArgs.push(...args.slice(i + 1)); + break; + } + + if (arg === '--profile') { + if (!args[i + 1]) { + throw new Error('--profile requires an identity name'); + } + profileName = args[i + 1]; + i++; + continue; + } + + if (arg.startsWith('--profile=')) { + profileName = arg.slice('--profile='.length); + continue; + } + + gitArgs.push(arg); + } + + return { gitArgs, profileName }; +} + +/** Use the hidden reader only when no explicit or ambient identity exists. */ +export function shouldUsePublicReaderProfile(explicitProfile?: string, resolvedProfile?: string | null): boolean { + return !explicitProfile && !resolvedProfile; +} + /** Infer the working-tree path that `git clone` will create. */ export function inferCloneDirectory(repoName: string, args: string[]): string { const positionals: string[] = []; @@ -87,6 +174,74 @@ export function inferCloneDirectory(repoName: string, args: string[]): string { return positionals.at(-1) ?? repoName; } +/** Store gitd repo context in the cloned repository's local git config. */ +export function recordCloneContext( + cloneDir: string, + target: CloneTarget, + profileName?: string, +): string[] { + const warnings: string[] = []; + const entries: [string, string][] = [ + ['enbox.repo', target.repo], + ['enbox.owner', target.did], + ]; + if (profileName) { + entries.push(['enbox.profile', profileName]); + } + + for (const [key, value] of entries) { + const result = spawnSync('git', ['config', '--local', key, value], { + cwd : cloneDir, + stdio : 'pipe', + }); + if (result.status !== 0) { + warnings.push(`could not write ${key} in ${cloneDir}`); + } + } + + return warnings; +} + +/** Pass resolved gitd profile selection through to Git's remote helper. */ +export function gitCloneEnv( + env: NodeJS.ProcessEnv, + profileName?: string, + password?: string, +): NodeJS.ProcessEnv { + if (!profileName && !password) { return env; } + + return { + ...env, + ...(profileName ? { GITD_PROFILE: profileName } : {}), + ...(password ? { GITD_PASSWORD: password } : {}), + }; +} + +/** Resolve clone identity, creating a local read-only cache when needed. */ +export function selectCloneProfile( + parsedArgs: CloneCommandArgs, + env: NodeJS.ProcessEnv = process.env, +): CloneProfileSelection { + const resolvedProfile = resolveProfile(parsedArgs.profileName); + if (!shouldUsePublicReaderProfile(parsedArgs.profileName, resolvedProfile)) { + const profileName = resolvedProfile ?? undefined; + return { + profileName, + env : gitCloneEnv(env, profileName), + publicReader : false, + publicReaderCreated : false, + }; + } + + const reader = getOrCreatePublicReaderPassword(); + return { + profileName : PUBLIC_READER_PROFILE, + env : gitCloneEnv(env, PUBLIC_READER_PROFILE, reader.password), + publicReader : true, + publicReaderCreated : reader.created, + }; +} + // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- @@ -95,7 +250,7 @@ export async function cloneCommand(args: string[]): Promise { const target = args[0]; if (!target) { - console.error('Usage: gitd clone / [git-clone-args...]'); + console.error('Usage: gitd clone / [--profile ] [git-clone-args...]'); console.error(''); console.error('Examples:'); console.error(' gitd clone did:dht:abc123/my-repo'); @@ -104,45 +259,41 @@ export async function cloneCommand(args: string[]): Promise { process.exit(1); } - // Parse the target: expect "did::/" format. - const slashIdx = target.indexOf('/'); - if (slashIdx === -1 || !target.startsWith('did:')) { - console.error(`Invalid target: "${target}"`); - console.error('Expected format: did::/'); + let parsed: CloneTarget; + try { + parsed = parseCloneTarget(target); + } catch (err) { + console.error((err as Error).message); console.error('Example: did:dht:abc123/my-repo'); process.exit(1); } - const didPart = target.slice(0, slashIdx); - const repoPart = target.slice(slashIdx + 1); - - if (didPart.split(':').length < 3) { - console.error(`Invalid DID: "${didPart}"`); - console.error('Expected format: did::'); - process.exit(1); - } - - if (!repoPart) { - console.error('Missing repository name after DID.'); - console.error('Expected format: did::/'); + let parsedArgs: CloneCommandArgs; + try { + parsedArgs = parseCloneCommandArgs(args.slice(1)); + } catch (err) { + console.error((err as Error).message); process.exit(1); } - - // Collect extra git args. A leading `--` remains supported from the - // original command shape, but it is no longer required. - const extraArgs = gitCloneArgs(args.slice(1)); + const profileSelection = selectCloneProfile(parsedArgs); + const profileName = profileSelection.profileName; + const extraArgs = parsedArgs.gitArgs; // Build the DID transport URL: `did::/` - const didUrl = `did::${didPart}/${repoPart}`; + const didUrl = `did::${parsed.did}/${parsed.repo}`; - console.log(`Cloning ${didPart}/${repoPart} via DID transport...`); + console.log(`Cloning ${parsed.did}/${parsed.repo}`); + if (profileSelection.publicReader) { + const cacheLabel = profileSelection.publicReaderCreated ? 'Created' : 'Using'; + console.log(`${cacheLabel} local public-read cache (no identity setup required).`); + } console.log(''); // Spawn git clone with inherited stdio so the user sees progress. const exitCode = await new Promise((resolve, reject) => { const child = spawn('git', ['clone', didUrl, ...extraArgs], { stdio : 'inherit', - env : process.env, + env : profileSelection.env, }); child.on('error', reject); child.on('exit', (code) => resolve(code ?? 128)); @@ -154,15 +305,24 @@ export async function cloneCommand(args: string[]): Promise { // Determine the clone directory (git uses the repo name by default, // unless the user specified a destination in extraArgs). - const cloneDir = inferCloneDirectory(repoPart, extraArgs); + const cloneDir = inferCloneDirectory(parsed.repo, extraArgs); + const warnings = recordCloneContext(cloneDir, parsed, profileName); + recordLockfileRepoContext({ + ownerDid : parsed.did, + repo : parsed.repo, + path : resolve(cloneDir), + remoteUrl : didUrl, + }, profileName); - // Store the repo name in git config so subsequent commands can auto-detect it. - spawnSync('git', ['config', 'enbox.repo', repoPart], { - cwd : cloneDir, - stdio : 'pipe', - }); - spawnSync('git', ['config', 'enbox.owner', didPart], { - cwd : cloneDir, - stdio : 'pipe', - }); + console.log(''); + console.log(`Checked out ${cloneDir}`); + console.log(`Repo: ${parsed.did}/${parsed.repo}`); + if (profileSelection.publicReader) { + console.log('Access: public-read cache'); + } else if (profileName) { + console.log(`Identity: ${profileName}`); + } + for (const warning of warnings) { + console.error(`Warning: ${warning}`); + } } diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts new file mode 100644 index 0000000..31031d6 --- /dev/null +++ b/src/cli/commands/doctor.ts @@ -0,0 +1,377 @@ +/** + * `gitd doctor` — read-only diagnostics for local gitd setup. + * + * Checks the pieces users should not have to understand during normal use: + * Git availability, command wrappers, credential helper config, profile + * selection, repo config, and local helper status. + * + * @module + */ + +import { homedir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { accessSync, constants, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +import { DidDht, DidJwk, DidKey, DidWeb, UniversalResolver } from '@enbox/dids'; + +import { checkGit } from '../preflight.js'; +import { flagValue } from '../flags.js'; +import { getVersion } from '../../version.js'; +import { parseDidUrl } from '../../git-remote/parse-url.js'; +import { resolveAgentDwnEndpoints } from '../agent.js'; +import { configPath, profileDataPath, readConfig, resolveProfile } from '../../profiles/config.js'; +import { daemonLogPath, daemonStatus } from '../../daemon/lifecycle.js'; + +type CheckStatus = 'ok' | 'warn' | 'fail'; + +type CheckCounters = { + fail: number; + warn: number; +}; + +const DEFAULT_BIN_DIR = join(homedir(), '.gitd', 'bin'); +const REQUIRED_WRAPPERS = ['git-remote-did', 'git-remote-did-credential'] as const; +const DOCTOR_DID_RESOLUTION_TIMEOUT_MS = 5_000; +const DOCTOR_DWN_ENDPOINT_TIMEOUT_MS = 2_000; + +export async function doctorCommand(args: string[]): Promise { + const counters: CheckCounters = { fail: 0, warn: 0 }; + const binDir = flagValue(args, '--bin-dir') ?? DEFAULT_BIN_DIR; + const profileFlag = flagValue(args, '--profile'); + const profileName = resolveProfile(profileFlag) ?? undefined; + + console.log('gitd doctor'); + console.log(''); + + checkVersion(counters); + checkGitInstall(counters); + checkPath(counters, binDir); + checkWrappers(counters, binDir); + checkCredentialHelper(counters); + checkProfile(counters, profileName); + await checkDwnEndpoints(counters); + await checkRepoContext(counters); + checkLocalHelper(counters, profileName); + + console.log(''); + if (counters.fail > 0) { + console.log(`Found ${counters.fail} issue${counters.fail === 1 ? '' : 's'} that gitd repair may fix.`); + console.log('Try: gitd repair'); + process.exitCode = 1; + return; + } + + if (counters.warn > 0) { + console.log(`No blocking issues found. ${counters.warn} warning${counters.warn === 1 ? '' : 's'} reported.`); + return; + } + + console.log('All checked items look ready.'); +} + +function report( + counters: CheckCounters, + status: CheckStatus, + name: string, + detail: string, +): void { + if (status === 'fail') { counters.fail++; } + if (status === 'warn') { counters.warn++; } + console.log(`[${status}] ${name}: ${detail}`); +} + +function checkVersion(counters: CheckCounters): void { + report(counters, 'ok', 'version', getVersion() ?? 'unknown'); +} + +function checkGitInstall(counters: CheckCounters): void { + const git = checkGit(); + if (!git.installed) { + report(counters, 'fail', 'git', 'not found on PATH'); + return; + } + if (!git.meetsMinimum) { + report(counters, 'fail', 'git', `${git.version ?? 'unknown'} is older than required 2.28.0`); + return; + } + report(counters, 'ok', 'git', git.version ?? 'installed'); +} + +function checkPath(counters: CheckCounters, binDir: string): void { + if (isOnPath(binDir)) { + report(counters, 'ok', 'PATH', `${binDir} is on PATH`); + return; + } + + report(counters, 'warn', 'PATH', `${binDir} is not on PATH for this shell`); +} + +function checkWrappers(counters: CheckCounters, binDir: string): void { + for (const name of REQUIRED_WRAPPERS) { + const path = join(binDir, name); + if (!existsSync(path)) { + report(counters, 'fail', name, `missing at ${path}`); + continue; + } + + if (!isExecutable(path)) { + report(counters, 'fail', name, `not executable at ${path}`); + continue; + } + + report(counters, 'ok', name, path); + } +} + +function checkCredentialHelper(counters: CheckCounters): void { + const helpers = gitConfigGlobalAll('credential.helper'); + if (helpers.length === 0) { + report(counters, 'fail', 'credential helper', 'not configured'); + return; + } + + const gitdHelper = helpers.find((helper) => helper.includes('git-remote-did-credential')); + if (!gitdHelper) { + report(counters, 'fail', 'credential helper', `configured without gitd: ${helpers.join(', ')}`); + return; + } + + report(counters, 'ok', 'credential helper', gitdHelper); +} + +function checkProfile(counters: CheckCounters, profileName?: string): void { + const config = safeReadConfig(); + if (!config) { + report(counters, 'warn', 'identity', `no config found at ${configPath()}`); + return; + } + + if (Object.keys(config.profiles).length === 0) { + report(counters, 'warn', 'identity', 'none configured; run gitd auth login'); + return; + } + + if (!profileName) { + report(counters, 'warn', 'identity', 'multiple profiles exist and no default/profile was resolved'); + return; + } + + const entry = config.profiles[profileName]; + if (!entry) { + report(counters, 'fail', 'identity', `profile "${profileName}" not found in ${configPath()}`); + return; + } + + report(counters, 'ok', 'identity', `${profileName} (${entry.did})`); + report(counters, existsSync(profileDataPath(profileName)) ? 'ok' : 'warn', 'identity data', profileDataPath(profileName)); +} + +async function checkDwnEndpoints(counters: CheckCounters): Promise { + const endpoints = resolveAgentDwnEndpoints(); + if (endpoints.length === 0) { + report(counters, 'warn', 'DWN endpoint', 'none configured'); + return; + } + + await Promise.all(endpoints.map(async (endpoint) => { + const result = await probeDwnEndpoint(endpoint); + report(counters, result.ok ? 'ok' : 'warn', 'DWN endpoint', result.detail); + })); +} + +async function checkRepoContext(counters: CheckCounters): Promise { + const inside = gitRevParseGitDir(); + if (!inside) { + report(counters, 'warn', 'repo context', 'not inside a Git repository'); + return; + } + + const repo = gitConfigLocal('--get', 'enbox.repo'); + const owner = gitConfigLocal('--get', 'enbox.owner'); + const profile = gitConfigLocal('--get', 'enbox.profile'); + const origin = gitConfigLocal('--get', 'remote.origin.url'); + + if (repo && owner) { + report(counters, 'ok', 'repo context', `${owner}/${repo}`); + } else { + report(counters, 'warn', 'repo context', 'missing enbox.owner or enbox.repo'); + } + + if (profile) { + report(counters, 'ok', 'repo profile', profile); + } + + if (origin?.startsWith('did::')) { + await checkDidRemote(counters, origin); + } else if (origin) { + report(counters, 'warn', 'DID remote', `origin is ${origin}`); + } +} + +export function parseDoctorDidRemote(origin: string): ReturnType | null { + if (origin.startsWith('did::')) { + return parseDidUrl(origin.slice('did::'.length)); + } + if (origin.startsWith('did://')) { + return parseDidUrl(origin); + } + return null; +} + +async function checkDidRemote(counters: CheckCounters, origin: string): Promise { + let parsed: ReturnType | null; + try { + parsed = parseDoctorDidRemote(origin); + } catch (err) { + report(counters, 'fail', 'DID remote', (err as Error).message); + return; + } + + if (!parsed) { + report(counters, 'warn', 'DID remote', `origin is ${origin}`); + return; + } + + report(counters, 'ok', 'DID remote', `${parsed.did}${parsed.repo ? `/${parsed.repo}` : ''}`); + + const resolution = await inspectDidServices(parsed.did); + report(counters, resolution.ok ? 'ok' : 'warn', 'DID resolution', resolution.detail); +} + +function checkLocalHelper(counters: CheckCounters, profileName?: string): void { + const status = daemonStatus({ profileName }); + if (!status.running) { + report(counters, 'warn', 'local helper', `not running; log path ${daemonLogPath(profileName)}`); + return; + } + + report(counters, 'ok', 'local helper', `port ${status.port}, pid ${status.pid}, uptime ${status.uptime}`); +} + +function isExecutable(path: string): boolean { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +function isOnPath(dir: string): boolean { + return (process.env.PATH ?? '') + .split(':') + .some((entry) => resolve(entry) === resolve(dir)); +} + +function gitConfigGlobalAll(key: string): string[] { + const result = spawnSync('git', ['config', '--global', '--get-all', key], { + encoding : 'utf-8', + stdio : ['ignore', 'pipe', 'pipe'], + timeout : 2_000, + }); + if (result.status !== 0) { return []; } + return (result.stdout ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function gitConfigLocal(...args: string[]): string | undefined { + const result = spawnSync('git', ['config', ...args], { + encoding : 'utf-8', + stdio : ['ignore', 'pipe', 'pipe'], + timeout : 2_000, + }); + const value = result.stdout?.trim(); + return result.status === 0 && value ? value : undefined; +} + +function gitRevParseGitDir(): string | undefined { + const result = spawnSync('git', ['rev-parse', '--git-dir'], { + encoding : 'utf-8', + stdio : ['ignore', 'pipe', 'pipe'], + timeout : 2_000, + }); + const value = result.stdout?.trim(); + return result.status === 0 && value ? value : undefined; +} + +function safeReadConfig(): ReturnType | undefined { + try { + return readConfig(); + } catch { + return undefined; + } +} + +type ProbeResult = { + ok: boolean; + detail: string; +}; + +async function probeDwnEndpoint(endpoint: string): Promise { + try { + const url = new URL(endpoint); + const response = await fetch(url, { + method : 'GET', + signal : AbortSignal.timeout(DOCTOR_DWN_ENDPOINT_TIMEOUT_MS), + }); + return { + ok : response.status < 500, + detail : `${endpoint} returned HTTP ${response.status}`, + }; + } catch (err) { + return { + ok : false, + detail : `${endpoint} unreachable: ${(err as Error).message}`, + }; + } +} + +type DidInspection = { + ok: boolean; + detail: string; +}; + +let doctorResolver: UniversalResolver | undefined; + +function getDoctorResolver(): UniversalResolver { + doctorResolver ??= new UniversalResolver({ + didResolvers: [DidDht, DidJwk, DidWeb, DidKey], + }); + return doctorResolver; +} + +async function inspectDidServices(did: string): Promise { + try { + const resolved = await Promise.race([ + getDoctorResolver().resolve(did), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`timed out after ${DOCTOR_DID_RESOLUTION_TIMEOUT_MS}ms`)), DOCTOR_DID_RESOLUTION_TIMEOUT_MS), + ), + ]); + + if (resolved.didResolutionMetadata.error) { + return { ok: false, detail: `${did}: ${resolved.didResolutionMetadata.error}` }; + } + + const services = resolved.didDocument?.service ?? []; + const serviceTypes = services.map((service) => service.type).join(', ') || '(none)'; + + if (services.some((service) => service.type === 'GitTransport')) { + return { ok: true, detail: `${did} services: ${serviceTypes}` }; + } + + if (services.some((service) => service.type === 'DecentralizedWebNode')) { + return { + ok : false, + detail : `${did} has DWN service but no GitTransport; local helper must be available for clone/fetch`, + }; + } + + return { ok: false, detail: `${did} has no GitTransport service; services: ${serviceTypes}` }; + } catch (err) { + return { ok: false, detail: `${did} could not be resolved: ${(err as Error).message}` }; + } +} diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 92763e8..b69c102 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -5,6 +5,7 @@ * * Usage: gitd init [--description ] [--branch ] * [--repos ] [--dwn-endpoint ] + * [--publish] [--public-url ] * [--no-local] * * By default the command also initializes a git repository in the current @@ -22,6 +23,7 @@ import type { AgentContext } from '../agent.js'; import { getDwnEndpoints } from '../../git-server/did-service.js'; import { GitBackend } from '../../git-server/git-backend.js'; +import { recordLockfileRepoContext } from '../../daemon/lockfile.js'; import { flagValue, hasFlag, resolveReposPath } from '../flags.js'; // --------------------------------------------------------------------------- @@ -37,9 +39,11 @@ export async function initCommand(ctx: AgentContext, args: string[]): Promise [--private] [--description ] [--branch ] [--repos ] [--dwn-endpoint ] [--no-local]'); + console.error('Usage: gitd init [--private] [--description ] [--branch ] [--repos ] [--dwn-endpoint ] [--publish] [--public-url ] [--no-local]'); process.exit(1); } @@ -92,11 +96,12 @@ export async function initCommand(ctx: AgentContext, args: string[]): Promise 1 ? positionals.slice(1).join(' ') : undefined); + return { + id: positionals[0], + ...(body ? { body } : {}), + }; +} + +async function promptIssueCreateInputs(inputs: IssueCreateInputs): Promise { + if (inputs.title) { return inputs; } + + const title = await promptRequiredText(undefined, 'Issue title:'); + if (!title) { return inputs; } + + return { + title, + body: inputs.body ?? await promptOptionalText('Issue body (optional):'), + }; +} + +async function promptIssueId(value: string | undefined, message: string): Promise { + return promptRequiredText(value, message); +} + +async function promptRequiredText(value: string | undefined, message: string): Promise { + if (!shouldPromptForMissingInput(value)) { return value; } + + const response = await p.text({ + message, + validate(input) { + if (!input?.trim()) { return 'Required.'; } + }, + }); + + if (p.isCancel(response)) { + p.cancel('Cancelled.'); + process.exit(130); + } + + return (response as string).trim(); +} + +async function promptOptionalText(message: string): Promise { + if (!shouldPromptForMissingInput(undefined)) { return undefined; } + + const response = await p.text({ message }); + if (p.isCancel(response)) { + p.cancel('Cancelled.'); + process.exit(130); + } + + const value = (response as string).trim(); + return value || undefined; +} + // --------------------------------------------------------------------------- // issue create // --------------------------------------------------------------------------- async function issueCreate(ctx: AgentContext, args: string[]): Promise { - const title = args[0]; - const body = flagValue(args, '--body') ?? flagValue(args, '-m') ?? ''; + const parsed = issueCreateInputs(args); + const prompted = await promptIssueCreateInputs(parsed); + const title = prompted.title; + const body = prompted.body ?? ''; if (!title) { console.error('Usage: gitd issue create [--body <text>]'); @@ -102,7 +187,7 @@ async function issueCreate(ctx: AgentContext, args: string[]): Promise<void> { // --------------------------------------------------------------------------- async function issueShow(ctx: AgentContext, args: string[]): Promise<void> { - const idStr = args[0]; + const idStr = await promptIssueId(issueCommandPositionals(args)[0], 'Issue ID:'); if (!idStr) { console.error('Usage: gitd issue show <id>'); process.exit(1); @@ -156,10 +241,9 @@ async function issueShow(ctx: AgentContext, args: string[]): Promise<void> { // --------------------------------------------------------------------------- async function issueComment(ctx: AgentContext, args: string[]): Promise<void> { - const idStr = args[0]; - const flagBody = flagValue(args, '--body') ?? flagValue(args, '-m'); - const positional = args.slice(1).filter(a => !a.startsWith('-')).join(' '); - const body = flagBody ?? (positional || undefined); + const parsed = issueCommentInputs(args); + const idStr = await promptIssueId(parsed.id, 'Issue ID:'); + const body = await promptRequiredText(parsed.body, 'Comment:'); if (!idStr || !body) { console.error('Usage: gitd issue comment <id> <body>'); @@ -205,7 +289,7 @@ async function issueComment(ctx: AgentContext, args: string[]): Promise<void> { // --------------------------------------------------------------------------- async function issueClose(ctx: AgentContext, args: string[]): Promise<void> { - const idStr = args[0]; + const idStr = await promptIssueId(issueCommandPositionals(args)[0], 'Issue ID:'); if (!idStr) { console.error('Usage: gitd issue close <id>'); process.exit(1); @@ -265,7 +349,7 @@ async function issueClose(ctx: AgentContext, args: string[]): Promise<void> { // --------------------------------------------------------------------------- async function issueReopen(ctx: AgentContext, args: string[]): Promise<void> { - const idStr = args[0]; + const idStr = await promptIssueId(issueCommandPositionals(args)[0], 'Issue ID:'); if (!idStr) { console.error('Usage: gitd issue reopen <id>'); process.exit(1); diff --git a/src/cli/commands/mod.ts b/src/cli/commands/mod.ts index e2b867b..84b756f 100644 --- a/src/cli/commands/mod.ts +++ b/src/cli/commands/mod.ts @@ -223,6 +223,16 @@ async function createModerationEvent( createdAt : new Date().toISOString(), }; const tags = moderationTags(data); + for (const line of formatModerationEventSummary({ + action : data.action, + actorDid : ctx.did, + ownerDid, + repoName : repo.name, + target : moderationTargetLabel(data), + reason : data.reason, + })) { + console.log(line); + } const { status, record } = await ctx.repo.records.create('repo/moderationEvent' as any, { data, @@ -287,3 +297,29 @@ function parseReportKind(value: string | undefined): ModerationEventData['target if (value === 'pr-comment') { return 'prComment'; } return 'issue'; } + +export type ModerationEventSummary = { + action : string; + actorDid : string; + ownerDid : string; + repoName : string; + target : string; + reason?: string; +}; + +export function formatModerationEventSummary(summary: ModerationEventSummary): string[] { + return [ + `Moderation: ${summary.action}`, + ` Repo: ${summary.ownerDid}/${summary.repoName}`, + ` Actor: ${summary.actorDid}`, + ` Target: ${summary.target}`, + ...(summary.reason ? [` Reason: ${summary.reason}`] : []), + ]; +} + +function moderationTargetLabel(data: ModerationEventData): string { + if (data.targetDid) { return data.targetDid; } + if (data.targetId && data.targetKind) { return `${data.targetKind}:${data.targetId}`; } + if (data.interactionLimit) { return `repo:${data.interactionLimit}`; } + return data.targetKind ?? 'repo'; +} diff --git a/src/cli/commands/pr.ts b/src/cli/commands/pr.ts index d3b37f1..a1770e9 100644 --- a/src/cli/commands/pr.ts +++ b/src/cli/commands/pr.ts @@ -2,7 +2,7 @@ * `gitd pr` — create, list, show, checkout, comment on, and merge pull requests. * * Usage: - * gitd pr create <title> [--body <text>] [--base <branch>] [--head <branch>] + * gitd pr create <title> [--body <text>] [--base <branch>] [--head <branch>] [--push|--no-push] * gitd pr checkout <id> [--branch <name>] [--detach] * gitd pr show <id> * gitd pr comment <id> <body> @@ -23,10 +23,11 @@ import type { RepoContext, RepoRoleName } from '../repo-context.js'; import { Buffer } from 'node:buffer'; import { join } from 'node:path'; -import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import * as p from '@clack/prompts'; import { HttpDwnRpcClient } from '@enbox/dwn-clients'; import { DataStream, @@ -46,6 +47,7 @@ import { processMessageOnTargetEndpoints, sendRecordToTarget, } from '../record-send.js'; +import { contributorBranchPrefix, isContributorBranchRef } from '../../branch-state.js'; import { discussionIsLocked, latestActiveBlock, visibleCommentRecords } from '../moderation-state.js'; import { flagValue, hasFlag, resolveRepoName, resolveRepoOwner } from '../flags.js'; import { fromOpt, getRepoContext, getRepoContextForDid, resolveRepoProtocolRole } from '../repo-context.js'; @@ -84,12 +86,15 @@ export async function prCommand(ctx: AgentContext, args: string[]): Promise<void async function prCreate(ctx: AgentContext, args: string[]): Promise<void> { const title = args[0]; const body = flagValue(args, '--body') ?? flagValue(args, '-m') ?? ''; - const base = flagValue(args, '--base') ?? 'main'; + const explicitBase = flagValue(args, '--base'); const head = flagValue(args, '--head'); const noBundle = hasFlag(args, '--no-bundle'); + const pushContributorBranch = hasFlag(args, '--push'); + const noPushContributorBranch = hasFlag(args, '--no-push'); + const pushRemote = flagValue(args, '--remote') ?? 'origin'; if (!title) { - console.error('Usage: gitd pr create <title> [--body <text>] [--base <branch>] [--head <branch>] [--no-bundle]'); + console.error('Usage: gitd pr create <title> [--body <text>] [--base <branch>] [--head <branch>] [--no-bundle] [--push|--no-push]'); process.exit(1); } @@ -100,10 +105,16 @@ async function prCreate(ctx: AgentContext, args: string[]): Promise<void> { await (ctx.patches as any).configure?.({ encryption: true }); } + const base = inferPrBaseBranch({ + explicitBase, + repoDefaultBranch: target.repo.defaultBranch, + git, + }); + // Detect git context for revision + bundle creation. const gitInfo = noBundle ? null : detectGitContext(base); - const headBranch = head ?? gitInfo?.headBranch; + const headBranch = inferPrHeadBranch(head, gitInfo, git); const tags: Record<string, string> = { status : 'open', @@ -138,6 +149,25 @@ async function prCreate(ctx: AgentContext, args: string[]): Promise<void> { if (gitInfo) { await createRevisionAndBundle(ctx, record, gitInfo, target); } + + const publishPlan = target.remote + ? contributorBranchPublishPlan(ctx.did, headBranch, pushRemote) + : undefined; + if (publishPlan) { + console.log(` Contributor branch: ${publishPlan.refName}`); + const shouldPush = await shouldPublishContributorBranch({ + plan : publishPlan, + pushRequested : pushContributorBranch, + noPush : noPushContributorBranch, + stdinIsTTY : process.stdin.isTTY, + stdoutIsTTY : process.stdout.isTTY, + }); + if (shouldPush) { + await publishContributorBranchToRemote(publishPlan); + } else { + console.log(` Publish branch: ${publishPlan.commandText}`); + } + } } // --------------------------------------------------------------------------- @@ -423,6 +453,7 @@ async function prMerge(ctx: AgentContext, args: string[]): Promise<void> { process.exit(1); } + const protocolRole = await patchMaintainerRole(ctx, target); const baseBranch = tags?.baseBranch ?? 'main'; const headBranch = tags?.headBranch ?? `pr/${idStr}`; @@ -448,6 +479,20 @@ async function prMerge(ctx: AgentContext, args: string[]): Promise<void> { const countStr = git(['rev-list', '--count', `${baseBranch}..${headBranch}`]); const commitCount = parseInt(countStr ?? '0', 10); + for (const line of formatPrMergeSummary({ + id : idStr, + title : String(data.title ?? ''), + actorDid : ctx.did, + ownerDid : target.ownerDid, + repoName : target.repo.name, + baseBranch, + headBranch, + strategy, + commitCount, + })) { + console.log(line); + } + // Perform the merge with the chosen strategy. if (strategy === 'squash') { const sq = spawnSync('git', ['merge', '--squash', headBranch], { @@ -533,7 +578,6 @@ async function prMerge(ctx: AgentContext, args: string[]): Promise<void> { const mergeCommit = git(['rev-parse', 'HEAD']) ?? 'unknown'; // Update the patch status to merged. - const protocolRole = await patchMaintainerRole(ctx, target, 'maintainer'); if (isEndpointBackedRecord(patch)) { await updateEndpointBackedPatch(ctx, target, patch, data, { ...tags, status: 'merged' }, protocolRole); } else { @@ -930,6 +974,8 @@ type GitContext = { diffStat : { additions: number; deletions: number; filesChanged: number }; }; +type GitCommandRunner = (args: string[]) => string | null; + /** Run a git command synchronously, returning trimmed stdout or `null` on failure. */ function git(args: string[]): string | null { const result = spawnSync('git', args, { @@ -941,6 +987,187 @@ function git(args: string[]): string | null { return result.stdout?.trim() ?? null; } +export type PrBaseBranchInferenceOptions = { + explicitBase?: string; + repoDefaultBranch?: string; + git?: GitCommandRunner; +}; + +export function inferPrBaseBranch(options: PrBaseBranchInferenceOptions = {}): string { + const explicitBase = cleanBranchName(options.explicitBase); + if (explicitBase) { return explicitBase; } + + const repoDefaultBranch = cleanBranchName(options.repoDefaultBranch); + if (repoDefaultBranch) { return repoDefaultBranch; } + + const runGit = options.git ?? git; + const configured = cleanBranchName(runGit(['config', '--get', 'enbox.defaultBranch'])); + if (configured) { return configured; } + + const remoteHead = normalizeRemoteHeadBranch(runGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])); + if (remoteHead) { return remoteHead; } + + const initDefault = cleanBranchName(runGit(['config', '--get', 'init.defaultBranch'])); + if (initDefault && branchExists(runGit, initDefault)) { return initDefault; } + + for (const branch of ['main', 'master', 'trunk', 'develop']) { + if (branchExists(runGit, branch)) { return branch; } + } + + return 'main'; +} + +export function inferPrHeadBranch( + explicitHead?: string, + gitContext?: Pick<GitContext, 'headBranch'> | null, + runGit: GitCommandRunner = git, +): string | undefined { + const explicit = cleanBranchName(explicitHead); + if (explicit) { return explicit; } + + const fromContext = cleanBranchName(gitContext?.headBranch); + if (fromContext && fromContext !== 'HEAD') { return fromContext; } + + const current = cleanBranchName(runGit(['rev-parse', '--abbrev-ref', 'HEAD'])); + return current && current !== 'HEAD' ? current : undefined; +} + +export type ContributorBranchPublishPlan = { + remote : string; + refName : string; + refspec : string; + commandText : string; +}; + +export type ContributorBranchPublishPromptOptions = { + plan?: ContributorBranchPublishPlan; + pushRequested?: boolean; + noPush?: boolean; + stdinIsTTY?: boolean; + stdoutIsTTY?: boolean; +}; + +export type ContributorBranchPublishConfirm = (plan: ContributorBranchPublishPlan) => Promise<boolean | 'cancel'>; + +export function contributorBranchRefForHead( + actorDid: string, + headBranch?: string, +): string | undefined { + const branch = cleanBranchName(headBranch); + if (!branch || branch === 'HEAD') { return undefined; } + + const refName = branch.startsWith('refs/heads/') + ? branch + : `refs/heads/${branch}`; + if (isContributorBranchRef(refName, actorDid)) { return refName; } + + const suffix = branch.startsWith('refs/heads/') + ? branch.slice('refs/heads/'.length) + : branch; + if (!isSafeContributorBranchSuffix(suffix)) { return undefined; } + + return `${contributorBranchPrefix(actorDid)}${suffix}`; +} + +export function contributorBranchPublishPlan( + actorDid: string, + headBranch?: string, + remote = 'origin', +): ContributorBranchPublishPlan | undefined { + const refName = contributorBranchRefForHead(actorDid, headBranch); + if (!refName) { return undefined; } + + const refspec = `HEAD:${refName}`; + return { + remote, + refName, + refspec, + commandText: `git push ${shellQuote(remote)} ${shellQuote(refspec)}`, + }; +} + +export function shouldPromptContributorBranchPublish(options: ContributorBranchPublishPromptOptions): boolean { + return Boolean( + options.plan + && !options.pushRequested + && !options.noPush + && options.stdinIsTTY + && options.stdoutIsTTY, + ); +} + +export async function shouldPublishContributorBranch( + options: ContributorBranchPublishPromptOptions, + confirm: ContributorBranchPublishConfirm = confirmContributorBranchPublish, +): Promise<boolean> { + if (!options.plan) { return false; } + if (options.pushRequested) { return true; } + if (!shouldPromptContributorBranchPublish(options)) { return false; } + + const confirmed = await confirm(options.plan); + if (confirmed === 'cancel') { + p.cancel('Cancelled.'); + process.exit(130); + } + + return Boolean(confirmed); +} + +async function confirmContributorBranchPublish(plan: ContributorBranchPublishPlan): Promise<boolean | 'cancel'> { + const confirmed = await p.confirm({ + message: `Publish current branch to ${plan.refName}?`, + }); + return p.isCancel(confirmed) ? 'cancel' : Boolean(confirmed); +} + +async function publishContributorBranchToRemote(plan: ContributorBranchPublishPlan): Promise<void> { + console.log(` Publishing branch: ${plan.commandText}`); + const status = await new Promise<number>((resolveExit, reject) => { + const child = spawn('git', ['push', plan.remote, plan.refspec], { + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code) => resolveExit(code ?? 128)); + }); + + if (status !== 0) { + console.error(`Failed to publish contributor branch ${plan.refName}.`); + process.exit(status); + } +} + +function cleanBranchName(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function isSafeContributorBranchSuffix(value: string): boolean { + return value.length > 0 + && !value.startsWith('/') + && !value.endsWith('/') + && !value.includes('..') + && !/[\s\0~^:?*[\]\\]/.test(value) + && !value.split('/').some((part) => part === '' || part === '.' || part.endsWith('.lock')); +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) { return value; } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function normalizeRemoteHeadBranch(value: string | undefined | null): string | undefined { + const branch = cleanBranchName(value); + if (!branch) { return undefined; } + return branch.startsWith('origin/') + ? branch.slice('origin/'.length) + : branch; +} + +function branchExists(runGit: GitCommandRunner, branch: string): boolean { + return runGit(['rev-parse', '--verify', `refs/heads/${branch}`]) !== null + || runGit(['rev-parse', '--verify', `refs/remotes/origin/${branch}`]) !== null; +} + /** * Detect git context for the current working directory. * @@ -1469,6 +1696,31 @@ type PatchTarget = { remote : boolean; }; +export type PrMergeSummary = { + id : string; + title : string; + actorDid : string; + ownerDid : string; + repoName : string; + baseBranch : string; + headBranch : string; + strategy : string; + commitCount : number; +}; + +export function formatPrMergeSummary(summary: PrMergeSummary): string[] { + const commitLabel = `${summary.commitCount} commit${summary.commitCount === 1 ? '' : 's'}`; + return [ + `Merging PR ${summary.id}: ${summary.title}`, + ` Repo: ${summary.ownerDid}/${summary.repoName}`, + ` Actor: ${summary.actorDid}`, + ` Base: ${summary.baseBranch}`, + ` Head: ${summary.headBranch}`, + ` Strategy: ${summary.strategy}`, + ` Commits: ${commitLabel}`, + ]; +} + async function resolvePatchTarget(ctx: AgentContext, args: string[]): Promise<PatchTarget> { const ownerDid = resolveRepoOwner(args) ?? ctx.did; const repo = await getRepoContextForDid(ctx, ownerDid, resolveRepoName(args)); @@ -1498,9 +1750,13 @@ async function patchDiscussionRole(ctx: AgentContext, target: PatchTarget): Prom async function patchMaintainerRole( ctx: AgentContext, target: PatchTarget, - fallback?: RepoRoleName, ): Promise<string | undefined> { - return resolvePatchRole(ctx, target, ['maintainer'], fallback); + const protocolRole = await resolvePatchRole(ctx, target, ['maintainer']); + if (target.remote && !protocolRole) { + console.error(`You need maintainer access to write maintainer actions on ${target.ownerDid}/${target.repo.name}.`); + process.exit(1); + } + return protocolRole; } async function resolvePatchRole( diff --git a/src/cli/commands/repair.ts b/src/cli/commands/repair.ts new file mode 100644 index 0000000..802f375 --- /dev/null +++ b/src/cli/commands/repair.ts @@ -0,0 +1,207 @@ +/** + * `gitd repair` — safe local repair for common setup/helper problems. + * + * This command intentionally avoids mutating repo data. It repairs the local + * command wrappers, Git credential helper configuration, and stale/corrupt + * helper lockfiles. It also repairs dangling bare-repo HEAD refs when an + * existing default branch can be inferred safely. + * + * @module + */ + +import type { Dirent } from 'node:fs'; + +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { repairGitdDwnSqliteStore } from '../dwn-sqlite.js'; +import { setupCommand } from './setup.js'; + +import { existsSync, readdirSync, unlinkSync } from 'node:fs'; +import { flagValue, resolveReposPath } from '../flags.js'; +import { lockfilePath, readLockfile } from '../../daemon/lockfile.js'; +import { profileDataPath, resolveProfile } from '../../profiles/config.js'; + +export async function repairCommand(args: string[]): Promise<void> { + const profileFlag = flagValue(args, '--profile'); + const profileName = resolveProfile(profileFlag) ?? profileFlag; + const binDir = flagValue(args, '--bin-dir'); + const reposPath = resolveReposPath(args, profileName); + + console.log('Repairing gitd local setup...'); + console.log(''); + + await setupCommand([ + ...(binDir ? ['--bin-dir', binDir] : []), + ]); + + console.log(''); + repairLockfile(undefined, 'legacy/global helper lockfile'); + if (profileName) { + repairLockfile(profileName, `profile "${profileName}" helper lockfile`); + } + + console.log(''); + await repairDwnSqlite(profileName); + + console.log(''); + repairBareRepoHeads(reposPath); + + console.log(''); + console.log('Repair complete.'); + console.log('Next: gitd doctor'); +} + +function repairLockfile(profileName: string | undefined, label: string): void { + const path = lockfilePath(profileName); + const existed = existsSync(path); + const lock = readLockfile(profileName); + + if (lock) { + console.log(` [ok] ${label} is active on port ${lock.port}`); + return; + } + + if (!existed) { + console.log(` [ok] ${label} not present`); + return; + } + + if (!existsSync(path)) { + console.log(` [fixed] removed stale ${label}`); + return; + } + + try { + unlinkSync(path); + console.log(` [fixed] removed invalid ${label}`); + } catch (err) { + console.log(` [warn] could not remove ${label}: ${(err as Error).message}`); + } +} + +async function repairDwnSqlite(profileName: string | undefined): Promise<void> { + if (!profileName) { + console.log(' [ok] DWN SQLite store skipped; no active identity'); + return; + } + + try { + const result = await repairGitdDwnSqliteStore(profileDataPath(profileName)); + if (result.status === 'missing') { + console.log(` [ok] DWN SQLite store not present at ${result.dbPath}`); + return; + } + + if (result.status === 'fixed') { + console.log(` [fixed] DWN SQLite migrations applied: ${result.appliedMigrations.join(', ')}`); + return; + } + + console.log(` [ok] DWN SQLite migrations current at ${result.dbPath}`); + } catch (err) { + console.log(` [warn] DWN SQLite repair failed: ${(err as Error).message}`); + } +} + +type HeadRepairResult = { + status : 'ok' | 'fixed' | 'warn'; + detail : string; +}; + +export function repairBareRepoHead(repoPath: string): HeadRepairResult { + if (!existsSync(join(repoPath, 'HEAD'))) { + return { status: 'warn', detail: `not a bare repo: ${repoPath}` }; + } + + if (gitHeadResolves(repoPath)) { + return { status: 'ok', detail: `${repoPath} HEAD resolves` }; + } + + const branch = inferBareRepoHeadBranch(repoPath); + if (!branch) { + return { status: 'warn', detail: `${repoPath} HEAD is dangling and no clear branch exists` }; + } + + const result = spawnSync('git', ['--git-dir', repoPath, 'symbolic-ref', 'HEAD', `refs/heads/${branch}`], { + stdio : ['ignore', 'pipe', 'pipe'], + timeout : 5_000, + }); + + if (result.status !== 0) { + const message = result.stderr?.toString().trim() || 'unknown error'; + return { status: 'warn', detail: `${repoPath} could not set HEAD to ${branch}: ${message}` }; + } + + return { status: 'fixed', detail: `${repoPath} HEAD -> refs/heads/${branch}` }; +} + +function repairBareRepoHeads(reposPath: string): void { + const repos = findBareRepos(reposPath); + if (repos.length === 0) { + console.log(` [ok] no bare repos found under ${reposPath}`); + return; + } + + for (const repoPath of repos) { + const result = repairBareRepoHead(repoPath); + console.log(` [${result.status}] ${result.detail}`); + } +} + +function findBareRepos(basePath: string): string[] { + if (!existsSync(basePath)) { return []; } + + const repos: string[] = []; + for (const ownerEntry of safeReadDir(basePath)) { + const ownerPath = join(basePath, ownerEntry.name); + if (!ownerEntry.isDirectory()) { continue; } + + for (const repoEntry of safeReadDir(ownerPath)) { + if (!repoEntry.isDirectory() || !repoEntry.name.endsWith('.git')) { continue; } + const repoPath = join(ownerPath, repoEntry.name); + if (existsSync(join(repoPath, 'HEAD'))) { + repos.push(repoPath); + } + } + } + + return repos.sort(); +} + +function safeReadDir(path: string): Dirent[] { + try { + return readdirSync(path, { withFileTypes: true }); + } catch { + return []; + } +} + +function gitHeadResolves(repoPath: string): boolean { + const result = spawnSync('git', ['--git-dir', repoPath, 'rev-parse', '--verify', 'HEAD^{commit}'], { + stdio : ['ignore', 'pipe', 'pipe'], + timeout : 5_000, + }); + return result.status === 0; +} + +function inferBareRepoHeadBranch(repoPath: string): string | undefined { + const branches = listBareRepoBranches(repoPath); + if (branches.includes('main')) { return 'main'; } + if (branches.includes('master')) { return 'master'; } + if (branches.length === 1) { return branches[0]; } + return undefined; +} + +function listBareRepoBranches(repoPath: string): string[] { + const result = spawnSync('git', ['--git-dir', repoPath, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'], { + encoding : 'utf-8', + stdio : ['ignore', 'pipe', 'pipe'], + timeout : 5_000, + }); + if (result.status !== 0) { return []; } + return (result.stdout ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} diff --git a/src/cli/commands/repo.ts b/src/cli/commands/repo.ts index 3aea9ad..dc4e1e0 100644 --- a/src/cli/commands/repo.ts +++ b/src/cli/commands/repo.ts @@ -19,10 +19,13 @@ import type { AgentContext } from '../agent.js'; import type { RepoContext } from '../repo-context.js'; +import * as p from '@clack/prompts'; + import { getDwnEndpoints } from '../../git-server/did-service.js'; +import { getRepoContext } from '../repo-context.js'; import { applyMessageToDwnEndpoint, applyRecordToDwnEndpoint } from '../record-send.js'; import { flagValue, resolveRepoName } from '../flags.js'; -import { getRepoContext, getRepoContextId } from '../repo-context.js'; +import { positionalArgs, shouldPromptForMissingInput } from '../command-input.js'; // --------------------------------------------------------------------------- // Valid roles @@ -32,6 +35,7 @@ const PRIMARY_ROLES = ['maintainer', 'moderator', 'contributor', 'viewer'] as co const COMPATIBILITY_ROLES = ['triager'] as const; const VALID_ROLES = [...PRIMARY_ROLES, ...COMPATIBILITY_ROLES] as const; type Role = typeof VALID_ROLES[number]; +const REPO_FLAGS_WITH_VALUE = new Set(['--alias', '-a', '--repo']); // --------------------------------------------------------------------------- // Sub-command dispatch @@ -56,6 +60,73 @@ export async function repoCommand(ctx: AgentContext, args: string[]): Promise<vo } } +export type RepoRoleInputs = { + did?: string; + role?: string; +}; + +export function repoCommandPositionals(args: readonly string[]): string[] { + return positionalArgs(args, REPO_FLAGS_WITH_VALUE); +} + +export function repoRoleInputs(args: readonly string[]): RepoRoleInputs { + const positionals = repoCommandPositionals(args); + return { + did : positionals[0], + role : positionals[1], + }; +} + +async function promptRepoRoleInputs( + inputs: RepoRoleInputs, + options: { roleRequired: boolean }, +): Promise<RepoRoleInputs> { + const did = await promptRepoDid(inputs.did, 'Collaborator DID:'); + const role = options.roleRequired + ? await promptRepoRole(inputs.role) + : inputs.role; + + return { + ...(did ? { did } : {}), + ...(role ? { role } : {}), + }; +} + +async function promptRepoDid(value: string | undefined, message: string): Promise<string | undefined> { + if (!shouldPromptForMissingInput(value)) { return value; } + + const response = await p.text({ + message, + validate(input) { + if (!input?.trim()) { return 'Required.'; } + if (!input.startsWith('did:')) { return 'Enter a DID, for example did:dht:...'; } + }, + }); + + if (p.isCancel(response)) { + p.cancel('Cancelled.'); + process.exit(130); + } + + return (response as string).trim(); +} + +async function promptRepoRole(value: string | undefined): Promise<string | undefined> { + if (!shouldPromptForMissingInput(value)) { return value; } + + const response = await p.select({ + message : 'Role:', + options : PRIMARY_ROLES.map((role) => ({ value: role, label: role })), + }); + + if (p.isCancel(response)) { + p.cancel('Cancelled.'); + process.exit(130); + } + + return response as string; +} + // --------------------------------------------------------------------------- // repo info // --------------------------------------------------------------------------- @@ -143,8 +214,9 @@ async function repoList(ctx: AgentContext): Promise<void> { // --------------------------------------------------------------------------- async function addCollaborator(ctx: AgentContext, args: string[]): Promise<void> { - const did = args[0]; - const role = args[1] as Role | undefined; + const inputs = await promptRepoRoleInputs(repoRoleInputs(args), { roleRequired: true }); + const did = inputs.did; + const role = inputs.role as Role | undefined; if (!did || !role) { console.error('Usage: gitd repo add-collaborator <did> <role> [--alias <name>]'); @@ -157,11 +229,11 @@ async function addCollaborator(ctx: AgentContext, args: string[]): Promise<void> process.exit(1); } - await addRole(ctx, args, role); + await addRole(ctx, args, role, did); } -async function addRole(ctx: AgentContext, args: string[], role: Role): Promise<void> { - const did = args[0]; +async function addRole(ctx: AgentContext, args: string[], role: Role, didOverride?: string): Promise<void> { + const did = didOverride ?? await promptRepoDid(repoCommandPositionals(args)[0], `DID to grant ${role}:`); const alias = flagValue(args, '--alias') ?? flagValue(args, '-a'); if (!did) { @@ -170,6 +242,16 @@ async function addRole(ctx: AgentContext, args: string[], role: Role): Promise<v } const repo = await getRepoContext(ctx, resolveRepoName(args)); + for (const line of formatRoleChangeSummary({ + action : 'grant', + actorDid : ctx.did, + ownerDid : ctx.did, + repoName : repo.name, + role, + targetDid : did, + })) { + console.log(line); + } const { status, record } = await ctx.repo.records.create(`repo/${role}` as any, { data : { did, alias: alias ?? '' }, @@ -263,7 +345,7 @@ async function removeCollaborator(ctx: AgentContext, args: string[]): Promise<vo } async function removeRole(ctx: AgentContext, args: string[], onlyRole?: Role): Promise<void> { - const did = args[0]; + const did = await promptRepoDid(repoCommandPositionals(args)[0], `DID to revoke ${onlyRole ?? 'collaborator'}:`); if (!did) { const usage = onlyRole @@ -273,7 +355,18 @@ async function removeRole(ctx: AgentContext, args: string[], onlyRole?: Role): P process.exit(1); } - const repoContextId = await getRepoContextId(ctx, resolveRepoName(args)); + const repo = await getRepoContext(ctx, resolveRepoName(args)); + const repoContextId = repo.contextId; + for (const line of formatRoleChangeSummary({ + action : 'revoke', + actorDid : ctx.did, + ownerDid : ctx.did, + repoName : repo.name, + role : onlyRole, + targetDid : did, + })) { + console.log(line); + } let found = false; for (const role of onlyRole ? [onlyRole] : VALID_ROLES) { @@ -295,3 +388,21 @@ async function removeRole(ctx: AgentContext, args: string[], onlyRole?: Role): P process.exit(1); } } + +export type RoleChangeSummary = { + action : 'grant' | 'revoke'; + actorDid : string; + ownerDid : string; + repoName : string; + role?: string; + targetDid : string; +}; + +export function formatRoleChangeSummary(summary: RoleChangeSummary): string[] { + return [ + `${summary.action === 'grant' ? 'Granting' : 'Revoking'} ${summary.role ?? 'collaborator'} role`, + ` Repo: ${summary.ownerDid}/${summary.repoName}`, + ` Actor: ${summary.actorDid}`, + ` Target: ${summary.targetDid}`, + ]; +} diff --git a/src/cli/commands/serve-lifecycle.ts b/src/cli/commands/serve-lifecycle.ts index 324e670..0a34dc8 100644 --- a/src/cli/commands/serve-lifecycle.ts +++ b/src/cli/commands/serve-lifecycle.ts @@ -1,14 +1,21 @@ /** - * `gitd serve status|stop|restart|logs` — daemon lifecycle management. + * Local helper lifecycle management. * * These subcommands do not require the Web5 agent. They read the * lockfile and interact with the daemon process directly. * * Usage: - * gitd serve status Show daemon status (PID, port, uptime, version) - * gitd serve stop Stop the running daemon - * gitd serve restart Stop + start the daemon in the background - * gitd serve logs Tail the daemon log file + * gitd helper status Show local helper status + * gitd helper start Start the local helper + * gitd helper stop Stop the local helper + * gitd helper restart Stop + start the local helper + * gitd helper logs Tail the helper log file + * + * Compatibility: + * gitd serve status Alias for helper status + * gitd serve stop Alias for helper stop + * gitd serve restart Alias for helper restart + * gitd serve logs Alias for helper logs * * @module */ @@ -18,6 +25,8 @@ import { spawn } from 'node:child_process'; import { flagValue } from '../flags.js'; import { getVaultPassword } from '../../git-remote/tty-prompt.js'; +import { resolveCommandProfile } from '../profile-session.js'; +import { createFirstIdentityFromSetup, maybePromptForFirstIdentitySetup } from '../identity-wizard.js'; import { daemonLogPath, daemonStatus, ensureDaemon, stopDaemon } from '../../daemon/lifecycle.js'; // --------------------------------------------------------------------------- @@ -25,70 +34,181 @@ import { daemonLogPath, daemonStatus, ensureDaemon, stopDaemon } from '../../dae // --------------------------------------------------------------------------- export async function serveDaemonCommand(args: string[]): Promise<void> { - const sub = args[0]; - const profileName = flagValue(args, '--profile') ?? undefined; + return lifecycleCommand(args, 'serve'); +} + +export async function helperCommand(args: string[]): Promise<void> { + return lifecycleCommand(args, 'helper'); +} + +async function lifecycleCommand(args: string[], surface: 'serve' | 'helper'): Promise<void> { + const sub = lifecycleSubcommand(args); + let profileName: string | undefined; + try { + profileName = resolveLifecycleProfileName(args); + } catch (err) { + console.error(`gitd: ${(err as Error).message}`); + process.exit(1); + } switch (sub) { + case undefined: case 'status': - return statusCmd(profileName); + return statusCmd(surface, profileName); + + case 'start': + return startCmd(surface, args, profileName); case 'stop': - return stopCmd(profileName); + return stopCmd(surface, profileName); case 'restart': - return restartCmd(profileName); + return restartCmd(surface, args, profileName); case 'logs': return logsCmd(profileName); default: - console.error(`Unknown serve subcommand: ${sub}`); - console.error('Usage: gitd serve status|stop|restart|logs'); + console.error(`Unknown ${surface} subcommand: ${sub}`); + console.error(`Usage: gitd ${surface} status|start|stop|restart|logs`); process.exit(1); } } +function lifecycleSubcommand(args: string[]): string | undefined { + const first = args[0]; + if (!first || first.startsWith('-')) { return undefined; } + return first; +} + +export function resolveLifecycleProfileName(args: string[]): string { + return resolveCommandProfile(flagValue(args, '--profile')).name; +} + // --------------------------------------------------------------------------- // Subcommands // --------------------------------------------------------------------------- -function statusCmd(profileName?: string): void { +function label(_surface: 'serve' | 'helper'): string { + return 'Local helper'; +} + +function expiryLabel(policy: string | undefined): string | undefined { + if (policy === 'helper-lifetime') { return 'when helper stops'; } + return policy; +} + +function repoContextLine(context: NonNullable<ReturnType<typeof daemonStatus>['repoContexts']>[number]): string { + const branch = context.defaultBranch ? ` (${context.defaultBranch})` : ''; + const path = context.path ? ` at ${context.path}` : ''; + return `${context.ownerDid}/${context.repo}${branch}${path}`; +} + +function statusCmd(surface: 'serve' | 'helper', profileName?: string): void { const status = daemonStatus({ profileName }); if (!status.running) { - console.log('Daemon is not running.'); + console.log(`${label(surface)} is not running.`); return; } - console.log('Daemon is running.'); + console.log(`${label(surface)} is running.`); console.log(` PID: ${status.pid}`); console.log(` Port: ${status.port}`); console.log(` Uptime: ${status.uptime}`); + if (status.sessionId) { + console.log(` Session: ${status.sessionId}`); + } + if (status.profileName) { + console.log(` Profile: ${status.profileName}`); + } + if (status.ownerDid) { + console.log(` DID: ${status.ownerDid}`); + } if (status.version) { console.log(` Version: ${status.version}`); } + if (status.reposPath) { + console.log(` Repos: ${status.reposPath}`); + } + if (status.capabilities?.length) { + console.log(' Capabilities:'); + for (const capability of status.capabilities) { + console.log(` - ${capability}`); + } + } else if (status.dwnHelper) { + console.log(' Capabilities: dwn-restore'); + } + const expiry = expiryLabel(status.expiryPolicy); + if (expiry) { + console.log(` Expires: ${expiry}`); + } + if (status.repoContexts?.length) { + console.log(' Seen repos:'); + for (const context of status.repoContexts) { + console.log(` - ${repoContextLine(context)}`); + } + } console.log(` Started: ${status.startedAt}`); console.log(` Log: ${daemonLogPath(profileName)}`); } -function stopCmd(profileName?: string): void { +async function helperStartCredentials( + surface: 'serve' | 'helper', + args: string[], + profileName?: string, +): Promise<{ password?: string; profileName?: string }> { + const setup = await maybePromptForFirstIdentitySetup(surface, args, flagValue(args, '--profile')); + if (!setup) { + return { + password: getVaultPassword() ?? undefined, + profileName, + }; + } + + await createFirstIdentityFromSetup(setup); + return { + password : setup.password, + profileName : setup.profileName, + }; +} + +async function startCmd(surface: 'serve' | 'helper', args: string[], profileName?: string): Promise<void> { + const status = daemonStatus({ profileName }); + if (status.running) { + console.log(`${label(surface)} is already running on port ${status.port}.`); + return; + } + + console.log(`Starting ${label(surface).toLowerCase()}...`); + try { + const credentials = await helperStartCredentials(surface, args, profileName); + const result = await ensureDaemon(credentials.password, { profileName: credentials.profileName }); + console.log(`${label(surface)} started on port ${result.port}.`); + } catch (err) { + console.error(`Failed to start ${label(surface).toLowerCase()}: ${(err as Error).message}`); + process.exit(1); + } +} + +function stopCmd(surface: 'serve' | 'helper', profileName?: string): void { const stopped = stopDaemon({ profileName }); if (stopped) { - console.log('Daemon stopped.'); + console.log(`${label(surface)} stopped.`); } else { - console.log('No daemon is running.'); + console.log(`No ${label(surface).toLowerCase()} is running.`); } } -async function restartCmd(profileName?: string): Promise<void> { +async function restartCmd(surface: 'serve' | 'helper', args: string[], profileName?: string): Promise<void> { stopDaemon({ profileName }); - console.log('Starting daemon...'); + console.log(`Starting ${label(surface).toLowerCase()}...`); try { - const password = getVaultPassword() ?? undefined; - const result = await ensureDaemon(password, { profileName }); - console.log(`Daemon started on port ${result.port}.`); + const credentials = await helperStartCredentials(surface, args, profileName); + const result = await ensureDaemon(credentials.password, { profileName: credentials.profileName }); + console.log(`${label(surface)} started on port ${result.port}.`); } catch (err) { - console.error(`Failed to start daemon: ${(err as Error).message}`); + console.error(`Failed to start ${label(surface).toLowerCase()}: ${(err as Error).message}`); process.exit(1); } } diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index b84a32f..0b399d9 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -1,5 +1,5 @@ /** - * `gitd serve` — start the git transport sidecar server. + * `gitd serve` — start the GitTransport sidecar. * * Starts a smart HTTP git server that serves bare repositories and * authenticates pushes using DID-signed tokens. After each successful @@ -9,8 +9,9 @@ * Multi-repo: ref/bundle sync is resolved per-push using the repo name * from the push URL. Each repo has its own contextId in the DWN. * - * Usage: gitd serve [--port <port>] [--repos <path>] [--prefix <path>] - * [--public-url <url>] [--check] + * Usage: gitd publish --public-url <url> [--port <port>] [--repos <path>] + * gitd serve --public-url <url> [--port <port>] [--repos <path>] + * gitd serve --foreground [--port <port>] [--repos <path>] * * Environment: * GITD_PORT — server port (default: 9418) @@ -48,6 +49,7 @@ import { encodePushToken, formatAuthPassword, } from '../../git-server/auth.js'; +import { DEFAULT_HELPER_CAPABILITIES, removeLockfile, writeLockfile } from '../../daemon/lockfile.js'; import { flagValue, hasFlag, parsePort, resolveReposPath } from '../flags.js'; import { fromOpt, getRepoContext, getRepoContextForDid } from '../repo-context.js'; import { @@ -55,8 +57,6 @@ import { registerGitService, startDidRepublisher, } from '../../git-server/did-service.js'; -import { removeLockfile, writeLockfile } from '../../daemon/lockfile.js'; - // --------------------------------------------------------------------------- // Public URL check @@ -684,8 +684,10 @@ export async function serveCommand(ctx: AgentContext, args: string[]): Promise<v // Register the daemon so git-remote-did can discover it. writeLockfile(server.port, getVersion() ?? undefined, ctx.did, { - dwnHelper : true, - profileName : ctx.profileName, + capabilities : DEFAULT_HELPER_CAPABILITIES, + dwnHelper : true, + profileName : ctx.profileName, + reposPath : basePath, }); // Wire up the idle shutdown function now that we have all the pieces. diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 9c822a6..cb8dcb8 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -6,9 +6,9 @@ * helper so that `git push` to DID remotes uses DID-signed tokens. * * Usage: - * gitd setup [--bin-dir <path>] Install and configure - * gitd setup --check Validate without modifying anything - * gitd setup --uninstall Remove configuration and wrapper commands + * gitd setup [--bin-dir <path>] [--quiet] Install and configure + * gitd setup --check Validate without modifying anything + * gitd setup --uninstall Remove configuration and wrapper commands * * The default bin directory is `~/.gitd/bin`. * @@ -16,6 +16,7 @@ */ import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { accessSync, @@ -28,7 +29,7 @@ import { unlinkSync, writeFileSync, } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { flagValue, hasFlag } from '../flags.js'; @@ -68,18 +69,23 @@ function gitConfigUnset(key: string): void { } } -/** Resolve the dist/esm directory relative to the compiled setup.js file. */ -function resolveDistEsm(): string { - const thisFile = new URL(import.meta.url).pathname; - return resolve(thisFile, '..', '..', '..'); +/** Resolve the dist/esm or src root relative to this command module. */ +function resolveModuleRoot(): string { + const thisDir = dirname(fileURLToPath(import.meta.url)); + return resolve(thisDir, '..', '..'); } /** Resolve the source binary paths. */ function resolveSourceBinaries(): Record<string, string> { - const distEsm = resolveDistEsm(); + const moduleRoot = resolveModuleRoot(); + const remoteJs = join(moduleRoot, 'git-remote', 'main.js'); + const credentialJs = join(moduleRoot, 'git-remote', 'credential-main.js'); + const remoteTs = join(moduleRoot, 'git-remote', 'main.ts'); + const credentialTs = join(moduleRoot, 'git-remote', 'credential-main.ts'); + return { - 'git-remote-did' : join(distEsm, 'git-remote', 'main.js'), - 'git-remote-did-credential' : join(distEsm, 'git-remote', 'credential-main.js'), + 'git-remote-did' : existsSync(remoteJs) ? remoteJs : remoteTs, + 'git-remote-did-credential' : existsSync(credentialJs) ? credentialJs : credentialTs, }; } @@ -230,6 +236,7 @@ function uninstallSetup(binDir: string): void { export async function setupCommand(args: string[]): Promise<void> { const binDir = flagValue(args, '--bin-dir') ?? DEFAULT_BIN_DIR; + const quiet = hasFlag(args, '--quiet'); if (hasFlag(args, '--check')) { checkSetup(binDir); @@ -242,6 +249,9 @@ export async function setupCommand(args: string[]): Promise<void> { } // --- Install mode --- + const log = (...values: string[]): void => { + if (!quiet) { console.log(...values); } + }; // 1. Create wrapper commands. mkdirSync(binDir, { recursive: true }); @@ -259,7 +269,7 @@ export async function setupCommand(args: string[]): Promise<void> { } writeWrapper(wrapperPath, target); - console.log(` Installed: ${name} -> ${target}`); + log(` Installed: ${name} -> ${target}`); } // 2. Configure credential helper. @@ -270,31 +280,31 @@ export async function setupCommand(args: string[]): Promise<void> { // Already configured — update the path in case binDir changed. gitConfigUnset('credential.helper'); } else if (existingHelper) { - console.log(''); - console.log(` Note: existing credential.helper detected: ${existingHelper}`); - console.log(' Adding gitd helper alongside it.'); + log(''); + log(` Note: existing credential.helper detected: ${existingHelper}`); + log(' Adding gitd helper alongside it.'); } gitConfigSet('credential.helper', credBinPath); - console.log(` Configured: credential.helper = ${credBinPath}`); + log(` Configured: credential.helper = ${credBinPath}`); // 3. Summary. - console.log(''); + log(''); const onPath = isOnPath(binDir); if (onPath) { - console.log(`Setup complete. ${binDir} is already on your PATH.`); + log(`Setup complete. ${binDir} is already on your PATH.`); } else { - console.log('Setup complete. Add the bin directory to your PATH:'); - console.log(''); - console.log(` export PATH="${binDir}:$PATH"`); - console.log(''); - console.log('Add that line to your ~/.bashrc or ~/.zshrc to make it permanent.'); + log('Setup complete. Add the bin directory to your PATH:'); + log(''); + log(` export PATH="${binDir}:$PATH"`); + log(''); + log('Add that line to your ~/.bashrc or ~/.zshrc to make it permanent.'); } - console.log(''); - console.log('Next steps:'); - console.log(' gitd auth login Create an identity'); - console.log(' git clone did::<did>/<repo> Clone a repo'); - console.log(' gitd setup --check Verify configuration'); + log(''); + log('Next steps:'); + log(' gitd auth login Create an identity'); + log(' git clone did::<did>/<repo> Clone a repo'); + log(' gitd setup --check Verify configuration'); } diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 3246527..5feb1ad 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -49,6 +49,7 @@ export async function dispatchAgentCommand( break; case 'serve': + case 'publish': await serveCommand(ctx, rest); break; diff --git a/src/cli/dwn-sqlite.ts b/src/cli/dwn-sqlite.ts index c7e0d11..adee18f 100644 --- a/src/cli/dwn-sqlite.ts +++ b/src/cli/dwn-sqlite.ts @@ -17,7 +17,7 @@ import type { Dialect } from '@enbox/dwn-sql-store'; import type { DwnMigrationFactory } from '@enbox/dwn-sql-store'; import { join } from 'node:path'; -import { mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync } from 'node:fs'; import { AgentDwnApi } from '@enbox/agent'; import { Kysely, sql } from 'kysely'; @@ -63,7 +63,7 @@ export async function createSqliteDwnApi( ): Promise<AgentDwnApi> { mkdirSync(dataPath, { recursive: true }); - const dbPath = join(dataPath, 'dwn.sqlite'); + const dbPath = gitdDwnSqlitePath(dataPath); const sqliteDb = createBunSqliteDatabase(dbPath); const dialect: Dialect = new SqliteDialect({ database: async () => sqliteDb }); @@ -103,6 +103,39 @@ export async function runGitdDwnStoreMigrations( return runDwnStoreMigrations(db, dialect, gitdDwnMigrations); } +export type DwnSqliteRepairResult = { + status : 'missing' | 'ok' | 'fixed'; + dbPath : string; + appliedMigrations : string[]; +}; + +export function gitdDwnSqlitePath(dataPath: string): string { + return join(dataPath, 'dwn.sqlite'); +} + +export async function repairGitdDwnSqliteStore(dataPath: string): Promise<DwnSqliteRepairResult> { + const dbPath = gitdDwnSqlitePath(dataPath); + if (!existsSync(dbPath)) { + return { status: 'missing', dbPath, appliedMigrations: [] }; + } + + const sqliteDb = createBunSqliteDatabase(dbPath); + const dialect: Dialect = new SqliteDialect({ database: async () => sqliteDb }); + const db = new Kysely<Record<string, unknown>>({ dialect }); + + try { + const appliedMigrations = await runGitdDwnStoreMigrations(db, dialect); + return { + status: appliedMigrations.length > 0 ? 'fixed' : 'ok', + dbPath, + appliedMigrations, + }; + } finally { + await db.destroy(); + sqliteDb.close(); + } +} + const migration003AddSquashColumnIfMissing: DwnMigrationFactory = (): ReturnType<DwnMigrationFactory> => ({ async up(db: Kysely<any>): Promise<void> { const columns = await sql<{ name: string }>` diff --git a/src/cli/identity-wizard.ts b/src/cli/identity-wizard.ts new file mode 100644 index 0000000..f128079 --- /dev/null +++ b/src/cli/identity-wizard.ts @@ -0,0 +1,210 @@ +/** + * First-run identity setup prompts for commands that need write authority. + * + * The CLI can still create the default profile implicitly for scripts that set + * `GITD_PASSWORD`, but humans should see an explicit identity setup flow before + * the first repo/issue/PR/moderation command. + * + * @module + */ + +import * as p from '@clack/prompts'; + +import { connectAgent } from './agent.js'; +import { flagValue } from './flags.js'; +import { listProfiles, profileDataPath } from '../profiles/config.js'; +import { + normalizeIdentityNameInput, + suggestIdentityName, + validateIdentityNameInput, +} from './commands/auth.js'; +import { printImplicitRecoveryPhrase, recordConnectedProfile } from './profile-session.js'; + +const IDENTITY_COMMANDS = new Set([ + 'init', + 'repo', + 'issue', + 'pr', + 'patch', + 'mod', + 'helper', + 'publish', + 'serve', + 'release', + 'ci', + 'registry', + 'wiki', + 'org', + 'social', + 'notification', + 'migrate', + 'web', + 'daemon', + 'indexer', + 'github-api', + 'shim', + 'whoami', +]); + +export type FirstIdentityPromptOptions = { + commandName : string; + args?: readonly string[]; + profileFlag?: string; + env?: NodeJS.ProcessEnv; + stdinIsTTY?: boolean; + stdoutIsTTY?: boolean; + identityCount?: number; +}; + +export type FirstIdentitySetup = { + profileName : string; + dataPath : string; + password : string; + recoveryPhrase?: string; +}; + +export function commandNeedsIdentity(commandName: string): boolean { + return IDENTITY_COMMANDS.has(commandName); +} + +export function hasAmbientIdentitySelection( + profileFlag?: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return Boolean(profileFlag || env.GITD_PROFILE || env.ENBOX_PROFILE); +} + +export function shouldPromptForFirstIdentitySetup(options: FirstIdentityPromptOptions): boolean { + if (!commandNeedsIdentity(options.commandName)) { return false; } + if (options.env?.GITD_PASSWORD) { return false; } + if (hasAmbientIdentitySelection(options.profileFlag, options.env)) { return false; } + if (options.identityCount !== 0) { return false; } + if (!options.stdinIsTTY || !options.stdoutIsTTY) { return false; } + return true; +} + +export function firstIdentityPermissionSummary(commandName: string): string[] { + const action = commandName === 'whoami' + ? 'show your active DID' + : `continue with \`gitd ${commandName}\``; + return [ + `gitd needs an identity to ${action}.`, + 'It will use this identity to sign gitd records and local Git operations.', + 'The local helper session can be inspected with `gitd auth sessions` and revoked with `gitd auth revoke helper`.', + ]; +} + +export async function maybePromptForFirstIdentitySetup( + commandName: string, + args: string[], + profileFlag = flagValue(args, '--profile'), +): Promise<FirstIdentitySetup | undefined> { + const existingProfiles = listProfiles(); + if (!shouldPromptForFirstIdentitySetup({ + commandName, + args, + profileFlag, + env : process.env, + stdinIsTTY : process.stdin.isTTY, + stdoutIsTTY : process.stdout.isTTY, + identityCount : existingProfiles.length, + })) { + return undefined; + } + + p.intro('First identity'); + for (const line of firstIdentityPermissionSummary(commandName)) { + p.log.info(line); + } + + const defaultName = suggestIdentityName(existingProfiles); + const action = await p.select({ + message : 'How would you like to set up this identity?', + options : [ + { value: 'create', label: 'Create a new identity' }, + { value: 'import', label: 'Import from recovery phrase' }, + ], + }); + + if (p.isCancel(action)) { + p.cancel('Cancelled.'); + process.exit(130); + } + + const name = await p.text({ + message : 'Name this identity:', + defaultValue : defaultName, + placeholder : defaultName, + validate(val) { + return validateIdentityNameInput(val, existingProfiles, defaultName); + }, + }); + + if (p.isCancel(name)) { + p.cancel('Cancelled.'); + process.exit(130); + } + + const password = await p.password({ + message: 'Create a password to unlock gitd:', + validate(val) { + if (!val || (val as string).length < 4) { return 'Password must be at least 4 characters.'; } + }, + }); + + if (p.isCancel(password)) { + p.cancel('Cancelled.'); + process.exit(130); + } + + let recoveryPhrase: string | undefined; + if (action === 'import') { + const phrase = await p.text({ + message : 'Enter your 12-word recovery phrase:', + placeholder : 'abandon ability able about above absent ...', + validate(val) { + if (!val) { return 'Recovery phrase is required.'; } + const words = val.trim().split(/\s+/); + if (words.length !== 12) { return 'Recovery phrase must be exactly 12 words.'; } + }, + }); + + if (p.isCancel(phrase)) { + p.cancel('Cancelled.'); + process.exit(130); + } + + recoveryPhrase = (phrase as string).trim(); + } + + const profileName = normalizeIdentityNameInput(name as string, defaultName); + return { + profileName, + dataPath : profileDataPath(profileName), + password : password as string, + ...(recoveryPhrase ? { recoveryPhrase } : {}), + }; +} + +export async function createFirstIdentityFromSetup(setup: FirstIdentitySetup): Promise<void> { + const ctx = await connectAgent({ + password : setup.password, + dataPath : setup.dataPath, + recoveryPhrase : setup.recoveryPhrase, + sync : 'off', + }); + + try { + const recordedProfile = recordConnectedProfile(setup.profileName, ctx.did); + if (recordedProfile.created && ctx.recoveryPhrase) { + printImplicitRecoveryPhrase(recordedProfile.name, ctx.recoveryPhrase); + console.log(''); + } + } finally { + try { + await ctx.close?.(); + } catch (err) { + console.error(`[agent] Could not release local identity resources: ${(err as Error).message}`); + } + } +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 9ac588a..8acc3ee 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -66,12 +66,19 @@ * gitd shim go [--port 4874] Start Go module proxy (GOPROXY) * gitd shim oci [--port 5555] Start OCI/Docker registry proxy * gitd log Show recent activity - * gitd serve [--port <port>] [--foreground] Start the git transport server + * gitd helper status Show local helper status + * gitd helper start Start the local helper + * gitd auth sessions List active local helper sessions + * gitd auth revoke helper Revoke the active helper session + * gitd publish --public-url <url> Publish a public GitTransport endpoint + * gitd serve --public-url <url> Publish a public GitTransport endpoint + * gitd doctor Diagnose local setup + * gitd repair Repair local setup * gitd whoami Show connected DID * * Environment: - * GITD_PASSWORD — vault password (prompted interactively if not set) - * GITD_PORT — server port for `serve` (default: 9418) + * GITD_PASSWORD — identity unlock password (prompted interactively if not set) + * GITD_PORT — local helper/GitTransport port (default: 9418) * GITD_REPOS — base path for bare repos (default: ./repos) * * @module @@ -79,19 +86,28 @@ import { fileURLToPath } from 'node:url'; import { readFileSync } from 'node:fs'; +import { spawn } from 'node:child_process'; import { dirname, join } from 'node:path'; +import type { FirstIdentitySetup } from './identity-wizard.js'; + import { authCommand } from './commands/auth.js'; import { cloneCommand } from './commands/clone.js'; import { connectAgent } from './agent.js'; import { dispatchAgentCommand } from './dispatch.js'; -import { ensureDaemon } from '../daemon/lifecycle.js'; +import { doctorCommand } from './commands/doctor.js'; +import { flagValue } from './flags.js'; import { forwardCliCommandIfAvailable } from './local-rpc.js'; -import { serveDaemonCommand } from './commands/serve-lifecycle.js'; +import { readPublicReaderPasswordForActiveProfile } from '../profiles/public-reader.js'; +import { repairCommand } from './commands/repair.js'; import { setupCommand } from './commands/setup.js'; import { checkGit, requireGit, warnGit } from './preflight.js'; -import { flagValue, hasFlag } from './flags.js'; -import { profileDataPath, resolveProfile } from '../profiles/config.js'; +import { createFirstIdentityFromSetup, maybePromptForFirstIdentitySetup } from './identity-wizard.js'; +import { ensureDaemon, findGitdBin, markDeferredDaemonStart } from '../daemon/lifecycle.js'; +import { helperCommand, serveDaemonCommand } from './commands/serve-lifecycle.js'; +import { printImplicitRecoveryPhrase, recordConnectedProfile, resolveCommandProfile } from './profile-session.js'; +import { publicReaderWriteBlockMessage, shouldBlockPublicReaderCommand } from './public-reader-guard.js'; +import { serveLocalHelperAliasLines, shouldStartLocalHelperFromServe } from './serve-ux.js'; // --------------------------------------------------------------------------- // Arg parsing @@ -101,6 +117,8 @@ const args = process.argv.slice(2); const command = args[0]; const rest = args.slice(1); +const HELPER_START_DELAY_ENV = 'GITD_HELPER_START_DELAY_MS'; + // --------------------------------------------------------------------------- // Usage // --------------------------------------------------------------------------- @@ -110,17 +128,28 @@ function printUsage(): void { console.log('Commands:'); console.log(' auth Show current identity info'); console.log(' auth login Create or import an identity'); - console.log(' auth list List all profiles'); - console.log(' auth use <profile> [--global] Set active profile'); + console.log(' auth list List identities'); + console.log(' auth switch <identity> Set default identity'); + console.log(' auth use <identity> [--global] Set active identity'); + console.log(' auth sessions List active local helper sessions'); + console.log(' auth revoke helper Revoke the active helper session'); console.log(''); console.log(' setup [--check | --uninstall] Configure git for DID-based remotes'); + console.log(' doctor Diagnose local setup'); + console.log(' repair Repair local wrappers, git config, and helper locks'); console.log(' clone <did>/<repo> Clone a repository via DID'); console.log(' init <name> Create a repo record + bare git repo'); - console.log(' serve [--port <port>] [--foreground] Start the git transport server'); - console.log(' serve status Show daemon status'); - console.log(' serve stop Stop the background daemon'); - console.log(' serve restart Restart the daemon'); - console.log(' serve logs Tail daemon log file'); + console.log(' helper status Show local helper status'); + console.log(' helper start Start the local helper'); + console.log(' helper stop Stop the local helper'); + console.log(' helper restart Restart the local helper'); + console.log(' helper logs Tail local helper logs'); + console.log(' publish --public-url <url> Publish a public GitTransport endpoint'); + console.log(' serve --public-url <url> [--foreground] Publish a public GitTransport endpoint'); + console.log(' serve status Alias for helper status'); + console.log(' serve stop Alias for helper stop'); + console.log(' serve restart Alias for helper restart'); + console.log(' serve logs Alias for helper logs'); console.log(''); console.log(' repo info Show repo metadata'); console.log(' repo add-moderator <did> Grant moderator role'); @@ -151,7 +180,7 @@ function printUsage(): void { console.log(' issue ignore <did> <id> Ignore an external issue submission'); console.log(' issue list [--status <open|closed>] List issues'); console.log(''); - console.log(' pr create <title> [--base ...] [--head ...] Open a pull request'); + console.log(' pr create <title> [--base ...] [--head ...] [--push|--no-push] Open a pull request'); console.log(' pr show <number> Show PR details and reviews'); console.log(' pr comment <number> <body> Add a comment/review'); console.log(' pr merge <number> [--squash|--rebase] Merge a PR with actual git merge'); @@ -227,9 +256,9 @@ function printUsage(): void { console.log(' whoami Show connected DID'); console.log(' help Show this message\n'); console.log('Environment:'); - console.log(' GITD_PASSWORD vault password (prompted if not set)'); + console.log(' GITD_PASSWORD identity unlock password (prompted if not set)'); console.log(' GITD_PROFILE active identity profile (alias: ENBOX_PROFILE)'); - console.log(' GITD_PORT server port for `serve` (default: 9418)'); + console.log(' GITD_PORT local helper/GitTransport port (default: 9418)'); console.log(' GITD_WEB_PORT web UI port for `web` (default: 8080)'); console.log(' GITD_REPOS base path for bare repos (default: ~/.enbox/profiles/<name>/repos/)'); console.log(' GITD_PUBLIC_URL public URL for `serve` (enables DID service registration)'); @@ -258,8 +287,11 @@ async function getPassword(): Promise<string> { const env = process.env.GITD_PASSWORD; if (env) { return env; } + const publicReaderPassword = readPublicReaderPasswordForActiveProfile(); + if (publicReaderPassword) { return publicReaderPassword; } + // Interactive prompt — hide input when running in a TTY. - process.stdout.write('Vault password: '); + process.stdout.write('Identity password: '); if (process.stdin.isTTY) { // Raw mode: read character-by-character, echo nothing. @@ -313,6 +345,69 @@ async function getPassword(): Promise<string> { return response; } +async function maybeDelayHelperStart(args: string[]): Promise<void> { + if (args[0] !== 'start') { return; } + const raw = process.env[HELPER_START_DELAY_ENV]; + if (!raw) { return; } + const delayMs = Number.parseInt(raw, 10); + if (!Number.isFinite(delayMs) || delayMs <= 0) { return; } + await new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function isTransientHelperLockError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return message.includes('Database is not open') + || message.includes('LEVEL_LOCKED') + || message.includes('LOCK'); +} + +function scheduleDeferredHelperStart(password: string, profileName?: string): boolean { + const gitdBin = findGitdBin(); + const env: Record<string, string | undefined> = { + ...process.env, + GITD_PASSWORD : password, + [HELPER_START_DELAY_ENV] : '250', + }; + if (profileName) { + env.GITD_PROFILE = profileName; + } + + const child = spawn(gitdBin.command, [ + ...gitdBin.prefix, + 'helper', + 'start', + ...(profileName ? ['--profile', profileName] : []), + ], { + detached : true, + stdio : 'ignore', + env, + }); + + child.unref(); + markDeferredDaemonStart(profileName); + return true; +} + +async function connectAgentWithRetry(options: Parameters<typeof connectAgent>[0]): ReturnType<typeof connectAgent> { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + return await connectAgent(options); + } catch (err) { + lastError = err; + if (!isTransientHelperLockError(err)) { throw err; } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + throw lastError; +} + +function shouldAutoStartHelperAfterCommand(commandName: string, commandArgs: string[]): boolean { + if (commandName === 'whoami') { return false; } + if (commandName === 'init' && commandArgs.includes('--no-local')) { return false; } + return true; +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -347,6 +442,11 @@ async function main(): Promise<void> { return; } + if (command === 'doctor') { + await doctorCommand(rest); + return; + } + // All functional commands require git. requireGit(); @@ -356,40 +456,54 @@ async function main(): Promise<void> { await setupCommand(rest); return; + case 'repair': + await repairCommand(rest); + return; + case 'clone': await cloneCommand(rest); return; case 'auth': - // Auth can run without a pre-existing profile (for `login`). + // Auth can run without a pre-existing identity (for `login`). await authCommand(null, rest); + return process.exit(0); + + case 'helper': + await maybeDelayHelperStart(rest); + await helperCommand(rest); return; case 'serve': // Lifecycle subcommands don't need the agent. - if (rest[0] === 'status' || rest[0] === 'stop' || rest[0] === 'restart' || rest[0] === 'logs') { + if (rest[0] === 'status' || rest[0] === 'start' || rest[0] === 'stop' || rest[0] === 'restart' || rest[0] === 'logs') { await serveDaemonCommand(rest); return; } - // Auto-background: unless --foreground is passed or we ARE the - // background daemon, fork a background process and exit. This - // follows the Ollama pattern — `gitd serve` returns immediately - // with a one-liner status, and only `gitd serve --foreground` - // blocks the terminal. - if (!hasFlag(rest, '--foreground') && process.env.GITD_DAEMON_BACKGROUND !== '1') { - const pw = await getPassword(); - const profileName = resolveProfile(flagValue(rest, '--profile')) ?? undefined; + // Compatibility alias: bare `gitd serve` still starts the local helper, + // but public publishing flags must fall through to the agent-backed + // GitTransport path. + if (shouldStartLocalHelperFromServe(rest)) { + const setup = await maybePromptForFirstIdentitySetup('serve', rest, flagValue(rest, '--profile')); + if (setup) { + await createFirstIdentityFromSetup(setup); + } + const pw = setup?.password ?? await getPassword(); + let profileName: string | undefined; + try { + profileName = setup?.profileName ?? resolveCommandProfile(flagValue(rest, '--profile')).name; + } catch (err) { + console.error(`gitd: ${(err as Error).message}`); + process.exit(1); + } try { const result = await ensureDaemon(pw, { profileName }); - if (result.spawned) { - console.log(`gitd server started in the background on port ${result.port}.`); - } else { - console.log(`gitd server is already running on port ${result.port}.`); + for (const line of serveLocalHelperAliasLines(result.spawned, result.port)) { + console.log(line); } - console.log('Run `gitd serve status` for details, `gitd serve logs` to tail output.'); } catch (err) { - console.error(`Failed to start daemon: ${(err as Error).message}`); + console.error(`Failed to start local helper: ${(err as Error).message}`); process.exit(1); } return; @@ -398,17 +512,43 @@ async function main(): Promise<void> { } const profileFlag = flagValue(rest, '--profile'); - const profileName = resolveProfile(profileFlag); + let commandProfile: ReturnType<typeof resolveCommandProfile>; + try { + commandProfile = resolveCommandProfile(profileFlag); + } catch (err) { + console.error(`gitd: ${(err as Error).message}`); + process.exit(1); + } + let profileName = commandProfile.name; + + if (shouldBlockPublicReaderCommand(profileName ?? undefined, command, rest)) { + for (const line of publicReaderWriteBlockMessage(command, rest)) { + console.error(line); + } + process.exit(1); + } // Resolve DWN sync interval. // Long-running commands default to '30s'; one-shot commands default to 'off'. - const longRunning = ['serve', 'web', 'daemon', 'indexer', 'github-api', 'shim'].includes(command); + const longRunning = ['serve', 'publish', 'web', 'daemon', 'indexer', 'github-api', 'shim'].includes(command); const syncDefault = longRunning ? '30s' : 'off'; const noSync = rest.includes('--no-sync'); const syncEnv = process.env.GITD_SYNC; const syncFlag = flagValue(rest, '--sync'); const sync = noSync ? 'off' : (syncFlag ?? syncEnv ?? syncDefault); + let firstIdentitySetup: FirstIdentitySetup | undefined; + if (!longRunning) { + firstIdentitySetup = await maybePromptForFirstIdentitySetup(command, rest, profileFlag); + if (firstIdentitySetup) { + commandProfile = { + name : firstIdentitySetup.profileName, + dataPath : firstIdentitySetup.dataPath, + }; + profileName = commandProfile.name; + } + } + if (!longRunning) { const forwarded = await forwardCliCommandIfAvailable(profileName ?? undefined, command, rest); if (forwarded) { @@ -419,30 +559,56 @@ async function main(): Promise<void> { } // Commands that require the Enbox agent. - const password = await getPassword(); - const dataPath = profileName ? profileDataPath(profileName) : undefined; + const password = firstIdentitySetup?.password ?? await getPassword(); - const ctx = await connectAgent({ password, dataPath, sync: sync as any }); - ctx.profileName = profileName ?? undefined; + const ctx = await connectAgentWithRetry({ + password, + dataPath : commandProfile.dataPath, + sync : sync as any, + recoveryPhrase : firstIdentitySetup?.recoveryPhrase, + }); + const recordedProfile = recordConnectedProfile(profileName, ctx.did); + ctx.profileName = profileName; - // For one-shot commands, ensure the background daemon is running so that - // `git push` (via git-remote-did) works immediately after. Long-running - // commands either *are* the server or manage their own lifecycle. - if (!longRunning) { + if (recordedProfile.created && ctx.recoveryPhrase) { + printImplicitRecoveryPhrase(recordedProfile.name, ctx.recoveryPhrase); + console.log(''); + } + + let completed = false; + try { + if (command === 'whoami') { + console.log(ctx.did); + } else { + await dispatchAgentCommand(ctx, command, rest); + } + completed = true; + } finally { + if (!longRunning) { + try { + await ctx.close?.(); + } catch (err) { + console.error(`[agent] Could not release local identity resources: ${(err as Error).message}`); + } + } + } + + // For one-shot commands, ensure the background helper is running after the + // foreground agent has released its LevelDB handles. This keeps `git push` + // and native `git clone did::...` working immediately after commands such as + // `gitd init` without hiding first-run recovery phrase output in the helper. + if (completed && !longRunning && shouldAutoStartHelperAfterCommand(command, rest)) { try { await ensureDaemon(password, { profileName: profileName ?? undefined }); - } catch { + } catch (err) { + if (isTransientHelperLockError(err) && scheduleDeferredHelperStart(password, profileName ?? undefined)) { + process.exit(0); + } // Non-fatal — warn but don't block the command. - console.error('[daemon] Could not start background server. Run `gitd serve` manually for push/clone.'); + console.error('[helper] Could not start local helper. Run `gitd helper start` manually for push/clone.'); } } - if (command === 'whoami') { - console.log(ctx.did); - } else { - await dispatchAgentCommand(ctx, command, rest); - } - // One-shot commands reach here after completing. The Enbox agent keeps // LevelDB stores and other handles open, which prevents the process from // exiting naturally. Long-running commands (serve, web, daemon, indexer, diff --git a/src/cli/profile-session.ts b/src/cli/profile-session.ts new file mode 100644 index 0000000..9ed9aff --- /dev/null +++ b/src/cli/profile-session.ts @@ -0,0 +1,80 @@ +/** + * Helpers for resolving and recording the identity profile used by one CLI run. + * + * `connectAgent()` can create an agent store on first use, but profile metadata + * lives in gitd's config file. These helpers keep first-run commands such as + * `gitd init` aligned with `gitd auth login`: the implicit first identity is + * named `default`, stored in config, and available to helper discovery. + * + * @module + */ + +import { profileDataPath, readConfig, resolveProfile, upsertProfile } from '../profiles/config.js'; +import { PUBLIC_READER_PROFILE, recordPublicReaderDid } from '../profiles/public-reader.js'; + +const DEFAULT_IDENTITY_NAME = 'default'; + +export type CommandProfile = { + name : string; + dataPath : string; +}; + +export type RecordedProfile = { + created : boolean; + name : string; +}; + +export function resolveCommandProfile(flagProfile?: string): CommandProfile { + const resolved = resolveProfile(flagProfile); + if (resolved) { + return { name: resolved, dataPath: profileDataPath(resolved) }; + } + + const config = readConfig(); + const names = Object.keys(config.profiles); + if (names.length > 1) { + throw new Error( + `Multiple identities are configured and none is selected. Run \`gitd auth use <identity> --global\` or pass \`--profile <identity>\`.`, + ); + } + + return { + name : DEFAULT_IDENTITY_NAME, + dataPath : profileDataPath(DEFAULT_IDENTITY_NAME), + }; +} + +export function recordConnectedProfile(profileName: string, did: string): RecordedProfile { + if (profileName === PUBLIC_READER_PROFILE) { + recordPublicReaderDid(did); + return { created: false, name: profileName }; + } + + const config = readConfig(); + const existing = config.profiles[profileName]; + + upsertProfile(profileName, { + name : profileName, + did, + createdAt : existing?.createdAt ?? new Date().toISOString(), + }); + + return { created: !existing, name: profileName }; +} + +export function implicitRecoveryPhraseMessage(profileName: string, recoveryPhrase: string): string[] { + return [ + `Created identity "${profileName}".`, + '', + 'Recovery phrase:', + ` ${recoveryPhrase}`, + '', + 'Store it securely. It will not be shown again.', + ]; +} + +export function printImplicitRecoveryPhrase(profileName: string, recoveryPhrase: string): void { + for (const line of implicitRecoveryPhraseMessage(profileName, recoveryPhrase)) { + console.log(line); + } +} diff --git a/src/cli/public-reader-guard.ts b/src/cli/public-reader-guard.ts new file mode 100644 index 0000000..dd1c839 --- /dev/null +++ b/src/cli/public-reader-guard.ts @@ -0,0 +1,77 @@ +import { PUBLIC_READER_PROFILE } from '../profiles/public-reader.js'; + +const READ_ONLY_SUBCOMMANDS: Record<string, Set<string>> = { + ci : new Set(['status', 'list', 'ls', 'show']), + issue : new Set(['list', 'ls', 'show']), + pr : new Set(['list', 'ls', 'show', 'checkout', 'co']), + patch : new Set(['list', 'ls', 'show', 'checkout', 'co']), + registry : new Set(['info', 'versions', 'list', 'resolve', 'verify', 'verify-deps', 'attestations']), + release : new Set(['list', 'ls', 'show']), + repo : new Set(['info', 'list', 'ls']), + wiki : new Set(['show', 'list', 'ls']), +}; + +const READ_ONLY_COMMANDS = new Set([ + 'log', + 'web', + 'whoami', +]); + +const LOCAL_HELPER_SUBCOMMANDS = new Set(['', 'status', 'start', 'stop', 'restart', 'logs']); + +export function isPublicReaderCommandAllowed(command: string, rest: string[]): boolean { + if (READ_ONLY_COMMANDS.has(command)) { return true; } + if (command === 'helper') { + return LOCAL_HELPER_SUBCOMMANDS.has(rest[0] ?? ''); + } + if (command === 'serve') { + return isLocalHelperServe(rest); + } + + const allowedSubcommands = READ_ONLY_SUBCOMMANDS[command]; + if (!allowedSubcommands) { return false; } + + const subcommand = rest[0] ?? defaultReadOnlySubcommand(command); + return allowedSubcommands.has(subcommand); +} + +export function shouldBlockPublicReaderCommand( + profileName: string | undefined, + command: string, + rest: string[], +): boolean { + return profileName === PUBLIC_READER_PROFILE && !isPublicReaderCommandAllowed(command, rest); +} + +export function publicReaderWriteBlockMessage(command: string, rest: string[]): string[] { + const retry = ['gitd', command, ...rest].join(' '); + return [ + 'gitd: this repo is using the public-read cache.', + 'The public-read cache can inspect public repo data, but it cannot write issues, PRs, roles, packages, or branches.', + '', + 'Run:', + ' gitd auth login', + ' gitd auth use <identity>', + '', + `Then retry: ${retry}`, + ]; +} + +function defaultReadOnlySubcommand(command: string): string { + if (command === 'repo') { return 'info'; } + if (command === 'ci') { return 'status'; } + return ''; +} + +function isLocalHelperServe(rest: string[]): boolean { + if (rest.some((arg) => arg === '--public-url' || arg.startsWith('--public-url='))) { + return false; + } + if (rest.includes('--publish')) { + return false; + } + + const subcommand = rest[0]; + if (!subcommand || subcommand.startsWith('-')) { return true; } + return LOCAL_HELPER_SUBCOMMANDS.has(subcommand); +} diff --git a/src/cli/repo-context.ts b/src/cli/repo-context.ts index 8a327b4..d0d24c0 100644 --- a/src/cli/repo-context.ts +++ b/src/cli/repo-context.ts @@ -31,6 +31,8 @@ export type RepoContext = { visibility: 'public' | 'private'; /** The repo name. */ name: string; + /** Repository default branch, when readable from the repo record data. */ + defaultBranch?: string; }; /** Repo role names that can be used as cross-protocol role grants. */ @@ -90,7 +92,7 @@ export async function getRepoContextForDid( throw new Error(`Repository "${repoName}" not found in ${owner}.`); } - const repo = extractContext(records[0], repoName); + const repo = await extractContext(records[0], repoName); rememberRepoContext(targetDid, repo); return repo; } @@ -236,14 +238,38 @@ export async function resolveRepoProtocolRole( // Internal // --------------------------------------------------------------------------- -function extractContext(record: any, name: string): RepoContext { +async function extractContext(record: any, name: string): Promise<RepoContext> { const contextId = record.contextId; if (!contextId) { throw new Error('Repository record has no contextId — this should not happen.'); } + const data = await safeRecordJson(record); + const repoName = typeof data?.name === 'string' && data.name.trim() + ? data.name + : name; + const defaultBranch = typeof data?.defaultBranch === 'string' && data.defaultBranch.trim() + ? data.defaultBranch + : undefined; const visibility = (record.tags?.visibility as 'public' | 'private') ?? 'public'; - return { recordId: record.id ?? record.recordId, contextId, visibility, name }; + return { + recordId : record.id ?? record.recordId, + contextId, + visibility, + name : repoName, + ...(defaultBranch ? { defaultBranch } : {}), + }; +} + +async function safeRecordJson(record: any): Promise<Record<string, unknown> | undefined> { + try { + const data = await record.data?.json?.(); + return typeof data === 'object' && data !== null + ? data as Record<string, unknown> + : undefined; + } catch { + return undefined; + } } function repoContextCacheKey(targetDid: string, repoName: string): string { diff --git a/src/cli/serve-ux.ts b/src/cli/serve-ux.ts new file mode 100644 index 0000000..94f11b1 --- /dev/null +++ b/src/cli/serve-ux.ts @@ -0,0 +1,26 @@ +import { flagValue, hasFlag } from './flags.js'; + +type Env = Record<string, string | undefined>; + +export function serveUsesPublicTransport(args: string[], env: Env = process.env): boolean { + return Boolean(flagValue(args, '--public-url') ?? env.GITD_PUBLIC_URL) || hasFlag(args, '--check'); +} + +export function shouldStartLocalHelperFromServe(args: string[], env: Env = process.env): boolean { + if (env.GITD_DAEMON_BACKGROUND === '1') { return false; } + if (hasFlag(args, '--foreground')) { return false; } + return !serveUsesPublicTransport(args, env); +} + +export function serveLocalHelperAliasLines(spawned: boolean, port: number): string[] { + const state = spawned + ? `Local helper started on port ${port}.` + : `Local helper is already running on port ${port}.`; + + return [ + '`gitd serve` without --public-url is a compatibility alias for the local helper.', + 'Prefer `gitd helper start` for local Git/DWN work.', + state, + 'Run `gitd helper status` for details, `gitd helper logs` to tail output.', + ]; +} diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 2665e25..f1955bc 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -10,8 +10,15 @@ export type { DaemonInstance, DaemonOptions } from './server.js'; export { createAdapterServer, resolveConfig, startDaemon } from './server.js'; export { builtinAdapters, findAdapter } from './adapters/index.js'; -export type { DaemonLock } from './lockfile.js'; -export { lockfilePath, readLockfile, removeLockfile, writeLockfile } from './lockfile.js'; +export type { DaemonLock, HelperCapability, HelperRepoContext, HelperSessionExpiryPolicy } from './lockfile.js'; +export { + DEFAULT_HELPER_CAPABILITIES, + lockfilePath, + readLockfile, + recordLockfileRepoContext, + removeLockfile, + writeLockfile, +} from './lockfile.js'; export type { DaemonStatus, EnsureDaemonResult } from './lifecycle.js'; export { daemonLogPath, daemonStatus, ensureDaemon, stopDaemon } from './lifecycle.js'; diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts index 0428013..f822b52 100644 --- a/src/daemon/lifecycle.ts +++ b/src/daemon/lifecycle.ts @@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; -import { closeSync, existsSync, mkdirSync, openSync } from 'node:fs'; +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { getVersion } from '../version.js'; @@ -37,6 +37,7 @@ const MAX_BACKOFF_MS = 1_000; /** Timeout for each individual health probe (ms). */ const HEALTH_PROBE_TIMEOUT_MS = 2_000; +const DEFERRED_START_WINDOW_MS = 5_000; // --------------------------------------------------------------------------- // Paths @@ -51,6 +52,50 @@ export function daemonLogPath(profileName?: string): string { return join(enboxHome(), 'gitd', 'daemon.log'); } +/** Read the last lines from the daemon log for actionable startup failures. */ +export function daemonLogTail(profileName?: string, maxLines = 20): string { + const path = daemonLogPath(profileName); + if (!existsSync(path)) { + return ''; + } + + try { + return readFileSync(path, 'utf-8') + .split(/\r?\n/) + .filter((line) => line.length > 0) + .slice(-maxLines) + .join('\n'); + } catch { + return ''; + } +} + +export function deferredDaemonStartPath(profileName?: string): string { + return join(dirname(daemonLogPath(profileName)), 'daemon-start.pending'); +} + +export function markDeferredDaemonStart(profileName?: string): void { + const path = deferredDaemonStartPath(profileName); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, String(Date.now()), 'utf-8'); +} + +export function hasRecentDeferredDaemonStart(profileName?: string, now = Date.now()): boolean { + const path = deferredDaemonStartPath(profileName); + if (!existsSync(path)) { return false; } + + try { + const startedAt = Number.parseInt(readFileSync(path, 'utf-8'), 10); + if (Number.isFinite(startedAt) && now - startedAt <= DEFERRED_START_WINDOW_MS) { + return true; + } + unlinkSync(path); + } catch { + try { unlinkSync(path); } catch { /* ignore cleanup */ } + } + return false; +} + // --------------------------------------------------------------------------- // Health probe // --------------------------------------------------------------------------- @@ -132,8 +177,18 @@ export async function ensureDaemon( } } - // Spawn a new daemon in the background. - return spawnDaemon(password, options); + // Spawn a new daemon in the background. A just-closed foreground agent can + // leave LevelDB handles unavailable for a brief moment, so retry one early + // spawn failure before surfacing the startup error. + try { + return await spawnDaemon(password, options); + } catch (err) { + if (!shouldRetrySpawnFailure(err, options.profileName)) { + throw err; + } + await sleep(500); + return spawnDaemon(password, options); + } } // --------------------------------------------------------------------------- @@ -196,6 +251,15 @@ async function spawnDaemon( }); }); + const earlyExit = new Promise<never>((_, reject) => { + child.once('exit', (code, signal) => { + reject(new Error( + `gitd helper exited before becoming healthy (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}).` + + daemonTailMessage(options.profileName), + )); + }); + }); + // Detach the child so it survives after we exit. child.unref(); @@ -204,10 +268,20 @@ async function spawnDaemon( // Poll the health endpoint until the daemon is ready, but fail fast // if the spawn itself errored (e.g. binary not found). - const port = await Promise.race([waitForDaemon(options), spawnError]); + const port = await Promise.race([waitForDaemon(options), spawnError, earlyExit]); return { port, spawned: true }; } +function shouldRetrySpawnFailure(err: unknown, profileName?: string): boolean { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('Database is not open')) { return true; } + if (message.includes('LEVEL_LOCKED') || message.includes('LOCK')) { return true; } + const tail = daemonLogTail(profileName); + return tail.includes('Database is not open') + || tail.includes('LEVEL_LOCKED') + || tail.includes('LOCK'); +} + /** * Poll the lockfile + health endpoint with exponential backoff. * @@ -231,10 +305,17 @@ async function waitForDaemon(options: DaemonLifecycleOptions = {}): Promise<numb throw new Error( 'Timed out waiting for the gitd daemon to start. ' - + `Check the log at ${daemonLogPath(options.profileName)} for details, or run \`gitd serve\` manually to debug.`, + + `Check the log at ${daemonLogPath(options.profileName)} for details, or run \`gitd helper start\` manually to debug.` + + daemonTailMessage(options.profileName), ); } +function daemonTailMessage(profileName?: string): string { + const tail = daemonLogTail(profileName); + if (!tail) { return ''; } + return `\n\nLast daemon log lines:\n${tail}`; +} + // --------------------------------------------------------------------------- // Stop // --------------------------------------------------------------------------- @@ -275,6 +356,14 @@ export type DaemonStatus = { startedAt?: string; version?: string; uptime?: string; + ownerDid?: string; + sessionId?: string; + profileName?: string; + reposPath?: string; + capabilities?: string[]; + expiryPolicy?: DaemonLock['expiryPolicy']; + repoContexts?: DaemonLock['repoContexts']; + dwnHelper?: boolean; }; /** @@ -298,11 +387,19 @@ export function daemonStatus(options: DaemonLifecycleOptions = {}): DaemonStatus : `${secs}s`; return { - running : true, - pid : lock.pid, - port : lock.port, - startedAt : lock.startedAt, - version : lock.version, + running : true, + pid : lock.pid, + port : lock.port, + startedAt : lock.startedAt, + version : lock.version, + ownerDid : lock.ownerDid, + sessionId : lock.sessionId, + profileName : lock.profileName, + reposPath : lock.reposPath, + capabilities : lock.capabilities, + expiryPolicy : lock.expiryPolicy, + repoContexts : lock.repoContexts, + dwnHelper : lock.dwnHelper, uptime, }; } diff --git a/src/daemon/lockfile.ts b/src/daemon/lockfile.ts index dc9ba9f..2382d52 100644 --- a/src/daemon/lockfile.ts +++ b/src/daemon/lockfile.ts @@ -22,6 +22,38 @@ import { enboxHome, profilesDir } from '../profiles/config.js'; // Types // --------------------------------------------------------------------------- +export const DEFAULT_HELPER_CAPABILITIES = [ + 'git-transport', + 'dwn-restore', + 'push-tokens', + 'contributor-branch-writeback', + 'public-read-cache', +] as const; + +export type HelperCapability = typeof DEFAULT_HELPER_CAPABILITIES[number]; +export type HelperSessionExpiryPolicy = 'helper-lifetime'; + +export type HelperRepoContext = { + /** Canonical repo owner DID. */ + ownerDid: string; + /** Repository name under the owner DID. */ + repo: string; + /** Local working tree path, when the helper saw one. */ + path?: string; + /** Git remote URL used for this repo, when known. */ + remoteUrl?: string; + /** Repo default branch, when known. */ + defaultBranch?: string; + /** Last time this repo context was observed by gitd. */ + lastSeenAt: string; +}; + +export type HelperRepoContextInput = Omit<HelperRepoContext, 'lastSeenAt'> & { + lastSeenAt?: string; +}; + +const MAX_HELPER_REPO_CONTEXTS = 20; + /** Data stored in the daemon lockfile. */ export type DaemonLock = { /** The PID of the daemon process. */ @@ -39,6 +71,24 @@ export type DaemonLock = { /** The DID of the identity that owns this daemon. */ ownerDid?: string; + /** Stable enough local session id for display/revocation UX. */ + sessionId?: string; + + /** Named profile this daemon serves. */ + profileName?: string; + + /** Bare repository cache path served by this daemon. */ + reposPath?: string; + + /** Session-like capabilities currently exposed by the local helper. */ + capabilities?: string[]; + + /** Helper session expiry policy. MVP sessions last until the helper stops. */ + expiryPolicy?: HelperSessionExpiryPolicy; + + /** Repositories this helper session has seen through local CLI use. */ + repoContexts?: HelperRepoContext[]; + /** * True when this daemon can serve as a local DWN-backed helper for repos * owned by DIDs other than `ownerDid`. @@ -74,8 +124,72 @@ export type WriteLockfileOptions = { dwnHelper?: boolean; /** Named profile this daemon serves. Defaults to the active GITD/ENBOX profile env. */ profileName?: string; + /** Bare repository cache path served by this daemon. */ + reposPath?: string; + /** Session-like helper capabilities to advertise. */ + capabilities?: readonly string[]; + /** Helper session expiry policy. Defaults to helper lifetime. */ + expiryPolicy?: HelperSessionExpiryPolicy; + /** Initial repositories this helper session has seen through local CLI use. */ + repoContexts?: readonly HelperRepoContextInput[]; + /** Override the generated local session id. Mostly useful for tests. */ + sessionId?: string; }; +export function helperSessionId(profileName: string | undefined, pid = process.pid): string { + return `helper:${profileName ?? 'global'}:${pid}`; +} + +function normalizeCapabilities(capabilities: readonly string[] | undefined): string[] { + const seen = new Set<string>(); + const normalized: string[] = []; + + for (const capability of capabilities ?? []) { + const trimmed = capability.trim(); + if (!trimmed || seen.has(trimmed)) { continue; } + seen.add(trimmed); + normalized.push(trimmed); + } + + return normalized; +} + +function repoContextKey(context: Pick<HelperRepoContext, 'ownerDid' | 'repo' | 'path'>): string { + return `${context.ownerDid}\0${context.repo}\0${context.path ?? ''}`; +} + +function normalizeRepoContext(context: HelperRepoContextInput): HelperRepoContext | null { + const ownerDid = context.ownerDid.trim(); + const repo = context.repo.trim(); + + if (!ownerDid || !repo) { return null; } + + return { + ownerDid, + repo, + ...(context.path ? { path: context.path } : {}), + ...(context.remoteUrl ? { remoteUrl: context.remoteUrl } : {}), + ...(context.defaultBranch ? { defaultBranch: context.defaultBranch } : {}), + lastSeenAt: context.lastSeenAt ?? new Date().toISOString(), + }; +} + +function normalizeRepoContexts(contexts: readonly HelperRepoContextInput[] | undefined): HelperRepoContext[] { + const seen = new Set<string>(); + const normalized: HelperRepoContext[] = []; + + for (const context of contexts ?? []) { + const next = normalizeRepoContext(context); + if (!next) { continue; } + const key = repoContextKey(next); + if (seen.has(key)) { continue; } + seen.add(key); + normalized.push(next); + } + + return normalized.slice(0, MAX_HELPER_REPO_CONTEXTS); +} + /** Write the daemon lockfile. Overwrites any existing file. */ export function writeLockfile( port: number, @@ -83,12 +197,21 @@ export function writeLockfile( ownerDid?: string, options: WriteLockfileOptions = {}, ): void { + const profileName = lockfileProfile(options.profileName); + const capabilities = normalizeCapabilities(options.capabilities); + const repoContexts = normalizeRepoContexts(options.repoContexts); const lock: DaemonLock = { - pid : process.pid, + pid : process.pid, port, - startedAt : new Date().toISOString(), + startedAt : new Date().toISOString(), ...(version ? { version } : {}), ...(ownerDid ? { ownerDid } : {}), + sessionId : options.sessionId ?? helperSessionId(profileName), + ...(profileName ? { profileName } : {}), + ...(options.reposPath ? { reposPath: options.reposPath } : {}), + ...(capabilities.length > 0 ? { capabilities } : {}), + expiryPolicy : options.expiryPolicy ?? 'helper-lifetime', + ...(repoContexts.length > 0 ? { repoContexts } : {}), ...(options.dwnHelper ? { dwnHelper: true } : {}), }; const path = lockfilePath(options.profileName); @@ -96,6 +219,32 @@ export function writeLockfile( writeFileSync(path, JSON.stringify(lock, null, 2) + '\n', { mode: 0o644 }); } +/** Record a repo context on an already-running helper lockfile. */ +export function recordLockfileRepoContext( + repoContext: HelperRepoContextInput, + profileName?: string, +): boolean { + const lock = readLockfile(profileName); + if (!lock) { return false; } + + const next = normalizeRepoContext(repoContext); + if (!next) { return false; } + + const key = repoContextKey(next); + const repoContexts = [ + next, + ...(lock.repoContexts ?? []).filter((context) => repoContextKey(context) !== key), + ].slice(0, MAX_HELPER_REPO_CONTEXTS); + + const path = lockfilePath(profileName); + writeFileSync(path, JSON.stringify({ + ...lock, + repoContexts, + }, null, 2) + '\n', { mode: 0o644 }); + + return true; +} + /** Remove the daemon lockfile if it exists and belongs to this process. */ export function removeLockfile(profileName?: string): void { const path = lockfilePath(profileName); diff --git a/src/git-remote/credential-main.ts b/src/git-remote/credential-main.ts index bd23050..36df4ea 100644 --- a/src/git-remote/credential-main.ts +++ b/src/git-remote/credential-main.ts @@ -146,8 +146,8 @@ async function handleGet(request: { protocol?: string; host?: string; path?: str // --- Fallback: direct agent connection (no daemon running) --- const password = getVaultPassword(); if (!password) { - console.error('git-remote-did-credential: no running daemon and no vault password available.'); - console.error('Hint: run `gitd serve` in another terminal, or set GITD_PASSWORD and retry.'); + console.error('git-remote-did-credential: no running daemon and no identity password available.'); + console.error('Hint: run `gitd helper start` in another terminal, or set GITD_PASSWORD and retry.'); process.exit(1); } diff --git a/src/git-remote/main.ts b/src/git-remote/main.ts index 478110b..7ab3029 100644 --- a/src/git-remote/main.ts +++ b/src/git-remote/main.ts @@ -144,7 +144,7 @@ async function main(): Promise<void> { console.error('Please make sure:'); console.error(' - The repository exists (create it with `gitd init <name>`)'); console.error(' - The DID is correct and resolvable'); - console.error(' - The daemon is running (`gitd serve`)'); + console.error(' - The local helper is running (`gitd helper status`)'); } process.exit(code ?? 128); }); diff --git a/src/git-remote/resolve.ts b/src/git-remote/resolve.ts index 8cd71b0..cb665d4 100644 --- a/src/git-remote/resolve.ts +++ b/src/git-remote/resolve.ts @@ -17,10 +17,15 @@ import { promises as dns } from 'node:dns'; import { DidDht, DidJwk, DidKey, DidWeb, UniversalResolver } from '@enbox/dids'; -import { ensureDaemon } from '../daemon/lifecycle.js'; import { getVaultPassword } from './tty-prompt.js'; import { readLockfile } from '../daemon/lockfile.js'; import { resolveProfile } from '../profiles/config.js'; +import { ensureDaemon, hasRecentDeferredDaemonStart } from '../daemon/lifecycle.js'; +import { + getOrCreatePublicReaderPassword, + PUBLIC_READER_PROFILE, + recordPublicReaderDid, +} from '../profiles/public-reader.js'; // --------------------------------------------------------------------------- // Types @@ -132,11 +137,11 @@ export async function resolveGitEndpoint(did: string, repo?: string): Promise<Gi throw new Error( `No GitTransport service found for ${did}. ` - + 'The DID has a DecentralizedWebNode service, but no local gitd daemon is available ' + + 'The DID has a DecentralizedWebNode service, but no local gitd helper is available ' + 'to restore the repo from DWN records.\n' - + 'Hint: run `gitd serve` in another terminal, set GITD_PASSWORD so gitd can ' - + 'auto-start the helper, or register a public GitTransport endpoint with ' - + '`gitd serve --public-url <url>`.', + + 'Hint: run `gitd helper start` in another terminal, set GITD_PASSWORD so gitd can ' + + 'auto-start the helper, or publish a public GitTransport endpoint with ' + + '`gitd publish --public-url <url>`.', ); } @@ -162,10 +167,32 @@ function didResolutionTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { /** Timeout for the local daemon health probe (ms). */ const LOCAL_PROBE_TIMEOUT_MS = 2_000; +const LOCAL_DAEMON_WAIT_MS = 3_000; +const LOCAL_DAEMON_WAIT_INTERVAL_MS = 100; const LOCAL_LOOPBACK_ORIGIN = 'http://127.0.0.1'; type LocalDaemonMode = 'owner-only' | 'dwn-helper'; +export type LocalDaemonProfileSelection = { + profileName?: string; + implicitPublicReader : boolean; +}; + +export function selectLocalDaemonProfile( + mode: LocalDaemonMode, + resolvedProfile = resolveProfile() ?? undefined, +): LocalDaemonProfileSelection { + if (resolvedProfile) { + return { profileName: resolvedProfile, implicitPublicReader: false }; + } + + if (mode === 'dwn-helper') { + return { profileName: PUBLIC_READER_PROFILE, implicitPublicReader: true }; + } + + return { implicitPublicReader: false }; +} + /** * Check whether a local gitd daemon is running and reachable. * @@ -180,52 +207,32 @@ async function resolveLocalDaemon( repo?: string, mode: LocalDaemonMode = 'owner-only', ): Promise<GitEndpoint | null> { - const profileName = resolveProfile() ?? undefined; + const profileSelection = selectLocalDaemonProfile(mode); + const profileName = profileSelection.profileName; // Fast path: check for an already-running daemon. - const lock = readLockfile(profileName) ?? (profileName ? readLockfile() : null); - if (lock) { - // Only use the local daemon when the requested DID matches the - // daemon's owner. Cloning someone else's repo must fall through - // to DID document resolution so the request reaches the correct - // remote server. Lockfiles without `ownerDid` (written by older - // versions) are treated as matching for backwards compatibility. - if (mode === 'owner-only' && lock.ownerDid && lock.ownerDid !== did) { - return null; - } - if (mode === 'dwn-helper' && !lock.dwnHelper) { - return null; - } - - const healthUrl = `${LOCAL_LOOPBACK_ORIGIN}:${lock.port}/health`; - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), LOCAL_PROBE_TIMEOUT_MS); - const res = await fetch(healthUrl, { signal: controller.signal }); - clearTimeout(timer); - if (res.ok) { - return { - url : buildUrl(`${LOCAL_LOOPBACK_ORIGIN}:${lock.port}`, did, repo), - did, - source : mode === 'dwn-helper' ? 'LocalDwnHelper' : 'LocalDaemon', - }; - } - } catch { - // Not responding — fall through to auto-start. - } - } + const local = await resolveReachableLocalDaemon(did, repo, mode, profileName); + if (local) { return local; } // Slow path: try to auto-start a daemon. Prompt for the vault // password lazily — only when we actually need to spawn. This avoids // prompting when the daemon is already running (the common case). // Skip auto-start entirely when no password is available — spawning // a daemon without a password will always fail (vault can't unlock). - const password = getVaultPassword() ?? undefined; - if (!password) { return null; } + const password = profileSelection.implicitPublicReader + ? getOrCreatePublicReaderPassword().password + : getVaultPassword() ?? undefined; + if (!password) { + if (!hasRecentDeferredDaemonStart(profileName)) { return null; } + return waitForReachableLocalDaemon(did, repo, mode, profileName); + } try { const result = await ensureDaemon(password, { profileName }); const spawnedLock = readLockfile(profileName); + if (profileSelection.implicitPublicReader && spawnedLock?.ownerDid) { + recordPublicReaderDid(spawnedLock.ownerDid); + } if (mode === 'owner-only' && spawnedLock?.ownerDid && spawnedLock.ownerDid !== did) { return null; } @@ -244,12 +251,66 @@ async function resolveLocalDaemon( console.error( `git-remote-did: could not start local daemon: ${(err as Error).message}\n` + 'Hint: ensure gitd is installed and on your PATH, or run from the project directory.\n' - + 'Hint: run `gitd serve` in another terminal, or set GITD_PASSWORD and retry.', + + 'Hint: run `gitd helper start` in another terminal, or set GITD_PASSWORD and retry.', ); return null; } } +async function resolveReachableLocalDaemon( + did: string, + repo: string | undefined, + mode: LocalDaemonMode, + profileName?: string, +): Promise<GitEndpoint | null> { + const lock = readLockfile(profileName) ?? (profileName ? readLockfile() : null); + if (!lock) { return null; } + + // Only use the local daemon when the requested DID matches the daemon's + // owner. Cloning someone else's repo must fall through to DID document + // resolution so the request reaches the correct remote server. Lockfiles + // without `ownerDid` (written by older versions) are treated as matching for + // backwards compatibility. + if (mode === 'owner-only' && lock.ownerDid && lock.ownerDid !== did) { + return null; + } + if (mode === 'dwn-helper' && !lock.dwnHelper) { + return null; + } + + const healthUrl = `${LOCAL_LOOPBACK_ORIGIN}:${lock.port}/health`; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), LOCAL_PROBE_TIMEOUT_MS); + const res = await fetch(healthUrl, { signal: controller.signal }); + clearTimeout(timer); + if (!res.ok) { return null; } + return { + url : buildUrl(`${LOCAL_LOOPBACK_ORIGIN}:${lock.port}`, did, repo), + did, + source : mode === 'dwn-helper' ? 'LocalDwnHelper' : 'LocalDaemon', + }; + } catch { + return null; + } +} + +async function waitForReachableLocalDaemon( + did: string, + repo: string | undefined, + mode: LocalDaemonMode, + profileName?: string, +): Promise<GitEndpoint | null> { + if (!profileName) { return null; } + const deadline = Date.now() + LOCAL_DAEMON_WAIT_MS; + while (Date.now() < deadline) { + const local = await resolveReachableLocalDaemon(did, repo, mode, profileName); + if (local) { return local; } + await new Promise((resolve) => setTimeout(resolve, LOCAL_DAEMON_WAIT_INTERVAL_MS)); + } + return null; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/src/git-remote/tty-prompt.ts b/src/git-remote/tty-prompt.ts index e4c43f0..93fc718 100644 --- a/src/git-remote/tty-prompt.ts +++ b/src/git-remote/tty-prompt.ts @@ -4,11 +4,12 @@ * When Git invokes `git-remote-did` or `git-remote-did-credential`, it * owns stdin and stdout for the helper protocol. This module opens * `/dev/tty` directly — the same technique used by `ssh`, `gpg`, and - * `sudo` — so we can prompt the user for a vault password without + * `sudo` — so we can prompt the user for an identity password without * interfering with the git protocol streams. * - * Falls back to `GITD_PASSWORD` if the env var is already set, or - * returns `null` when no TTY is available (e.g. CI, piped input). + * Falls back to `GITD_PASSWORD` if the env var is already set, then to the + * hidden public-read profile password when the active profile is the local + * reader. Returns `null` when no password source is available. * * @module */ @@ -16,17 +17,20 @@ import { execSync } from 'node:child_process'; import { closeSync, openSync, readSync, writeSync } from 'node:fs'; +import { readPublicReaderPasswordForActiveProfile } from '../profiles/public-reader.js'; + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** - * Get the vault password, prompting on `/dev/tty` if necessary. + * Get the identity unlock password, prompting on `/dev/tty` if necessary. * * Resolution order: * 1. `GITD_PASSWORD` environment variable (non-interactive) - * 2. Interactive prompt via `/dev/tty` (hidden input) - * 3. `null` if no TTY is available + * 2. Hidden public-read profile password + * 3. Interactive prompt via `/dev/tty` (hidden input) + * 4. `null` if no TTY is available * * @returns The password string, or `null` if unavailable. */ @@ -34,7 +38,10 @@ export function getVaultPassword(): string | null { const env = process.env.GITD_PASSWORD; if (env) { return env; } - return promptTtyPassword('Vault password: '); + const publicReaderPassword = readPublicReaderPasswordForActiveProfile(); + if (publicReaderPassword) { return publicReaderPassword; } + + return promptTtyPassword('Identity password: '); } // --------------------------------------------------------------------------- @@ -54,7 +61,7 @@ export function getVaultPassword(): string | null { * line editing natively, which is more reliable across shells than * reading raw bytes. * - * @param prompt - The prompt string to display (e.g. "Vault password: ") + * @param prompt - The prompt string to display (e.g. "Identity password: ") * @returns The entered string, or `null` if no TTY is available. */ function promptTtyPassword(prompt: string): string | null { diff --git a/src/git-server/server.ts b/src/git-server/server.ts index 8bdaf2c..501d479 100644 --- a/src/git-server/server.ts +++ b/src/git-server/server.ts @@ -330,7 +330,7 @@ export async function createGitServer(options: GitServerOptions): Promise<GitSer reject(new Error( `Port ${port} is already in use.\n` + 'Hint: another gitd instance or service may be running on this port.\n' - + 'Run `gitd serve status` to check, `gitd serve stop` to stop it,\n' + + 'Run `gitd helper status` to check, `gitd helper stop` to stop it,\n' + 'or use `--port <number>` to pick a different port.', )); } else { diff --git a/src/profiles/public-reader.ts b/src/profiles/public-reader.ts new file mode 100644 index 0000000..823c31c --- /dev/null +++ b/src/profiles/public-reader.ts @@ -0,0 +1,95 @@ +/** + * Hidden public-read profile support. + * + * Public clone/fetch still needs a local helper while the DWN APIs require a + * local agent. This module stores a generated unlock secret for a throwaway + * reader profile without adding it to the user's normal identity list. + * + * @module + */ + +import { randomBytes } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { enboxHome, resolveProfile } from './config.js'; + +export const PUBLIC_READER_PROFILE = 'public-reader'; + +type PublicReaderState = { + version : 1; + profileName : typeof PUBLIC_READER_PROFILE; + password : string; + createdAt : string; + did? : string; +}; + +export type PublicReaderPassword = { + password : string; + created : boolean; +}; + +export function publicReaderStatePath(): string { + return join(enboxHome(), 'public-reader.json'); +} + +export function readPublicReaderState(): PublicReaderState | undefined { + const path = publicReaderStatePath(); + if (!existsSync(path)) { return undefined; } + + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<PublicReaderState>; + if ( + parsed.version === 1 + && parsed.profileName === PUBLIC_READER_PROFILE + && typeof parsed.password === 'string' + && parsed.password.length > 0 + && typeof parsed.createdAt === 'string' + ) { + return parsed as PublicReaderState; + } + } catch { + return undefined; + } + + return undefined; +} + +export function getOrCreatePublicReaderPassword(now = new Date()): PublicReaderPassword { + const existing = readPublicReaderState(); + if (existing) { + return { password: existing.password, created: false }; + } + + const password = `gitd-reader-${randomBytes(32).toString('hex')}`; + writePublicReaderState({ + version : 1, + profileName : PUBLIC_READER_PROFILE, + password, + createdAt : now.toISOString(), + }); + return { password, created: true }; +} + +export function recordPublicReaderDid(did: string): void { + const existing = readPublicReaderState(); + if (!existing) { return; } + + writePublicReaderState({ ...existing, did }); +} + +export function readPublicReaderPasswordForActiveProfile(profileName = resolveProfile() ?? undefined): string | undefined { + if (profileName !== PUBLIC_READER_PROFILE) { return undefined; } + return readPublicReaderState()?.password; +} + +function writePublicReaderState(state: PublicReaderState): void { + const path = publicReaderStatePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(state, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }); + try { + chmodSync(path, 0o600); + } catch { + // Best-effort on platforms/filesystems that support POSIX modes. + } +} diff --git a/src/repo.ts b/src/repo.ts index d9fa4ad..00d4474 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -92,6 +92,18 @@ export type ModerationEventData = { createdAt : string; }; +/** Squashed view checkpoint for reduced issue, PR, moderation, or report views. */ +export type ViewSnapshotData = { + kind : 'issue' | 'pr' | 'moderation' | 'report'; + targetId? : string; + reducerVersion? : string; + lastRecordId? : string; + recordCount? : number; + state : Record<string, unknown>; + actorDid : string; + createdAt : string; +}; + export type RepositoryRulesetStateData = { id: number; name: string; @@ -584,6 +596,7 @@ export type ForgeRepoSchemaMap = { topic : TopicData; submissionDecision : SubmissionDecisionData; moderationEvent : ModerationEventData; + viewSnapshot : ViewSnapshotData; webhook : WebhookData; }; @@ -641,6 +654,10 @@ export const ForgeRepoDefinition = { schema : 'https://enbox.id/schemas/forge/moderation-event', dataFormats : ['application/json'], }, + viewSnapshot: { + schema : 'https://enbox.id/schemas/forge/view-snapshot', + dataFormats : ['application/json'], + }, bundle: { dataFormats: ['application/x-git-bundle'], }, @@ -809,6 +826,23 @@ export const ForgeRepoDefinition = { }, }, + viewSnapshot: { + $squash : true, + $actions : [ + { who: 'anyone', can: ['read'] }, + { role: 'repo/maintainer', can: ['create', 'squash'] }, + { role: 'repo/moderator', can: ['create', 'squash'] }, + ], + $tags: { + $requiredTags : ['kind'], + $allowUndefinedTags : false, + kind : { type: 'string', enum: ['issue', 'pr', 'moderation', 'report'] }, + targetId : { type: 'string' }, + actorDid : { type: 'string' }, + lastRecordId : { type: 'string' }, + }, + }, + settings: { $recordLimit: { max: 1, strategy: 'reject' }, // Owner-only: no $actions = only the DWN tenant can read/write diff --git a/tests/auth-helpers.spec.ts b/tests/auth-helpers.spec.ts new file mode 100644 index 0000000..9fbf76f --- /dev/null +++ b/tests/auth-helpers.spec.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; + +import { + firstAuthPositionalArg, + formatAuthSessionsStatus, + normalizeIdentityNameInput, + resetIdentityLocally, + setDefaultIdentity, + suggestIdentityName, + validateIdentityNameInput, +} from '../src/cli/commands/auth.js'; +import { profileDataPath, readConfig, upsertProfile } from '../src/profiles/config.js'; + +let originalEnboxHome: string | undefined; +let tempDir: string; + +describe('auth login helpers', () => { + beforeEach(() => { + originalEnboxHome = process.env.ENBOX_HOME; + tempDir = mkdtempSync(join(tmpdir(), 'gitd-auth-helpers-')); + process.env.ENBOX_HOME = join(tempDir, 'enbox'); + }); + + afterEach(() => { + if (originalEnboxHome) { + process.env.ENBOX_HOME = originalEnboxHome; + } else { + delete process.env.ENBOX_HOME; + } + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('suggests default for the first identity', () => { + expect(suggestIdentityName([])).toBe('default'); + }); + + it('suggests the next generated identity when default exists', () => { + expect(suggestIdentityName(['default'])).toBe('identity-2'); + expect(suggestIdentityName(['default', 'identity-2'])).toBe('identity-3'); + }); + + it('normalizes blank input to the default identity name', () => { + expect(normalizeIdentityNameInput('', 'default')).toBe('default'); + expect(normalizeIdentityNameInput(' ', 'identity-2')).toBe('identity-2'); + expect(normalizeIdentityNameInput(' work ', 'default')).toBe('work'); + }); + + it('validates generated names against existing identities', () => { + expect(validateIdentityNameInput('', [], 'default')).toBeUndefined(); + expect(validateIdentityNameInput('default', ['default'])).toContain('already exists'); + expect(validateIdentityNameInput('bad name', [])).toContain('Only letters'); + }); + + it('sets the default identity', () => { + upsertProfile('default', { name: 'default', did: 'did:dht:default', createdAt: '2026-06-23T00:00:00.000Z' }); + upsertProfile('work', { name: 'work', did: 'did:dht:work', createdAt: '2026-06-23T00:00:00.000Z' }); + + expect(setDefaultIdentity('work')).toBe('Default identity set to "work".'); + expect(readConfig().defaultProfile).toBe('work'); + }); + + it('rejects switching to an unknown identity', () => { + expect(() => setDefaultIdentity('missing')).toThrow('Identity "missing" not found'); + }); + + it('archives and removes an identity during reset', () => { + upsertProfile('default', { name: 'default', did: 'did:dht:default', createdAt: '2026-06-23T00:00:00.000Z' }); + upsertProfile('work', { name: 'work', did: 'did:dht:work', createdAt: '2026-06-23T00:00:00.000Z' }); + + const dataPath = profileDataPath('default'); + mkdirSync(dataPath, { recursive: true }); + writeFileSync(join(dataPath, 'marker.txt'), 'profile data\n', 'utf-8'); + + const result = resetIdentityLocally('default', new Date('2026-06-23T12:00:00.000Z')); + const config = readConfig(); + + expect(result.name).toBe('default'); + expect(result.defaultProfile).toBe('work'); + expect(result.backupPath).toContain('default-2026-06-23T12-00-00-000Z'); + expect(config.profiles.default).toBeUndefined(); + expect(config.defaultProfile).toBe('work'); + expect(existsSync(dataPath)).toBe(false); + expect(existsSync(join(result.backupPath!, 'DATA', 'AGENT', 'marker.txt'))).toBe(true); + }); + + it('rejects resetting an unknown identity', () => { + expect(() => resetIdentityLocally('missing')).toThrow('Identity "missing" not found'); + }); + + it('formats the empty helper session state', () => { + expect(formatAuthSessionsStatus({ running: false })).toEqual([ + 'No active helper session.', + 'Run `gitd helper start` to start one.', + ]); + }); + + it('formats an active helper session with capabilities', () => { + expect(formatAuthSessionsStatus({ + running : true, + pid : 123, + port : 9418, + ownerDid : 'did:dht:alice', + sessionId : 'helper:default:123', + profileName : 'default', + reposPath : '/tmp/gitd/repos', + capabilities : ['git-transport', 'dwn-restore'], + expiryPolicy : 'helper-lifetime', + repoContexts : [{ + ownerDid : 'did:dht:alice', + repo : 'demo', + path : '/tmp/demo', + defaultBranch : 'main', + lastSeenAt : '2026-06-23T00:00:00.000Z', + }], + })).toEqual([ + 'Active helper session', + ' ID: helper:default:123', + ' Profile: default', + ' DID: did:dht:alice', + ' Port: 9418', + ' Repos: /tmp/gitd/repos', + ' Capabilities:', + ' - git-transport', + ' - dwn-restore', + ' Expires: when helper stops', + ' Seen repos:', + ' - did:dht:alice/demo (main) at /tmp/demo', + ' Revoke: gitd auth revoke helper', + ]); + }); + + it('parses auth positional args without treating --profile values as targets', () => { + expect(firstAuthPositionalArg(['helper', '--profile', 'default'])).toBe('helper'); + expect(firstAuthPositionalArg(['--profile', 'default', 'helper'])).toBe('helper'); + expect(firstAuthPositionalArg(['--profile=default', 'helper'])).toBe('helper'); + }); +}); diff --git a/tests/cli.spec.ts b/tests/cli.spec.ts index e181b90..f096c5e 100644 --- a/tests/cli.spec.ts +++ b/tests/cli.spec.ts @@ -37,8 +37,9 @@ import { shortId } from '../src/github-shim/helpers.js'; // Constants // --------------------------------------------------------------------------- -const DATA_PATH = '__TESTDATA__/cli-agent'; -const REPOS_PATH = '__TESTDATA__/cli-repos'; +const TEST_DATA_ROOT = resolve('__TESTDATA__'); +const DATA_PATH = join(TEST_DATA_ROOT, 'cli-agent'); +const REPOS_PATH = join(TEST_DATA_ROOT, 'cli-repos'); // --------------------------------------------------------------------------- // Test helpers @@ -84,6 +85,16 @@ function captureError(fn: () => Promise<void>): Promise<{ errors: string[]; exit })(); } +function localGitConfig(cwd: string, key: string): string { + const result = spawnSync('git', ['config', '--local', key], { + cwd, + encoding : 'utf-8', + stdio : 'pipe', + }); + expect(result.status).toBe(0); + return result.stdout.trim(); +} + function fakeJsonRecord( id: string, contextId: string, @@ -405,7 +416,7 @@ describe('gitd CLI commands', () => { const logs = await captureLog(() => initCommand(ctx, ['my-test-repo', '--branch', 'main', '--repos', REPOS_PATH]), ); - expect(logs.some((l) => l.includes('Initialized forge repo'))).toBe(true); + expect(logs.some((l) => l.includes('Created repo my-test-repo'))).toBe(true); expect(logs.some((l) => l.includes('my-test-repo'))).toBe(true); expect(logs.some((l) => l.includes('Record ID'))).toBe(true); expect(logs.some((l) => l.includes('Git path'))).toBe(true); @@ -422,7 +433,7 @@ describe('gitd CLI commands', () => { const logs = await captureLog(() => initCommand(ctx, ['second-repo', '--branch', 'main', '--repos', REPOS_PATH]), ); - expect(logs.some((l) => l.includes('Initialized forge repo'))).toBe(true); + expect(logs.some((l) => l.includes('Created repo second-repo'))).toBe(true); expect(logs.some((l) => l.includes('second-repo'))).toBe(true); // With multiple repos, set the default so subsequent tests resolve @@ -645,18 +656,20 @@ describe('gitd CLI commands', () => { const logs = await captureLog(() => initCommand(ctx, ['post-init-test', '--repos', REPOS_PATH]), ); - expect(logs.some((l) => l.includes('Next steps'))).toBe(true); + expect(logs.some((l) => l.includes('Next:'))).toBe(true); expect(logs.some((l) => l.includes('git push'))).toBe(true); - expect(logs.some((l) => l.includes('gitd serve'))).toBe(true); + expect(logs.some((l) => l.includes('gitd publish'))).toBe(false); + expect(logs.some((l) => l.includes('DEPLOY.md'))).toBe(false); // Should include the DID somewhere in the output. expect(logs.some((l) => l.includes(ctx.did))).toBe(true); }); - it('should mention --public-url and DEPLOY.md in post-init output', async () => { + it('should mention publish guidance only when requested', async () => { const { initCommand } = await import('../src/cli/commands/init.js'); const logs = await captureLog(() => - initCommand(ctx, ['post-init-url-test', '--repos', REPOS_PATH]), + initCommand(ctx, ['post-init-url-test', '--repos', REPOS_PATH, '--public-url', 'https://git.example.com']), ); + expect(logs.some((l) => l.includes('gitd publish --public-url https://git.example.com'))).toBe(true); expect(logs.some((l) => l.includes('--public-url'))).toBe(true); expect(logs.some((l) => l.includes('DEPLOY.md'))).toBe(true); }); @@ -711,7 +724,7 @@ describe('gitd CLI commands', () => { } }); - it('should store the active profile in local git config', async () => { + it('should store repo context in local git config', async () => { const { initCommand } = await import('../src/cli/commands/init.js'); const absReposPath = resolve(REPOS_PATH); const tmpDir = join(absReposPath, '__profile-local-test'); @@ -722,12 +735,10 @@ describe('gitd CLI commands', () => { await captureLog(() => initCommand({ ...ctx, profileName: 'alice' }, ['profile-local-test', '--repos', absReposPath]), ); - const profileCheck = spawnSync('git', ['config', '--local', 'enbox.profile'], { - cwd : tmpDir, - stdio : 'pipe', - }); - expect(profileCheck.status).toBe(0); - expect(profileCheck.stdout.toString().trim()).toBe('alice'); + expect(localGitConfig(tmpDir, 'enbox.profile')).toBe('alice'); + expect(localGitConfig(tmpDir, 'enbox.owner')).toBe(ctx.did); + expect(localGitConfig(tmpDir, 'enbox.repo')).toBe('profile-local-test'); + expect(localGitConfig(tmpDir, 'enbox.defaultBranch')).toBe('main'); } finally { process.chdir(origCwd); rmSync(tmpDir, { recursive: true, force: true }); @@ -1555,6 +1566,7 @@ describe('gitd CLI commands', () => { ]), ); expect(createLogs.some((l) => l.includes('Created PR'))).toBe(true); + expect(createLogs.some((l) => l.includes('Publish branch: git push origin HEAD:refs/heads/users/'))).toBe(true); expect(sent).toHaveLength(1); expect(sent[0].targetDid).toBe(remoteOwnerDid); expect(captured[0]).toMatchObject({ @@ -1585,6 +1597,49 @@ describe('gitd CLI commands', () => { )).toBe(true); }); + it('should reject remote PR merge before local Git work without maintainer access', async () => { + const { prCommand } = await import('../src/cli/commands/pr.js'); + const remoteOwnerDid = 'did:jwk:remote-pr-merge-owner'; + const { records: repos } = await ctx.repo.records.query('repo', { + filter: { tags: { name: 'my-test-repo' } }, + }); + const patch = { + ...fakeJsonRecord( + 'remote-moderator-merge-pr', + repos[0].contextId, + { title: 'Moderator cannot merge', body: '' }, + { status: 'open', baseBranch: 'main', headBranch: 'feature-x' }, + ), + protocolPath: 'repo/patch', + }; + const sent: SentRecord[] = [{ record: patch, targetDid: remoteOwnerDid }]; + const captured: CapturedCreate[] = []; + const remoteCtx = withRemoteOwnerRecords(ctx, remoteOwnerDid, repos[0], 'moderator', sent, captured); + const tmpRepo = resolve('__TESTDATA__/pr-remote-moderator-merge-repo'); + rmSync(tmpRepo, { recursive: true, force: true }); + + spawnSync('git', ['init', '-b', 'main', tmpRepo], { stdio: 'pipe' }); + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpRepo, stdio: 'pipe' }); + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpRepo, stdio: 'pipe' }); + writeFileSync(join(tmpRepo, 'README.md'), '# Remote merge permission test\n'); + spawnSync('git', ['add', '.'], { cwd: tmpRepo, stdio: 'pipe' }); + spawnSync('git', ['commit', '-m', 'initial commit'], { cwd: tmpRepo, stdio: 'pipe' }); + + const origCwd = process.cwd(); + try { + process.chdir(tmpRepo); + const { errors, exitCode } = await captureError(() => + prCommand(remoteCtx, ['merge', shortId(patch.id), '--owner', remoteOwnerDid]), + ); + expect(exitCode).toBe(1); + expect(errors[0]).toContain('maintainer access'); + expect(captured).toHaveLength(0); + } finally { + process.chdir(origCwd); + rmSync(tmpRepo, { recursive: true, force: true }); + } + }); + it('should enforce PR locks and hide moderated PR comments', async () => { const { prCommand } = await import('../src/cli/commands/pr.js'); const remoteOwnerDid = 'did:jwk:remote-moderated-pr-owner'; diff --git a/tests/clone-helpers.spec.ts b/tests/clone-helpers.spec.ts new file mode 100644 index 0000000..e05ff6f --- /dev/null +++ b/tests/clone-helpers.spec.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from 'bun:test'; + +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, rmSync } from 'node:fs'; + +import { + gitCloneEnv, + parseCloneCommandArgs, + parseCloneTarget, + recordCloneContext, + shouldUsePublicReaderProfile, +} from '../src/cli/commands/clone.js'; + +const BASE = resolve('__TESTDATA__/clone-helpers'); + +function gitConfig(cwd: string, key: string): string { + const result = spawnSync('git', ['config', '--local', key], { + cwd, + encoding : 'utf-8', + stdio : ['pipe', 'pipe', 'pipe'], + }); + expect(result.status).toBe(0); + return result.stdout.trim(); +} + +describe('clone helpers', () => { + afterEach(() => { + rmSync(BASE, { recursive: true, force: true }); + }); + + it('parses did/repo clone targets', () => { + expect(parseCloneTarget('did:dht:abc123/demo')).toEqual({ + did : 'did:dht:abc123', + repo : 'demo', + }); + }); + + it('rejects malformed clone targets', () => { + expect(() => parseCloneTarget('not-a-did/demo')).toThrow('Invalid target'); + expect(() => parseCloneTarget('did:dht:abc123')).toThrow('Invalid target'); + expect(() => parseCloneTarget('did:dht:abc123/')).toThrow('Missing repository name'); + }); + + it('strips gitd profile flags before passing args to git clone', () => { + expect(parseCloneCommandArgs(['--profile', 'work', '--depth', '1', 'local'])).toEqual({ + profileName : 'work', + gitArgs : ['--depth', '1', 'local'], + }); + expect(parseCloneCommandArgs(['--profile=work', '--branch', 'main'])).toEqual({ + profileName : 'work', + gitArgs : ['--branch', 'main'], + }); + }); + + it('treats args after the separator as native git clone args', () => { + expect(parseCloneCommandArgs(['--', '--profile', 'native-value'])).toEqual({ + profileName : undefined, + gitArgs : ['--profile', 'native-value'], + }); + }); + + it('stores repo context and active profile in local git config', () => { + mkdirSync(BASE, { recursive: true }); + const init = spawnSync('git', ['init', BASE], { stdio: 'pipe' }); + expect(init.status).toBe(0); + + const warnings = recordCloneContext( + BASE, + { did: 'did:dht:abc123', repo: 'demo' }, + 'work', + ); + + expect(warnings).toEqual([]); + expect(gitConfig(BASE, 'enbox.owner')).toBe('did:dht:abc123'); + expect(gitConfig(BASE, 'enbox.repo')).toBe('demo'); + expect(gitConfig(BASE, 'enbox.profile')).toBe('work'); + }); + + it('passes resolved profile selection to the native git clone environment', () => { + expect(gitCloneEnv({ PATH: '/bin' }, 'work')).toEqual({ + PATH : '/bin', + GITD_PROFILE : 'work', + }); + expect(gitCloneEnv({ PATH: '/bin' }, 'public-reader', 'secret')).toEqual({ + PATH : '/bin', + GITD_PROFILE : 'public-reader', + GITD_PASSWORD : 'secret', + }); + expect(gitCloneEnv({ PATH: '/bin' })).toEqual({ PATH: '/bin' }); + }); + + it('uses the public reader only when no identity is selected', () => { + expect(shouldUsePublicReaderProfile(undefined, null)).toBe(true); + expect(shouldUsePublicReaderProfile(undefined, undefined)).toBe(true); + expect(shouldUsePublicReaderProfile('work', null)).toBe(false); + expect(shouldUsePublicReaderProfile(undefined, 'work')).toBe(false); + }); +}); diff --git a/tests/command-wizards.spec.ts b/tests/command-wizards.spec.ts new file mode 100644 index 0000000..e5b977c --- /dev/null +++ b/tests/command-wizards.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'bun:test'; + +import { issueCommandPositionals, issueCommentInputs, issueCreateInputs } from '../src/cli/commands/issue.js'; +import { positionalArgs, shouldPromptForMissingInput } from '../src/cli/command-input.js'; +import { repoCommandPositionals, repoRoleInputs } from '../src/cli/commands/repo.js'; + +describe('command wizard input helpers', () => { + it('extracts positionals without treating flag values as positionals', () => { + expect(positionalArgs([ + '--repo', + 'demo', + 'did:dht:alice', + '--alias', + 'Alice', + 'contributor', + ], new Set(['--alias', '--repo']))).toEqual(['did:dht:alice', 'contributor']); + + expect(positionalArgs(['--repo=demo', 'Title', '--', '--literal'], new Set(['--repo']))).toEqual([ + 'Title', + '--literal', + ]); + }); + + it('only prompts for missing input in an interactive terminal', () => { + expect(shouldPromptForMissingInput(undefined, { + stdinIsTTY : true, + stdoutIsTTY : true, + })).toBe(true); + expect(shouldPromptForMissingInput('value', { + stdinIsTTY : true, + stdoutIsTTY : true, + })).toBe(false); + expect(shouldPromptForMissingInput(undefined, { + stdinIsTTY : false, + stdoutIsTTY : true, + })).toBe(false); + }); + + it('parses issue create and comment inputs around repo/owner flags', () => { + expect(issueCommandPositionals([ + '--repo', + 'demo', + '--owner', + 'did:dht:owner', + 'Bug report', + '--body', + 'Broken', + ])).toEqual(['Bug report']); + + expect(issueCreateInputs([ + '--body', + 'Broken', + '--repo', + 'demo', + 'Bug report', + ])).toEqual({ + title : 'Bug report', + body : 'Broken', + }); + + expect(issueCommentInputs([ + '--repo', + 'demo', + 'abc1234', + 'Looks', + 'good', + '--owner', + 'did:dht:owner', + ])).toEqual({ + id : 'abc1234', + body : 'Looks good', + }); + }); + + it('parses repo role inputs around alias and repo flags', () => { + expect(repoCommandPositionals([ + '--repo', + 'demo', + 'did:dht:alice', + '--alias', + 'Alice', + 'maintainer', + ])).toEqual(['did:dht:alice', 'maintainer']); + + expect(repoRoleInputs([ + '--repo', + 'demo', + 'did:dht:alice', + 'contributor', + '--alias', + 'Alice', + ])).toEqual({ + did : 'did:dht:alice', + role : 'contributor', + }); + }); +}); diff --git a/tests/daemon-lifecycle.spec.ts b/tests/daemon-lifecycle.spec.ts index 50fdd02..66eadfa 100644 --- a/tests/daemon-lifecycle.spec.ts +++ b/tests/daemon-lifecycle.spec.ts @@ -11,7 +11,7 @@ import { createGitServer } from '../src/git-server/server.js'; import { getVersion } from '../src/version.js'; import { profilesDir } from '../src/profiles/config.js'; import { daemonLogPath, daemonStatus, findGitdBin, stopDaemon } from '../src/daemon/lifecycle.js'; -import { lockfilePath, readLockfile, removeLockfile, writeLockfile } from '../src/daemon/lockfile.js'; +import { lockfilePath, readLockfile, recordLockfileRepoContext, removeLockfile, writeLockfile } from '../src/daemon/lockfile.js'; // --------------------------------------------------------------------------- // Lockfile version field @@ -60,6 +60,52 @@ describe('lockfile version field', () => { removeLockfile(); }); + it('should write helper session metadata when advertised', () => { + writeLockfile(9418, '1.0.0', 'did:dht:owner123', { + capabilities : ['git-transport', 'dwn-restore', 'git-transport'], + dwnHelper : true, + profileName : 'default', + reposPath : '/tmp/gitd/repos', + }); + const lock = readLockfile('default'); + expect(lock).not.toBeNull(); + expect(lock!.sessionId).toBe(`helper:default:${process.pid}`); + expect(lock!.profileName).toBe('default'); + expect(lock!.reposPath).toBe('/tmp/gitd/repos'); + expect(lock!.capabilities).toEqual(['git-transport', 'dwn-restore']); + expect(lock!.expiryPolicy).toBe('helper-lifetime'); + removeLockfile('default'); + }); + + it('should record and dedupe helper repo contexts on a running lockfile', () => { + writeLockfile(9418, '1.0.0', 'did:dht:owner123', { profileName: 'default' }); + + expect(recordLockfileRepoContext({ + ownerDid : 'did:dht:alice', + repo : 'demo', + path : '/tmp/demo', + remoteUrl : 'did::did:dht:alice/demo', + defaultBranch : 'main', + lastSeenAt : '2026-06-23T00:00:00.000Z', + }, 'default')).toBe(true); + expect(recordLockfileRepoContext({ + ownerDid : 'did:dht:alice', + repo : 'demo', + path : '/tmp/demo', + lastSeenAt : '2026-06-23T00:01:00.000Z', + }, 'default')).toBe(true); + + const lock = readLockfile('default'); + expect(lock!.repoContexts).toHaveLength(1); + expect(lock!.repoContexts![0]).toMatchObject({ + ownerDid : 'did:dht:alice', + repo : 'demo', + path : '/tmp/demo', + lastSeenAt : '2026-06-23T00:01:00.000Z', + }); + removeLockfile('default'); + }); + it('should isolate lockfiles by profile name', () => { const alicePath = lockfilePath('alice'); const bobPath = lockfilePath('bob'); @@ -109,15 +155,25 @@ describe('daemonStatus', () => { }); it('should return running with details when lockfile exists', () => { - writeLockfile(9418, '0.6.1'); - const status = daemonStatus(); + writeLockfile(9418, '0.6.1', 'did:dht:owner123', { + capabilities : ['git-transport'], + profileName : 'default', + reposPath : '/tmp/gitd/repos', + }); + const status = daemonStatus({ profileName: 'default' }); expect(status.running).toBe(true); expect(status.pid).toBe(process.pid); expect(status.port).toBe(9418); expect(status.version).toBe('0.6.1'); + expect(status.ownerDid).toBe('did:dht:owner123'); + expect(status.sessionId).toBe(`helper:default:${process.pid}`); + expect(status.profileName).toBe('default'); + expect(status.reposPath).toBe('/tmp/gitd/repos'); + expect(status.capabilities).toEqual(['git-transport']); + expect(status.expiryPolicy).toBe('helper-lifetime'); expect(status.uptime).toBeDefined(); expect(status.startedAt).toBeDefined(); - removeLockfile(); + removeLockfile('default'); }); }); @@ -266,9 +322,9 @@ describe('createGitServer EADDRINUSE', () => { ).rejects.toThrow(/Port \d+ is already in use/); }); - it('should include a hint about gitd serve status', async () => { + it('should include a hint about gitd helper status', async () => { await expect( createGitServer({ basePath: '__TESTDATA__/eaddrinuse', port: blockedPort }), - ).rejects.toThrow(/gitd serve status/); + ).rejects.toThrow(/gitd helper status/); }); }); diff --git a/tests/doctor.spec.ts b/tests/doctor.spec.ts new file mode 100644 index 0000000..1df71b0 --- /dev/null +++ b/tests/doctor.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'bun:test'; + +import { parseDoctorDidRemote } from '../src/cli/commands/doctor.js'; + +describe('doctor helpers', () => { + it('parses native did remotes', () => { + expect(parseDoctorDidRemote('did::did:dht:abc123/demo')).toEqual({ + did : 'did:dht:abc123', + repo : 'demo', + }); + }); + + it('parses short did remotes', () => { + expect(parseDoctorDidRemote('did::dht:abc123/demo')).toEqual({ + did : 'did:dht:abc123', + repo : 'demo', + }); + }); + + it('ignores non-DID remotes', () => { + expect(parseDoctorDidRemote('https://example.com/repo.git')).toBeNull(); + }); +}); diff --git a/tests/dwn-sqlite.spec.ts b/tests/dwn-sqlite.spec.ts index 04d4ffb..bea8673 100644 --- a/tests/dwn-sqlite.spec.ts +++ b/tests/dwn-sqlite.spec.ts @@ -6,7 +6,7 @@ import { mkdirSync, rmSync } from 'node:fs'; import { createBunSqliteDatabase, SqliteDialect } from '@enbox/dwn-sql-store'; import { Kysely, sql } from 'kysely'; -import { runGitdDwnStoreMigrations } from '../src/cli/dwn-sqlite.js'; +import { repairGitdDwnSqliteStore, runGitdDwnStoreMigrations } from '../src/cli/dwn-sqlite.js'; const TEST_DIR = '__TESTDATA__/dwn-sqlite'; @@ -58,4 +58,61 @@ describe('gitd DWN SQLite migrations', () => { sqliteDb.close(); } }); + + it('should repair a profile store with a pre-existing squash column and missing migration marker', async () => { + mkdirSync(TEST_DIR, { recursive: true }); + const sqliteDb = createBunSqliteDatabase(join(TEST_DIR, 'dwn.sqlite')); + const dialect = new SqliteDialect({ database: async (): Promise<typeof sqliteDb> => sqliteDb }); + const db = new Kysely<Record<string, unknown>>({ dialect }); + + try { + await sql` + CREATE TABLE kysely_migration ( + name varchar(255) PRIMARY KEY, + timestamp varchar(255) NOT NULL + ) + `.execute(db); + await sql` + CREATE TABLE kysely_migration_lock ( + id varchar(255) PRIMARY KEY, + is_locked integer DEFAULT 0 NOT NULL + ) + `.execute(db); + await sql`INSERT INTO kysely_migration (name, timestamp) VALUES ('001-initial-schema', '2026-06-23T00:00:00.000Z')`.execute(db); + await sql`INSERT INTO kysely_migration (name, timestamp) VALUES ('002-content-addressed-datastore', '2026-06-23T00:00:01.000Z')`.execute(db); + await sql` + CREATE TABLE messageStoreMessages ( + tenant varchar(255) NOT NULL, + messageCid varchar(60) NOT NULL, + protocol varchar(200), + squash boolean + ) + `.execute(db); + } finally { + await db.destroy(); + sqliteDb.close(); + } + + const result = await repairGitdDwnSqliteStore(TEST_DIR); + expect(result.status).toBe('fixed'); + expect(result.appliedMigrations).toContain('003-add-squash-column'); + + const repairedSqliteDb = createBunSqliteDatabase(join(TEST_DIR, 'dwn.sqlite')); + const repairedDialect = new SqliteDialect({ database: async (): Promise<typeof repairedSqliteDb> => repairedSqliteDb }); + const repairedDb = new Kysely<Record<string, unknown>>({ dialect: repairedDialect }); + try { + const migration = await sql<{ name: string }>` + SELECT name FROM kysely_migration WHERE name = '003-add-squash-column' + `.execute(repairedDb); + expect(migration.rows).toHaveLength(1); + } finally { + await repairedDb.destroy(); + repairedSqliteDb.close(); + } + }); + + it('should not create a missing profile store during repair', async () => { + const result = await repairGitdDwnSqliteStore(TEST_DIR); + expect(result.status).toBe('missing'); + }); }); diff --git a/tests/e2e-passive-dwn-sync.spec.ts b/tests/e2e-passive-dwn-sync.spec.ts index a3e89af..efb58a3 100644 --- a/tests/e2e-passive-dwn-sync.spec.ts +++ b/tests/e2e-passive-dwn-sync.spec.ts @@ -5,7 +5,7 @@ import type { DidDocument } from '@enbox/dids'; import { createServer } from 'node:http'; import { resolve } from 'node:path'; -import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { spawn, spawnSync } from 'node:child_process'; import { HttpDwnRpcClient } from '@enbox/dwn-clients'; @@ -79,6 +79,229 @@ describe('E2E: spawned helper syncs to a passive DWN endpoint', () => { } }, 90_000); + it('clones Alice public repo on a fresh reader machine without identity setup', async () => { + rmSync(BASE, { recursive: true, force: true }); + + const passiveDwn = await startPassiveDwnServer({ dataPath: resolve(BASE, 'passive-dwn') }); + try { + const alice = bootstrapProfile('alice', passiveDwn.url); + passiveDwn.addDidDocument({ + didDocument : alice.didDocument, + didDocumentMetadata : alice.didDocumentMetadata, + }); + + run('bun', [ + GITD_MAIN, + 'init', + 'passive-demo', + '--no-local', + ], { env: testEnv('alice', passiveDwn.url) }); + + const alicePort = await freePort(); + const aliceServe = await startServe('alice', passiveDwn.url, alicePort); + processes.push(aliceServe.proc); + + const remoteUrl = await authenticatedRemoteUrl( + alicePort, + alice.did, + 'passive-demo', + () => `stdout:\n${aliceServe.stdout()}\nstderr:\n${aliceServe.stderr()}`, + ); + createAndPushMain(resolve(BASE, 'alice-public-reader-work'), remoteUrl); + + await waitFor(async () => { + return aliceServe.stderr().includes('[push-sync] dwn pushed'); + }, async () => { + return `Alice helper did not sync pushed repo to passive DWN\nstdout:\n${aliceServe.stdout()}\nstderr:\n${aliceServe.stderr()}`; + }, 40_000); + + const binDir = resolve(BASE, 'reader-bin'); + writeRemoteHelperWrapper(binDir); + const readerPort = await freePort(); + const readerEnv = freshReaderEnv(passiveDwn.url, binDir, readerPort); + const readerClone = resolve(BASE, 'fresh-reader-clone'); + let clone = { stdout: '', stderr: '' }; + + await waitFor(async () => { + rmSync(readerClone, { recursive: true, force: true }); + const result = await runAsyncRaw('bun', [ + GITD_MAIN, + 'clone', + `${alice.did}/passive-demo`, + readerClone, + ], { + cwd : resolve('.'), + timeoutMs : 90_000, + env : readerEnv, + }); + clone = { + stdout : result.stdout, + stderr : result.stderr, + }; + return result.status === 0; + }, async () => { + return [ + 'Fresh reader could not clone Alice repo through passive DWN', + `stdout:\n${clone.stdout}`, + `stderr:\n${clone.stderr}`, + ].join('\n'); + }, 120_000); + + expect(clone.stdout).toContain('local public-read cache'); + expect(clone.stdout).not.toContain('Recovery phrase'); + expect(clone.stdout).not.toContain('Identity password'); + expect(clone.stderr).toContain('(via LocalDwnHelper)'); + expect(readFileSync(resolve(readerClone, 'README.md'), 'utf-8')).toContain('Passive DWN demo'); + expect(run('git', ['config', '--local', 'enbox.profile'], { cwd: readerClone }).stdout.trim()).toBe('public-reader'); + + const readerConfigPath = resolve(BASE, 'fresh-reader-home', 'config.json'); + if (existsSync(readerConfigPath)) { + const readerConfig = JSON.parse(readFileSync(readerConfigPath, 'utf-8')) as { profiles?: Record<string, unknown> }; + expect(readerConfig.profiles ?? {}).toEqual({}); + } + + await runAsyncRaw('bun', [ + GITD_MAIN, + 'helper', + 'stop', + '--profile', + 'public-reader', + ], { + cwd : resolve('.'), + timeoutMs : 10_000, + env : readerEnv, + }); + } finally { + await passiveDwn.stop(); + } + }, 180_000); + + it('creates a contributor PR with --push without a manual branch refspec', async () => { + rmSync(BASE, { recursive: true, force: true }); + + const passiveDwn = await startPassiveDwnServer({ dataPath: resolve(BASE, 'passive-dwn') }); + try { + const alice = bootstrapProfile('alice', passiveDwn.url); + const bob = bootstrapProfile('bob', passiveDwn.url); + const actors = [alice, bob]; + + for (const actor of actors) { + passiveDwn.addDidDocument({ + didDocument : actor.didDocument, + didDocumentMetadata : actor.didDocumentMetadata, + }); + } + cacheDidDocuments(actors, passiveDwn.url); + + run('bun', [ + GITD_MAIN, + 'init', + 'passive-demo', + '--no-local', + ], { env: testEnv('alice', passiveDwn.url) }); + + const addContributor = await runEventually('bun', [ + GITD_MAIN, + 'repo', + 'add-contributor', + bob.did, + '--repo', + 'passive-demo', + ], { env: testEnv('alice', passiveDwn.url) }); + expect(addContributor.stdout).toContain('Added contributor'); + + const alicePort = await freePort(); + const aliceServe = await startServe('alice', passiveDwn.url, alicePort); + processes.push(aliceServe.proc); + + const remoteUrl = await authenticatedRemoteUrl( + alicePort, + alice.did, + 'passive-demo', + () => `stdout:\n${aliceServe.stdout()}\nstderr:\n${aliceServe.stderr()}`, + ); + createAndPushMain(resolve(BASE, 'alice-contributor-pr-work'), remoteUrl); + + await waitFor(async () => { + return aliceServe.stderr().includes('[push-sync] dwn pushed'); + }, async () => { + return `Alice helper did not sync pushed repo to passive DWN\nstdout:\n${aliceServe.stdout()}\nstderr:\n${aliceServe.stderr()}`; + }, 40_000); + + await stopProcess(aliceServe.proc); + + const bobPort = await freePort(); + const bobServe = await startServe('bob', passiveDwn.url, bobPort); + processes.push(bobServe.proc); + + const binDir = resolve(BASE, 'contributor-bin'); + writeRemoteHelperWrapper(binDir); + const bobEnv = { + ...testEnv('bob', passiveDwn.url), + PATH: `${binDir}:${process.env.PATH ?? ''}`, + }; + const bobClone = resolve(BASE, 'bob-contributor-pr-clone'); + + const clone = await runAsync('bun', [ + GITD_MAIN, + 'clone', + `${alice.did}/passive-demo`, + bobClone, + ], { + cwd : resolve('.'), + timeoutMs : 90_000, + env : bobEnv, + }); + expect(clone.stdout).toContain('Checked out'); + + run('git', ['config', 'user.email', 'bob@example.com'], { cwd: bobClone }); + run('git', ['config', 'user.name', 'Bob'], { cwd: bobClone }); + run('git', ['checkout', '-b', 'contributor-pr'], { cwd: bobClone }); + writeFileSync(resolve(bobClone, 'contributor-pr.txt'), 'Contributor PR through gitd pr create --push.\n', 'utf-8'); + run('git', ['add', 'contributor-pr.txt'], { cwd: bobClone }); + run('git', ['commit', '-m', 'feat: contributor pr create push'], { cwd: bobClone }); + + const pr = await runAsync('bun', [ + GITD_MAIN, + 'pr', + 'create', + 'Contributor PR create push', + '--body', + 'Created without manually pushing a contributor refspec.', + '--repo', + 'passive-demo', + '--owner', + alice.did, + '--push', + ], { + cwd : bobClone, + timeoutMs : 120_000, + env : bobEnv, + }); + + expect(pr.stdout).toContain('Created PR'); + expect(pr.stdout).toContain('Contributor branch: refs/heads/users/'); + expect(pr.stdout).toContain('Publishing branch: git push origin HEAD:refs/heads/users/'); + expect(pr.stderr).toContain('[dwn-apply] PR: Applied'); + + await waitFor(async () => { + return bobServe.stderr().includes('[push-sync] remote branch writeback complete') + && bobServe.stderr().includes('[dwn-apply] remote branch writeback') + && bobServe.stderr().includes(': Applied'); + }, async () => { + return [ + 'Bob contributor PR --push did not write the branch to Alice passive DWN', + `pr stdout:\n${pr.stdout}`, + `pr stderr:\n${pr.stderr}`, + `helper stdout:\n${bobServe.stdout()}`, + `helper stderr:\n${bobServe.stderr()}`, + ].join('\n'); + }, 60_000); + } finally { + await passiveDwn.stop(); + } + }, 180_000); + it('runs contributor push, canonical work items, moderation, maintainer merge, and clone through passive DWN', async () => { rmSync(BASE, { recursive: true, force: true }); @@ -727,6 +950,26 @@ function testEnv(profile: string, dwnEndpoint: string): NodeJS.ProcessEnv { }; } +function freshReaderEnv(dwnEndpoint: string, binDir: string, port: number): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME : resolve(BASE, 'fresh-reader-home'), + ENBOX_HOME : resolve(BASE, 'fresh-reader-home'), + PATH : `${binDir}:${process.env.PATH ?? ''}`, + GITD_PORT : String(port), + GITD_DWN_ENDPOINT : dwnEndpoint, + GITD_DWN_REGISTRATION : 'off', + GITD_DID_REPUBLISH : 'off', + GITD_DEBUG : '1', + GITD_DID_RESOLUTION_TIMEOUT_MS : '1000', + GIT_TERMINAL_PROMPT : '0', + }; + delete env.GITD_PROFILE; + delete env.ENBOX_PROFILE; + delete env.GITD_PASSWORD; + return env; +} + async function freePort(): Promise<number> { const server = createServer(); await new Promise<void>((resolveListen) => { diff --git a/tests/e2e-ux-mvp.spec.ts b/tests/e2e-ux-mvp.spec.ts new file mode 100644 index 0000000..fdc70b0 --- /dev/null +++ b/tests/e2e-ux-mvp.spec.ts @@ -0,0 +1,142 @@ +import { afterAll, describe, expect, it } from 'bun:test'; + +import { createServer } from 'node:http'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const BASE = resolve('__TESTDATA__/ux-mvp-e2e'); +const HOME = join(BASE, 'home'); +const BIN = join(BASE, 'bin'); +const WORK = join(BASE, 'author-work'); +const CLONE = join(BASE, 'author-clone'); +const GITD_MAIN = resolve('src/cli/main.ts'); +const PASSWORD = 'ux-mvp-e2e-password'; + +describe('E2E: UX MVP fresh author machine', () => { + afterAll(() => { + runOptional('bun', [GITD_MAIN, 'helper', 'stop', '--profile', 'default'], { cwd: BASE, env: testEnv(false) }); + rmSync(BASE, { recursive: true, force: true }); + }); + + it('sets up DID git, creates an implicit identity, pushes, and clones without manual helper commands', async () => { + const port = await freePort(); + rmSync(BASE, { recursive: true, force: true }); + mkdirSync(WORK, { recursive: true }); + + const setup = run('bun', [GITD_MAIN, 'setup', '--bin-dir', BIN, '--quiet'], { + cwd : BASE, + env : testEnv(true, port), + }); + expect(setup.stderr).not.toContain('source binary not found'); + + const helperConfig = run('git', ['config', '--global', '--get', 'credential.helper'], { + cwd : BASE, + env : testEnv(false, port), + }).stdout.trim(); + expect(helperConfig).toBe(join(BIN, 'git-remote-did-credential')); + + const init = run('bun', [GITD_MAIN, 'init', 'demo'], { + cwd : WORK, + env : testEnv(true, port), + timeout : 45_000, + }); + expect(init.stdout).toContain('Created identity "default".'); + expect(init.stdout).toContain('Next:'); + expect(init.stderr).not.toContain('Could not start local helper'); + + writeFileSync(join(WORK, 'README.md'), 'hello from ux mvp\n'); + run('git', ['config', 'user.email', 'ux-mvp@example.com'], { cwd: WORK, env: testEnv(false, port) }); + run('git', ['config', 'user.name', 'UX MVP'], { cwd: WORK, env: testEnv(false, port) }); + run('git', ['add', 'README.md'], { cwd: WORK, env: testEnv(false, port) }); + run('git', ['commit', '-m', 'initial commit'], { cwd: WORK, env: testEnv(false, port) }); + + const push = run('git', ['push', '-u', 'origin', 'main'], { + cwd : WORK, + env : testEnv(false, port), + timeout : 45_000, + }); + expect(`${push.stdout}\n${push.stderr}`).toContain('main -> main'); + + const origin = run('git', ['remote', 'get-url', 'origin'], { cwd: WORK, env: testEnv(false, port) }).stdout.trim(); + expect(origin).toMatch(/^did::did:dht:[^/]+\/demo$/); + + run('git', ['clone', origin, CLONE], { + cwd : BASE, + env : testEnv(false, port), + timeout : 45_000, + }); + expect(existsSync(join(CLONE, 'README.md'))).toBe(true); + expect(readFileSync(join(CLONE, 'README.md'), 'utf-8')).toBe('hello from ux mvp\n'); + }, 120_000); +}); + +type RunOptions = { + cwd: string; + env: NodeJS.ProcessEnv; + timeout?: number; +}; + +function testEnv(includePassword: boolean, port?: number): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME, + ENBOX_HOME : HOME, + PATH : `${BIN}:${process.env.PATH ?? ''}`, + GITD_DWN_REGISTRATION : 'off', + GITD_DID_REPUBLISH : 'off', + GITD_PORT : port === undefined ? undefined : String(port), + GITD_SYNC : 'off', + GIT_TERMINAL_PROMPT : '0', + }; + delete env.GITD_PROFILE; + delete env.ENBOX_PROFILE; + if (includePassword) { + env.GITD_PASSWORD = PASSWORD; + } else { + delete env.GITD_PASSWORD; + } + return env; +} + +async function freePort(): Promise<number> { + const server = createServer(); + await new Promise<void>((resolveListen) => { + server.listen(0, '127.0.0.1', resolveListen); + }); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + await new Promise<void>((resolveClose) => server.close(() => resolveClose())); + return port; +} + +function run(command: string, args: string[], options: RunOptions): { stdout: string; stderr: string } { + const result = spawnSync(command, args, { + cwd : options.cwd, + env : options.env, + encoding : 'utf-8', + timeout : options.timeout ?? 30_000, + }); + + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} failed with ${result.status}\n` + + `stdout:\n${result.stdout}\n` + + `stderr:\n${result.stderr}`, + ); + } + + return { + stdout : result.stdout ?? '', + stderr : result.stderr ?? '', + }; +} + +function runOptional(command: string, args: string[], options: RunOptions): void { + spawnSync(command, args, { + cwd : options.cwd, + env : options.env, + encoding : 'utf-8', + timeout : options.timeout ?? 10_000, + }); +} diff --git a/tests/git-remote.spec.ts b/tests/git-remote.spec.ts index c9ee3ce..74cda84 100644 --- a/tests/git-remote.spec.ts +++ b/tests/git-remote.spec.ts @@ -7,7 +7,7 @@ */ import { DidDht } from '@enbox/dids'; import { parseDidUrl } from '../src/git-remote/parse-url.js'; -import { __setResolverForTests, resolveGitEndpoint } from '../src/git-remote/resolve.js'; +import { __setResolverForTests, resolveGitEndpoint, selectLocalDaemonProfile } from '../src/git-remote/resolve.js'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; @@ -385,6 +385,7 @@ import { createServer } from 'node:http'; import { dirname } from 'node:path'; import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync as writeFs } from 'node:fs'; +import { PUBLIC_READER_PROFILE } from '../src/profiles/public-reader.js'; import { writeConfig } from '../src/profiles/config.js'; import { lockfilePath, readLockfile, removeLockfile, writeLockfile } from '../src/daemon/lockfile.js'; @@ -556,6 +557,109 @@ describe('resolveGitEndpoint with a default profile daemon', () => { }); }); +describe('resolveGitEndpoint with implicit public reader daemon', () => { + let server: ReturnType<typeof createServer>; + let port: number; + + const envHome = process.env.ENBOX_HOME; + const envGitdProfile = process.env.GITD_PROFILE; + const envEnboxProfile = process.env.ENBOX_PROFILE; + const envGitdPassword = process.env.GITD_PASSWORD; + const remoteDid = 'did:dht:remote-public-reader'; + const readerDid = 'did:dht:local-public-reader'; + const testHome = '__TESTDATA__/git-remote-public-reader-home'; + + beforeAll(async () => { + server = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + }); + await new Promise<void>((resolve) => { + server.listen(0, () => { + port = (server.address() as any).port; + resolve(); + }); + }); + }); + + beforeEach(() => { + rmSync(testHome, { recursive: true, force: true }); + process.env.ENBOX_HOME = testHome; + delete process.env.GITD_PROFILE; + delete process.env.ENBOX_PROFILE; + delete process.env.GITD_PASSWORD; + writeConfig({ version: 1, defaultProfile: '', profiles: {} }); + writeLockfile(port, '1.0.0', readerDid, { + profileName : PUBLIC_READER_PROFILE, + dwnHelper : true, + }); + __setResolverForTests({ + resolve: async (did: string) => ({ + didDocument: { + id : did, + service : [ + { id: '#dwn', type: 'DecentralizedWebNode', serviceEndpoint: 'https://dwn.example.com' }, + ], + }, + didDocumentMetadata : {}, + didResolutionMetadata : {}, + } as any), + }); + }); + + afterEach(() => { + removeLockfile(PUBLIC_READER_PROFILE); + rmSync(testHome, { recursive: true, force: true }); + __setResolverForTests(); + if (envHome !== undefined) { + process.env.ENBOX_HOME = envHome; + } else { + delete process.env.ENBOX_HOME; + } + if (envGitdProfile !== undefined) { + process.env.GITD_PROFILE = envGitdProfile; + } else { + delete process.env.GITD_PROFILE; + } + if (envEnboxProfile !== undefined) { + process.env.ENBOX_PROFILE = envEnboxProfile; + } else { + delete process.env.ENBOX_PROFILE; + } + if (envGitdPassword !== undefined) { + process.env.GITD_PASSWORD = envGitdPassword; + } else { + delete process.env.GITD_PASSWORD; + } + }); + + afterAll(() => { + server.close(); + }); + + it('selects the hidden reader only for DWN-helper fallback', () => { + expect(selectLocalDaemonProfile('owner-only', undefined)).toEqual({ + implicitPublicReader: false, + }); + expect(selectLocalDaemonProfile('dwn-helper', undefined)).toEqual({ + profileName : PUBLIC_READER_PROFILE, + implicitPublicReader : true, + }); + expect(selectLocalDaemonProfile('dwn-helper', 'work')).toEqual({ + profileName : 'work', + implicitPublicReader : false, + }); + }); + + it('uses a public-reader scoped helper for native public clone fallback', async () => { + const result = await resolveGitEndpoint(remoteDid, 'their-repo'); + + expect(result.source).toBe('LocalDwnHelper'); + expect(result.did).toBe(remoteDid); + expect(result.url).toBe(`http://127.0.0.1:${port}/${encodeURIComponent(remoteDid)}/their-repo`); + }); +}); + // --------------------------------------------------------------------------- // Local daemon DID ownership check // --------------------------------------------------------------------------- diff --git a/tests/identity-wizard.spec.ts b/tests/identity-wizard.spec.ts new file mode 100644 index 0000000..31785e1 --- /dev/null +++ b/tests/identity-wizard.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'bun:test'; + +import { + commandNeedsIdentity, + firstIdentityPermissionSummary, + hasAmbientIdentitySelection, + shouldPromptForFirstIdentitySetup, +} from '../src/cli/identity-wizard.js'; + +describe('first-run identity wizard helpers', () => { + it('identifies commands that need an identity', () => { + expect(commandNeedsIdentity('init')).toBe(true); + expect(commandNeedsIdentity('pr')).toBe(true); + expect(commandNeedsIdentity('mod')).toBe(true); + expect(commandNeedsIdentity('helper')).toBe(true); + expect(commandNeedsIdentity('clone')).toBe(false); + expect(commandNeedsIdentity('setup')).toBe(false); + }); + + it('prompts for a fresh interactive write command', () => { + expect(shouldPromptForFirstIdentitySetup({ + commandName : 'init', + args : ['demo'], + env : {}, + stdinIsTTY : true, + stdoutIsTTY : true, + identityCount : 0, + })).toBe(true); + }); + + it('does not prompt when automation provides the password', () => { + expect(shouldPromptForFirstIdentitySetup({ + commandName : 'init', + args : ['demo'], + env : { GITD_PASSWORD: 'secret' }, + stdinIsTTY : true, + stdoutIsTTY : true, + identityCount : 0, + })).toBe(false); + }); + + it('does not prompt when an identity already exists or is selected explicitly', () => { + expect(shouldPromptForFirstIdentitySetup({ + commandName : 'init', + args : ['demo'], + env : {}, + stdinIsTTY : true, + stdoutIsTTY : true, + identityCount : 1, + })).toBe(false); + + expect(shouldPromptForFirstIdentitySetup({ + commandName : 'init', + args : ['demo'], + profileFlag : 'work', + env : {}, + stdinIsTTY : true, + stdoutIsTTY : true, + identityCount : 0, + })).toBe(false); + }); + + it('does not prompt in non-interactive sessions or read-only clone commands', () => { + expect(shouldPromptForFirstIdentitySetup({ + commandName : 'init', + args : ['demo'], + env : {}, + stdinIsTTY : false, + stdoutIsTTY : true, + identityCount : 0, + })).toBe(false); + + expect(shouldPromptForFirstIdentitySetup({ + commandName : 'clone', + args : ['did:dht:abc/demo'], + env : {}, + stdinIsTTY : true, + stdoutIsTTY : true, + identityCount : 0, + })).toBe(false); + }); + + it('prompts for init metadata-only creation because it still writes repo records', () => { + expect(shouldPromptForFirstIdentitySetup({ + commandName : 'init', + args : ['demo', '--no-local'], + env : {}, + stdinIsTTY : true, + stdoutIsTTY : true, + identityCount : 0, + })).toBe(true); + }); + + it('respects ambient profile environment variables', () => { + expect(hasAmbientIdentitySelection(undefined, { GITD_PROFILE: 'work' })).toBe(true); + expect(hasAmbientIdentitySelection(undefined, { ENBOX_PROFILE: 'work' })).toBe(true); + expect(hasAmbientIdentitySelection('work', {})).toBe(true); + expect(hasAmbientIdentitySelection(undefined, {})).toBe(false); + }); + + it('formats a permission summary in user-facing identity language', () => { + expect(firstIdentityPermissionSummary('pr')).toEqual([ + 'gitd needs an identity to continue with `gitd pr`.', + 'It will use this identity to sign gitd records and local Git operations.', + 'The local helper session can be inspected with `gitd auth sessions` and revoked with `gitd auth revoke helper`.', + ]); + }); +}); diff --git a/tests/permission-summary.spec.ts b/tests/permission-summary.spec.ts new file mode 100644 index 0000000..094d8b4 --- /dev/null +++ b/tests/permission-summary.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'bun:test'; + +import { formatModerationEventSummary } from '../src/cli/commands/mod.js'; +import { formatRoleChangeSummary } from '../src/cli/commands/repo.js'; + +describe('permission summaries', () => { + it('formats role grant summaries', () => { + expect(formatRoleChangeSummary({ + action : 'grant', + actorDid : 'did:dht:owner', + ownerDid : 'did:dht:owner', + repoName : 'demo', + role : 'moderator', + targetDid : 'did:dht:mod', + })).toEqual([ + 'Granting moderator role', + ' Repo: did:dht:owner/demo', + ' Actor: did:dht:owner', + ' Target: did:dht:mod', + ]); + }); + + it('formats moderation action summaries', () => { + expect(formatModerationEventSummary({ + action : 'block', + actorDid : 'did:dht:moderator', + ownerDid : 'did:dht:owner', + repoName : 'demo', + target : 'did:dht:spammer', + reason : 'spam', + })).toEqual([ + 'Moderation: block', + ' Repo: did:dht:owner/demo', + ' Actor: did:dht:moderator', + ' Target: did:dht:spammer', + ' Reason: spam', + ]); + }); +}); diff --git a/tests/pr-helpers.spec.ts b/tests/pr-helpers.spec.ts new file mode 100644 index 0000000..45c86ba --- /dev/null +++ b/tests/pr-helpers.spec.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'bun:test'; + +import { contributorBranchPrefix } from '../src/branch-state.js'; +import { + contributorBranchPublishPlan, + contributorBranchRefForHead, + formatPrMergeSummary, + inferPrBaseBranch, + inferPrHeadBranch, + shouldPromptContributorBranchPublish, + shouldPublishContributorBranch, +} from '../src/cli/commands/pr.js'; + +function gitFrom(outputs: Record<string, string | null>) { + return (args: string[]): string | null => outputs[args.join('\0')] ?? null; +} + +describe('PR command helpers', () => { + it('uses an explicit base branch first', () => { + expect(inferPrBaseBranch({ + explicitBase : 'release/v1', + repoDefaultBranch : 'trunk', + git : gitFrom({}), + })).toBe('release/v1'); + }); + + it('uses the repo record default branch before local git fallbacks', () => { + expect(inferPrBaseBranch({ + repoDefaultBranch : 'trunk', + git : gitFrom({ ['config\0--get\0enbox.defaultBranch']: 'main' }), + })).toBe('trunk'); + }); + + it('uses local enbox default branch config when repo data is unavailable', () => { + expect(inferPrBaseBranch({ + git: gitFrom({ ['config\0--get\0enbox.defaultBranch']: 'develop' }), + })).toBe('develop'); + }); + + it('normalizes origin HEAD when local repo config has no default branch', () => { + expect(inferPrBaseBranch({ + git: gitFrom({ + ['symbolic-ref\0--quiet\0--short\0refs/remotes/origin/HEAD']: 'origin/trunk', + }), + })).toBe('trunk'); + }); + + it('falls back to an existing conventional local branch', () => { + expect(inferPrBaseBranch({ + git: gitFrom({ + ['rev-parse\0--verify\0refs/heads/master']: 'abc123', + }), + })).toBe('master'); + }); + + it('falls back to main when no base signal exists', () => { + expect(inferPrBaseBranch({ git: gitFrom({}) })).toBe('main'); + }); + + it('infers head branch from explicit input, git context, or current branch', () => { + expect(inferPrHeadBranch('feature/manual', null, gitFrom({}))).toBe('feature/manual'); + expect(inferPrHeadBranch(undefined, { headBranch: 'feature/context' }, gitFrom({}))).toBe('feature/context'); + expect(inferPrHeadBranch(undefined, null, gitFrom({ + ['rev-parse\0--abbrev-ref\0HEAD']: 'feature/current', + }))).toBe('feature/current'); + }); + + it('does not report detached HEAD as a PR head branch', () => { + expect(inferPrHeadBranch(undefined, { headBranch: 'HEAD' }, gitFrom({ + ['rev-parse\0--abbrev-ref\0HEAD']: 'HEAD', + }))).toBeUndefined(); + }); + + it('maps local PR heads into the actor contributor namespace', () => { + const did = 'did:dht:alice'; + expect(contributorBranchRefForHead(did, 'feat/demo')).toBe( + `${contributorBranchPrefix(did)}feat/demo`, + ); + expect(contributorBranchRefForHead(did, 'refs/heads/feat/demo')).toBe( + `${contributorBranchPrefix(did)}feat/demo`, + ); + }); + + it('keeps already canonical contributor refs unchanged', () => { + const did = 'did:dht:alice'; + const ref = `${contributorBranchPrefix(did)}feat/demo`; + expect(contributorBranchRefForHead(did, ref)).toBe(ref); + expect(contributorBranchRefForHead(did, ref.slice('refs/heads/'.length))).toBe(ref); + }); + + it('rejects unsafe contributor branch names', () => { + expect(contributorBranchRefForHead('did:dht:alice', 'bad branch')).toBeUndefined(); + expect(contributorBranchRefForHead('did:dht:alice', '../bad')).toBeUndefined(); + expect(contributorBranchRefForHead('did:dht:alice', 'HEAD')).toBeUndefined(); + }); + + it('builds the contributor branch publish command', () => { + const did = 'did:dht:alice'; + const ref = `${contributorBranchPrefix(did)}feat/demo`; + + expect(contributorBranchPublishPlan(did, 'feat/demo')).toEqual({ + remote : 'origin', + refName : ref, + refspec : `HEAD:${ref}`, + commandText : `git push origin HEAD:${ref}`, + }); + }); + + it('prompts to publish contributor branches only in interactive undecided sessions', () => { + const plan = contributorBranchPublishPlan('did:dht:alice', 'feat/demo'); + + expect(shouldPromptContributorBranchPublish({ + plan, + stdinIsTTY : true, + stdoutIsTTY : true, + })).toBe(true); + + expect(shouldPromptContributorBranchPublish({ + plan, + pushRequested : true, + stdinIsTTY : true, + stdoutIsTTY : true, + })).toBe(false); + + expect(shouldPromptContributorBranchPublish({ + plan, + noPush : true, + stdinIsTTY : true, + stdoutIsTTY : true, + })).toBe(false); + + expect(shouldPromptContributorBranchPublish({ + plan, + stdinIsTTY : false, + stdoutIsTTY : true, + })).toBe(false); + }); + + it('uses the interactive contributor branch publish confirmation result', async () => { + const plan = contributorBranchPublishPlan('did:dht:alice', 'feat/demo'); + expect(plan).toBeDefined(); + + const confirmed = await shouldPublishContributorBranch({ + plan, + stdinIsTTY : true, + stdoutIsTTY : true, + }, async () => true); + expect(confirmed).toBe(true); + + const declined = await shouldPublishContributorBranch({ + plan, + stdinIsTTY : true, + stdoutIsTTY : true, + }, async () => false); + expect(declined).toBe(false); + }); + + it('formats a maintainer merge preflight summary', () => { + expect(formatPrMergeSummary({ + id : 'abc1234', + title : 'Add feature', + actorDid : 'did:dht:maintainer', + ownerDid : 'did:dht:owner', + repoName : 'demo', + baseBranch : 'main', + headBranch : 'feature', + strategy : 'squash', + commitCount : 2, + })).toEqual([ + 'Merging PR abc1234: Add feature', + ' Repo: did:dht:owner/demo', + ' Actor: did:dht:maintainer', + ' Base: main', + ' Head: feature', + ' Strategy: squash', + ' Commits: 2 commits', + ]); + }); +}); diff --git a/tests/profile-session.spec.ts b/tests/profile-session.spec.ts new file mode 100644 index 0000000..eb48440 --- /dev/null +++ b/tests/profile-session.spec.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; + +import { resolveLifecycleProfileName } from '../src/cli/commands/serve-lifecycle.js'; +import { + getOrCreatePublicReaderPassword, + PUBLIC_READER_PROFILE, + readPublicReaderState, +} from '../src/profiles/public-reader.js'; +import { + implicitRecoveryPhraseMessage, + recordConnectedProfile, + resolveCommandProfile, +} from '../src/cli/profile-session.js'; +import { readConfig, upsertProfile, writeConfig } from '../src/profiles/config.js'; + +let originalCwd: string; +let originalEnboxHome: string | undefined; +let originalGitdProfile: string | undefined; +let originalEnboxProfile: string | undefined; +let tempDir: string; + +describe('profile session helpers', () => { + beforeEach(() => { + originalCwd = process.cwd(); + originalEnboxHome = process.env.ENBOX_HOME; + originalGitdProfile = process.env.GITD_PROFILE; + originalEnboxProfile = process.env.ENBOX_PROFILE; + tempDir = mkdtempSync(join(tmpdir(), 'gitd-profile-session-')); + process.chdir(tempDir); + process.env.ENBOX_HOME = join(tempDir, 'enbox'); + delete process.env.GITD_PROFILE; + delete process.env.ENBOX_PROFILE; + }); + + afterEach(() => { + process.chdir(originalCwd); + if (originalEnboxHome) { + process.env.ENBOX_HOME = originalEnboxHome; + } else { + delete process.env.ENBOX_HOME; + } + if (originalGitdProfile) { + process.env.GITD_PROFILE = originalGitdProfile; + } else { + delete process.env.GITD_PROFILE; + } + if (originalEnboxProfile) { + process.env.ENBOX_PROFILE = originalEnboxProfile; + } else { + delete process.env.ENBOX_PROFILE; + } + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('uses default for the first implicit identity', () => { + const profile = resolveCommandProfile(); + expect(profile.name).toBe('default'); + expect(profile.dataPath).toContain(join('profiles', 'default', 'DATA', 'AGENT')); + }); + + it('uses default for first-run helper lifecycle commands', () => { + expect(resolveLifecycleProfileName(['start'])).toBe('default'); + expect(resolveLifecycleProfileName(['status'])).toBe('default'); + }); + + it('respects explicit profile names', () => { + const profile = resolveCommandProfile('work'); + expect(profile.name).toBe('work'); + expect(profile.dataPath).toContain(join('profiles', 'work', 'DATA', 'AGENT')); + expect(resolveLifecycleProfileName(['start', '--profile', 'work'])).toBe('work'); + }); + + it('rejects ambiguous multiple identities without a default', () => { + upsertProfile('work', { name: 'work', did: 'did:dht:work', createdAt: '2026-06-23T00:00:00.000Z' }); + upsertProfile('personal', { name: 'personal', did: 'did:dht:personal', createdAt: '2026-06-23T00:00:00.000Z' }); + const config = readConfig(); + config.defaultProfile = ''; + writeConfig(config); + + expect(() => resolveCommandProfile()).toThrow('Multiple identities'); + expect(() => resolveLifecycleProfileName(['start'])).toThrow('Multiple identities'); + }); + + it('records connected profile metadata', () => { + const result = recordConnectedProfile('default', 'did:dht:abc123'); + const config = readConfig(); + + expect(result.created).toBe(true); + expect(config.defaultProfile).toBe('default'); + expect(config.profiles.default.did).toBe('did:dht:abc123'); + }); + + it('records hidden public reader metadata outside the normal identity list', () => { + getOrCreatePublicReaderPassword(); + const result = recordConnectedProfile(PUBLIC_READER_PROFILE, 'did:dht:reader'); + const config = readConfig(); + + expect(result.created).toBe(false); + expect(config.profiles).toEqual({}); + expect(readPublicReaderState()?.did).toBe('did:dht:reader'); + }); + + it('formats recovery phrase message for implicit identities', () => { + expect(implicitRecoveryPhraseMessage('default', 'one two three')).toContain('Created identity "default".'); + expect(implicitRecoveryPhraseMessage('default', 'one two three')).toContain(' one two three'); + }); +}); diff --git a/tests/protocols.spec.ts b/tests/protocols.spec.ts index e2590e3..e936112 100644 --- a/tests/protocols.spec.ts +++ b/tests/protocols.spec.ts @@ -54,6 +54,7 @@ describe('@enbox/gitd', () => { expect(ForgeRepoDefinition.types.topic).toBeDefined(); expect(ForgeRepoDefinition.types.submissionDecision).toBeDefined(); expect(ForgeRepoDefinition.types.moderationEvent).toBeDefined(); + expect(ForgeRepoDefinition.types.viewSnapshot).toBeDefined(); expect(ForgeRepoDefinition.types.webhook).toBeDefined(); }); @@ -115,6 +116,7 @@ describe('@enbox/gitd', () => { expect(ForgeRepoDefinition.structure.repo.webhook).toBeDefined(); expect(ForgeRepoDefinition.structure.repo.bundle).toBeDefined(); expect(ForgeRepoDefinition.structure.repo.moderationEvent).toBeDefined(); + expect(ForgeRepoDefinition.structure.repo.viewSnapshot).toBeDefined(); }); it('should enable $squash on bundle records', () => { @@ -174,6 +176,24 @@ describe('@enbox/gitd', () => { expect(moderatorAction?.can).toContain('create'); }); + it('should support squashed view snapshots for reduced repo views', () => { + const snapshot = ForgeRepoDefinition.structure.repo.viewSnapshot; + expect(snapshot.$squash).toBe(true); + expect(snapshot.$tags.$requiredTags).toEqual(['kind']); + expect(snapshot.$tags.$allowUndefinedTags).toBe(false); + expect(snapshot.$tags.kind.enum).toEqual(['issue', 'pr', 'moderation', 'report']); + expect(snapshot.$tags.targetId.type).toBe('string'); + expect(snapshot.$tags.actorDid.type).toBe('string'); + expect(snapshot.$tags.lastRecordId.type).toBe('string'); + + const anyoneAction = snapshot.$actions.find((a) => 'who' in a && a.who === 'anyone'); + const maintainerAction = snapshot.$actions.find((a) => 'role' in a && a.role === 'repo/maintainer'); + const moderatorAction = snapshot.$actions.find((a) => 'role' in a && a.role === 'repo/moderator'); + expect(anyoneAction?.can).toContain('read'); + expect(maintainerAction?.can).toEqual(expect.arrayContaining(['create', 'squash'])); + expect(moderatorAction?.can).toEqual(expect.arrayContaining(['create', 'squash'])); + }); + it('should wrap definition via defineProtocol()', () => { expect(ForgeRepoProtocol.definition).toBe(ForgeRepoDefinition); }); diff --git a/tests/public-reader-guard.spec.ts b/tests/public-reader-guard.spec.ts new file mode 100644 index 0000000..610ce73 --- /dev/null +++ b/tests/public-reader-guard.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test'; + +import { PUBLIC_READER_PROFILE } from '../src/profiles/public-reader.js'; +import { + isPublicReaderCommandAllowed, + publicReaderWriteBlockMessage, + shouldBlockPublicReaderCommand, +} from '../src/cli/public-reader-guard.js'; + +describe('public reader command guard', () => { + it('allows read-only repo commands', () => { + expect(isPublicReaderCommandAllowed('repo', [])).toBe(true); + expect(isPublicReaderCommandAllowed('repo', ['info'])).toBe(true); + expect(isPublicReaderCommandAllowed('issue', ['list'])).toBe(true); + expect(isPublicReaderCommandAllowed('pr', ['checkout', 'abc1234'])).toBe(true); + expect(isPublicReaderCommandAllowed('release', ['show', 'v1.0.0'])).toBe(true); + }); + + it('allows local helper lifecycle for the hidden reader profile', () => { + expect(isPublicReaderCommandAllowed('helper', ['start'])).toBe(true); + expect(isPublicReaderCommandAllowed('helper', ['status'])).toBe(true); + expect(isPublicReaderCommandAllowed('serve', [])).toBe(true); + expect(isPublicReaderCommandAllowed('serve', ['--foreground'])).toBe(true); + expect(isPublicReaderCommandAllowed('serve', ['status'])).toBe(true); + }); + + it('blocks public publishing commands for the hidden reader profile', () => { + expect(isPublicReaderCommandAllowed('publish', ['--public-url', 'https://git.example.com'])).toBe(false); + expect(isPublicReaderCommandAllowed('serve', ['--public-url', 'https://git.example.com'])).toBe(false); + expect(isPublicReaderCommandAllowed('serve', ['--publish'])).toBe(false); + }); + + it('blocks write commands when the hidden reader profile is active', () => { + expect(shouldBlockPublicReaderCommand(PUBLIC_READER_PROFILE, 'issue', ['create', 'bug'])).toBe(true); + expect(shouldBlockPublicReaderCommand(PUBLIC_READER_PROFILE, 'pr', ['create', 'feature'])).toBe(true); + expect(shouldBlockPublicReaderCommand(PUBLIC_READER_PROFILE, 'repo', ['add-moderator', 'did:dht:abc'])).toBe(true); + expect(shouldBlockPublicReaderCommand('work', 'issue', ['create', 'bug'])).toBe(false); + }); + + it('explains how to switch from public-read cache to a write identity', () => { + expect(publicReaderWriteBlockMessage('pr', ['create', 'Feature']).join('\n')).toContain('gitd auth login'); + expect(publicReaderWriteBlockMessage('pr', ['create', 'Feature']).join('\n')).toContain('gitd auth use <identity>'); + expect(publicReaderWriteBlockMessage('pr', ['create', 'Feature']).join('\n')).toContain('Then retry: gitd pr create Feature'); + }); +}); diff --git a/tests/public-reader.spec.ts b/tests/public-reader.spec.ts new file mode 100644 index 0000000..e018776 --- /dev/null +++ b/tests/public-reader.spec.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; + +import { readConfig } from '../src/profiles/config.js'; +import { + getOrCreatePublicReaderPassword, + PUBLIC_READER_PROFILE, + publicReaderStatePath, + readPublicReaderPasswordForActiveProfile, + readPublicReaderState, + recordPublicReaderDid, +} from '../src/profiles/public-reader.js'; + +describe('public reader profile', () => { + let originalEnboxHome: string | undefined; + let originalGitdProfile: string | undefined; + let originalEnboxProfile: string | undefined; + let tempHome: string; + + beforeEach(() => { + originalEnboxHome = process.env.ENBOX_HOME; + originalGitdProfile = process.env.GITD_PROFILE; + originalEnboxProfile = process.env.ENBOX_PROFILE; + tempHome = mkdtempSync(join(tmpdir(), 'gitd-public-reader-')); + process.env.ENBOX_HOME = tempHome; + delete process.env.GITD_PROFILE; + delete process.env.ENBOX_PROFILE; + }); + + afterEach(() => { + if (originalEnboxHome !== undefined) { + process.env.ENBOX_HOME = originalEnboxHome; + } else { + delete process.env.ENBOX_HOME; + } + if (originalGitdProfile !== undefined) { + process.env.GITD_PROFILE = originalGitdProfile; + } else { + delete process.env.GITD_PROFILE; + } + if (originalEnboxProfile !== undefined) { + process.env.ENBOX_PROFILE = originalEnboxProfile; + } else { + delete process.env.ENBOX_PROFILE; + } + rmSync(tempHome, { recursive: true, force: true }); + }); + + it('creates and reuses a hidden password without adding a normal profile', () => { + const now = new Date('2026-06-23T00:00:00.000Z'); + const created = getOrCreatePublicReaderPassword(now); + + expect(created.created).toBe(true); + expect(created.password.startsWith('gitd-reader-')).toBe(true); + expect(existsSync(publicReaderStatePath())).toBe(true); + expect(readConfig().profiles).toEqual({}); + + const reused = getOrCreatePublicReaderPassword(new Date('2026-06-24T00:00:00.000Z')); + expect(reused).toEqual({ password: created.password, created: false }); + + recordPublicReaderDid('did:dht:reader'); + expect(readPublicReaderState()).toMatchObject({ + profileName : PUBLIC_READER_PROFILE, + password : created.password, + createdAt : now.toISOString(), + did : 'did:dht:reader', + }); + }); + + it('only exposes the hidden password for the public reader profile', () => { + const created = getOrCreatePublicReaderPassword(); + + expect(readPublicReaderPasswordForActiveProfile(PUBLIC_READER_PROFILE)).toBe(created.password); + expect(readPublicReaderPasswordForActiveProfile('work')).toBeUndefined(); + expect(readPublicReaderPasswordForActiveProfile()).toBeUndefined(); + }); +}); diff --git a/tests/repair.spec.ts b/tests/repair.spec.ts new file mode 100644 index 0000000..523db73 --- /dev/null +++ b/tests/repair.spec.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it } from 'bun:test'; + +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; + +import { repairBareRepoHead } from '../src/cli/commands/repair.js'; + +const BASE = resolve('__TESTDATA__/repair'); + +function git(args: string[], cwd?: string): void { + const result = spawnSync('git', args, { + cwd, + encoding : 'utf-8', + stdio : ['ignore', 'pipe', 'pipe'], + }); + expect(result.status, result.stderr).toBe(0); +} + +function gitOutput(args: string[], cwd?: string): string { + const result = spawnSync('git', args, { + cwd, + encoding : 'utf-8', + stdio : ['ignore', 'pipe', 'pipe'], + }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); +} + +function createBareRepoWithBranch(repoName: string, branchName: string): string { + const barePath = resolve(BASE, `${repoName}.git`); + const workPath = resolve(BASE, `${repoName}-work`); + + git(['init', '--bare', barePath]); + git(['init', '-b', branchName, workPath]); + writeFileSync(resolve(workPath, 'README.md'), 'hello\n'); + git(['config', 'user.email', 'test@example.com'], workPath); + git(['config', 'user.name', 'Test User'], workPath); + git(['add', 'README.md'], workPath); + git(['commit', '-m', 'initial'], workPath); + git(['remote', 'add', 'origin', barePath], workPath); + git(['push', '-u', 'origin', branchName], workPath); + + return barePath; +} + +describe('repairBareRepoHead', () => { + afterEach(() => { + rmSync(BASE, { recursive: true, force: true }); + }); + + it('repairs a dangling HEAD to main when main exists', () => { + mkdirSync(BASE, { recursive: true }); + const barePath = createBareRepoWithBranch('main-repo', 'main'); + git(['--git-dir', barePath, 'symbolic-ref', 'HEAD', 'refs/heads/master']); + + const result = repairBareRepoHead(barePath); + + expect(result.status).toBe('fixed'); + expect(gitOutput(['--git-dir', barePath, 'symbolic-ref', '--short', 'HEAD'])).toBe('main'); + }); + + it('repairs a dangling HEAD to the only branch when main and master are absent', () => { + mkdirSync(BASE, { recursive: true }); + const barePath = createBareRepoWithBranch('trunk-repo', 'trunk'); + git(['--git-dir', barePath, 'symbolic-ref', 'HEAD', 'refs/heads/master']); + + const result = repairBareRepoHead(barePath); + + expect(result.status).toBe('fixed'); + expect(gitOutput(['--git-dir', barePath, 'symbolic-ref', '--short', 'HEAD'])).toBe('trunk'); + }); + + it('warns instead of guessing when multiple non-default branches exist', () => { + mkdirSync(BASE, { recursive: true }); + const barePath = createBareRepoWithBranch('ambiguous-repo', 'feature-a'); + const workPath = resolve(BASE, 'ambiguous-repo-work'); + git(['checkout', '-b', 'feature-b'], workPath); + writeFileSync(resolve(workPath, 'OTHER.md'), 'other\n'); + git(['add', 'OTHER.md'], workPath); + git(['commit', '-m', 'other'], workPath); + git(['push', 'origin', 'feature-b'], workPath); + git(['--git-dir', barePath, 'symbolic-ref', 'HEAD', 'refs/heads/missing']); + + const result = repairBareRepoHead(barePath); + + expect(result.status).toBe('warn'); + expect(gitOutput(['--git-dir', barePath, 'symbolic-ref', '--short', 'HEAD'])).toBe('missing'); + }); +}); diff --git a/tests/repo-context.spec.ts b/tests/repo-context.spec.ts new file mode 100644 index 0000000..2e23f21 --- /dev/null +++ b/tests/repo-context.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'bun:test'; + +import { getRepoContext } from '../src/cli/repo-context.js'; + +function repoRecord(name: string, defaultBranch: string): any { + return { + id : `record-${name}`, + contextId : `context-${name}`, + tags : { name, visibility: 'public' }, + data : { + json: async (): Promise<Record<string, unknown>> => ({ + name, + defaultBranch, + dwnEndpoints: [], + }), + }, + }; +} + +function agentContextWithRepos(records: any[]): any { + return { + did : 'did:dht:repo-context-test', + repo : { + records: { + query: async (_path: string, options?: any) => { + const name = options?.filter?.tags?.name; + return { + records: name + ? records.filter(record => record.tags.name === name) + : records, + }; + }, + }, + }, + } as any; +} + +describe('repo context helpers', () => { + it('includes the repo default branch when record data is readable', async () => { + const ctx = agentContextWithRepos([repoRecord('demo-trunk', 'trunk')]); + + const repo = await getRepoContext(ctx, 'demo-trunk'); + + expect(repo).toMatchObject({ + recordId : 'record-demo-trunk', + contextId : 'context-demo-trunk', + name : 'demo-trunk', + visibility : 'public', + defaultBranch : 'trunk', + }); + }); +}); diff --git a/tests/schemas.spec.ts b/tests/schemas.spec.ts index 730d14d..c1f545b 100644 --- a/tests/schemas.spec.ts +++ b/tests/schemas.spec.ts @@ -348,6 +348,15 @@ describe('JSON Schemas', () => { expect(schema.properties.reportStatus.enum).toEqual(['open', 'resolved', 'dismissed']); }); + it('view-snapshot.json should define squashed reducer checkpoints', () => { + const schema = readSchema('repo', 'view-snapshot.json'); + expect(schema.required).toEqual(['kind', 'state', 'actorDid', 'createdAt']); + expect(schema.properties.kind.enum).toEqual(['issue', 'pr', 'moderation', 'report']); + expect(schema.properties.state.type).toBe('object'); + expect(schema.properties.recordCount.minimum).toBe(0); + expect(schema.properties.lastRecordId.type).toBe('string'); + }); + it('settings.json should restrict mergeStrategies items to merge, squash, rebase', () => { const schema = readSchema('repo', 'settings.json'); const items = schema.properties.mergeStrategies.items; diff --git a/tests/serve-ux.spec.ts b/tests/serve-ux.spec.ts new file mode 100644 index 0000000..0a1b3e6 --- /dev/null +++ b/tests/serve-ux.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'bun:test'; + +import { + serveLocalHelperAliasLines, + serveUsesPublicTransport, + shouldStartLocalHelperFromServe, +} from '../src/cli/serve-ux.js'; + +describe('serve UX routing', () => { + it('treats bare serve as a local helper compatibility alias', () => { + expect(shouldStartLocalHelperFromServe([], {})).toBe(true); + }); + + it('does not intercept public transport serve invocations', () => { + expect(serveUsesPublicTransport(['--public-url', 'https://git.example.com'], {})).toBe(true); + expect(shouldStartLocalHelperFromServe(['--public-url', 'https://git.example.com'], {})).toBe(false); + expect(shouldStartLocalHelperFromServe([], { GITD_PUBLIC_URL: 'https://git.example.com' })).toBe(false); + }); + + it('lets foreground and background daemon serve paths pass through', () => { + expect(shouldStartLocalHelperFromServe(['--foreground'], {})).toBe(false); + expect(shouldStartLocalHelperFromServe([], { GITD_DAEMON_BACKGROUND: '1' })).toBe(false); + }); + + it('does not intercept public URL checks', () => { + expect(serveUsesPublicTransport(['--check'], {})).toBe(true); + expect(shouldStartLocalHelperFromServe(['--check'], {})).toBe(false); + }); + + it('prints local helper wording for the compatibility alias', () => { + const lines = serveLocalHelperAliasLines(true, 9418); + expect(lines.join('\n')).toContain('compatibility alias'); + expect(lines.join('\n')).toContain('gitd helper start'); + expect(lines.join('\n')).toContain('Local helper started on port 9418.'); + }); +}); diff --git a/tests/tty-prompt.spec.ts b/tests/tty-prompt.spec.ts index cbeec36..d12b220 100644 --- a/tests/tty-prompt.spec.ts +++ b/tests/tty-prompt.spec.ts @@ -10,7 +10,15 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; + import { getVaultPassword } from '../src/git-remote/tty-prompt.js'; +import { + getOrCreatePublicReaderPassword, + PUBLIC_READER_PROFILE, +} from '../src/profiles/public-reader.js'; // --------------------------------------------------------------------------- // getVaultPassword @@ -18,9 +26,18 @@ import { getVaultPassword } from '../src/git-remote/tty-prompt.js'; describe('getVaultPassword', () => { let origPassword: string | undefined; + let origProfile: string | undefined; + let origEnboxProfile: string | undefined; + let origEnboxHome: string | undefined; + let tempHome: string | undefined; beforeEach(() => { origPassword = process.env.GITD_PASSWORD; + origProfile = process.env.GITD_PROFILE; + origEnboxProfile = process.env.ENBOX_PROFILE; + origEnboxHome = process.env.ENBOX_HOME; + delete process.env.GITD_PROFILE; + delete process.env.ENBOX_PROFILE; }); afterEach(() => { @@ -29,6 +46,25 @@ describe('getVaultPassword', () => { } else { delete process.env.GITD_PASSWORD; } + if (origProfile !== undefined) { + process.env.GITD_PROFILE = origProfile; + } else { + delete process.env.GITD_PROFILE; + } + if (origEnboxProfile !== undefined) { + process.env.ENBOX_PROFILE = origEnboxProfile; + } else { + delete process.env.ENBOX_PROFILE; + } + if (origEnboxHome !== undefined) { + process.env.ENBOX_HOME = origEnboxHome; + } else { + delete process.env.ENBOX_HOME; + } + if (tempHome) { + rmSync(tempHome, { recursive: true, force: true }); + tempHome = undefined; + } }); it('should return GITD_PASSWORD when set', () => { @@ -36,6 +72,16 @@ describe('getVaultPassword', () => { expect(getVaultPassword()).toBe('test-secret'); }); + it('should return the hidden public reader password for public-read repos', () => { + delete process.env.GITD_PASSWORD; + tempHome = mkdtempSync(join(tmpdir(), 'gitd-tty-public-reader-')); + process.env.ENBOX_HOME = tempHome; + process.env.GITD_PROFILE = PUBLIC_READER_PROFILE; + + const reader = getOrCreatePublicReaderPassword(); + expect(getVaultPassword()).toBe(reader.password); + }); + it('should return GITD_PASSWORD even when empty string', () => { // An empty string is falsy but still "set" — the user explicitly // provided it, so we should respect it (e.g. unlocked vaults).