From a07c4305b096c38b43d5ca3488818995d838a926 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Fri, 23 Oct 2026 20:20:16 +0800 Subject: [PATCH] fix(init): anchor workspace onboarding to repository roots - Resolve Git and HTTP workspace onboarding against the nearest repository or linked-worktree root, so saved configuration, printed paths and skill delivery agree with later commands. - Reject child-folder reinitialization of an existing ancestor even with --force. Inspect ancestor state read-only and preserve invalid or legacy-role configs without migration, partition adoption or API-key writes. - Cover ordinary folders, nested repositories, linked worktrees and Git/HTTP rejection paths, including byte-identical ancestor-config regression cases; update both onboarding guides and setup skill guidance. - Verify the built CLI with all 16 Muse-support E2E cases, 96 focused init unit tests, TypeScript and lint. Refs: #1338 --- docs/guide/getting-started.md | 2 + docs/guide/zh-CN/getting-started.md | 2 + skill-data/setup/references/join-member.md | 1 + src/__tests__/e2e/muse-support.test.ts | 88 ++++++++++++++++++++++ src/init.ts | 47 ++++++++---- 5 files changed, 124 insertions(+), 16 deletions(-) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 2bd022585..977711cab 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -49,6 +49,8 @@ You need the repo URL. teamai init https://github.com/your-org/your-repo ``` Add `--scope user` to install into your home directory. Every project on this machine then sees the files. Without that flag, files install into this project. + + Inside a Git repository, init uses the repository root even when run from a subdirectory. If that root already has a workspace config, run init at the root to change it; a plain subfolder cannot hold a separate config, even with `--force`. A nested repository or linked worktree has its own workspace root. 2. Check it. ```bash teamai doctor diff --git a/docs/guide/zh-CN/getting-started.md b/docs/guide/zh-CN/getting-started.md index 6ec5150de..64f5e9c9c 100644 --- a/docs/guide/zh-CN/getting-started.md +++ b/docs/guide/zh-CN/getting-started.md @@ -49,6 +49,8 @@ teamai init https://github.com/your-org/your-repo ``` 加上 `--scope user` 会装到用户主目录,这台机器上的每个项目都能看见。不加的话,文件只进当前项目。 + + 在 Git 仓库中,即使从子目录运行 init,也会使用仓库根目录。如果根目录已有 workspace 配置,要到根目录修改;普通子文件夹不能单独配置,即使加 `--force` 也不能覆盖父配置。嵌套仓库和 linked worktree 各自使用自己的工作区根目录。 2. 检查一下。 ```bash teamai doctor diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 826182f6b..b89b6feb9 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -33,6 +33,7 @@ If it fails, Node.js ≥ 20 is missing — have them install Node 20+ first. ## Step 2 — Choose scope (ASK) - **This project only** (default): `cd` into the project directory first. + Inside Git, init uses the repository root. If it already has a workspace config, re-run init at that root; a plain subfolder cannot replace it, even with `--force`. Nested repositories and linked worktrees keep their own roots. - **Whole machine**: add `--scope user`. - **A folder holding several repos** (a workspace): set up each repo from inside it, then run the same `init` in the folder. The folder also receives diff --git a/src/__tests__/e2e/muse-support.test.ts b/src/__tests__/e2e/muse-support.test.ts index 50c6f0580..74ddaad71 100644 --- a/src/__tests__/e2e/muse-support.test.ts +++ b/src/__tests__/e2e/muse-support.test.ts @@ -3,6 +3,7 @@ import { execFileSync, spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import YAML from 'yaml'; import { fileURLToPath } from 'node:url'; // ─── Muse Code support ────────────────────────────────────────────────────── @@ -146,6 +147,93 @@ describe('Muse Code resource delivery', () => { expect(JSON.parse(fs.readFileSync(file, 'utf8')).mcpServers).toEqual({ personal }); }, 120_000); + it.each(['repository', 'linked worktree', 'nested repository', 'plain folder'])( + 'anchors a subdirectory init to its %s root', async (kind) => { + const home = makeHome(`home-anchor-${kind}-${Date.now()}`); + fs.mkdirSync(path.join(home, '.config', 'muse'), { recursive: true }); + const parent = fs.mkdtempSync(path.join(sandbox, `anchor-${kind}-`)); + fs.mkdirSync(parent, { recursive: true }); + let root = parent; + if (kind !== 'plain folder') git(['init', '-q', '-b', 'main'], parent, home); + if (kind === 'linked worktree') { + git(['commit', '-q', '--allow-empty', '-m', 'fixture'], parent, home); + root = `${parent}-linked`; + git(['worktree', 'add', '-q', '-b', 'linked', root], parent, home); + } + if (kind === 'nested repository') { + root = path.join(parent, 'nested'); + fs.mkdirSync(root); + git(['init', '-q', '-b', 'main'], root, home); + } + const cwd = path.join(root, 'packages', 'web'); + fs.mkdirSync(cwd, { recursive: true }); + const expected = fs.realpathSync.native(kind === 'plain folder' ? cwd : root); + const result = await runCLI(['init', FAKE_URL, '--scope', 'workspace', '--role', 'dev', '--agent', 'muse', '--force'], cwd, home); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`Scope: workspace (${expected})`); + expect(fs.existsSync(path.join(expected, '.agents', 'skills', 'teamai', 'SKILL.md'))).toBe(true); + if (kind !== 'plain folder') expect(fs.existsSync(path.join(cwd, '.agents'))).toBe(false); + const pulled = await runCLI(['pull'], cwd, home); + expect(pulled.code, pulled.output).toBe(0); + expect(fs.existsSync(path.join(expected, '.agents', 'skills', 'demo', 'SKILL.md'))).toBe(true); + }, 120_000, + ); + + it.each([['git', false], ['git', true], ['http', false], ['http', true]] as const)( + 'rejects reinitializing an existing ancestor from a plain folder (%s, force=%s)', async (mode, force) => { + const home = makeHome(`home-guard-${mode}-${force}-${Date.now()}`); + fs.mkdirSync(path.join(home, '.config', 'muse'), { recursive: true }); + const root = fs.mkdtempSync(path.join(sandbox, `guard-${mode}-${force}-`)); + const cwd = path.join(root, 'backend'); + fs.mkdirSync(cwd, { recursive: true }); + git(['init', '-q', '-b', 'main'], root, home); + const first = await runCLI(['init', FAKE_URL, '--scope', 'workspace', '--role', 'dev', '--agent', 'muse', '--force'], root, home); + expect(first.code, first.output).toBe(0); + const configFiles = () => fs.readdirSync(path.join(home, '.teamai'), { recursive: true }) + .filter(file => String(file).endsWith('config.yaml')) + .map(file => [String(file), fs.readFileSync(path.join(home, '.teamai', String(file)), 'utf8')]); + const before = configFiles(); + expect(before.length).toBeGreaterThan(0); + const args = mode === 'git' ? ['init', FAKE_URL] : ['init', '--http', 'http://127.0.0.1:1', '--token', 'must-not-be-saved']; + const result = await runCLI([...args, '--scope', 'workspace', '--role', 'ops', '--agent', 'muse', ...(force ? ['--force'] : [])], cwd, home); + expect(result.code, result.output).toBe(1); + expect(result.output).toContain('already has a workspace config'); + expect(result.output).toContain(fs.realpathSync.native(root)); + expect(configFiles()).toEqual(before); + expect(fs.existsSync(path.join(cwd, '.agents'))).toBe(false); + expect(fs.existsSync(path.join(home, '.teamai', 'apikey'))).toBe(false); + }, 120_000, + ); + + it.each(['invalid', 'legacy'])('leaves an existing %s ancestor config byte-identical', async (kind) => { + const home = makeHome(`home-existing-${kind}-${Date.now()}`); + fs.mkdirSync(path.join(home, '.config', 'muse'), { recursive: true }); + const root = fs.mkdtempSync(path.join(sandbox, `existing-${kind}-`)); + const cwd = path.join(root, 'backend'); + fs.mkdirSync(cwd); + git(['init', '-q', '-b', 'main'], root, home); + const first = await runCLI(['init', FAKE_URL, '--scope', 'workspace', '--role', 'dev', '--agent', 'muse', '--force'], root, home); + expect(first.code, first.output).toBe(0); + const relative = fs.readdirSync(path.join(home, '.teamai'), { recursive: true }) + .find(file => String(file).endsWith('config.yaml')); + expect(relative).toBeDefined(); + const file = path.join(home, '.teamai', String(relative)); + const config = YAML.parse(fs.readFileSync(file, 'utf8')); + if (kind === 'invalid') fs.writeFileSync(file, 'repo: [broken\n'); + else { + delete config.primaryRole; + fs.writeFileSync(file, YAML.stringify(config)); + const manifest = path.join(config.repo.localPath, 'manifest', 'roles.yaml'); + fs.mkdirSync(path.dirname(manifest), { recursive: true }); + fs.writeFileSync(manifest, YAML.stringify({ version: 1, roles: [{ id: 'hai', resources: { knowledge: [], skills: [] } }] })); + } + const before = fs.readFileSync(file, 'utf8'); + const result = await runCLI(['init', FAKE_URL, '--scope', 'workspace', '--role', 'ops', '--agent', 'muse', '--force'], cwd, home); + expect(result.code, result.output).toBe(1); + expect(result.output).toContain('already has a workspace config'); + expect(fs.readFileSync(file, 'utf8')).toBe(before); + }, 120_000); + it('keeps Muse-normalized MCP entries removable through the real CLI', async () => { const workDir = path.join(sandbox, 'normalized-work'); fs.mkdirSync(workDir, { recursive: true }); diff --git a/src/init.ts b/src/init.ts index 6505647ee..8b8fce051 100644 --- a/src/init.ts +++ b/src/init.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { readWorkspaceChildren } from './workspace-children.js'; import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConfigForScope, loadStateForScope, saveStateForScope, resolveProjectDataHome } from './config.js'; import { codexTrustPolicy, deliveredHookFiles, describeUnappliedTeamHooks, hasTeamaiHooks, reconcileHooks, reconcileTeamHooksForConfig, reportCodexTrust, trustCodexForScope } from './hooks.js'; -import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, getConfiguredRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, DetachedTeamRepoError, pushRepoDirectly, autoPushViaMR, gitBlockTimeoutMs, createGitWithBlockTimeout } from './utils/git.js'; +import { configureGitUser, resolveAnchors, initRepo, isGitRepo, getRemoteUrl, getConfiguredRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, DetachedTeamRepoError, pushRepoDirectly, autoPushViaMR, gitBlockTimeoutMs, createGitWithBlockTimeout } from './utils/git.js'; import { withTimeout } from './utils/async.js'; import { getProvider, detectProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; import { parseGenericGitExistingRemote } from './providers/git/repo-url.js'; @@ -574,12 +574,35 @@ async function newTeamConfigProvider(input: string, providerName: string, forced ); } +/** Init and later readers must agree on the repository that owns a workspace. */ +async function resolveInitContext(scopeFlag: string | undefined) { + const cwd = await fs.promises.realpath(process.cwd()); + const root = scopeFlag === 'user' ? cwd : (await resolveAnchors(cwd))?.workspaceRoot ?? cwd; + const resolved = resolveInitScope(scopeFlag, root, getUserHome()); + const inside = resolved.scope === 'project' && resolved.projectRoot !== cwd; + // A rejected init must not migrate roles, adopt a partition or bootstrap the ancestor. + const existingLocalConfig = await loadLocalConfigForScope(resolved.scope, resolved.projectRoot, + inside ? { dryRun: true, suppressMigrationNotice: true, selfHeal: false } : undefined); + const existingFile = inside && resolved.projectRoot && ( + await pathExists(path.join(await resolveProjectDataHome(resolved.projectRoot, { dryRun: true }), 'config.yaml')) + || await pathExists(path.join(resolved.projectRoot, '.teamai', 'config.yaml')) + ); + if (inside && (existingLocalConfig || existingFile)) { + throw new Error( + `${cwd} is inside the repository ${resolved.projectRoot}, which already has a workspace config. ` + + 'A plain folder inside a repository cannot have its own config; only repositories are children of a workspace. ' + + `Run teamai init in ${resolved.projectRoot} to change that config, or make ${cwd} a repository of its own.`, + ); + } + return { ...resolved, existingLocalConfig }; +} + function printScopeSummary( scope: Scope, projectRoot: string | undefined, explicit: boolean, + configPath: string, ): void { - const configPath = getConfigPath(scope, projectRoot); const baseDir = scope === 'project' ? (projectRoot ?? process.cwd()) : getUserHome(); log.info(`Scope: ${installScopeName(scope)}${scope === 'project' ? ` (${projectRoot})` : ''}`); log.info(` config → ${configPath}`); @@ -619,18 +642,14 @@ export async function initHttp( let projectRoot: string | undefined; let explicit: boolean; let fallbackReason: string | undefined; + let existingLocalConfig: LocalConfig | null; try { - ({ scope, projectRoot, explicit, fallbackReason } = resolveInitScope( - options.scope, - process.cwd(), - getUserHome(), - )); + ({ scope, projectRoot, explicit, fallbackReason, existingLocalConfig } = await resolveInitContext(options.scope)); } catch (e) { log.error((e as Error).message); process.exit(1); return; } - const existingLocalConfig = await loadLocalConfigForScope(scope, projectRoot); let inheritUserScope: boolean | undefined; try { inheritUserScope = resolveInheritUserScope( @@ -649,7 +668,7 @@ export async function initHttp( const teamaiHome = scope === 'project' && projectRoot ? (existingLocalConfig?.dataHome ?? await resolveProjectDataHome(projectRoot)) : getTeamaiHome(scope, projectRoot); - printScopeSummary(scope, projectRoot, explicit); + printScopeSummary(scope, projectRoot, explicit, path.join(teamaiHome, 'config.yaml')); if (scope === 'project' && !(await isInsideGitRepo(process.cwd()))) { log.warn(`cwd is not inside a git repository; will create ${teamaiHome}/`); @@ -1581,18 +1600,14 @@ export async function init(options: GlobalOptions & { let projectRoot: string | undefined; let explicit: boolean; let fallbackReason: string | undefined; + let existingLocalConfig: LocalConfig | null; try { - ({ scope, projectRoot, explicit, fallbackReason } = resolveInitScope( - options.scope, - process.cwd(), - getUserHome(), - )); + ({ scope, projectRoot, explicit, fallbackReason, existingLocalConfig } = await resolveInitContext(options.scope)); } catch (e) { log.error((e as Error).message); process.exit(1); return; } - const existingLocalConfig = await loadLocalConfigForScope(scope, projectRoot); const teamaiHome = scope === 'project' && projectRoot ? (existingLocalConfig?.dataHome ?? await resolveProjectDataHome(projectRoot)) : getTeamaiHome(scope, projectRoot); @@ -1616,7 +1631,7 @@ export async function init(options: GlobalOptions & { if (fallbackReason) { log.warn(fallbackReason); } - printScopeSummary(scope, projectRoot, explicit); + printScopeSummary(scope, projectRoot, explicit, path.join(teamaiHome, 'config.yaml')); if (scope === 'project' && !(await isInsideGitRepo(process.cwd()))) { log.warn(`cwd is not inside a git repository; will create ${teamaiHome}/`);