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/);
});