You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
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:
exportfunctionresolveDocsDestination(teamConfig: TeamaiConfig,localConfig: LocalConfig): string{constlocalDir=teamConfig.sharing.docs.localDir;if(localConfig.scope==='project'&&localConfig.projectRoot&&localDir.startsWith('~/')){returnpath.join(localConfig.projectRoot,localDir.substring(2));// anchored}returnexpandHome(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
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:
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.
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 ~.
Problem
sharing.docs.localDiris resolved against the process cwd whenever it is not spelled~/…. The team docs bundle therefore lands whereverteamaihappened 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 inititself generates. For project scope it writes a relative value (src/init.ts:845,src/init.ts:1381):detectProjectConfigwas 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: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"]teamai.yamldoes 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 pullwrites a second bundle and reports successThis is the shape #598 was filed about: the command says "Synced N docs" and they are not where they belong.
2.
teamai doctorreports a false failure, with advice that makes it worsePulled once from the project root, then asked from the subdirectory:
Nothing is missing. Following the fix creates the duplicate from (1). The
Team docs deliveredcheck 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 uninstalldeletes a directory it does not own, and misses the real onesrc/uninstall.ts:516-524re-implements the same logic inline rather than callingresolveDocsDestination. With an unrelatedpackages/web/.teamai/docs/my-notes.mdpresent: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:
~\…, the natural Windows spelling, matches neither gate:expandHometestsstartsWith('~/')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(asteamai initwrites it):cd <projectRoot> && teamai pull --force→ bundle at<projectRoot>/.teamai/docscd packages/web && teamai doctor→✖ Team docs delivered — Missing from ./.teamai/docscd packages/web && teamai pull --force→ a second bundle appears underpackages/webmkdir -p .teamai/docs && touch .teamai/docs/my-notes.md && teamai uninstall --force→my-notes.mdis deleted,<projectRoot>/.teamai/docssurvivesScripts used are throwaway; happy to attach them if useful.
Suggested direction
Anchor a relative
localDirto the scope's base directory rather than the cwd, and makeuninstallcall 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:
Team docs deliveredcheck would then report it missing, which is at least honest but noisy on first upgrade.~\should be accepted. Either teachexpandHomethe 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 extractedresolveDocsDestinationprecisely so the read and write paths agree;uninstall.tsstill 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.