Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>/<org>/projects/<name>-<uid>`; 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 `<base>/<org>/projects/<name>-<uid>` (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. |
Expand Down
11 changes: 9 additions & 2 deletions docs/workspace-environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<home>/mainsequence`. Host-saved overrides remain the host's responsibility.
`https://api.main-sequence.app`. The folder uses `mainsequence_path`, otherwise
`<home>/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, `<base>/<organization>/projects/`, as the CLI does.

The structured `createDevTools().dispatch` API accepts `mcp-environments` to read
all authorized organization environments through the existing paginated backend
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 3 additions & 3 deletions src/shared/organizationProjectWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/shared/projectOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
5 changes: 3 additions & 2 deletions src/shared/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<unknown> {
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<unknown> {
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.');
Expand Down
13 changes: 12 additions & 1 deletion src/shared/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand Down Expand Up @@ -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';
}
Expand Down
6 changes: 3 additions & 3 deletions src/shared/workspaceSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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'); }
Expand Down
17 changes: 17 additions & 0 deletions tests/project-operations.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 6 additions & 2 deletions tests/setup.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions tests/workspace-settings.test.cjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading