diff --git a/README.md b/README.md index 46b3b8c..934ad42 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ identity. Ambiguous Python/frontend projects require `--type python` or `--type | --- | --- | | `status` | Local Git context, session metadata, and installed command capabilities. | | `resolve` | Backend Git-context resolution with identity/branch/ref/commit validation. | -| `setup --uid UID --branch BRANCH --base-dir DIR [--install]` | Fetch the registered binding; reuse/register the repository SSH key; verify access; clone into `//projects/-`; write an endpoint-only `.env`; optionally install dependencies. Existing checkout folders are never replaced. | +| `setup --uid UID --branch BRANCH --base-dir DIR [--install]` | Fetch the registered binding; reuse/register the repository SSH key; verify access; clone into `//projects/-` (a bare `DIR` such as `mainsequence-dev` is a folder in your home directory, as the `mainsequence` CLI reads it); write an endpoint-only `.env`; optionally install dependencies. Existing checkout folders are never replaced. | | `install` | `uv sync` for Python; `npm ci` for frontend projects. | | `sync` | Refresh the project's dependency files with its installed SDK: Python `code-repository sync` runs `uv lock`, `uv sync` and the locked `requirements.txt` export (`mainsequence` 9.0.5 or later); Node `code-repository sync` refreshes `package-lock.json` and runs `npm ci` (`@dev-mainsequence/command-center-sdk` 0.5.13 or later). No commit message, dry run, Git command or backend request: review the changes, then commit and push them yourself. An installed SDK that still has the retired release sync (`--dry-run`/`--message` in its sync help) is reported unavailable without running it; run `update-sdk`. | | `sdk-status` | Isolated Python distribution metadata plus stable source version, or Node `application sdk-status`. A source version does not prove registry publication. | diff --git a/docs/workspace-environments.md b/docs/workspace-environments.md index 17100a3..4c78121 100644 --- a/docs/workspace-environments.md +++ b/docs/workspace-environments.md @@ -3,8 +3,15 @@ `readWorkspacePreferences({ env?, configPath?, homeDir? })` resolves only the backend URL and repositories folder. It never signs in or rewrites configuration. The backend uses `MAINSEQUENCE_ENDPOINT`, then existing `backend_url`, then -`https://api.main-sequence.app`. The folder uses an absolute `mainsequence_path`, -otherwise `/mainsequence`. Host-saved overrides remain the host's responsibility. +`https://api.main-sequence.app`. The folder uses `mainsequence_path`, otherwise +`/mainsequence`. Host-saved overrides remain the host's responsibility. + +Every action reads a repositories folder as the MainSequence CLI does +(`repositoriesFolder(value, home?)`): `~` is the home folder, a bare name such as +`mainsequence-dev` is a folder in it, and any other path is an ordinary one. A bare name is +never resolved against the working directory, which for a Claude, Codex or VS Code host is +whatever folder its session has open. Setup, local listings, Projects setup and saved +settings therefore all use one tree, `//projects/`, as the CLI does. The structured `createDevTools().dispatch` API accepts `mcp-environments` to read all authorized organization environments through the existing paginated backend diff --git a/package-lock.json b/package-lock.json index 72cd5f3..8104327 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.15", + "version": "0.3.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dev-mainsequence/devtools", - "version": "0.3.15", + "version": "0.3.16", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index 01bdc90..a0f4c2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.15", + "version": "0.3.16", "description": "Shared MainSequence developer actions, project operations, MCP client and command runner", "license": "MIT", "engines": { diff --git a/src/index.ts b/src/index.ts index d25d9c8..a312e31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ export { clearSession, readSession, saveSession, SESSION_SERVICE, CredentialStor export type { MachineSession, ProgramRunner } from './shared/machineSession'; export type { ProjectAction, SessionPersistence } from './shared/models'; export { normalizeBackendUrl } from './shared/config'; -export { readWorkspacePreferences, projectContext, detectProjectType, canonicalRepositoryDir, safeSlug } from './shared/repositories'; +export { readWorkspacePreferences, projectContext, detectProjectType, canonicalRepositoryDir, repositoriesFolder, safeSlug } from './shared/repositories'; export { RepositoryClient } from './shared/repositoryClient'; export type { CodeRepository, CodeRepositoryBranch, CodeRepositoryGitContextResolution, GitHubRepositoryBinding } from './shared/repositoryModels'; export { repositorySshKeyIdentity, repositorySshKeyName, repositorySshKeyPaths } from './shared/repositoryIdentity'; diff --git a/src/shared/organizationProjectWorkspace.ts b/src/shared/organizationProjectWorkspace.ts index 1c0e082..705b3a8 100644 --- a/src/shared/organizationProjectWorkspace.ts +++ b/src/shared/organizationProjectWorkspace.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; import { HostServices, SessionService, ToolingError } from './models'; import { OrganizationProjectClient, projectUid } from './organizationProjects'; -import { canonicalRepositoryDir, projectContext, repositorySetupInfo, safeSlug } from './repositories'; +import { canonicalRepositoryDir, projectContext, repositoriesFolder, repositorySetupInfo, safeSlug } from './repositories'; import { ExecutionState, setupRepository, withProjectLock, projectEnvironment } from './projects'; import { repositorySshKeyIdentity } from './repositoryIdentity'; import { requireBackend } from './session'; @@ -96,9 +96,9 @@ export async function setupOrganizationProject(input: Input, options: Options): // Apply the same environment to nested Git helpers, including context and .env setup. for (const key of Object.keys(env)) if (key.startsWith('GIT_') && key !== 'GIT_SSH_COMMAND') delete env[key]; const host: HostServices = { ...options.host, runProcess: spec => options.host.runProcess({ ...spec, env: spec.env ?? env }) }; - if (typeof input.baseDir !== 'string' || !path.isAbsolute(input.baseDir) || /[\r\n\0]/.test(input.baseDir)) throw new ToolingError('INVALID_REQUEST', 'Choose an absolute repositories base folder in General.'); + if (typeof input.baseDir !== 'string' || !input.baseDir.trim() || /[\r\n\0]/.test(input.baseDir)) throw new ToolingError('INVALID_REQUEST', 'Choose a repositories base folder in General.'); // Resolve the base once (macOS /tmp and home aliases are legitimate). Children must be ordinary directories. - let base = path.resolve(input.baseDir); + let base = repositoriesFolder(input.baseDir); if (await exists(base)) base = await fs.realpath(base); const client = new OrganizationProjectClient(session, backend, signal); const { project, branches } = await client.getProject(environmentUid, uid); diff --git a/src/shared/projectOperations.ts b/src/shared/projectOperations.ts index d231aa2..05721bb 100644 --- a/src/shared/projectOperations.ts +++ b/src/shared/projectOperations.ts @@ -2,7 +2,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; import { ActionResult, HostServices, ToolingError } from './models'; -import { git, safeSlug } from './repositories'; +import { git, repositoriesFolder, safeSlug } from './repositories'; import { withProjectLock } from './projects'; import { updateDevcontainerJsonc } from './devcontainerJsonc'; @@ -71,7 +71,7 @@ export function createProjectOperations(options: ProjectOperationOptions) { return safeSlug(value); } async function localContext(request: LocalSelection) { - const base = await realDirectory(request.baseDir, 'Repositories base folder'); + const base = await realDirectory(repositoriesFolder(request.baseDir), 'Repositories base folder'); const org = organization(request.organization); const parent = path.join(base, org, 'projects'); const requested = absolute(request.root, 'Local repository'); @@ -118,7 +118,7 @@ export function createProjectOperations(options: ProjectOperationOptions) { signal?.throwIfAborted(); let data: unknown; if (request.action === 'local-repositories') { - const requestedBase = absolute(request.baseDir, 'Repositories base folder'); + const requestedBase = repositoriesFolder(request.baseDir); const baseExists = await fs.stat(requestedBase).then(() => true).catch(error => { if (isMissing(error)) return false; throw error; }); const base = baseExists ? await realDirectory(requestedBase, 'Repositories base folder') : requestedBase; const org = organization(request.organization), organizationRoot = path.join(base, org, 'projects'); diff --git a/src/shared/projects.ts b/src/shared/projects.ts index f4992be..029b133 100644 --- a/src/shared/projects.ts +++ b/src/shared/projects.ts @@ -3,7 +3,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; import { ActionCapability, HostServices, ProcessSpec, ProjectAction, ProjectContext, SessionService, ToolingError } from './models'; -import { canonicalRepositoryDir, git, projectContext, readProjectEnv, repositorySetupInfo } from './repositories'; +import { canonicalRepositoryDir, git, projectContext, readProjectEnv, repositoriesFolder, repositorySetupInfo } from './repositories'; import { hasProjectCredentials, writeMachineEnv } from './envfile'; import { repositorySshKeyIdentity, repositorySshKeyPaths, repositorySshKeyName } from './repositoryIdentity'; import { resolveCodeRepositoryCli, buildCodeRepositoryCliInvocations } from './cli'; @@ -205,7 +205,8 @@ function quoteSshPath(value: string): string { } return "'" + value.replace(/'/g, "'\\''") + "'"; } -export async function setupRepository(host: HostServices, session: SessionService, input: { backend: string; repositoryUid: string; branch: string; baseDir: string; install?: boolean }, options: { env: NodeJS.ProcessEnv; signal?: AbortSignal; state: ExecutionState; destination?: string; reference?: string }): Promise { +export async function setupRepository(host: HostServices, session: SessionService, request: { backend: string; repositoryUid: string; branch: string; baseDir: string; install?: boolean }, options: { env: NodeJS.ProcessEnv; signal?: AbortSignal; state: ExecutionState; destination?: string; reference?: string }): Promise { + const input = { ...request, baseDir: repositoriesFolder(request.baseDir) }; const { repository, binding, orgSlug } = await repositorySetupInfo(session, input.backend, input.repositoryUid, input.branch); const type = repository.code_repository_type; if (type !== 'python' && type !== 'vite_react') throw new ToolingError('UNSUPPORTED_ACTION', 'This CodeRepository type is not supported.'); diff --git a/src/shared/repositories.ts b/src/shared/repositories.ts index 1c86134..7d55c0f 100644 --- a/src/shared/repositories.ts +++ b/src/shared/repositories.ts @@ -42,7 +42,7 @@ export async function readWorkspacePreferences(options: { env?: NodeJS.ProcessEn .then(text => JSON.parse(text)).catch(() => ({})); const folder = config?.mainsequence_path; return { backend: requireBackend(env.MAINSEQUENCE_ENDPOINT || (typeof config?.backend_url === 'string' && config.backend_url.trim()) || 'https://api.main-sequence.app'), - baseDir: typeof folder === 'string' && path.isAbsolute(folder) && !/[\0\r\n]/.test(folder) ? folder : path.join(home, 'mainsequence') }; + baseDir: typeof folder === 'string' && folder.trim() && !/[\0\r\n]/.test(folder) ? repositoriesFolder(folder, home) : path.join(home, 'mainsequence') }; } export async function git(host: HostServices, cwd: string, args: string[], signal?: AbortSignal): Promise { @@ -118,6 +118,17 @@ export async function repositorySetupInfo(session: SessionService, backend: stri const orgName = typeof organization === 'string' ? organization : organization.name || organization.slug || organization.uid || 'default'; return { repository, branch, binding, orgSlug: safeSlug(orgName) }; } +/** The repositories folder as the MainSequence CLI reads it (`normalize_mainsequence_path`), so every + * tool shares one tree: `~` is the home folder, a bare name such as `mainsequence-dev` is a folder in it, + * and any other path is an ordinary one. A bare name is never resolved against the working directory, + * which for a Claude, Codex or VS Code host is whatever folder its session has open. */ +export function repositoriesFolder(value: unknown, home: string = os.homedir(), label = 'Repositories base folder'): string { + const raw = typeof value === 'string' ? value.trim() : ''; + if (!raw || /[\0\r\n]/.test(raw)) throw new ToolingError('INVALID_REQUEST', `${label} is required.`); + if (raw === '~' || raw.startsWith('~/')) return path.resolve(home, raw.slice(2)); + if (path.isAbsolute(raw) || raw.startsWith('.') || raw.includes('/') || raw.includes('\\')) return path.resolve(raw); + return path.resolve(home, raw); +} export function safeSlug(value: string): string { return String(value || 'code-repository').toLowerCase().replace(/[^a-z0-9-_]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'code-repository'; } diff --git a/src/shared/workspaceSettings.ts b/src/shared/workspaceSettings.ts index 337bc7b..9e5df56 100644 --- a/src/shared/workspaceSettings.ts +++ b/src/shared/workspaceSettings.ts @@ -4,7 +4,7 @@ import * as os from 'node:os'; import { randomUUID } from 'node:crypto'; import { ToolingError } from './models'; import { requireBackend } from './session'; -import { readWorkspacePreferences } from './repositories'; +import { readWorkspacePreferences, repositoriesFolder } from './repositories'; type Options = { env?: NodeJS.ProcessEnv; configPath?: string; homeDir?: string }; function location(options: Options) { const home = options.homeDir ?? os.homedir(), env = options.env ?? process.env; @@ -26,7 +26,7 @@ export async function readDesktopPreferences(options: Options = {}) { /** Updates only non-secret workspace fields, preserving existing CLI settings. */ export async function writeWorkspacePreferences(input: { backend: string; baseDir: string; commandCenterUrl?: string }, options: Options = {}) { const backend = requireBackend(input.backend); - if (!path.isAbsolute(input.baseDir) || /[\0\r\n]/.test(input.baseDir)) throw new ToolingError('INVALID_REQUEST', 'Repositories folder must be an absolute path.'); + const baseDir = repositoriesFolder(input.baseDir, options.homeDir, 'Repositories folder'); const commandCenterUrl = input.commandCenterUrl ? requireBackend(input.commandCenterUrl) : ''; const file = location(options); await fs.mkdir(path.dirname(file), { recursive: true }); let lock; @@ -35,7 +35,7 @@ export async function writeWorkspacePreferences(input: { backend: string; baseDi const temporary = file + '.' + randomUUID() + '.tmp'; try { const config = await read(file); - await fs.writeFile(temporary, JSON.stringify({ ...config, backend_url: backend, mainsequence_path: path.resolve(input.baseDir), command_center_url: commandCenterUrl }, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); + await fs.writeFile(temporary, JSON.stringify({ ...config, backend_url: backend, mainsequence_path: baseDir, command_center_url: commandCenterUrl }, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); await fs.rename(temporary, file); return readDesktopPreferences(options); } finally { await fs.rm(temporary, { force: true }); await lock.close(); await fs.unlink(file + '.preferences.lock'); } diff --git a/tests/project-operations.test.cjs b/tests/project-operations.test.cjs index d25aa9c..98ee6a2 100644 --- a/tests/project-operations.test.cjs +++ b/tests/project-operations.test.cjs @@ -131,6 +131,23 @@ test('local discovery maps UID suffixes without following symlink directories', assert.deepEqual(result.data.items[0], { name: 'Example', path: selection.root, repositoryUid: 'repo-1', isGitRepository: true }); }); +test('the repositories folder is read as the MainSequence CLI reads it, never against the working directory', async t => { + const { repositoriesFolder } = require('../dist'); + const home = path.join(path.sep, 'fixture', 'home'); + assert.equal(repositoriesFolder('mainsequence-dev', home), path.join(home, 'mainsequence-dev')); + assert.equal(repositoriesFolder(' mainsequence-dev ', home), path.join(home, 'mainsequence-dev')); + assert.equal(repositoriesFolder('~', home), home); + assert.equal(repositoriesFolder('~/code/ms', home), path.join(home, 'code', 'ms')); + assert.equal(repositoriesFolder(path.join(path.sep, 'srv', 'ms') + path.sep, home), path.join(path.sep, 'srv', 'ms')); + assert.equal(repositoriesFolder('./ms', home), path.resolve('ms')); + for (const bad of ['', ' ', 'a\nb', undefined, 3]) assert.throws(() => repositoriesFolder(bad, home), { code: 'INVALID_REQUEST' }); + // Listing a bare name reads the folder in the home directory, as setup clones into it. + const selection = await localProject(t), host = createNodeHost(), previous = process.env.HOME; + process.env.HOME = path.dirname(selection.baseDir); t.after(() => { process.env.HOME = previous; }); + const result = await createProjectOperations({ host }).dispatch({ action: 'local-repositories', baseDir: path.basename(selection.baseDir), organization: selection.organization, repositories: [{ uid: 'repo-1', name: 'Example' }] }); + assert.equal(result.status, 'success'); assert.deepEqual(result.data.items.map(item => item.path), [selection.root]); +}); + test('local discovery keeps a missing base folder read-only and rejects redirected organization folders', async t => { const selection = await localProject(t), host = createNodeHost(); const baseDir = path.join(selection.baseDir, 'not created'); diff --git a/tests/setup.test.cjs b/tests/setup.test.cjs index 34d7590..57866b6 100644 --- a/tests/setup.test.cjs +++ b/tests/setup.test.cjs @@ -49,7 +49,9 @@ const tools = createDevTools({host,store:null,env:{...process.env,MAINSEQUENCE_E await fs.unlink(lock); const result=await tools.dispatch(request); const second=await tools.dispatch(request); - console.log(JSON.stringify({result,second,busy,calls,checks})); + // A bare folder name is a folder in the home directory, as the MainSequence CLI reads it; never the working directory. + const homed=await tools.dispatch({...request, baseDir:'repositories in home'}); + console.log(JSON.stringify({result,second,busy,homed,calls,checks})); })(); `; const env = { ...process.env, HOME: home, USERPROFILE: home }; @@ -61,8 +63,10 @@ const tools = createDevTools({host,store:null,env:{...process.env,MAINSEQUENCE_E assert.equal(result.result.data.root, path.join(base, 'fixture-org', 'projects', 'shared-repo-1')); assert.equal(result.second.error.code, 'INVALID_REQUEST'); assert.equal(result.busy.error.code, 'PROJECT_BUSY'); + assert.equal(result.homed.status, 'success', execution.stdout); + assert.equal(result.homed.data.root, path.join(home, 'repositories in home', 'fixture-org', 'projects', 'shared-repo-1')); assert.equal(result.calls.filter(call => call.endpoint.endsWith('/add-deploy-key/')).length, 1); - assert.equal(result.checks, 2); + assert.equal(result.checks, 3, 'one key check per setup that clones'); assert.equal(await fs.readFile(path.join(result.result.data.root, '.env'), 'utf8'), '# BEGIN MAINSEQUENCE AUTH (managed by VS Code)\nMAINSEQUENCE_ENDPOINT=https://fixture.example\n# END MAINSEQUENCE AUTH\n'); assert.doesNotMatch(execution.stdout, /fixture-process-access|PRIVATE KEY/); assert.equal((await fs.readdir(path.join(home, '.ssh'))).length, 2); diff --git a/tests/workspace-settings.test.cjs b/tests/workspace-settings.test.cjs index 9e3d6cd..846b242 100644 --- a/tests/workspace-settings.test.cjs +++ b/tests/workspace-settings.test.cjs @@ -1,10 +1,12 @@ const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path'); const {writeWorkspacePreferences,readDesktopPreferences}=require('../dist'); -test('workspace settings preserve unrelated fields and reject corrupt files and relative paths',async t=>{ +test('workspace settings preserve unrelated fields, save a bare folder name under home as the CLI does, and reject corrupt files',async t=>{ const root=await fs.mkdtemp(path.join(os.tmpdir(),'ms-settings-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));const configPath=path.join(root,'config.json'),options={configPath,homeDir:root,env:{}}; await fs.writeFile(configPath,JSON.stringify({custom:{keep:true}}));const input={backend:'https://fixture.example',baseDir:path.join(root,'repos'),commandCenterUrl:'https://command.example'}; assert.equal((await writeWorkspacePreferences(input,options)).baseDir,input.baseDir);assert.deepEqual(JSON.parse(await fs.readFile(configPath)).custom,{keep:true}); - await assert.rejects(writeWorkspacePreferences({...input,baseDir:'relative'},options),/absolute/); + assert.equal((await writeWorkspacePreferences({...input,baseDir:'mainsequence-dev'},options)).baseDir,path.join(root,'mainsequence-dev')); + assert.equal(JSON.parse(await fs.readFile(configPath)).mainsequence_path,path.join(root,'mainsequence-dev')); + await assert.rejects(writeWorkspacePreferences({...input,baseDir:' '},options),/required/); await fs.writeFile(configPath,'broken');await assert.rejects(writeWorkspacePreferences(input,options),/preserved/);assert.equal(await fs.readFile(configPath,'utf8'),'broken'); await assert.rejects(readDesktopPreferences(options),/preserved/); });