Skip to content

fix(docs): sharing.docs.localDir is resolved against the process cwd, so the bundle follows wherever teamai ran #635

Description

@SaulMoro

Problem

sharing.docs.localDir is resolved against the process cwd whenever it is not spelled ~/…. The team docs bundle therefore lands wherever teamai happened to be run from, and three commands each fail differently because of it — including one that deletes a directory it does not own.

This is reachable with the config teamai init itself generates. For project scope it writes a relative value (src/init.ts:845, src/init.ts:1381):

docs: { localDir: scope === 'project' ? './.teamai/docs' : '~/.teamai/docs' }
//                                       ^^^^^^^^^^^^^^^^ relative

detectProjectConfig was deliberately made cwd-independent for #374 — "a repo with both a partition and a legacy .teamai/ must resolve to the SAME config whether run from the root or a subdirectory" (src/config.ts:278-281). The docs destination is the same property one step later in the pipeline, and it does not hold.

Cause

resolveDocsDestination (src/resources/docs.ts:13-19) has two gates and both test for ~/. Anything else falls through as a bare relative string:

export function resolveDocsDestination(teamConfig: TeamaiConfig, localConfig: LocalConfig): string {
  const localDir = teamConfig.sharing.docs.localDir;
  if (localConfig.scope === 'project' && localConfig.projectRoot && localDir.startsWith('~/')) {
    return path.join(localConfig.projectRoot, localDir.substring(2));   // anchored
  }
  return expandHome(localDir);   // expandHome only matches '~/' too (src/utils/fs.ts:22)
}
flowchart LR
  I["sharing.docs.localDir"] --> G{"starts with '~/' ?"}
  G -->|yes, project scope| A["join(projectRoot, …) — stable"]
  G -->|yes, user scope| B["join(home, …) — stable"]
  G -->|"no — './.teamai/docs'"| C["returned unchanged"]
  C --> D["fse.copy / pathExists resolve it<br>against process.cwd()"]
  D --> E["destination follows the cwd"]
Loading

teamai.yaml does not constrain the value either — localDir: z.string().default('~/.teamai/docs') (src/types.ts:56).

Impact

All three verified against the real CLI, project scope, localDir: ./.teamai/docs, commands run from <projectRoot>/packages/web:

1. teamai pull writes a second bundle and reports success

teamai pull --force   (from <projectRoot>)          -> <projectRoot>/.teamai/docs/guide.md
teamai pull --force   (from <projectRoot>/packages/web)
                                                    -> <projectRoot>/.teamai/docs/guide.md
                                                    -> <projectRoot>/packages/web/.teamai/docs/guide.md

This is the shape #598 was filed about: the command says "Synced N docs" and they are not where they belong.

2. teamai doctor reports a false failure, with advice that makes it worse

Pulled once from the project root, then asked from the subdirectory:

✖ Team docs delivered
  → Missing from ./.teamai/docs: guide.md. Run `teamai pull --force`: a plain pull
    skips a scope whose team repo has not changed, so it cannot restore these.

Nothing is missing. Following the fix creates the duplicate from (1). The Team docs delivered check added in #625 resolves through the same function, so it cannot catch this — it is self-consistent with the write path and simply relocates with it.

3. teamai uninstall deletes a directory it does not own, and misses the real one

src/uninstall.ts:516-524 re-implements the same logic inline rather than calling resolveDocsDestination. With an unrelated packages/web/.teamai/docs/my-notes.md present:

$ cd packages/web && teamai uninstall --force
   Docs directory:
     ./.teamai/docs
✔ Removed docs: ./.teamai/docs

after:
  <projectRoot>/.teamai/docs/guide.md     present   <- the real bundle, not cleaned up
  packages/web/.teamai/docs/my-notes.md   DELETED   <- collateral, never part of the bundle

Platforms

The defect is platform-independent: Node resolves a relative path against the cwd on all three. Windows is worse in two ways. Modelling both path flavours through the same two gates, and comparing the destination resolved from two different cwds in one project:

posix   './.teamai/docs'    DRIFT   /repo/project/.teamai/docs
                                    /repo/project/packages/web/.teamai/docs
        '~/.teamai/docs'    stable

win32   './.teamai/docs'    DRIFT   C:\repo\project\.teamai\docs
                                    D:\scratch\.teamai\docs         <- a different volume
        '~\.teamai\docs'    DRIFT   D:\scratch\~\.teamai\docs       <- literal "~" directory
        '~/.teamai/docs'    stable
  • On Windows the cwd can sit on another drive, so the bundle can leave the project's volume entirely.
  • ~\…, the natural Windows spelling, matches neither gate: expandHome tests startsWith('~/') only, so it is never expanded and a directory literally named ~ is created under the cwd. A Windows user who hand-writes the setting the obvious way gets this silently.

Repro

Project scope, sharing.docs.localDir: ./.teamai/docs (as teamai init writes it):

  1. cd <projectRoot> && teamai pull --force → bundle at <projectRoot>/.teamai/docs
  2. cd packages/web && teamai doctor → ✖ Team docs delivered — Missing from ./.teamai/docs
  3. cd packages/web && teamai pull --force → a second bundle appears under packages/web
  4. mkdir -p .teamai/docs && touch .teamai/docs/my-notes.md && teamai uninstall --force → my-notes.md is deleted, <projectRoot>/.teamai/docs survives

Scripts used are throwaway; happy to attach them if useful.

Suggested direction

Anchor a relative localDir to the scope's base directory rather than the cwd, and make uninstall call the one resolver instead of its own copy:

 export function resolveDocsDestination(teamConfig: TeamaiConfig, localConfig: LocalConfig): string {
   const localDir = teamConfig.sharing.docs.localDir;
   if (localConfig.scope === 'project' && localConfig.projectRoot && localDir.startsWith('~/')) {
     return path.join(localConfig.projectRoot, localDir.substring(2));
   }
-  return expandHome(localDir);
+  const expanded = expandHome(localDir);
+  // A relative localDir would otherwise resolve against the process cwd, so the
+  // destination would follow whichever directory the command was run from.
+  return path.isAbsolute(expanded) ? expanded : path.join(resolveBaseDir(localConfig), expanded);
 }

Worth deciding alongside it:

  • Migration. Existing checkouts already have a bundle at the old cwd-relative location. Moving the destination silently orphans it; the Team docs delivered check would then report it missing, which is at least honest but noisy on first upgrade.
  • Whether ~\ should be accepted. Either teach expandHome the Windows spelling, or reject a non-~/, non-absolute value at config parse time so the failure is a clear error rather than a directory named ~.
  • uninstall's duplicate. feat(doctor): run the checks after a pull, and check what actually landed #625 extracted resolveDocsDestination precisely so the read and write paths agree; uninstall.ts still has its own copy, which is how it can delete the wrong directory.

I have not written the fix — filing first because the migration question and the ~\ decision are both judgement calls.

Found while reviewing the docs delivery check in #625.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions